niceeval 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +164 -0
  2. package/README.zh.md +160 -0
  3. package/bin/niceeval.js +11 -0
  4. package/package.json +96 -0
  5. package/src/agents/bub.ts +223 -0
  6. package/src/agents/builtin.ts +6 -0
  7. package/src/agents/claude-code.ts +96 -0
  8. package/src/agents/codex.ts +122 -0
  9. package/src/agents/index.ts +25 -0
  10. package/src/agents/shared.ts +145 -0
  11. package/src/cli.ts +421 -0
  12. package/src/context/context.test.ts +96 -0
  13. package/src/context/context.ts +427 -0
  14. package/src/context/control-flow.ts +27 -0
  15. package/src/context/session.ts +124 -0
  16. package/src/define.ts +112 -0
  17. package/src/expect/index.ts +243 -0
  18. package/src/i18n/en.ts +131 -0
  19. package/src/i18n/index.ts +39 -0
  20. package/src/i18n/zh-CN.ts +132 -0
  21. package/src/index.ts +39 -0
  22. package/src/loaders/index.ts +56 -0
  23. package/src/o11y/cost.ts +57 -0
  24. package/src/o11y/derive.ts +304 -0
  25. package/src/o11y/otlp/canonical.ts +112 -0
  26. package/src/o11y/otlp/mappers/bub.ts +30 -0
  27. package/src/o11y/otlp/mappers/codex.ts +38 -0
  28. package/src/o11y/otlp/mappers/index.ts +25 -0
  29. package/src/o11y/otlp/parse.ts +330 -0
  30. package/src/o11y/otlp/receiver.ts +100 -0
  31. package/src/o11y/otlp/sandbox-receiver.ts +113 -0
  32. package/src/o11y/otlp/select.ts +82 -0
  33. package/src/o11y/parsers/bub.ts +240 -0
  34. package/src/o11y/parsers/claude-code.ts +270 -0
  35. package/src/o11y/parsers/codex.ts +480 -0
  36. package/src/o11y/parsers/index.ts +37 -0
  37. package/src/o11y/prices.json +9873 -0
  38. package/src/runner/discover.ts +77 -0
  39. package/src/runner/reporters/artifacts.ts +81 -0
  40. package/src/runner/reporters/console.ts +76 -0
  41. package/src/runner/reporters/index.ts +5 -0
  42. package/src/runner/reporters/json.ts +52 -0
  43. package/src/runner/reporters/live.ts +178 -0
  44. package/src/runner/reporters/table.ts +328 -0
  45. package/src/runner/run.ts +860 -0
  46. package/src/runner/sandbox-prep.ts +91 -0
  47. package/src/sandbox/checkpoint.ts +39 -0
  48. package/src/sandbox/docker.ts +539 -0
  49. package/src/sandbox/e2b.ts +203 -0
  50. package/src/sandbox/index.ts +25 -0
  51. package/src/sandbox/registry.ts +60 -0
  52. package/src/sandbox/resolve.ts +131 -0
  53. package/src/sandbox/source-files.ts +30 -0
  54. package/src/sandbox/vercel.ts +236 -0
  55. package/src/scoring/collector.ts +113 -0
  56. package/src/scoring/judge.ts +236 -0
  57. package/src/scoring/scoped.ts +289 -0
  58. package/src/scoring/verdict.ts +20 -0
  59. package/src/source-loc.ts +53 -0
  60. package/src/types.ts +913 -0
  61. package/src/util.test.ts +31 -0
  62. package/src/util.ts +50 -0
  63. package/src/view/app/App.tsx +189 -0
  64. package/src/view/app/components/AttemptModal.tsx +66 -0
  65. package/src/view/app/components/CodeView.tsx +272 -0
  66. package/src/view/app/components/CopyControls.tsx +89 -0
  67. package/src/view/app/components/ExperimentTable.tsx +266 -0
  68. package/src/view/app/components/GroupSelector.tsx +61 -0
  69. package/src/view/app/components/LazyArtifact.tsx +50 -0
  70. package/src/view/app/components/Trace.tsx +100 -0
  71. package/src/view/app/components/Transcript.tsx +130 -0
  72. package/src/view/app/components/primitives.tsx +43 -0
  73. package/src/view/app/components/ui/badge.tsx +21 -0
  74. package/src/view/app/components/ui/dialog.tsx +34 -0
  75. package/src/view/app/components/ui/tabs.tsx +30 -0
  76. package/src/view/app/i18n.ts +341 -0
  77. package/src/view/app/index.html +13 -0
  78. package/src/view/app/lib/cn.ts +7 -0
  79. package/src/view/app/lib/format.ts +73 -0
  80. package/src/view/app/lib/guards.ts +61 -0
  81. package/src/view/app/lib/outcome.ts +96 -0
  82. package/src/view/app/lib/rows.ts +63 -0
  83. package/src/view/app/lib/transcript-data.tsx +121 -0
  84. package/src/view/app/main.tsx +17 -0
  85. package/src/view/app/pages/RunsPage.tsx +83 -0
  86. package/src/view/app/pages/TracesPage.tsx +40 -0
  87. package/src/view/app/shared.ts +10 -0
  88. package/src/view/app/types.ts +114 -0
  89. package/src/view/app/vite.config.ts +26 -0
  90. package/src/view/client-dist/app.css +2 -0
  91. package/src/view/client-dist/app.js +56 -0
  92. package/src/view/index.ts +406 -0
  93. package/src/view/styles.css +1074 -0
  94. package/src/view/template.html +15 -0
