跳到主要内容

会话与命令执行

本章回答两个直觉问题:为什么我上一条 cd /tmp,下一条命令还在 /tmp? 以及 stdout 和 stderr 明明跑在同一个 shell 里,SDK 怎么把它们干净地分回两路?

1. 会话是什么:一个隔离的执行上下文

架构 skill 给的定义:会话隔离执行上下文(cwd、环境变量等);默认会话自动创建, 一个沙箱可有多个会话。 你平时 sandbox.exec('...') 用的就是那个默认会话。

sandbox(一个容器)
├── session A cwd=/workspace env={FOO=1} ← 默认会话
├── session B cwd=/tmp/build env={NODE_ENV=ci}
└── session C ...

会话在容器侧由 SessionManager(packages/sandbox-container/src/services/session-manager.ts:43) 管理。每个会话有自己的 mutex,防止同一会话并发跑命令互相踩(session-manager.ts:45 sessionLocks);还有一张 creatingLocks 防止并发创建同名会话(session-manager.ts:48)。

DO 侧 exec 的会话选择逻辑在 resolveExecutionexecWithSession(sandbox.ts:3827/3855), 最终落到 this.client.commands.execute(command, sessionId, options)(sandbox.ts:3891)。

2. 状态为什么能跨命令存活:一个常驻 bash

答案在 packages/sandbox-container/src/session.ts 文件头,写得非常直白:

Maintains a persistent bash shell so session state (cwd, env vars, shell functions) persists across commands.

一个会话 = 一个长寿命的 bash 进程。你的每条 exec 不是「起一个新 shell 跑完就退」, 而是喂给那个一直活着的 bash。所以:

exec("cd /tmp") → 喂给常驻 bash → bash 的 cwd 真的变成 /tmp
exec("pwd") → 喂给同一个 bash → 输出 /tmp ✓ 状态保留
exec("export X=1") → 同一个 bash 记住了 X
exec("echo $X") → 输出 1 ✓

这跟「每条命令开新进程」的朴素实现形成对比——后者 cd 一出作用域就没了。

3. 难点:stdout / stderr 怎么分回两路

一个常驻 bash 只有一份 stdout、一份 stderr。要可靠地把每条命令的两路分开、还要带 exitCode, 并不简单。SDK 的做法(session.ts 文件头「Overview / Execution Modes」)是: 把两路都写进同一份日志,但每行加一个二进制前缀标明它来自哪路,事后再解析拆开。

两种执行模式,用不同机制:

模式触发 API机制
前台execstdout/stderr 各写临时文件,再加前缀合并进日志;bash 等重定向写完才发布 exitCode
后台execStream / startProcessFIFO 命名管道 + 后台「labeler」逐行加前缀写进日志;写 exitCode 文件,monitor 等 labeler 结束才报完成
┌── stdout ──▶ 临时文件/FIFO ──┐
常驻 bash 跑一条命令 ├─ 加二进制前缀 ─▶ 共享日志 ─▶ 解析回 {stdout, stderr}
└── stderr ──▶ 临时文件/FIFO ──┘ + 单独的 exitCode 文件

为什么用前缀合并而不是两条独立流? 因为要保留行间的相对顺序,同时又能标清归属—— 合并进一份带标签的日志,既不丢顺序也不混淆来源。

退出检测的健壮性。 exitCode 单独写一个文件,并用 fs.watch + 轮询的混合方式检测完成 (session.ts 文件头「Exit Detection」),注释解释这是为了在 tmpfs/overlayfs 上也可靠—— 这些文件系统的 fs.watch 事件不一定到,所以轮询兜底。

教学价值: session.ts 文件头还附了一份给非 bash 专家的「BASH CONCEPTS GLOSSARY」, 讲 FIFO、$!waittrap、原子写(mv rename)、子shell 等。读这个文件本身就是一堂 「如何用 shell 原语搭一个可靠执行器」的课。

4. exec vs execStream vs startProcess

三个 API 对应不同的「拿输出」姿势:

  • exec(sandbox.ts:3827)—— 一次性,命令跑完拿到完整 ExecResult(stdout/stderr/exitCode)。 若传 { stream: true, onOutput } 则走 executeWithStreaming(sandbox.ts:3938)边跑边回调, 但仍收尾成一个最终结果。
  • execStream(sandbox.ts:4693)—— 返回一个事件流,你自己消费(SSE/AsyncIterable)。
  • startProcess(sandbox.ts:4483)—— 起后台进程,返回 Process 句柄,之后可 listProcesses / getProcess / killProcess / streamProcessLogs(sandbox.ts:4606+)。

所有这些最终都汇到容器里同一套会话/进程机制——只是「输出怎么交付」不同。

5. 巧妙之处 / 边界

  • 每会话一把锁。 同一会话的命令串行化(session-manager.ts getSessionLock), 天然避免「两条命令抢同一个 bash」导致的输出交错。
  • 内部命令降噪。 SDK 自己跑的基础设施命令(备份、挂载、env 设置)打 origin: 'internal', 日志降级到 debug,不污染用户视图(sandbox.ts:3846 execInternal)。
  • shell 注入防护。 组装容器内脚本时用 sh 标签模板对每个插值做 shellEscape (sandbox.ts:435),把「可信脚本体 + 不可信字符串」分开。
  • 边界: 会话状态活在容器里——容器被回收(睡眠/驱逐)后 cwd/env/shell 函数都没了。 这点和备份(§5 文档)互补:要跨重启留住文件得显式备份到 R2。

6. 代码地图

主题文件符号名
DO 侧 execpackages/sandbox/src/sandbox.tsexecexecWithSessionexecuteWithStreaming
后台进程packages/sandbox/src/sandbox.tsstartProcesslistProcessesstreamProcessLogs
shell 转义模板packages/sandbox/src/sandbox.tssh(配合 shellEscape)
会话管理(容器侧)packages/sandbox-container/src/services/session-manager.tsSessionManagergetSessionLock
常驻 bash + 流分离packages/sandbox-container/src/session.tsSession(见文件头设计说明)
进程编排(容器侧)packages/sandbox-container/src/managers/process-manager.tsProcessManager