跳到主要内容

数据面:execd 在沙箱里跑命令、文件、PTY 与代码解释器

30 秒导读: 控制面(02)把一个沙箱容器造出来后,真正"在里面干活"的是容器里跑着的一个 Go 守护进程 execd。它监听一个端口,对外提供一组 HTTP + WebSocket 接口:跑一条命令、读写文件、开一个交互式终端、通过 Jupyter 内核执行 Python、查一次 SQL。平台承诺的每一次"执行",最后都落在 execd 上。 本章讲 execd 的启动、路由和五种执行后端的实现原理。

前置:本章假设你已读过 index(OpenSandbox 是什么)和 01(协议契约)。容器怎么被造出来见 02/03;容器里再套一层 bubblewrap 隔离(/v1/isolated 路由)单独留给 05;入站代理、出站策略见 06


1. execd 是什么(零基础也能懂)

一句话定义: execd 是装在每个沙箱容器镜像里的一个 Go 二进制,启动后变成一个常驻 HTTP 服务,别人通过网络调它的 API 来"在这个沙箱里执行东西"。

它站在哪一层? 用一个类比:如果把整个平台想成"一台远程电脑出租服务",那么

  • 控制面(网关 + runtime 后端)= 前台,负责"给你开一台机器、关一台机器";
  • execd = 那台机器里已经登录好、随时听你差遣的操作员,你说"跑这条命令""把这个文件读出来""给我开个终端",它照做并把结果传回来。

它对外提供什么(五类执行后端 + 文件):

能力路由前缀大白话
一次性命令/commandpip install ...,前台等结果或后台跑
持久 shell 会话/session像一个记得住 cd 和环境变量的终端
代码解释器/code /session(context)通过 Jupyter 内核跑 Python/JS/… 单元格
交互式 PTY/pty + WebSocket真·交互终端(vim、top、带颜色)
SQL/code(language=sql)对沙箱本地 MySQL 跑查询
文件系统/files /directories增删查改、上传下载、搜索、内容替换

用起来什么样: 客户端就是发 HTTP。跑一条命令大致是:

# 示意,非源码:向沙箱里的 execd 要一次命令执行
curl -N http://<sandbox>:44772/command \
-H 'X-EXECD-ACCESS-TOKEN: <token>' \
-d '{"command":"echo hi && ls","cwd":"/workspace"}'
# 返回是一条 SSE 流:init → stdout("hi") → stdout(...) → complete

一句话直觉: execd 是沙箱的"执行数据面"。控制面搬机器,execd 在机器里动手;它把"跑东西"这件事拆成命令 / 会话 / 代码 / 终端 / SQL 五种后端,统一用会话 ID + 流式输出的形态暴露出去。


2. 顶层全景(execd 大概怎么转)

2.1 启动:一个 main 干了什么

components/execd/main.go:39main() 按顺序做几件事(隔离相关的两步属于 05,这里只标出位置):

main() components/execd/main.go:39
├─ clone3compat.MaybeApply() :40 老内核 seccomp 兼容(见 05)
├─ flag.InitFlags() :44 读 env / 命令行:端口、jupyter地址、access-token…
├─ isolation.LoadConfig / Probe :47 探测 bubblewrap 能力(见 05)
├─ controller.InitCodeRunner() :63 ★造出核心 runtime.Controller,数据面的心脏
├─ InitIsolatedProbe / InitIsolatedRunner 嵌套隔离 runner(见 05)
├─ telemetry.Init() :82 OpenTelemetry 指标(失败则降级继续)
└─ web.NewRouter(flag.ServerAccessToken) :95 ★装配所有路由
└─ net.Listen("tcp4", ":44772") + RunListener :96 只监听 IPv4

两个 就是数据面的全部:一个 runtime.Controller(所有执行后端的实现),和一个 Gin router(把 HTTP 路由映射到 controller 方法)。默认端口 44772,access-token 默认空(flag.InitFlags at components/execd/pkg/flag/parser.go:37,env 名 EXECD_ACCESS_TOKEN)。

2.2 路由地图:HTTP 表面

web.NewRouter(components/execd/pkg/web/router.go:28)用 Gin 建一棵路由树,分成几组:

路由组典型端点干什么落到哪个 controller
/filesGET /files/downloadPOST /files/replacePOST /files/upload文件增删查改、上传下载、内容替换、chmod、搜索FilesystemController
/directoriesGET /directories/listPOST/DELETE ""列目录、建/删目录FilesystemController
/codePOST /codePOST /code/contextGET /code/contexts跑代码 + 管理 Jupyter 上下文CodeInterpretingController
/sessionPOST ""POST /:id/runDELETE /:id持久 bash 会话CodeInterpretingController
/commandPOST ""DELETE ""GET /status/:idGET /:id/logs前台/后台命令、状态、中断、日志CodeInterpretingController
/ptyPOST ""GET /:id/ws交互式终端 + WebSocketPTYController / PTYSessionWebSocket
/metricsGET ""GET /watch沙箱指标MetricController
/v1/isolatedPOST /session嵌套 bubblewrap 隔离会话(见 05)IsolatedSessionController

中间件链(router.go:32,按顺序):

请求 → logMiddleware → otelHTTPMetricsMiddleware → accessTokenMiddleware → ProxyMiddleware → 路由 handler
│ │
校验 X-EXECD-ACCESS-TOKEN /proxy/<port>/... 反向代理(见 06)
不符→401(router.go:150) 其余请求放行
  • access token 中间件(router.go:150 accessTokenMiddleware):token 为空时完全放行;非空时要求请求头 X-EXECD-ACCESS-TOKEN(model.ApiAccessTokenHeader = "X-EXECD-ACCESS-TOKEN")精确匹配,否则 401。这是 execd 唯一的鉴权,粒度是"整个进程一把 token"。
  • withX 闭包(router.go:120 起):每个请求 new 一个新的 controller 实例包住当次 *gin.Context,controller 本身不持有跨请求状态——所有跨请求状态都在那个单例 runtime.Controller 里。

2.3 一次执行请求的主线

POST /command(前台命令)为例,端到端走一遍(不进代码):

HTTP POST /command
│ RunCommand() controller/command.go:33
▼ 解析 body → buildExecuteCommandRequest → ExecuteCodeRequest{Language:"command",...}
│ setServerEventsHandler(ctx) 把 SSE 写出封装成 7 个回调 Hook(sse.go:57)

codeRunner.Execute(req) runtime/ctrl.go:80 ← 按 Language 分发
│ switch Language { command→runCommand; bash/python…→runJupyter; sql→runSQL; background-command→… }

runCommand(ctx, req) runtime/command.go:100
│ exec bash -c <code>,stdout/stderr 写进临时文件
│ 后台 goroutine tailStdPipe 每 100ms 读文件增量 → 回调 OnExecuteStdout

每个回调 → writeSingleEvent → 往 HTTP 响应写一帧 SSE

关键设计:后端与传输解耦。 runtime.Controller 从不知道 SSE、Gin、HTTP 的存在——它只认识一组 ExecuteResultHook 回调(runtime/types.go:26)。是 controller 层把这些回调绑到 SSE 写出上(sse.go:57 setServerEventsHandler)。所以同一套执行逻辑,换个 hook 就能换传输(测试里就直接用打印 hook,types.go:50 SetDefaultHooks)。


3. 数据模型:Controller 和它的会话表

一切执行都挂在单例 runtime.Controller 上(components/execd/pkg/runtime/ctrl.go:37)。它的字段几乎全是并发安全的会话表:

// components/execd/pkg/runtime/ctrl.go:37 Controller(节选)
type Controller struct {
baseURL, token string // Jupyter server 地址与 token
jupyterClientMap sync.Map // sessionID → *jupyterKernel 代码解释器上下文
defaultLanguageSessions sync.Map // Language → sessionID 无 context 时的预热内核
commandClientMap sync.Map // sessionID → *commandKernel 命令执行记录
bashSessionClientMap sync.Map // sessionID → *bashSession 持久 shell 会话
ptySessionMap sync.Map // sessionID → *ptySession PTY 终端
isolatedSessionMap sync.Map // sessionID → *isolatedSession 嵌套隔离(见 05)
db *sql.DB // 懒加载的本地 MySQL 连接
}

每种后端一张 sync.Map,key 都是一个 session/context ID(UUID)。"会话"在 execd 里就是这些 map 里的一条记录 + 它背后的一个进程或内核连接。