@@ -0,0 +1,91 @@
1
+ // 沙箱编排的固定段(对所有沙箱型 agent 一致):收集 workspace 文件、打 git 基线、
2
+ // 跑完采 diff。adapter 只管中间「把 agent 跑起来」那一段。
3
+
4
+ import { readFile, readdir, stat } from "node:fs/promises";
5
+ import { join, relative, sep } from "node:path";
6
+ import type { DiffData, Sandbox, SandboxFile } from "../types.ts";
7
+
8
+ const IGNORE_DIRS = new Set([
9
+ "node_modules",
10
+ ".git",
11
+ ".next",
12
+ "dist",
13
+ ".turbo",
14
+ ".niceeval",
15
+ "coverage",
16
+ ]);
17
+
18
+ /** 递归收集 workspace 目录下 agent 可见的文件(排除构建产物 / 依赖)。 */
19
+ export async function collectWorkspaceFiles(dir: string): Promise<SandboxFile[]> {
20
+ const out: SandboxFile[] = [];
21
+ async function walk(current: string): Promise<void> {
22
+ let entries;
23
+ try {
24
+ entries = await readdir(current, { withFileTypes: true });
25
+ } catch {
26
+ return;
27
+ }
28
+ for (const entry of entries) {
29
+ if (entry.name.startsWith(".DS_Store")) continue;
30
+ const full = join(current, entry.name);
31
+ if (entry.isDirectory()) {
32
+ if (IGNORE_DIRS.has(entry.name)) continue;
33
+ await walk(full);
34
+ } else if (entry.isFile()) {
35
+ const rel = relative(dir, full).split(sep).join("/");
36
+ const content = await readFile(full);
37
+ out.push({ path: rel, content });
38
+ }
39
+ }
40
+ }
41
+ await walk(dir);
42
+ return out;
43
+ }
44
+
45
+ /** 检查路径是否是目录(workspace 解析用)。 */
46
+ export async function isDirectory(path: string): Promise<boolean> {
47
+ try {
48
+ return (await stat(path)).isDirectory();
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /** 打 git 基线:gitignore 掉依赖 / 构建产物,提交一版,供之后 diff HEAD 采改动。 */
55
+ export async function initGitAndCommit(sandbox: Sandbox): Promise<void> {
56
+ await sandbox.writeFiles({
57
+ ".gitignore": "node_modules/\n.next/\ndist/\npackage-lock.json\n.niceeval/\n__niceeval__/\n",
58
+ });
59
+ await sandbox.runShell(
60
+ 'git init -q && git config user.email "niceeval@localhost" && git config user.name "niceeval" && git add -A && git commit -q -m "baseline" || true',
61
+ );
62
+ }
63
+
64
+ /** git diff HEAD 采 agent 生成 / 删除的文件。 */
65
+ export async function captureGeneratedFiles(sandbox: Sandbox): Promise<DiffData> {
66
+ const generatedFiles: Record<string, string> = {};
67
+ const deletedFiles: string[] = [];
68
+ try {
69
+ const res = await sandbox.runShell("git add -A && git diff HEAD --name-status");
70
+ const lines = res.stdout.trim().split("\n").filter(Boolean);
71
+ for (const line of lines) {
72
+ const tab = line.indexOf("\t");
73
+ if (tab === -1) continue;
74
+ const status = line.slice(0, tab).trim();
75
+ const path = line.slice(tab + 1).trim();
76
+ if (!path) continue;
77
+ if (status.startsWith("D")) {
78
+ deletedFiles.push(path);
79
+ } else {
80
+ try {
81
+ generatedFiles[path] = await sandbox.readFile(path);
82
+ } catch {
83
+ // 二进制 / 不可读跳过
84
+ }
85
+ }
86
+ }
87
+ } catch {
88
+ // 采集失败返回空
89
+ }
90
+ return { generatedFiles, deletedFiles };
91
+ }
@@ -0,0 +1,39 @@
1
+ // 沙箱文件系统快照工具——只依赖 Sandbox 接口的最小公约数:
2
+ // runShell — 在沙箱里执行 shell 脚本
3
+ // downloadFile — 从沙箱读任意路径的原始字节 → Buffer
4
+ // uploadFile — 向沙箱写任意路径的原始字节 ← Buffer
5
+ //
6
+ // 原理:
7
+ // capture: tar czf /tmp/__fe_cp__.tar.gz <paths> → downloadFile → Buffer
8
+ // restore: uploadFile → /tmp/__fe_rs__.tar.gz → tar xzf -C /
9
+ //
10
+ // tar / binary file I/O 在所有 Linux sandbox(Docker、Vercel、e2b、Modal…)里都支持,
11
+ // 这段代码对任意 backend 的 Sandbox 实现无改动即可使用。
12
+
13
+ import type { Sandbox } from "../types.ts";
14
+ import { t } from "../i18n/index.ts";
15
+
16
+ const CP_TMP = "/tmp/__fe_cp__.tar.gz";
17
+ const RS_TMP = "/tmp/__fe_rs__.tar.gz";
18
+
19
+ /** 把 paths 列出的目录打成 gzip tar,返回 Buffer。 */
20
+ export async function createCheckpoint(sb: Sandbox, paths: string[]): Promise<Buffer> {
21
+ const quoted = paths.map(shellQuote).join(" ");
22
+ // --ignore-failed-read:跳过不存在的路径(如 .cache/uv 可能未建);true 保证 exit 0。
23
+ await sb.runShell(`tar czf ${CP_TMP} --ignore-failed-read ${quoted} 2>/dev/null; true`);
24
+ const buf = await sb.downloadFile(CP_TMP);
25
+ await sb.runShell(`rm -f ${CP_TMP}`);
26
+ if (!buf || buf.length === 0) throw new Error(t("checkpoint.emptyTar", { paths: paths.join(", ") }));
27
+ return buf;
28
+ }
29
+
30
+ /** 把 createCheckpoint 返回的 Buffer 还原到沙箱根目录。 */
31
+ export async function restoreCheckpoint(sb: Sandbox, data: Buffer): Promise<void> {
32
+ await sb.uploadFile(RS_TMP, data);
33
+ // -C / 解压到根目录,覆盖同路径文件;rm 用 ; 保证无论成败都清理临时文件。
34
+ await sb.runShell(`tar xzf ${RS_TMP} -C / 2>/dev/null; rm -f ${RS_TMP}`);
35
+ }
36
+
37
+ function shellQuote(s: string): string {
38
+ return `'${s.replace(/'/g, "'\\''")}'`;
39
+ }
@@ -0,0 +1,539 @@
1
+ // Docker 沙箱后端:用 dockerode 把容器当隔离工作区跑 eval。
2
+ // 改编自 agent-eval 的 docker-sandbox.ts,签名对齐 ../types.ts 的 Sandbox 契约
3
+ //(runShell/runCommand 的 opts 一律是选项对象,不再用位置参数)。
4
+
5
+ import { readdir, readFile, stat } from "node:fs/promises";
6
+ import { basename, dirname, join, relative, sep } from "node:path";
7
+ import { Readable } from "node:stream";
8
+ import Docker from "dockerode";
9
+ import * as tar from "tar-stream";
10
+ import type {
11
+ Sandbox,
12
+ CommandResult,
13
+ CommandOptions,
14
+ SandboxFile,
15
+ SourceFile,
16
+ SourceFiles,
17
+ ReadSourceFilesOptions,
18
+ } from "../types.ts";
19
+ import { makeSourceFiles } from "./source-files.ts";
20
+ import { t } from "../i18n/index.ts";
21
+
22
+ const DEFAULT_SOURCE_EXTENSIONS = ["ts", "tsx", "js", "jsx"];
23
+ const DEFAULT_IGNORE_DIRS = [".git", ".next", "node_modules", "dist", "build", "coverage"];
24
+ const DEFAULT_IGNORE_FILES = ["EVAL.ts", "PROMPT.md"];
25
+ // 行首哨兵:源码文件几乎不可能出现这一串,用来切分单次 shell 输出里的多份文件。
26
+ const SOURCE_FILE_MARKER = "::FE-SRC-7b3f9c::";
27
+
28
+ // 各 Node 运行时对应的镜像。用 -slim 变体下载更快、兼容性够用。
29
+ const DOCKER_IMAGES: Record<string, string> = {
30
+ node20: "node:20-slim",
31
+ node24: "node:24-slim",
32
+ };
33
+
34
+ // 单条命令默认超时(10 分钟)。
35
+ const DEFAULT_TIMEOUT = 600_000;
36
+
37
+ // 容器「存活上限」(dead-man switch):PID1 用 `timeout <TTL> tail -F` 跑,到点自动退出 →
38
+ // 容器停止 → AutoRemove 清理。这样即便宿主进程被 kill -9 / 崩溃 / 断电(SIGINT handler 来不及
39
+ // 跑 stop()),孤儿容器也会在 TTL 后自行消失,不靠任何外部状态。TTL 取 attempt 超时的 2 倍并设
40
+ // 下限,确保正常运行(setup + agent + 脚本,本就受 attempt 超时约束)绝不会被它误杀。
41
+ const TTL_MULTIPLIER = 2;
42
+ const TTL_FLOOR_MS = 1_200_000; // 20 分钟
43
+
44
+ // 容器内工作目录。
45
+ const CONTAINER_WORKDIR = "/home/sandbox/workspace";
46
+
47
+ // 容器「主日志」文件:PID1 tail 它 → `docker logs` 实时显示;agent 命令的 stream 输出 tee 进来。
48
+ const CONTAINER_LOG = "/tmp/niceeval-agent.log";
49
+
50
+ /** 单引号包裹 + 转义,把一个参数安全嵌进 shell 命令串。 */
51
+ function shellQuote(s: string): string {
52
+ return `'${s.replace(/'/g, "'\\''")}'`;
53
+ }
54
+
55
+ // 命令默认以非 root 的 node 用户跑:安全 + 兼容(如 Claude Code 在 root 下拒绝
56
+ // --dangerously-skip-permissions)。node:*-slim 镜像自带 UID/GID 1000 的 node 用户。
57
+ // 需要 root 的命令(setup 装系统依赖)走 runCommand 的 `{ root: true }`;此默认非 root + 按需提
58
+ // root 的模型与 E2B / Vercel / Daytona 一致(见 types.ts 的 CommandOptions.root)。
59
+ const SANDBOX_UID = 1000;
60
+ const SANDBOX_GID = 1000;
61
+ const SANDBOX_USER = `${SANDBOX_UID}:${SANDBOX_GID}`;
62
+ const ROOT_USER = "root";
63
+
64
+ // npm 全局包安装目录(非 root 可写)。
65
+ const NPM_GLOBAL_DIR = "/home/node/.npm-global";
66
+
67
+ // 命令执行时注入的 PATH:把 npm 全局 bin 放最前。
68
+ const SANDBOX_PATH = `${NPM_GLOBAL_DIR}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`;
69
+
70
+ /** Readable stream → Buffer。 */
71
+ async function readableToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
72
+ return new Promise((resolve, reject) => {
73
+ const chunks: Buffer[] = [];
74
+ stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
75
+ stream.on("end", () => resolve(Buffer.concat(chunks)));
76
+ stream.on("error", reject);
77
+ });
78
+ }
79
+
80
+ /** 从单文件 tar 包里提取第一个 entry 的内容。 */
81
+ async function extractFileFromTar(tarBuf: Buffer): Promise<Buffer> {
82
+ return new Promise((resolve, reject) => {
83
+ const extract = tar.extract();
84
+ let found = false;
85
+ extract.on("entry", (header, stream, next) => {
86
+ if (!found) {
87
+ found = true;
88
+ const chunks: Buffer[] = [];
89
+ stream.on("data", (c: Buffer) => chunks.push(c));
90
+ stream.on("end", () => { resolve(Buffer.concat(chunks)); next(); });
91
+ stream.on("error", reject);
92
+ } else {
93
+ stream.resume();
94
+ next();
95
+ }
96
+ });
97
+ extract.on("finish", () => { if (!found) reject(new Error("tar: no entries found")); });
98
+ extract.on("error", reject);
99
+ extract.end(tarBuf);
100
+ });
101
+ }
102
+
103
+ /** 创建 Docker 沙箱的选项。 */
104
+ export interface DockerSandboxOptions {
105
+ /** 单条命令超时(毫秒)。 */
106
+ timeout?: number;
107
+ /** Node 运行时。 */
108
+ runtime?: "node20" | "node24";
109
+ /** 覆盖默认镜像(默认按 runtime 选 `node:*-slim`)。预制模板:烘焙好 agent CLI 的镜像名。 */
110
+ image?: string;
111
+ }
112
+
113
+ /**
114
+ * Docker 沙箱:为每次运行起一个隔离容器。
115
+ * 实现 ../types.ts 的 Sandbox 接口。
116
+ */
117
+ export class DockerSandbox implements Sandbox {
118
+ readonly otlpHost = "host.docker.internal";
119
+ private docker: Docker;
120
+ private container: Docker.Container | null = null;
121
+ private _containerId = "";
122
+ private timeout: number;
123
+ private runtime: string;
124
+ private image?: string;
125
+
126
+ constructor(options: DockerSandboxOptions = {}) {
127
+ this.docker = new Docker();
128
+ this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
129
+ this.runtime = options.runtime ?? "node24";
130
+ this.image = options.image;
131
+ }
132
+
133
+ /** 创建并启动一个 Docker 沙箱。 */
134
+ static async create(options: DockerSandboxOptions = {}): Promise<DockerSandbox> {
135
+ const sandbox = new DockerSandbox(options);
136
+ await sandbox.initialize();
137
+ return sandbox;
138
+ }
139
+
140
+ /** 拉镜像、起容器、装基础工具、备好工作区与 npm 前缀。 */
141
+ private async initialize(): Promise<void> {
142
+ // 显式 image(预制模板)优先;否则按 runtime 选默认 node:*-slim。
143
+ const imageName = this.image ?? DOCKER_IMAGES[this.runtime];
144
+ if (!imageName) {
145
+ throw new Error(t("docker.unsupportedRuntime", { runtime: this.runtime }));
146
+ }
147
+
148
+ // 确保镜像在本地。
149
+ await this.ensureImage(imageName);
150
+
151
+ // 起容器(先以 root 做初始化,之后命令切到非 root 用户)。
152
+ // PID1 改成 tail 一个日志文件(而非 sleep infinity):这样容器「主日志」= 这个文件,
153
+ // `docker logs` / Docker UI 的 Logs 标签页能实时显示我们 appendLog 进去的 agent 逐轮活动。
154
+ // 文件先 touch + chmod 666,好让之后以 1000 用户跑的 exec 也能往里 append。
155
+ // 外层 `timeout <TTL>` 是 dead-man switch:宿主异常退出(kill -9 / 崩溃)留下的孤儿容器,
156
+ // 到 TTL 后 PID1 自动退出 → 容器停止 → AutoRemove 清理(见 TTL_* 常量)。
157
+ const ttlSec = Math.ceil(Math.max(this.timeout * TTL_MULTIPLIER, TTL_FLOOR_MS) / 1000);
158
+ this.container = await this.docker.createContainer({
159
+ Image: imageName,
160
+ Cmd: [
161
+ "sh",
162
+ "-c",
163
+ `touch ${CONTAINER_LOG}; chmod 666 ${CONTAINER_LOG}; exec timeout ${ttlSec} tail -n +1 -F ${CONTAINER_LOG}`,
164
+ ],
165
+ WorkingDir: CONTAINER_WORKDIR,
166
+ Tty: true,
167
+ HostConfig: {
168
+ AutoRemove: true, // 停止即清理
169
+ // 容器经 host.docker.internal 回连宿主上的 OTLP 接收器(tracing agent 用)。
170
+ // Docker Desktop 自带这个名字;Linux 需显式映到 host-gateway,这里统一加上。
171
+ ExtraHosts: ["host.docker.internal:host-gateway"],
172
+ },
173
+ });
174
+
175
+ this._containerId = this.container.id;
176
+
177
+ await this.container.start();
178
+
179
+ // slim 镜像可能缺 CA 证书和 git,补装。
180
+ await this.runCommandAsRoot("bash", [
181
+ "-c",
182
+ "apt-get update -qq && apt-get install -y -qq ca-certificates git curl > /dev/null 2>&1",
183
+ ]);
184
+
185
+ // 工作目录交给非 root 用户(node:node)。node 用户(UID 1000)在 slim 镜像里已存在。
186
+ await this.runCommandAsRoot("mkdir", ["-p", CONTAINER_WORKDIR]);
187
+ await this.runCommandAsRoot("chown", ["-R", SANDBOX_USER, CONTAINER_WORKDIR]);
188
+
189
+ // 为非 root 全局安装准备 npm 目录。
190
+ await this.runCommandAsRoot("mkdir", ["-p", NPM_GLOBAL_DIR]);
191
+ await this.runCommandAsRoot("chown", ["-R", SANDBOX_USER, NPM_GLOBAL_DIR]);
192
+
193
+ // 让 npm 用这个目录当全局前缀(默认非 root,配置落在 node 家目录,供 agent 全局装 CLI 用)。
194
+ await this.runCommand("npm", ["config", "set", "prefix", NPM_GLOBAL_DIR]);
195
+ }
196
+
197
+ /** 确保镜像在本地,缺了就拉。 */
198
+ private async ensureImage(imageName: string): Promise<void> {
199
+ try {
200
+ const image = this.docker.getImage(imageName);
201
+ await image.inspect();
202
+ } catch {
203
+ // 镜像不存在,拉取。
204
+ console.log(t("docker.imagePullStart", { image: imageName }));
205
+ await this.pullImage(imageName);
206
+ console.log(t("docker.imagePullDone", { image: imageName }));
207
+ }
208
+ }
209
+
210
+ /** 拉取镜像并跟进度。 */
211
+ private async pullImage(imageName: string): Promise<void> {
212
+ return new Promise((resolve, reject) => {
213
+ this.docker.pull(imageName, (err: Error | null, stream: NodeJS.ReadableStream) => {
214
+ if (err) {
215
+ reject(err);
216
+ return;
217
+ }
218
+
219
+ this.docker.modem.followProgress(
220
+ stream,
221
+ (followErr: Error | null) => {
222
+ if (followErr) {
223
+ reject(followErr);
224
+ } else {
225
+ resolve();
226
+ }
227
+ },
228
+ // 进度回调(可在此挂进度条)。
229
+ () => {},
230
+ );
231
+ });
232
+ });
233
+ }
234
+
235
+ /** 容器短 ID(像 Docker CLI 那样取前 12 位)。 */
236
+ get sandboxId(): string {
237
+ return this._containerId.slice(0, 12);
238
+ }
239
+
240
+ /**
241
+ * 在容器里跑一条命令。默认以非 root 的 node 用户跑;`opts.root` 为真则以 root 跑
242
+ * (setup 装系统依赖用)。
243
+ */
244
+ async runCommand(
245
+ cmd: string,
246
+ args: string[] = [],
247
+ opts: CommandOptions = {},
248
+ ): Promise<CommandResult> {
249
+ // stream:把本命令输出也接到容器主日志(PID1 tail 它)→ Docker Logs 看到原始输出。
250
+ // 实现:把 cmd+args 安全拼成 shell 串,经 runShell 走 tee(只 tee stdout,保留 stderr 分离 + 退出码)。
251
+ if (opts.stream) {
252
+ const joined = [cmd, ...args].map(shellQuote).join(" ");
253
+ return this.runShell(joined, { env: opts.env, cwd: opts.cwd, stream: true, root: opts.root });
254
+ }
255
+
256
+ // 保证 npm 全局 bin 在 PATH 里;固定 HOME/USER,让 codex(~/.codex)、npm 全局、
257
+ // bash 的 ~ 展开都落在当前身份的家目录,不依赖 docker exec 是否注入 HOME。
258
+ const isRoot = opts.root === true;
259
+ const env = {
260
+ HOME: isRoot ? "/root" : "/home/node",
261
+ USER: isRoot ? "root" : "node",
262
+ LOGNAME: isRoot ? "root" : "node",
263
+ ...opts.env,
264
+ PATH: SANDBOX_PATH,
265
+ // root 跑 npm 时让 install 脚本也以 root 跑(否则 npm 会把脚本降权到目录属主,可能写不进)。
266
+ // 非 root 时此变量无影响。
267
+ ...(isRoot ? { npm_config_unsafe_perm: "true" } : {}),
268
+ };
269
+
270
+ return this.execCommand(cmd, args, {
271
+ env,
272
+ cwd: opts.cwd,
273
+ user: isRoot ? ROOT_USER : SANDBOX_USER,
274
+ });
275
+ }
276
+
277
+ /** 以 root 跑命令(后端内部用:容器初始化、属主收敛)。 */
278
+ private async runCommandAsRoot(
279
+ cmd: string,
280
+ args: string[] = [],
281
+ opts: CommandOptions = {},
282
+ ): Promise<CommandResult> {
283
+ return this.execCommand(cmd, args, {
284
+ env: opts.env,
285
+ cwd: opts.cwd,
286
+ user: ROOT_USER,
287
+ });
288
+ }
289
+
290
+ /** 把目录属主收敛回非 root 的沙箱用户(putArchive 以 root 解包后用)。 */
291
+ private async chownToSandboxUser(path: string): Promise<void> {
292
+ await this.runCommandAsRoot("chown", ["-R", SANDBOX_USER, path]);
293
+ }
294
+
295
+ /** 真正在容器里 exec 一条命令,demux stdout/stderr 并带超时。 */
296
+ private async execCommand(
297
+ cmd: string,
298
+ args: string[] = [],
299
+ opts: { env?: Record<string, string>; cwd?: string; user?: string } = {},
300
+ ): Promise<CommandResult> {
301
+ if (!this.container) {
302
+ throw new Error(t("docker.containerNotInitialized"));
303
+ }
304
+
305
+ const fullCmd = [cmd, ...args];
306
+ const env = opts.env
307
+ ? Object.entries(opts.env).map(([k, v]) => `${k}=${v}`)
308
+ : undefined;
309
+
310
+ const exec = await this.container.exec({
311
+ Cmd: fullCmd,
312
+ AttachStdout: true,
313
+ AttachStderr: true,
314
+ WorkingDir: opts.cwd ?? CONTAINER_WORKDIR,
315
+ Env: env,
316
+ User: opts.user,
317
+ });
318
+
319
+ const stream = await exec.start({ hijack: true, stdin: false });
320
+
321
+ return new Promise<CommandResult>((resolve, reject) => {
322
+ // Docker 把 stdout/stderr 复用在同一条流里(8 字节头 + 载荷),需手动 demux。
323
+ // 头:[stream_type(1B), 0, 0, 0, size(4B 大端)];stream_type:1=stdout,2=stderr。
324
+ //
325
+ // 关键:一帧可能被 Node 的可读流切到【多个 data 事件】里(尤其大输出,如 cat 一个
326
+ // ~100KB 的文件),帧头 / 载荷都可能跨 chunk。所以必须跨 data 累积一个 leftover,
327
+ // 只消费「已到齐的完整帧」,残帧留到下个 data —— 否则会在 chunk 边界丢字节 / 串帧,
328
+ // 表现为 transcript 里随机损坏的行(曾导致 bub tape 的 tool_result/tool_call 被吞)。
329
+ const stdoutChunks: Buffer[] = [];
330
+ const stderrChunks: Buffer[] = [];
331
+ let buffer: Buffer = Buffer.alloc(0); // 跨 data 累积的残帧;注解为 Buffer 以容纳 concat 的 ArrayBufferLike
332
+
333
+ stream.on("data", (chunk: Buffer) => {
334
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
335
+ while (buffer.length >= 8) {
336
+ const streamType = buffer[0];
337
+ const size = buffer.readUInt32BE(4);
338
+ if (buffer.length < 8 + size) break; // 载荷未到齐 → 等下个 data
339
+ const payload = buffer.subarray(8, 8 + size);
340
+ if (streamType === 2) stderrChunks.push(payload);
341
+ else stdoutChunks.push(payload); // 1 / 0 / 未知 → 归 stdout
342
+ buffer = buffer.subarray(8 + size);
343
+ }
344
+ });
345
+
346
+ // 超时:杀流并 reject。
347
+ const timeoutId = setTimeout(() => {
348
+ stream.destroy();
349
+ reject(new Error(t("docker.commandTimeout", { timeoutMs: this.timeout })));
350
+ }, this.timeout);
351
+
352
+ stream.on("end", async () => {
353
+ clearTimeout(timeoutId);
354
+ const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
355
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8");
356
+
357
+ try {
358
+ const inspection = await exec.inspect();
359
+ resolve({
360
+ stdout,
361
+ stderr,
362
+ exitCode: inspection.ExitCode ?? 0,
363
+ });
364
+ } catch (error) {
365
+ reject(error);
366
+ }
367
+ });
368
+
369
+ stream.on("error", (error: Error) => {
370
+ clearTimeout(timeoutId);
371
+ reject(error);
372
+ });
373
+ });
374
+ }
375
+
376
+ /** 经 bash -c 跑一段 shell 脚本。opts 为选项对象。 */
377
+ async runShell(script: string, opts: CommandOptions = {}): Promise<CommandResult> {
378
+ if (opts.stream) {
379
+ // 只 tee stdout 到容器主日志:保留 stderr 分离(解析器要)+ pipefail 保留命令退出码。
380
+ const wrapped = `set -o pipefail; { ${script} ; } | tee -a ${CONTAINER_LOG}`;
381
+ return this.runCommand("bash", ["-c", wrapped], { env: opts.env, cwd: opts.cwd, root: opts.root });
382
+ }
383
+ return this.runCommand("bash", ["-c", script], opts);
384
+ }
385
+
386
+ /** 追加一行到容器主日志(PID1 在 tail)→ Docker 的 Logs 标签页实时可见。 */
387
+ async appendLog(line: string): Promise<void> {
388
+ const esc = line.replace(/'/g, "'\\''");
389
+ await this.runCommand("sh", ["-c", `printf '%s\\n' '${esc}' >> ${CONTAINER_LOG}`]);
390
+ }
391
+
392
+ /** 读容器里的文件。 */
393
+ async readFile(path: string): Promise<string> {
394
+ const result = await this.runCommand("cat", [path]);
395
+ if (result.exitCode !== 0) {
396
+ throw new Error(t("docker.readFileFailed", { path, stderr: result.stderr }));
397
+ }
398
+ return result.stdout;
399
+ }
400
+
401
+ /** 判断容器里某文件是否存在。 */
402
+ async fileExists(path: string): Promise<boolean> {
403
+ const result = await this.runCommand("test", ["-f", path]);
404
+ return result.exitCode === 0;
405
+ }
406
+
407
+ /**
408
+ * 一次 shell 往返读全部源码文件。find 按目录名(任意深度)剪枝、按扩展名收,
409
+ * 每份文件前打一行哨兵 + 相对路径,再 cat 内容;在宿主侧按哨兵切分。
410
+ */
411
+ async readSourceFiles(opts: ReadSourceFilesOptions = {}): Promise<SourceFiles> {
412
+ const extensions = opts.extensions ?? DEFAULT_SOURCE_EXTENSIONS;
413
+ const ignoreDirs = opts.ignoreDirs ?? DEFAULT_IGNORE_DIRS;
414
+ const ignoreFiles = new Set(opts.ignoreFiles ?? DEFAULT_IGNORE_FILES);
415
+
416
+ const dirPrune = ignoreDirs.map((d) => `-name '${d}'`).join(" -o ");
417
+ const nameTests = extensions.map((e) => `-name '*.${e}'`).join(" -o ");
418
+ const script =
419
+ `find . \\( -type d \\( ${dirPrune} \\) \\) -prune -o -type f \\( ${nameTests} \\) -print | ` +
420
+ `while IFS= read -r f; do printf '%s%s\\n' '${SOURCE_FILE_MARKER}' "$f"; cat "$f"; printf '\\n'; done`;
421
+ const result = await this.runShell(script);
422
+
423
+ const files: SourceFile[] = [];
424
+ for (const chunk of result.stdout.split(SOURCE_FILE_MARKER)) {
425
+ const newline = chunk.indexOf("\n");
426
+ if (newline < 0) continue; // 第一段(哨兵前的空串)或畸形段
427
+ const path = chunk.slice(0, newline).trim().replace(/^\.\//, "");
428
+ if (!path || ignoreFiles.has(path.split("/").at(-1) ?? "")) continue;
429
+ // 去掉我们额外追加的那个结尾换行,还原文件原始内容。
430
+ const body = chunk.slice(newline + 1);
431
+ const content = body.endsWith("\n") ? body.slice(0, -1) : body;
432
+ files.push({ path, content });
433
+ }
434
+ return makeSourceFiles(files);
435
+ }
436
+
437
+ /** 批量写文件(路径 -> 文本内容)。 */
438
+ async writeFiles(files: Record<string, string>, targetDir?: string): Promise<void> {
439
+ const sandboxFiles: SandboxFile[] = Object.entries(files).map(([path, content]) => ({
440
+ path,
441
+ content: Buffer.from(content, "utf-8"),
442
+ }));
443
+
444
+ await this.uploadFiles(sandboxFiles, targetDir);
445
+ }
446
+
447
+ /** 用 tar 归档把文件灌进容器。 */
448
+ async uploadFiles(files: SandboxFile[], targetDir = CONTAINER_WORKDIR): Promise<void> {
449
+ if (!this.container) {
450
+ throw new Error(t("docker.containerNotInitialized"));
451
+ }
452
+
453
+ if (files.length === 0) {
454
+ return;
455
+ }
456
+
457
+ // 打 tar 包。
458
+ const pack = tar.pack();
459
+
460
+ for (const file of files) {
461
+ const content =
462
+ typeof file.content === "string" ? Buffer.from(file.content, "utf-8") : file.content;
463
+
464
+ pack.entry({ name: file.path }, content);
465
+ }
466
+
467
+ pack.finalize();
468
+
469
+ await this.runCommandAsRoot("mkdir", ["-p", targetDir]);
470
+
471
+ // putArchive 以 root 身份解包到目标目录。
472
+ await this.container.putArchive(pack, { path: targetDir });
473
+
474
+ // 修正属主:putArchive 上传成 root,改回 node 用户,agent 才能编辑。
475
+ await this.chownToSandboxUser(targetDir);
476
+ }
477
+
478
+ async uploadDirectory(localDir: string, targetDir: string, opts: { ignore?: string[] } = {}): Promise<void> {
479
+ const files = await collectLocalFiles(localDir, opts.ignore);
480
+ await this.uploadFiles(files, targetDir);
481
+ }
482
+
483
+ /**
484
+ * 从容器任意路径读文件 → Buffer。
485
+ * 用 Docker getArchive API(原生二进制,无 base64 开销);tar 只有一个 entry,直接解包取内容。
486
+ */
487
+ async downloadFile(path: string): Promise<Buffer> {
488
+ if (!this.container) throw new Error(t("docker.containerNotInitialized"));
489
+ const stream = await (this.container as Docker.Container).getArchive({ path });
490
+ const tarBuf = await readableToBuffer(stream as NodeJS.ReadableStream);
491
+ return extractFileFromTar(tarBuf);
492
+ }
493
+
494
+ /**
495
+ * 向容器任意路径写文件(二进制)。
496
+ * 打成单文件 tar → putArchive 到目标目录,与 uploadFiles 同一机制但目标路径自由。
497
+ */
498
+ async uploadFile(destPath: string, content: Buffer): Promise<void> {
499
+ if (!this.container) throw new Error(t("docker.containerNotInitialized"));
500
+ const pack = tar.pack();
501
+ pack.entry({ name: basename(destPath) }, content);
502
+ pack.finalize();
503
+ await (this.container as Docker.Container).putArchive(pack, { path: dirname(destPath) });
504
+ }
505
+
506
+ /** 停止并清理容器(AutoRemove 负责销毁)。 */
507
+ async stop(): Promise<void> {
508
+ if (this.container) {
509
+ try {
510
+ await this.container.stop({ t: 0 }); // 立即停止
511
+ } catch {
512
+ // 容器可能已停止或被移除,忽略。
513
+ }
514
+ this.container = null;
515
+ }
516
+ }
517
+ }
518
+
519
+ async function collectLocalFiles(localDir: string, ignore: readonly string[] = []): Promise<SandboxFile[]> {
520
+ const ignored = new Set(ignore);
521
+ const out: SandboxFile[] = [];
522
+ async function walk(dir: string): Promise<void> {
523
+ for (const entry of await readdir(dir)) {
524
+ if (ignored.has(entry)) continue;
525
+ const abs = join(dir, entry);
526
+ const st = await stat(abs);
527
+ if (st.isDirectory()) {
528
+ await walk(abs);
529
+ } else if (st.isFile()) {
530
+ out.push({
531
+ path: relative(localDir, abs).split(sep).join("/"),
532
+ content: await readFile(abs),
533
+ });
534
+ }
535
+ }
536
+ }
537
+ await walk(localDir);
538
+ return out;
539
+ }