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
记忆对了一半,触发点记错了:
- 机制存在:v2 确实有"终端环境跟随会话"的设计——TUI 打开/创建会话时,把当前终端的
完整环境(只剔除两个密码变量)通过
PUT /api/session/:id/environment上传给 server, 该会话之后所有本地 shell 命令都用它作为基础环境。 - 触发点不是"每次 prompt":上传发生在打开/创建会话的那一刻,后打开者覆盖。 两个终端连同一个会话,谁后打开(或 resume),环境就是谁的。
- 执行进程永远在 server:环境变量只是作为"数据"上传;spawn 子进程、捕获输出、收退出码 都在 daemon 进程里。客户端只出环境,不出执行。
- 没上传时回落到 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
三处调用点:
packages/tui/src/app.tsx:489-499:TUI 的响应式 effect——每次路由切到某个会话 (连接已建立、会话已存在)就PUT /api/session/:id/environment;packages/tui/src/component/prompt/index.tsx:1246-1247:新会话第一条消息创建会话后立刻上传;packages/cli/src/session-target.ts:76-81:CLIopencode run选定/新建会话后上传 (run.ts:102:托管服务模式传Env.session();--server显式连接时传undefined—— 连别人的 server 不上传)。
注意语义: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 / LSP | server 进程 | daemon 环境(不走 session 环境通道) |
"哪个终端发起 prompt 就用谁的"? 精确版是:哪个终端(后)打开/创建了这个会话, 就用谁的——粒度是会话,不是 prompt;方向是上传环境变量,不是远程执行。 多客户端连同一会话时(web + CLI),谁后打开谁的环境生效,Web 端(浏览器,无终端环境) 不上传、只渲染;执行进程始终是同一个 server。
实用建议
- SSH 断线重连 / waypipe socket 换了:daemon 里冻结的环境已死。在 TUI 里重新打开一下 会话(触发重新 PUT)即可刷新;
- 想让 daemon 有稳定的 GUI 基线环境:
opencode service set env DISPLAY ...+opencode service restart(service 配置的env会在 daemon spawn 时合并进环境); - API 直连 /
--server模式:客户端不上传环境,工具用的是 daemon 环境——脚本化使用时 留意; - 别指望会话环境跨 server 重启存活:它是内存态,重开后由 TUI 重新上传。
总结
| 主张 | 验证角度 | 结果 |
|---|---|---|
| 终端环境会上传为 session 环境 | 代码走读(env.ts / app.tsx / session-target.ts) | ✅ 完整环境,仅剔除两个密码变量 |
| 触发点是打开/创建会话,非每次 prompt | app.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 实验看端到端。凭单一来源(哪怕是源码)下结论,就容易在
"谁的环境"这种边界上记串。