分发中枢 Execute(ctrl.go:80)是唯一入口,按请求里的 Language 字段选后端:

// components/execd/pkg/runtime/ctrl.go:80 Execute(节选)
switch request.Language {
case Command: return c.runCommand(ctx, request) // 前台命令
case BackgroundCommand: return c.runBackgroundCommand(ctx, cancel, request) // 后台命令
case Bash, Python, Java, JavaScript, TypeScript, Go:
return c.runJupyter(ctx, request) // Jupyter 内核
case SQL: return c.runSQL(ctx, request) // SQL
}

Language 是一组常量(runtime/language.go:20:command / bash / python / sql / background-command …)。注意一个易错点:bash 走的是 Jupyter 内核(和 python 一样),而持久 bash 会话(/session)不走 Execute,而是单独的 RunInBashSession 路径(见 §5)。CreateContext 的注释也点破了这个区分(runtime/context.go:35)。

ExecuteCodeRequest(runtime/types.go:37)是统一请求体,带 CodeContext(会话 ID)、CwdEnvsUid/GidTimeoutHooks


4. 命令执行(/command)

这是最常用、也最能看清 execd "怎么落地一次执行"的后端。分前台和后台两条路。

4.1 前台命令:跑一条,流式吐输出

runCommand(components/execd/pkg/runtime/command.go:100)的核心思路:bash -c 起进程,把 stdout/stderr 重定向到临时文件,再另起 goroutine 追(tail)这两个文件,把增量喂给回调。

为什么绕一圈写文件、而不直接接管道?因为写文件让"输出"有了一个可回放的落点——后台命令、状态查询都能事后再读它(见 §4.2)。

// components/execd/pkg/runtime/command.go:120 起(节选)
shell := getShell() // 有 bash 用 bash,否则 sh(Alpine 兜底)
cmd := exec.CommandContext(ctx, shell, "-c", request.Code)
cmd.Stdout, cmd.Stderr = stdout, stderr // → /tmp/<session>.stdout / .stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true, // ★独立进程组,便于整组收信号
Credential: cred, // 可选降权到指定 uid/gid
}
// 两个 goroutine tail 文件 → OnExecuteStdout / OnExecuteStderr
go c.tailStdPipe(stdoutPath, request.Hooks.OnExecuteStdout, done)
go c.tailStdPipe(stderrPath, request.Hooks.OnExecuteStderr, done)
  • shell 兜底 getShell()(command.go:52):优先 bash,找不到退回 sh,专门照顾只带 sh 的 Alpine 镜像。
  • 降权执行 buildCredential(command.go:59):请求给了 uiduser.LookupId 补上主组和附加组,让命令以非 root 身份跑。
  • 输出追随 tailStdPipe(command_common.go:29):100ms tick,用 readFromPos(command_common.go:110)从上次位置读文件增量;readFromPos 还小心处理了跨两次读被切开的 \r\n,不至于多冒一行空行。

4.2 后台命令:发了就走,靠 cursor 拉日志

runBackgroundCommand(command.go:260)与前台的差别:

维度前台 runCommand后台 runBackgroundCommand
stdout/stderr分成两个文件合并到一个 .output 文件
stdin继承接到 /dev/null(command.go:304),交互程序立刻退出
返回阻塞到进程结束cmd.Start() 后立即返回,cmd.Wait() 在 goroutine 里
拉输出SSE 实时流GET /command/:id/logs?cursor=N 增量拉

后台输出靠 SeekBackgroundCommandOutput(command_status.go:78):按 cursor(字节偏移)Seek 到文件位置,读到末尾,返回新数据 + 新 cursor(通过响应头 EXECD-COMMANDS-TAIL-CURSOR 回传,controller/command.go:151)。客户端拿新 cursor 下次接着拉,实现断点续读。

4.3 状态、中断与"进程组信号"的坑

每条命令在 commandClientMap 里存一个 commandKernel(ctrl.go:58):pid、两个输出文件路径、起止时间、退出码、是否后台。

  • 查状态 GetCommandStatus(command_status.go:59)返回运行中/退出码/错误。
  • 中断Interrupt(runtime/interrupt.go:30),它先判断 session 属于哪类后端,命令类走 killPid

