opencode 工具执行用谁的 DISPLAY:"哪个终端发起 prompt 就用谁的"对不对

十月 09, 2026 [AI] #opencode #Wayland #SSH

背景:一个记忆,两篇文章

昨天写了 SSH + tmux 下 opencode v2 复制静默失效的排查, 结论之一是:opencode 的剪贴板(OSC52/wl-copy)完全在 TUI 客户端进程内执行, 用客户端终端的环境和 tty,跟 server 无关。

由此引出一个自然的问题,也是我此前的记忆:

如果 tool-use 调了 GUI 命令(xdg-open、GUI 程序),用的是哪个终端的 DISPLAY / WAYLAND_DISPLAY?是"哪个终端发起 prompt 就用哪个终端的"吗?

这篇文章用四路独立验证回答它。先说结论。

TL;DR

记忆对了一半,触发点记错了:

  1. 机制存在:v2 确实有"终端环境跟随会话"的设计——TUI 打开/创建会话时,把当前终端的 完整环境(只剔除两个密码变量)通过 PUT /api/session/:id/environment 上传给 server, 该会话之后所有本地 shell 命令都用它作为基础环境。
  2. 触发点不是"每次 prompt":上传发生在打开/创建会话的那一刻,后打开者覆盖。 两个终端连同一个会话,谁后打开(或 resume),环境就是谁的。
  3. 执行进程永远在 server:环境变量只是作为"数据"上传;spawn 子进程、捕获输出、收退出码 都在 daemon 进程里。客户端只出环境,不出执行。
  4. 没上传时回落到 daemon 的环境:daemon 的环境在它被启动那一刻由启动它的终端 "冻结"进来,之后一直不变——哪怕那个终端早关了(daemon 已 detach 被 systemd 收养)。

机制链:从终端环境到工具子进程

代码走读(dev 分支,与 release v2.0.24 行为一致,文末有二进制指纹验证):

① 客户端收集:完整环境,只藏两个密码

# packages/cli/src/env.ts:15
export function session() {
  return Object.fromEntries(
    Object.entries(process.env).filter(
      (entry) => entry[1] !== undefined
        && entry[0] !== "OPENCODE_PASSWORD" && entry[0] !== "OPENCODE_SERVER_PASSWORD",
    ),
  )
}

不是白名单——整个 process.env 原样上传(含 DISPLAY、WAYLAND_DISPLAY、 SSH_CONNECTION、TMUX……),只剔除两个服务密码变量。

② 上传时机:打开/创建会话,不是每次 prompt

三处调用点:

注意语义:TUI 的 effect 在路由切换时就会重跑,所以两个终端先后打开同一个会话, 会话环境是后打开者的。

"自动"的边界:上传是 TUI/CLI 会话生命周期的一部分(打开/切换/创建/恢复时各一次), 不挂在每次 prompt 提交上——同一会话里连发十条消息,只有打开会话时那一次 PUT。 两个客户端交替输入也不会来回改写环境:环境始终是"最后打开/恢复该会话者"的版本。 裸 API(opencode api、脚本直连 REST)不实现这个生命周期,因此不发 PUT—— 这正是下文对照组实验的构造方式;对这类调用者,想控制环境就得自己 PUT。

③ 服务端存储:每会话一份,内存态

packages/core/src/session/environment.ts——一个 Map<sessionID, Variables>, API 端点的自述就很直白:

PUT /api/session/:sessionID/environment — "Replace the process environment used by local shell commands for this session."

是整体替换,不是合并。内存态,server 重启即失(但 TUI 下次打开会话会重新 PUT,所以 日常感知不到)。

④ 子进程环境合成:session 环境优先,整体顶掉 daemon 环境

packages/core/src/shell.ts:258-273(Shell.create,agent 的 shell 工具和用户提交的 shell 都走这里):

const sessionEnvironment =
  location.workspaceID === undefined && Schema.is(SessionSchema.ID)(sessionID)
    ? yield* environments.get(sessionID)   // 该会话上传过的环境
    : undefined
const invocation = {
  ...
  env: {
    ...(sessionEnvironment ?? process.env),  // ← 二选一,session 环境整体顶掉 daemon 环境
    TERM: "xterm-256color",
    OPENCODE_TERMINAL: "1",
  },
}
// hooks: shell.create.before 再叠加 AGENT=1、OPENCODE=1、OPENCODE_SESSION_ID 等

⑤ 协议层没有"每请求带环境"

v2 协议里 client → server 的自定义 header 只有路由用的 x-opencode-directory / x-opencode-workspace 和 PTY 的 x-opencode-ticket——没有随请求透传环境的通道。 环境是会话属性,不是请求属性。

验证矩阵:四路独立证据

验证一:活体对照——daemon 的环境 ≠ 我的终端环境

这台机器上正跑着一个 daemon(今天 13:03 由某次 SSH 启动),直接读它的进程环境:

$ tr '\0' '\n' < /proc/<daemon_pid>/environ | grep -E '^(DISPLAY|SSH_CONNECTION)='
SSH_CONNECTION=192.168.7.99 36964 192.168.5.68 22
DISPLAY=localhost:10.0