killPid(interrupt.go:66)不是简单 kill 一个 pid,而是对整个进程组发信号(因为命令是 Setpgid 起的,pid 同时是 pgid):先 SIGTERM-pid,探活 3 秒还在就升级 SIGKILL

这里藏着一个真实的**"陈旧 PID"竞态**,代码里用大段注释和防护处理了,值得学:

命令结束后 markCommandFinished(command_status.go:116)会把 kernel.pid 清零。为什么?因为 syscall.Kill(-pid, ...)打一整组,一旦某条命令已结束、pid 被系统回收复用,一个迟到的 Interrupt 若还按老 pid 发 SIGKILL,就可能误杀一个不相干的进程组。所以 Interrupt 里先 commandSnapshot 拿一致快照,running==false || pid<=0 直接拒绝(interrupt.go:43)。

同样的防护在前台 runCommandctx.Done() 分支(command.go:180-209):用一个 done channel 做门,确认 cmd.Wait() 尚未返回才对 -pid 发 SIGKILL,避免信号打到复用的 pgid 上。这类"信号 vs 回收"的时序防护,是沙箱执行器最容易出安全事故的地方。


5. 持久 bash 会话(/session)

/session 想解决的小问题:命令彼此独立,cd /foo 之后再跑一条命令又回到原点;用户要的是记得住 cwd 和环境变量的终端

直觉上你会以为它常驻一个 bash 进程、把命令喂进去。但 execd 没这么做——看 bashSession(runtime/types.go:100)的字段:只有 env mapcwd stringcurrentProcessPid,没有一个长活的 stdin 管道

真正的机制在 bashSession.run(components/execd/pkg/runtime/bash_session.go:159):每跑一条命令,都新起一个一次性 bash 进程执行一个"包裹脚本",脚本尾部把最新的环境和 cwd 打印出来,execd 解析回来存进会话——下次运行时再重新注入。 "持久"是逻辑上的持久(状态在 execd 的 map 里),不是一个常驻进程。

包裹脚本由 buildWrappedScript(bash_session.go:322)拼出,结构是:

export K=V … # 把上次会话记住的所有环境变量重新导出
cd <上次的 cwd> # 恢复工作目录
<用户的命令>
__USER_EXIT_CODE__=$? # 记住用户命令的退出码
printf __ENV_DUMP_START__ ; export -p ; printf __ENV_DUMP_END__ # 把新环境全 dump 出来
printf __PWD__:$(pwd) # 把新 cwd 打出来
printf __EXIT_CODE__:$__USER_EXIT_CODE__
exit $__USER_EXIT_CODE__

run 用 scanner 扫子进程输出,靠这几个 marker 分流:__ENV_DUMP_*__ 之间的 declare -x 行交给 parseExportDump(bash_session.go:372)解析成新 env;__PWD__: 更新 cwd;__EXIT_CODE__: 取退出码;其余行才是真正的用户输出,喂给 OnExecuteStdout(bash_session.go:236-258)。解析完把新 env/cwd 写回 s.env/s.cwd(bash_session.go:279),下一次 run 就带上了。

几个务实的细节:

  • 不通过 cmd.Env 传环境,而是写进脚本顶部的 export(bash_session.go:208-212 的注释):会话环境可能很大,走 cmd.Env 会触发 "argument list too long";写文件里 export 就没这问题。
  • 过滤持久化的变量:PS1/PS2/PROMPT_COMMAND 等提示符变量不跨命令保留(envKeysNotPersisted,bash_session.go:364),单个值超 8KiB 也不留(maxPersistedEnvValueSize)。
  • DeleteSession / 中断:close()(bash_session.go:459)对当前跑着的进程组发 SIGKILL,并清空 env/cwd。

诚实说明: 本章的授权任务描述把 "replay_buffer 输出重放" 归在 bash_session 名下,但源码里 replayBuffer 实际服务的是 PTY 会话(见 §7),bash 会话并不用它——bash 会话的"重放"是上面这套 env/cwd 的 dump-and-reinject。


6. 代码解释器(/code + Jupyter 内核)

6.1 context 与 session 两个概念

代码解释器有两组会话概念,容易混:

概念创建端点背后是什么用途
contextPOST /code/context一个 Jupyter 内核会话(Python/JS/…)有状态地跑代码单元格,变量跨 cell 保留
(bash)sessionPOST /session一个持久 bash 会话(§5)有状态地跑 shell 命令
default context隐式,首次无 context 跑代码时每语言一个预热内核无状态、一次性执行

POST /code(RunCode,controller/codeinterpreting.go:111)执行一段代码:请求带 context.id 就用那个内核;不带就用/建该语言的 default context。

6.2 无 context 时:预热一个默认内核

runJupyter(runtime/jupyter.go:27)的开头逻辑:请求没给 context,就查该语言有没有 default session,没有就 createDefaultLanguageJupyterContext(context.go:148)现建一个并缓存。这样无状态调用不必每次付"建内核"的钱。

建内核的实际流程 createJupyterContext(context.go:182):

searchKernel(language) jupyter.go:148 从 kernelspecs 里按语言找内核名(跳过 python3)
→ newContextID() 生成 UUID
→ newIpynbPath() 在 cwd 下建一个 <id>.ipynb 落点
→ client.CreateSession() 在 Jupyter server 上建 session + kernel
→ ListKernels() 校验 kernel 确实起来了

6.3 真正的执行:说 Jupyter 协议

execd 自己不跑内核——内核由容器里另一个 Jupyter server 托管(地址来自 JUPYTER_HOST)。execd 是 Jupyter 的WebSocket 客户端

runJupyterCode(jupyter.go:60)的骨架:

// components/execd/pkg/runtime/jupyter.go:60(节选)
if !kernel.mu.TryLock() { return errors.New("session is busy") } // 一个内核同时只跑一段
kernel.client.ConnectToKernel(kernel.kernelID) // 连 ws
results := make(chan *execute.ExecutionResult, 10)
kernel.client.ExecuteCodeStream(kernel.kernelID, request.Code, results)
for {
select {
case r := <-results: dispatchExecutionResultHooks(request, r) // 结果→hook→SSE
case <-ctx.Done(): kernel.client.InterruptKernel(...) // 超时/取消→中断内核
}
}

ConnectToKernel(jupyter/client.go:166)把 baseURL 转成 ws(s)://host/api/kernels/<id>/channels?token=...。底层协议实现在 jupyter/execute/execute.go:

  • buildExecuteMessage(execute.go:160)拼一个 Jupyter v5.3 的 execute_request(Silent:false, StoreHistory:true, AllowStdin:false, StopOnError:true),发到 shell channel。
  • 一个接收 goroutine(receiveMessages,execute.go:462)按消息类型分发:execute_result→结果数据、stream→stdout/stderr、error→异常、status→内核忙/闲。
  • 终局判定 handleExecutionStatus(execute.go:275):收到 idle 状态才认为"这段执行完了",触发 finalizeExecution(execute.go:293)算耗时、等一会儿捞可能迟到的 execute_result/error,再关闭 results channel。轮询间隔是可配的 JupyterIdlePollInterval(默认 100ms)。

dispatchExecutionResultHooks(jupyter.go:103)把这些内核事件翻译成统一的 ExecuteResultHook 回调,和命令执行走同一套 SSE 出口。


7. PTY:交互式终端(/pty + WebSocket)

前面几种后端都是"发一段、收结果"。但 vimtop、带颜色的 REPL 需要真·终端:字符级双向、能改窗口大小、能发 Ctrl-C。这就是 PTY 会话。

7.1 两种模式:PTY vs pipe

ptySession(runtime/pty_session.go:75)可以两种方式起 bash:

模式启动特点
PTYStartPTY(pty_session.go:224)用 pty.StartWithSize分配伪终端,程序以为连着真终端;stdout/stderr 合流;能 resize
pipeStartPipe(pty_session.go:266)用三个 os.Pipe普通管道;stdout/stderr 分开;无终端语义

一个细节坑写在注释里(pty_session.go:244):PTY 模式不能再设 Setpgid,因为 pty.StartWithSize 内部已 Setsid+Setctty,对会话首进程再 setpgid 会 EPERM。

7.2 输出重放:1 MiB 环形缓冲

PTY 的难点是客户端可能断开重连(网络抖动、切标签页),重连后不能丢掉这期间的输出。replayBuffer(runtime/replay_buffer.go:25)解决它:

  • 一个 1 MiB 定长环形缓冲 + 一个单调递增的总字节计数 total
  • 不变式 head == total % size(replay_buffer.go:22 注释):绝对偏移 o 的字节永远在 buf[o % size],不管环有没有绕回。
  • ReadFrom(offset)(replay_buffer.go:85):返回从某偏移到当前的所有字节 + 实际起点;若 offset 太旧(已被覆盖)就 clamp 到最老可用位置。

进程每吐一段输出,writeAndFanout(pty_session.go:373)同时做两件事:写进 replay buffer,并转发给当前连着的 WebSocket 客户端。两步在 outMu 锁内原子完成,专门堵住"写进 replay 之后、attach 之前"那段字节被漏掉的窗口(pty_session.go:367 注释)。

7.3 WebSocket 协议与"接管"

PTYSessionWebSocket(components/execd/pkg/web/controller/pty_ws.go:71)处理 GET /pty/:id/ws,是全章并发最密的一段。它用一个自定义二进制帧协议(model/pty_ws.go):

首字节方向含义
0x00 BinStdin客户端→服务端原始 stdin 字节
0x01 BinStdout服务端→客户端stdout 字节
0x02 BinStderr服务端→客户端stderr(仅 pipe 模式)
0x03 BinReplay服务端→客户端[8字节 BE 偏移][重放字节]

握手后 execd 先发一帧 BinReplay(把断连期间的输出补上,pty_ws.go:231),再发 connected 帧,然后起几个 goroutine:ping 保活、stdout/stderr 泵、退出监视、客户端读循环。文本帧还支持 signal(发信号)、resize(改窗口)、ping

独占与接管:一个 PTY 同时只允许一个 WebSocket 客户端(LockWS/wsConnected 原子标志)。新客户端带 ?takeover=1 时,TakeoverWS(pty_session.go:175)会反复"驱逐"当前持有者(触发它注册的 eviction hook 关掉旧连接)直到抢到锁——其间 bash 进程一直活着,新客户端靠 replay 无缝续上。整个握手/驱逐顺序被精心排过(pty_ws.go:71 上方那段编号注释),保证"握手没成功就绝不驱逐别人"。


8. SQL 会话

runSQL(runtime/sql.go:42)是最轻的后端:对沙箱本地的 MySQL 跑一句 SQL。

  • 懒连接 initDB(sql.go:174):首次用 sync.Onceroot@tcp(127.0.0.1:3306),CREATE DATABASE IF NOT EXISTS sandboxUSE 它。
  • 粗粒度分流 getQueryType(sql.go:165):取 SQL 第一个 token,SELECTexecuteSelectSQLQuery(返回 columns+rows),其余走 executeUpdateSQLQuery(返回 affected_rows)。
  • 结果统一 JSON 化后经 OnExecuteResult 回调,和别的后端共用 SSE 出口。

注意源码显式标注了 // lgtm[go/sql-injection](sql.go:72):这是有意的——沙箱的定位就是"跑用户提交的任意 SQL 程序",不存在"可信模板 vs 注入"之分,整个沙箱就是信任边界。


9. 巧妙之处(值得借鉴)

  • 执行逻辑与传输彻底解耦:后端只认 7 个 ExecuteResultHook 回调(runtime/types.go:26),SSE/WebSocket 只是把回调接到不同写出口。测试可用打印 hook,生产用 SSE hook,后端零改动(sse.go:57)。
  • 信号打进程组,并防陈旧 PID:命令一律 Setpgid 起(command.go:132),中断时 Kill(-pid) 收整组子孙进程;命令结束即清零 pid、快照校验 running(command_status.go:137interrupt.go:43),堵住"误杀复用 pid 的无关进程组"这个真实事故点。
  • bash 会话用 marker 协议做无状态持久:不常驻进程,靠包裹脚本尾部 dump export -p/pwd/退出码再解析回注(bash_session.go:322),既拿到跨命令的状态,又不背一个长命进程的管理成本。
  • PTY 重连的原子重放:环形缓冲不变式 head==total%size 让"从任意偏移续读"变成一次取模(replay_buffer.go),写 replay 与转发在同锁内原子完成杜绝丢字节(pty_session.go:373)。
  • SSE 头懒提交:首个事件才写 text/event-stream 头(sse.go:181),于是"执行还没开始就同步失败"的情况仍能返回结构化 JSON 错误而非半截 SSE(controller/command.go:83 注释)。