$ env | grep -E '^(DISPLAY|SSH_CONNECTION)='      # 我当前发起命令的终端
SSH_CONNECTION=192.168.5.244 58170 192.168.5.68 22
DISPLAY=localhost:11.0

$ ps -o ppid= -p <daemon_pid>   # 父进程
      1 /usr/lib/systemd/systemd    # 已 detach,被 systemd 收养

daemon 的 X11 转发是 localhost:10.0、来自 192.168.7.99 那次连接;我现在用的终端是 localhost:11.0、来自 192.168.5.244。如果"哪个终端发起 prompt 就用谁的"为真, 这两组值应该一致——它们不一致。daemon 冻结的是它启动者的环境,与后来的 prompt 发起者无关。

验证二:端到端实验——session 环境整体顶掉 daemon 环境

不用模型,直接走 API(opencode api,与服务相同的发现/认证链路):

# 1. 建会话
$ opencode api post /api/session --data '{"title":"blog-env-probe"}'
{"data":{"id":"ses_ee09d77c7ffeYG7RWAWnim4956", ...}}

# 2. 注入一个假 DISPLAY(daemon 里明明是 localhost:10.0)
$ opencode api put /api/session/$SID/environment \
    --data '{"variables":{"DISPLAY":"localhost:99.9","WAYLAND_DISPLAY":"wayland-fake-probe",
                          "OPENCODE_BLOG_PROBE":"injected-by-put-environment"}}'

# 3. 让 server 跑一条 shell(session shell,后台执行)
$ opencode api post /api/session/$SID/shell --data '{"command":
    "echo PROBE=$OPENCODE_BLOG_PROBE; echo DISPLAY=$DISPLAY; echo PPID_CMD=$(cat /proc/$PPID/comm)"}'

# 4. 读回输出
PROBE=injected-by-put-environment
DISPLAY=localhost:99.9          ← 假值生效,daemon 的 10.0 被整体顶掉
PPID_CMD=opencode               ← 父进程是 server(执行在 daemon,不在任何终端)

验证三:对照组——不上传就用 daemon 冻结环境

# 新建会话,不做 PUT,直接跑同样的命令
DISPLAY=localhost:10.0           ← daemon 的(启动者 192.168.7.99 那次连接的)
WAYLAND_DISPLAY=wayland-2n9NQAWY

两个实验并排,二选一的 sessionEnvironment ?? process.env 分支被实测坐实。

验证四:release 二进制指纹

$ for s in "session.environment" "OPENCODE_TERMINAL"; do echo -n "$s: "; grep -ac "$s" ~/.opencode/bin/opencode; done
session.environment: 7
OPENCODE_TERMINAL: 3

机制不是 dev 分支的新玩具,v2.0.24 release 二进制里就在。

回答最初的两个误解

"客户端负责调用"? 昨天那篇文章的准确结论是:剪贴板(OSC52 序列、wl-copy 子进程) 在客户端进程执行——因为它要写客户端的 tty。而 tool-use 恰恰相反:执行进程是 server 的 子进程(实验里 PPID_CMD=opencode)。客户端把环境变量作为数据上传给 server,仅此而已。 这是同一条客户端-服务端分界线的两层:

能力归属用谁的环境
剪贴板(OSC52 / wl-copy)客户端进程客户端终端的 tty 与环境
tool-use(shell 命令、GUI 程序)server 进程session 环境(上传者),未上传则 daemon 冻结环境
MCP stdio / LSPserver 进程daemon 环境(不走 session 环境通道)

"哪个终端发起 prompt 就用谁的"? 精确版是:哪个终端(后)打开/创建了这个会话, 就用谁的——粒度是会话,不是 prompt;方向是上传环境变量,不是远程执行。 多客户端连同一会话时(web + CLI),谁后打开谁的环境生效,Web 端(浏览器,无终端环境) 不上传、只渲染;执行进程始终是同一个 server。

实用建议

总结

主张验证角度结果
终端环境会上传为 session 环境代码走读(env.ts / app.tsx / session-target.ts)✅ 完整环境,仅剔除两个密码变量
触发点是打开/创建会话,非每次 promptapp.tsx 响应式 effect + 路由切换语义✅ 后打开者覆盖
session 环境整体替换 daemon 环境活体实验:假 DISPLAY=99.9 生效✅ daemon 的 10.0 被顶掉
执行进程在 server实验输出 PPID_CMD=opencode✅ 与任何终端无关
未上传时回落 daemon 冻结环境对照组 + /proc/<pid>/environ✅ 与当前终端不一致(10.0 vs 11.0)
协议层无每请求环境透传protocol headers 审计✅ 只有 directory/workspace/ticket
机制在 release 存在二进制字符串指纹✅ v2.0.24 命中

记忆的来源也清楚了:v1 时代 server 内嵌在 opencode 进程里(老 TUI 的 Worker、 opencode run),工具自然用"启动 opencode 的那个终端"的环境——"终端环境生效"的直觉没错, 只是 v2 把它显式化成了会话级的环境上传,并把执行搬进了 daemon。

验证这类问题的方法论和昨天一致:每一环都要独立证据——代码看语义、协议看边界、 /proc 看活体、API 实验看端到端。凭单一来源(哪怕是源码)下结论,就容易在 "谁的环境"这种边界上记串。