10. 边界与局限

  • 鉴权只有一把 token:X-EXECD-ACCESS-TOKEN 精确匹配,token 空则全放行(router.go:150)。execd 假定自己跑在受信反向代理后面(WebSocket upgrader 也 CheckOrigin 恒真,pty_ws.go:39)。真正的多租户边界在控制面 + 容器隔离,不在 execd。
  • 只监听 IPv4:net.Listen("tcp4", ...)(main.go:97)。
  • 一个 Jupyter 内核串行执行:runJupyterCodeTryLock,同一 context 并发第二段代码直接 "session is busy"(jupyter.go:61)。
  • 一个 PTY 同时一个 WS 客户端:多开要靠 ?takeover=1 顶掉前一个,不是多路复用。
  • SQL 写死本地 MySQL:127.0.0.1:3306 root 无密码(sql.go:177),镜像里没有 MySQL 就 DBInitError
  • 命令输出经临时文件中转:/tmp/<session>.stdout 等;代码专门 MkdirAll 兜底 /tmp 被删重建(command_common.go:66),但极高吞吐场景下文件 tail 有 100ms 粒度延迟(command_common.go:31)。

11. 代码地图(导航索引)

主题文件路径关键符号
进程启动components/execd/main.gomain
CLI/env 配置components/execd/pkg/flag/parser.goInitFlagsaccessTokenEnv
路由装配 + 鉴权components/execd/pkg/web/router.goNewRouteraccessTokenMiddleware
执行分发中枢components/execd/pkg/runtime/ctrl.goControllerExecute
请求/回调模型components/execd/pkg/runtime/types.goExecuteCodeRequestExecuteResultHook
语言常量components/execd/pkg/runtime/language.goCommandBashBackgroundCommand
前台/后台命令components/execd/pkg/runtime/command.gorunCommandrunBackgroundCommandbuildCredential
输出 tailcomponents/execd/pkg/runtime/command_common.gotailStdPipereadFromPos
命令状态/后台日志components/execd/pkg/runtime/command_status.goGetCommandStatusSeekBackgroundCommandOutputmarkCommandFinished
中断与信号components/execd/pkg/runtime/interrupt.goInterruptkillPid
持久 bash 会话components/execd/pkg/runtime/bash_session.gobashSession.runbuildWrappedScriptparseExportDump
Jupyter 分发components/execd/pkg/runtime/jupyter.gorunJupyterrunJupyterCodesearchKernel
内核/上下文管理components/execd/pkg/runtime/context.goCreateContextcreateJupyterContextcreateDefaultLanguageJupyterContext
Jupyter 协议客户端components/execd/pkg/jupyter/execute/execute.goExecuteCodeStreamhandleExecutionStatusfinalizeExecution
Jupyter HTTP/WS 门面components/execd/pkg/jupyter/client.goClientConnectToKernel
PTY 会话components/execd/pkg/runtime/pty_session.goptySessionStartPTYStartPipewriteAndFanoutTakeoverWS
输出重放缓冲components/execd/pkg/runtime/replay_buffer.goreplayBufferReadFrom
PTY WebSocket 处理components/execd/pkg/web/controller/pty_ws.goPTYSessionWebSocketptyStreamPump
PTY RESTcomponents/execd/pkg/web/controller/pty_controller.goCreatePTYSessionGetPTYSessionStatus
SQL 会话components/execd/pkg/runtime/sql.gorunSQLinitDBgetQueryType
代码/命令/会话 controllercomponents/execd/pkg/web/controller/codeinterpreting.goRunCodeCreateSessionRunInSession
命令 controllercomponents/execd/pkg/web/controller/command.goRunCommandGetBackgroundCommandOutput
SSE 出口components/execd/pkg/web/controller/sse.gosetServerEventsHandlerwriteSingleEvent
文件系统components/execd/pkg/web/controller/filesystem.goListDirectorySearchFilesReplaceContent
反向代理中间件(见 06)components/execd/pkg/web/proxy.goProxyMiddleware