niceeval 0.8.1 → 0.9.1

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 (140) hide show
  1. package/INDEX.md +77 -45
  2. package/dist/agents/types.d.ts +28 -6
  3. package/dist/i18n/en.d.ts +2 -0
  4. package/dist/i18n/zh-CN.d.ts +3 -1
  5. package/dist/report/built-in/index.d.ts +3 -2
  6. package/dist/report/built-in/index.js +7 -8
  7. package/dist/report/built-in/standard.d.ts +1 -0
  8. package/dist/report/built-in/standard.js +30 -0
  9. package/dist/report/components.d.ts +69 -2
  10. package/dist/report/components.js +152 -3
  11. package/dist/report/compute.d.ts +28 -1
  12. package/dist/report/compute.js +123 -0
  13. package/dist/report/index.d.ts +4 -4
  14. package/dist/report/index.js +3 -2
  15. package/dist/report/locale.d.ts +39 -1
  16. package/dist/report/locale.js +69 -0
  17. package/dist/report/react/AttemptList.d.ts +3 -1
  18. package/dist/report/react/AttemptList.js +3 -3
  19. package/dist/report/react/CopyFixPrompt.d.ts +12 -0
  20. package/dist/report/react/CopyFixPrompt.js +12 -0
  21. package/dist/report/react/HeroCard.d.ts +13 -0
  22. package/dist/report/react/HeroCard.js +35 -0
  23. package/dist/report/react/PoweredBy.d.ts +5 -0
  24. package/dist/report/react/PoweredBy.js +7 -0
  25. package/dist/report/react/ScopeWarnings.d.ts +12 -0
  26. package/dist/report/react/ScopeWarnings.js +18 -0
  27. package/dist/report/react/TraceWaterfall.d.ts +14 -0
  28. package/dist/report/react/TraceWaterfall.js +22 -0
  29. package/dist/report/react/index.d.ts +6 -1
  30. package/dist/report/react/index.js +6 -0
  31. package/dist/report/report.d.ts +22 -6
  32. package/dist/report/report.js +68 -55
  33. package/dist/report/scope-warnings.d.ts +28 -0
  34. package/dist/report/scope-warnings.js +101 -0
  35. package/dist/report/text/faces.d.ts +19 -1
  36. package/dist/report/text/faces.js +61 -0
  37. package/dist/report/tree.d.ts +1 -1
  38. package/dist/report/tree.js +7 -2
  39. package/dist/report/types.d.ts +45 -0
  40. package/dist/report/web.d.ts +5 -4
  41. package/dist/report/web.js +7 -22
  42. package/dist/results/select.d.ts +17 -4
  43. package/dist/results/select.js +76 -11
  44. package/dist/results/types.d.ts +26 -0
  45. package/dist/runner/fingerprint.d.ts +3 -3
  46. package/dist/runner/sandbox-selection.d.ts +12 -0
  47. package/dist/runner/types.d.ts +18 -6
  48. package/dist/sandbox/types.d.ts +12 -0
  49. package/dist/shared/aggregate.d.ts +1 -1
  50. package/dist/shared/aggregate.js +1 -1
  51. package/docs-site/zh/explanation/evals.mdx +2 -1
  52. package/docs-site/zh/explanation/experiment.mdx +2 -0
  53. package/docs-site/zh/how-to/custom-reports.mdx +23 -7
  54. package/docs-site/zh/how-to/experiments.mdx +2 -2
  55. package/docs-site/zh/how-to/publish-report.mdx +11 -4
  56. package/docs-site/zh/how-to/viewing-results.mdx +17 -8
  57. package/docs-site/zh/how-to/write-experiment.mdx +40 -1
  58. package/docs-site/zh/reference/builtin-agents.mdx +40 -3
  59. package/docs-site/zh/reference/cli.mdx +3 -3
  60. package/docs-site/zh/reference/define-eval.mdx +8 -0
  61. package/docs-site/zh/reference/official-adapters.mdx +10 -5
  62. package/docs-site/zh/troubleshooting/debugging.mdx +3 -3
  63. package/package.json +2 -1
  64. package/src/agents/bub.ts +13 -1
  65. package/src/agents/claude-code.test.ts +43 -1
  66. package/src/agents/claude-code.ts +32 -14
  67. package/src/agents/codex.test.ts +168 -1
  68. package/src/agents/codex.ts +51 -15
  69. package/src/agents/mcp.ts +31 -0
  70. package/src/agents/post-setup.ts +33 -0
  71. package/src/agents/types.ts +28 -7
  72. package/src/cli.ts +15 -14
  73. package/src/define.ts +3 -0
  74. package/src/i18n/en.ts +8 -5
  75. package/src/i18n/zh-CN.ts +8 -4
  76. package/src/index.ts +1 -0
  77. package/src/report/built-in/index.tsx +8 -7
  78. package/src/report/built-in/standard.tsx +59 -0
  79. package/src/report/components.tsx +218 -2
  80. package/src/report/compute.ts +138 -1
  81. package/src/report/dual-render.test.tsx +141 -14
  82. package/src/report/index.ts +20 -0
  83. package/src/report/locale.ts +83 -1
  84. package/src/report/react/AttemptList.tsx +13 -1
  85. package/src/report/react/CopyFixPrompt.tsx +37 -0
  86. package/src/report/react/HeroCard.tsx +59 -0
  87. package/src/report/react/PoweredBy.tsx +20 -0
  88. package/src/report/react/ScopeWarnings.tsx +74 -0
  89. package/src/report/react/TraceWaterfall.tsx +78 -0
  90. package/src/report/react/enhance.js +14 -0
  91. package/src/report/react/index.tsx +11 -0
  92. package/src/report/react/styles.css +193 -7
  93. package/src/report/report.ts +99 -64
  94. package/src/report/scope-warnings.ts +155 -0
  95. package/src/report/site-components.test.tsx +526 -0
  96. package/src/report/text/faces.ts +66 -0
  97. package/src/report/tree.ts +10 -3
  98. package/src/report/types.ts +51 -0
  99. package/src/report/web.ts +7 -40
  100. package/src/results/host-equivalence.test.ts +6 -2
  101. package/src/results/open.ts +5 -4
  102. package/src/results/results.test.ts +78 -1
  103. package/src/results/select.ts +80 -12
  104. package/src/results/types.ts +27 -0
  105. package/src/runner/attempt.ts +10 -9
  106. package/src/runner/discover.test.ts +9 -1
  107. package/src/runner/discover.ts +3 -3
  108. package/src/runner/fingerprint.ts +9 -4
  109. package/src/runner/ledger.test.ts +30 -1
  110. package/src/runner/ledger.ts +26 -4
  111. package/src/runner/run.ts +5 -1
  112. package/src/runner/sandbox-selection.test.ts +131 -0
  113. package/src/runner/sandbox-selection.ts +110 -0
  114. package/src/runner/types.ts +19 -2
  115. package/src/sandbox/types.ts +6 -0
  116. package/src/shared/aggregate.ts +1 -1
  117. package/src/show/index.ts +19 -12
  118. package/src/show/render.ts +12 -12
  119. package/src/show/report-host.test.ts +34 -17
  120. package/src/show/report-host.ts +6 -5
  121. package/src/show/show.test.ts +141 -4
  122. package/src/view/app/App.test.tsx +79 -17
  123. package/src/view/app/App.tsx +25 -74
  124. package/src/view/app/components/CopyControls.tsx +4 -42
  125. package/src/view/app/i18n.ts +5 -227
  126. package/src/view/app/lib/rows.ts +3 -21
  127. package/src/view/app/shared.ts +1 -3
  128. package/src/view/app/types.ts +2 -2
  129. package/src/view/client-dist/app.css +1 -1
  130. package/src/view/client-dist/app.js +20 -20
  131. package/src/view/data.ts +21 -10
  132. package/src/view/index.ts +2 -12
  133. package/src/view/server.ts +4 -4
  134. package/src/view/shared/types.ts +11 -6
  135. package/src/view/styles.css +9 -252
  136. package/src/view/view-report.test.ts +99 -33
  137. package/src/view/app/components/LazyArtifact.tsx +0 -51
  138. package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
  139. package/src/view/app/pages/AttemptsPage.tsx +0 -80
  140. package/src/view/app/pages/TracesPage.tsx +0 -35
@@ -17,7 +17,7 @@ import { tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
  import { codexAgent, installPlugins, type CodexPluginSpec } from "./codex.ts";
19
19
  import { createAgentSession } from "../context/session.ts";
20
- import type { AgentContext, AgentSetupManifest, CommandOptions, CommandResult, Sandbox, SandboxFile } from "../types.ts";
20
+ import type { AgentContext, AgentSetupManifest, CommandOptions, CommandResult, McpServer, Sandbox, SandboxFile } from "../types.ts";
21
21
 
22
22
  /** 内存沙箱:runShell 记命令(可按命令包含的子串打脚本化输出),uploadFile / writeFiles 记内容。 */
23
23
  class FakeSandbox implements Partial<Sandbox> {
@@ -91,6 +91,32 @@ describe("codex installPlugins · 命令构造", () => {
91
91
  ]);
92
92
  });
93
93
 
94
+ it("marketplace.sparse 逐项生成 --sparse <path>(缺省不含由上面的精确命令断言覆盖);manifest 不记录 sparse", async () => {
95
+ const box = sb();
96
+ const out = await installPlugins(asSandbox(box), [
97
+ {
98
+ marketplace: {
99
+ name: "acme",
100
+ source: "acme/codex-plugins",
101
+ sparse: [".agents", "plugins/repo-map"],
102
+ },
103
+ name: "repo-map",
104
+ },
105
+ ]);
106
+ expect(box.commands[0]).toBe(
107
+ "codex plugin marketplace add 'acme/codex-plugins' --sparse '.agents' --sparse 'plugins/repo-map'",
108
+ );
109
+ expect(out[0]!.marketplace).toEqual({ name: "acme", source: "acme/codex-plugins" });
110
+ });
111
+
112
+ it("marketplace.sparse 空数组与缺省等价:add 命令不含 --sparse", async () => {
113
+ const box = sb();
114
+ await installPlugins(asSandbox(box), [
115
+ { marketplace: { name: "acme", source: "acme/codex-plugins", sparse: [] }, name: "repo-map" },
116
+ ]);
117
+ expect(box.commands[0]).toBe("codex plugin marketplace add 'acme/codex-plugins'");
118
+ });
119
+
94
120
  it("同名 marketplace 只连一次:两个 plugin 共用一个 marketplace.name → 只有一条 marketplace add,两条 plugin add", async () => {
95
121
  const box = sb();
96
122
  const plugins: CodexPluginSpec[] = [
@@ -339,3 +365,144 @@ describe("codexAgent · live step feedback", () => {
339
365
  expect(ctx.session.id).toBe("thread-1");
340
366
  });
341
367
  });
368
+
369
+ describe("codexAgent · exec 信任姿态", () => {
370
+ // bug: memory/codex-hook-trust-headless-silent-skip.md
371
+ it("首轮与 resume 的 exec 命令都含 --dangerously-bypass-hook-trust(headless 下未授信 hook 被 codex 静默跳过,零报错)", async () => {
372
+ const stdout = JSON.stringify({ type: "thread.started", thread_id: "thread-1" }) + "\n";
373
+ const box = sb([{ match: "codex exec", result: () => ({ stdout }) }]);
374
+ const ctx: AgentContext = {
375
+ signal: new AbortController().signal,
376
+ flags: {},
377
+ sandbox: asSandbox(box),
378
+ session: createAgentSession(),
379
+ progress() {},
380
+ diagnostic() {},
381
+ log() {},
382
+ };
383
+ const agent = codexAgent({ apiKey: "test-key" });
384
+
385
+ await agent.send!({ text: "first" }, ctx);
386
+ await agent.send!({ text: "second" }, ctx);
387
+
388
+ const execs = box.commands.filter((c) => c.startsWith("codex exec"));
389
+ expect(execs).toHaveLength(2);
390
+ expect(execs[0]).not.toContain("codex exec resume");
391
+ expect(execs[1]).toContain("codex exec resume thread-1");
392
+ for (const cmd of execs) expect(cmd).toContain("--dangerously-bypass-hook-trust");
393
+ });
394
+ });
395
+
396
+ describe("codexAgent mcpServers · 形态落位", () => {
397
+ const ctx = { flags: {} } as AgentContext;
398
+
399
+ it("HTTP 形态:url 行 + [mcp_servers.<name>.http_headers] 子表;manifest 只记 name/url,headers 值不落盘", async () => {
400
+ const box = sb();
401
+ await codexAgent({
402
+ apiKey: "k",
403
+ mcpServers: [
404
+ { name: "team-memory", url: "https://mem.example.com/mcp/", headers: { Authorization: "Bearer sekret" } },
405
+ ],
406
+ }).setup!(asSandbox(box), ctx);
407
+
408
+ const mcp = box.commands.find((c) => c.includes("[mcp_servers.team-memory]"))!;
409
+ expect(mcp).toContain('url = "https://mem.example.com/mcp/"');
410
+ expect(mcp).toContain("[mcp_servers.team-memory.http_headers]");
411
+ expect(mcp).toContain('"Authorization" = "Bearer sekret"');
412
+ expect(mcp).not.toContain("command =");
413
+
414
+ const manifestRaw = box.written["__niceeval__/agent-setup.json"]!;
415
+ const manifest = JSON.parse(manifestRaw) as AgentSetupManifest;
416
+ expect(manifest.mcpServers).toEqual([{ name: "team-memory", url: "https://mem.example.com/mcp/" }]);
417
+ expect(manifestRaw).not.toContain("sekret");
418
+ });
419
+
420
+ it("边界:HTTP 形态无 headers → 不写空 http_headers 子表", async () => {
421
+ const box = sb();
422
+ await codexAgent({
423
+ apiKey: "k",
424
+ mcpServers: [{ name: "team-memory", url: "https://mem.example.com/mcp/" }],
425
+ }).setup!(asSandbox(box), ctx);
426
+
427
+ const mcp = box.commands.find((c) => c.includes("[mcp_servers.team-memory]"))!;
428
+ expect(mcp).toContain('url = "https://mem.example.com/mcp/"');
429
+ expect(mcp).not.toContain("http_headers");
430
+ });
431
+
432
+ it("反例:同一 server 同时给出 command 与 url → setup 报错点名该 server,不写 MCP 块", async () => {
433
+ const box = sb();
434
+ // 形状判别不设 kind 标签,双字段的错误配置在类型上可能混得进来 —— 运行期兜底点名报错。
435
+ const dup = { name: "dup-server", command: "npx", url: "https://x.example.com/mcp" } as McpServer;
436
+ await expect(codexAgent({ apiKey: "k", mcpServers: [dup] }).setup!(asSandbox(box), ctx)).rejects.toThrow(
437
+ /dup-server/,
438
+ );
439
+ expect(box.commands.some((c) => c.includes("[mcp_servers."))).toBe(false);
440
+ });
441
+ });
442
+
443
+ describe("codexAgent postSetup · 安装后钩子", () => {
444
+ const mkCtx = (): AgentContext =>
445
+ ({
446
+ flags: {},
447
+ experimentId: "exp-1",
448
+ signal: new AbortController().signal,
449
+ progress: () => {},
450
+ diagnostic: () => {},
451
+ }) as unknown as AgentContext;
452
+
453
+ it("钩子在 Adapter 安装与 manifest 之后按数组顺序执行,拿到 SandboxHook 窄上下文", async () => {
454
+ const box = sb();
455
+ const seen: string[] = [];
456
+ let manifestPresentAtHook = false;
457
+ await codexAgent({
458
+ apiKey: "k",
459
+ mcpServers: [{ name: "browser", command: "npx" }],
460
+ postSetup: [
461
+ async (sandbox, hookCtx) => {
462
+ manifestPresentAtHook = box.written["__niceeval__/agent-setup.json"] !== undefined;
463
+ seen.push(`a:${hookCtx.experimentId}`);
464
+ await sandbox.runShell("post-hook-a");
465
+ },
466
+ async (sandbox) => {
467
+ seen.push("b");
468
+ await sandbox.runShell("post-hook-b");
469
+ },
470
+ ],
471
+ }).setup!(asSandbox(box), mkCtx());
472
+
473
+ expect(seen).toEqual(["a:exp-1", "b"]);
474
+ expect(manifestPresentAtHook).toBe(true);
475
+ const configIdx = box.commands.findIndex((c) => c.includes("config.toml"));
476
+ const aIdx = box.commands.indexOf("post-hook-a");
477
+ const bIdx = box.commands.indexOf("post-hook-b");
478
+ expect(aIdx).toBeGreaterThan(configIdx);
479
+ expect(bIdx).toBeGreaterThan(aIdx);
480
+ });
481
+
482
+ it("钩子返回的 cleanup 合成一个闭包交还 runner,按 LIFO 执行", async () => {
483
+ const box = sb();
484
+ const order: string[] = [];
485
+ const cleanup = await codexAgent({
486
+ apiKey: "k",
487
+ postSetup: [() => () => void order.push("a"), () => () => void order.push("b")],
488
+ }).setup!(asSandbox(box), mkCtx());
489
+
490
+ expect(typeof cleanup).toBe("function");
491
+ await (cleanup as () => Promise<void> | void)();
492
+ expect(order).toEqual(["b", "a"]);
493
+ });
494
+
495
+ it("反例:钩子抛错从 setup 传播(attempt errored 通道)", async () => {
496
+ const box = sb();
497
+ await expect(
498
+ codexAgent({
499
+ apiKey: "k",
500
+ postSetup: [
501
+ () => {
502
+ throw new Error("hook boom");
503
+ },
504
+ ],
505
+ }).setup!(asSandbox(box), mkCtx()),
506
+ ).rejects.toThrow("hook boom");
507
+ });
508
+ });
@@ -19,7 +19,9 @@ import {
19
19
  import { mapCodexSpans } from "../o11y/otlp/mappers/codex.ts";
20
20
  import { t } from "../i18n/index.ts";
21
21
  import { DEFAULT_CODEX_CLI_VERSION } from "./coding-cli-versions.ts";
22
- import type { Agent, AgentSetupManifest, McpServer, Sandbox, SkillSpec } from "../types.ts";
22
+ import { assertMcpServers, isHttpMcp, mcpManifestEntries } from "./mcp.ts";
23
+ import { runPostSetupHooks } from "./post-setup.ts";
24
+ import type { Agent, AgentSetupManifest, McpServer, Sandbox, SandboxHook, SkillSpec } from "../types.ts";
23
25
 
24
26
  // ───────────────────────────────────────────────────────────────────────────
25
27
  // OpenAI Codex CLI 的 agent adapter(沙箱型)。
@@ -59,6 +61,12 @@ export interface CodexPluginSpec {
59
61
  source: string;
60
62
  /** 固定 Marketplace 的 Tag、Commit 或 Branch(→ `codex plugin marketplace add --ref`)。 */
61
63
  ref?: string;
64
+ /**
65
+ * sparse 拉取的路径列表,每项生成一个 `codex plugin marketplace add --sparse <path>`
66
+ * (codex 的 `--sparse` 必须带路径参数、可重复):大仓库只取插件所需路径,省略或空数组即全量 clone。
67
+ * 只影响拉取速度,不影响装出来的内容;manifest 不记录它。
68
+ */
69
+ sparse?: string[];
62
70
  };
63
71
  /** Marketplace 中的 Plugin 名。 */
64
72
  name: string;
@@ -71,7 +79,8 @@ export interface CodexConfig {
71
79
  baseUrl?: string;
72
80
  /**
73
81
  * 额外 MCP server(每个沙箱 setup 时追加进 ~/.codex/config.toml)。
74
- * 格式对应 codex config.toml 的 [mcp_servers.<name>] 表。
82
+ * stdio 形态(command/args/env)写 [mcp_servers.<name>] 的 command 行;
83
+ * Streamable HTTP 形态(url/headers)写 url 行,headers 进 [mcp_servers.<name>.http_headers] 子表。
75
84
  */
76
85
  mcpServers?: McpServer[];
77
86
  /**
@@ -92,6 +101,14 @@ export interface CodexConfig {
92
101
  * 项目相对路径与字节 SHA-256,不落正文。
93
102
  */
94
103
  configFile?: string;
104
+ /**
105
+ * 安装后按数组顺序运行的用户钩子(复用 SandboxHook 的窄上下文):在写主配置、挂 MCP、
106
+ * 装 Skills / Plugin、写 manifest 全部完成后执行,适合跑插件自带的 setup 脚本这类
107
+ * 「安装产物就位后才能跑」的过程动作。钩子返回的 cleanup 按 LIFO 与 teardown 一起收尾;
108
+ * 抛错按基础设施错误计(attempt errored)。
109
+ * 见 docs/feature/adapters/library/coding-agent-extensions.md「安装后运行脚本」。
110
+ */
111
+ postSetup?: SandboxHook[];
95
112
  }
96
113
 
97
114
  export function codexAgent(config?: CodexConfig): Agent {
@@ -155,15 +172,25 @@ export function codexAgent(config?: CodexConfig): Agent {
155
172
  }
156
173
 
157
174
  if (config?.mcpServers?.length) {
175
+ assertMcpServers(config.mcpServers);
158
176
  const mcpToml = config.mcpServers
159
177
  .map((s) => {
160
178
  // 注意是复数 mcp_servers:单数 [mcp_server.x] 会被 codex 静默忽略,
161
179
  // MCP 压根挂不上(实测 codex-cli 0.142.x,`codex mcp list` 可核对)。
162
- const lines: string[] = [`[mcp_servers.${s.name}]`, `command = "${s.command}"`];
163
- if (s.args?.length) lines.push(`args = [${s.args.map((a) => `"${a}"`).join(", ")}]`);
164
- if (s.env && Object.keys(s.env).length) {
165
- lines.push(`[mcp_servers.${s.name}.env]`);
166
- for (const [k, v] of Object.entries(s.env)) lines.push(`${k} = "${v}"`);
180
+ const lines: string[] = [`[mcp_servers.${s.name}]`];
181
+ if (isHttpMcp(s)) {
182
+ lines.push(`url = "${s.url}"`);
183
+ if (s.headers && Object.keys(s.headers).length) {
184
+ lines.push(`[mcp_servers.${s.name}.http_headers]`);
185
+ for (const [k, v] of Object.entries(s.headers)) lines.push(`"${k}" = "${v}"`);
186
+ }
187
+ } else {
188
+ lines.push(`command = "${s.command}"`);
189
+ if (s.args?.length) lines.push(`args = [${s.args.map((a) => `"${a}"`).join(", ")}]`);
190
+ if (s.env && Object.keys(s.env).length) {
191
+ lines.push(`[mcp_servers.${s.name}.env]`);
192
+ for (const [k, v] of Object.entries(s.env)) lines.push(`${k} = "${v}"`);
193
+ }
167
194
  }
168
195
  return lines.join("\n");
169
196
  })
@@ -184,12 +211,8 @@ export function codexAgent(config?: CodexConfig): Agent {
184
211
  manifest.nativePlugins = await installPlugins(sb, config.plugins);
185
212
  }
186
213
  if (config?.mcpServers?.length) {
187
- // manifest 只记「挂了哪个 server、怎么起」;env 里可能有 token,不落盘。
188
- manifest.mcpServers = config.mcpServers.map((s) => ({
189
- name: s.name,
190
- command: s.command,
191
- ...(s.args?.length ? { args: [...s.args] } : {}),
192
- }));
214
+ // manifest 只记「挂了哪个 server、怎么连」;env / headers 里可能有 token,不落盘。
215
+ manifest.mcpServers = mcpManifestEntries(config.mcpServers);
193
216
  }
194
217
  if (nativeConfig) {
195
218
  // 只记来源路径与字节哈希,不落正文(任意官方配置都可能带敏感字符串)。
@@ -203,6 +226,10 @@ export function codexAgent(config?: CodexConfig): Agent {
203
226
  ) {
204
227
  await writeAgentSetupManifest(sb, manifest);
205
228
  }
229
+
230
+ // 安装后钩子(postSetup):排在 manifest 之后——manifest 审计 Adapter 自身的安装事实,
231
+ // 钩子失败不该丢掉这份证据。返回的合成 cleanup 交给 runner,与 teardown 一起 LIFO 收尾。
232
+ return await runPostSetupHooks(sb, ctx, config?.postSetup);
206
233
  },
207
234
 
208
235
  tracing: {
@@ -223,7 +250,12 @@ export function codexAgent(config?: CodexConfig): Agent {
223
250
 
224
251
  async send(input, ctx) {
225
252
  const sb = ctx.sandbox;
226
- const flags = "--json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check";
253
+ // hook trust bypass 是 runtime-only(config.toml 设不了):headless 下 codex 对非 managed
254
+ // 来源 hook 的交互式授信永远无人应答,不带它插件装的 hook 会被静默跳过、零报错
255
+ // (见 memory/codex-hook-trust-headless-silent-skip.md)。沙箱内 hook 来源全部由实验配置
256
+ // 声明,与 approvals/sandbox bypass 同一信任层级。
257
+ const flags =
258
+ "--json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check --dangerously-bypass-hook-trust";
227
259
  const prompt = shared.shellQuote(input.text);
228
260
  const resuming = ctx.session.id;
229
261
  const cmd = resuming
@@ -324,8 +356,12 @@ export async function installPlugins(
324
356
  const { marketplace } = plugin;
325
357
  if (!connected.has(marketplace.name)) {
326
358
  const refFlag = marketplace.ref ? ` --ref ${shared.shellQuote(marketplace.ref)}` : "";
359
+ // --sparse 只影响拉取速度,不影响装出来的内容;manifest 不记录它。
360
+ const sparseFlags = (marketplace.sparse ?? [])
361
+ .map((path) => ` --sparse ${shared.shellQuote(path)}`)
362
+ .join("");
327
363
  const add = await sb.runShell(
328
- `codex plugin marketplace add ${shared.shellQuote(marketplace.source)}${refFlag}`,
364
+ `codex plugin marketplace add ${shared.shellQuote(marketplace.source)}${refFlag}${sparseFlags}`,
329
365
  );
330
366
  if (add.exitCode !== 0) {
331
367
  throw new Error(
@@ -0,0 +1,31 @@
1
+ // MCP server 的形态判别与 manifest 映射 —— Claude Code / Codex 两个 adapter 共用。
2
+ // 契约见 docs/feature/adapters/architecture/coding-agent-extensions.md「类型边界」:
3
+ // stdio(command)与 Streamable HTTP(url)按形状判别;双字段是配置错误,setup 点名报错。
4
+
5
+ import { t } from "../i18n/index.ts";
6
+ import type { AgentSetupManifest, McpHttpServer, McpServer } from "./types.ts";
7
+
8
+ /** 形态判别:有 url 的是 HTTP。调用前先过 {@link assertMcpServers}(双字段在那里报错)。 */
9
+ export function isHttpMcp(server: McpServer): server is McpHttpServer {
10
+ return "url" in server && typeof server.url === "string";
11
+ }
12
+
13
+ /** 双字段(command + url)的配置错误在写任何沙箱配置前抛出,点名 server。 */
14
+ export function assertMcpServers(servers: readonly McpServer[]): void {
15
+ for (const server of servers) {
16
+ if ("command" in server && "url" in server) {
17
+ throw new Error(t("mcp.ambiguousTransport", { name: server.name }));
18
+ }
19
+ }
20
+ }
21
+
22
+ /** manifest 条目:只记非 secret 字段(stdio 不含 env,HTTP 不含 headers)。 */
23
+ export function mcpManifestEntries(
24
+ servers: readonly McpServer[],
25
+ ): NonNullable<AgentSetupManifest["mcpServers"]> {
26
+ return servers.map((s) =>
27
+ isHttpMcp(s)
28
+ ? { name: s.name, url: s.url }
29
+ : { name: s.name, command: s.command, ...(s.args?.length ? { args: [...s.args] } : {}) },
30
+ );
31
+ }
@@ -0,0 +1,33 @@
1
+ // factory 的 postSetup 钩子执行器 —— Claude Code / Codex / Bub 共用。
2
+ // 契约见 docs/feature/adapters/library/coding-agent-extensions.md「安装后运行脚本」:
3
+ // 在 adapter 全部安装步骤(含 manifest)之后按数组顺序执行;复用 SandboxHook 的窄上下文;
4
+ // 返回的 cleanup 合成一个 LIFO 闭包,由 runner 与 agent teardown 一起收尾;钩子抛错
5
+ // 直接传播(attempt errored)。
6
+
7
+ import type { Cleanup } from "../shared/types.ts";
8
+ import type { Sandbox, SandboxHook, SandboxHookContext } from "../sandbox/types.ts";
9
+ import type { AgentContext } from "./types.ts";
10
+
11
+ export async function runPostSetupHooks(
12
+ sb: Sandbox,
13
+ ctx: AgentContext,
14
+ hooks: readonly SandboxHook[] | undefined,
15
+ ): Promise<Cleanup | void> {
16
+ if (!hooks?.length) return;
17
+ // 窄上下文与沙箱钩子同款:不把 session / model / telemetry 借给过程钩子。
18
+ const hookCtx: SandboxHookContext = {
19
+ experimentId: ctx.experimentId,
20
+ signal: ctx.signal,
21
+ progress: (update) => ctx.progress(update),
22
+ diagnostic: (input) => ctx.diagnostic(input),
23
+ };
24
+ const cleanups: Cleanup[] = [];
25
+ for (const hook of hooks) {
26
+ const cleanup = await hook(sb, hookCtx);
27
+ if (typeof cleanup === "function") cleanups.push(cleanup);
28
+ }
29
+ if (!cleanups.length) return;
30
+ return async () => {
31
+ for (const cleanup of [...cleanups].reverse()) await cleanup();
32
+ };
33
+ }
@@ -7,21 +7,42 @@ import type { StreamEvent, TraceSpan, Usage } from "../o11y/types.ts";
7
7
  import type { Sandbox } from "../sandbox/types.ts";
8
8
 
9
9
  /**
10
- * MCP server 描述符 —— 支持 MCP adapter(Claude Code / Codex)共用的工具服务单元,
11
- * 不是 native plugin 的一种。在 agent factory config 里声明,setup 阶段写进各自的配置文件。
12
- * 见 docs/feature/adapters/architecture/coding-agent-extensions.md「类型边界」。
10
+ * 本地 stdio 形态的 MCP server:沙箱内起子进程,按 stdio MCP 协议。
11
+ * {@link McpHttpServer} 按形状判别(有 `command` 的是 stdio,有 `url` 的是 HTTP)。
13
12
  */
14
- export interface McpServer {
13
+ export interface McpStdioServer {
15
14
  /** 服务器唯一名(config key)。 */
16
15
  name: string;
17
16
  /** 启动命令(如 "npx"、"node"、"uvx")。 */
18
17
  command: string;
19
18
  /** 传给命令的参数。 */
20
19
  args?: string[];
21
- /** 注入服务器进程的环境变量。 */
20
+ /** 注入服务器进程的环境变量(可能含 secret,不进 manifest)。 */
22
21
  env?: Record<string, string>;
23
22
  }
24
23
 
24
+ /**
25
+ * 远程 Streamable HTTP 形态的 MCP server:沙箱直接连一个 HTTP 端点。
26
+ * `url` 必须沙箱内可达——宿主机上的服务先经隧道(cloudflared / tailscale 等)暴露。
27
+ */
28
+ export interface McpHttpServer {
29
+ /** 服务器唯一名(config key)。 */
30
+ name: string;
31
+ /** Streamable HTTP 端点(如 https://mem.example.com/mcp/)。 */
32
+ url: string;
33
+ /** 逐字写进每个请求的 HTTP 头(常用于 Authorization;可能含 secret,不进 manifest)。 */
34
+ headers?: Record<string, string>;
35
+ }
36
+
37
+ /**
38
+ * MCP server 描述符 —— 支持 MCP 的 adapter(Claude Code / Codex)共用的工具服务单元,
39
+ * 不是 native plugin 的一种。stdio 与 Streamable HTTP 两种形态按形状判别,不设 kind 标签
40
+ * (两种形态各有唯一必填判别字段);同时给出 `command` 与 `url` 属配置错误,setup 报错点名。
41
+ * 在 agent factory config 里声明,setup 阶段写进各自的配置文件。
42
+ * 见 docs/feature/adapters/architecture/coding-agent-extensions.md「类型边界」。
43
+ */
44
+ export type McpServer = McpStdioServer | McpHttpServer;
45
+
25
46
  /**
26
47
  * Skill 的来源描述 —— Claude Code / Codex / Bub 共用的**数据类型**:只统一「从哪里取得
27
48
  * 哪份 Skill」,安装位置、发现机制、要不要额外写 project instruction 由各 Adapter 决定。
@@ -67,8 +88,8 @@ export interface AgentSetupManifest {
67
88
  /** 安装后 CLI 报告的版本;取不到时省略。 */
68
89
  resolvedVersion?: string;
69
90
  }>;
70
- /** 挂上的 MCP server(不含 env:secret 不进 manifest)。 */
71
- mcpServers?: Array<{ name: string; command: string; args?: string[] }>;
91
+ /** 挂上的 MCP server(只记非 secret 字段:stdio 不含 env,HTTP 不含 headers)。 */
92
+ mcpServers?: Array<{ name: string; command: string; args?: string[] } | { name: string; url: string }>;
72
93
  /**
73
94
  * 官方原生配置文件(Claude Code `settings.json` / Codex `config.toml`):只记 Agent 名、
74
95
  * 项目相对来源路径与原始字节的 SHA-256,不落正文 —— 任意官方配置都可能携带敏感字符串,
package/src/cli.ts CHANGED
@@ -17,7 +17,7 @@ import { planCarry } from "./runner/fingerprint.ts";
17
17
  import { failureDetailFromResult } from "./runner/feedback/failure.ts";
18
18
  import { stopAllSandboxes, liveSandboxCount } from "./sandbox/registry.ts";
19
19
  import { evalLevelStats } from "./shared/verdict.ts";
20
- import { sandboxRecommendedConcurrency } from "./sandbox/resolve.ts";
20
+ import { prepareRunSandboxes, resolvedSandboxRecommendedConcurrency } from "./runner/sandbox-selection.ts";
21
21
  import { Json, JUnit } from "./runner/reporters/json.ts";
22
22
  import { Artifacts as ArtifactsReporter } from "./runner/reporters/artifacts.ts";
23
23
  import {
@@ -170,15 +170,15 @@ const FLAG_OPTIONS = {
170
170
  diff: { type: "boolean" },
171
171
  /** `show` 命令专用:执行时间轴——对匹配的每个 experiment × eval 分节,逐 attempt 列时间 / verdict / 摘要 / 耗时 / 成本 / locator;与 `--report` 互斥。 */
172
172
  history: { type: "boolean" },
173
- /** `show` / `view` 命令专用:按路径段前缀收窄 experiment;组名会选中组内全部配置。 */
174
- experiment: { type: "string" },
173
+ /** `show` / `view` 命令专用:按路径段前缀收窄 experiment(与 `niceeval exp` 位置参数同一套匹配);组名会选中组内全部配置。`view --out` 时同一收窄决定出站内容。 */
174
+ exp: { type: "string" },
175
175
  /** `show` / `view` / `sandbox enter|list|stop` 共用:结果根目录(`.niceeval` 之外的另一个根,如 `copySnapshots` 产出的发布根)。 */
176
176
  results: { type: "string" },
177
177
  /** `view` 命令专用:只打开这一份快照文件(`snapshot.json`);文件不可读时命令失败(扫描模式只跳过)。 */
178
178
  snapshot: { type: "string" },
179
179
  /** `show` / `view` 命令专用:用文件默认导出的 `defineReport(...)` 替换两者共用的默认报告。 */
180
180
  report: { type: "string" },
181
- /** `show` / `view` 命令专用:选择多页报告的页(页 id 见 `show --report` 的页索引);`view` 里定初始页。 */
181
+ /** `show` / `view` 命令专用:选择报告的初始页;`show` 渲染该页并在尾部附其余页索引,`view` 以它作初始路由。未命中的页 id 按用法错误退出并列出可用页 id。 */
182
182
  page: { type: "string" },
183
183
  /** 只打印本次会匹配到的 eval × 运行配置,不实际执行(按下面 `--output` 选中的 profile 给出预览)。 */
184
184
  dry: { type: "boolean" },
@@ -308,7 +308,7 @@ function parseArgs(argv: string[]): { command: string; positionals: string[]; fl
308
308
  sandboxPath: values.path as string | undefined,
309
309
  leaveRunning: values["leave-running"] === true,
310
310
  history: values.history === true,
311
- experiment: values.experiment as string | undefined,
311
+ experiment: values.exp as string | undefined,
312
312
  results: values.results as string | undefined,
313
313
  snapshot: values.snapshot as string | undefined,
314
314
  report: values.report as string | undefined,
@@ -320,7 +320,7 @@ function parseArgs(argv: string[]): { command: string; positionals: string[]; fl
320
320
  /**
321
321
  * exp 只接受两类输入:位置参数选「跑哪些 eval」+ 调度/输出/机器出口 flag 选「对着哪个 agent、
322
322
  * 怎么跑」。show / view 专属的证据切面(`--source`/`--execution`/`--diff`)、时间轴(`--history`)、
323
- * Scope 收窄(`--experiment`/`--results`)、报告装载(`--report`/`--page`)、查看器
323
+ * Scope 收窄(`--exp`/`--results`)、报告装载(`--report`/`--page`)、查看器
324
324
  * (`--snapshot`/`--out`/`--port`/`--open`)不能被 exp 静默忽略(见 docs/feature/experiments/
325
325
  * cli.md「用法错误」)。返回第一个被误用的 flag 及其归属命令(用于报错),没有误用返回 undefined。
326
326
  */
@@ -333,7 +333,7 @@ function firstViewerOnlyFlag(flags: Flags): { flag: string; command: string } |
333
333
  if (flags.timing !== undefined) return { flag: "--timing", command: SHOW };
334
334
  if (flags.diff || flags.diffPath !== undefined) return { flag: "--diff", command: SHOW };
335
335
  if (flags.history) return { flag: "--history", command: SHOW };
336
- if (flags.experiment !== undefined) return { flag: "--experiment", command: BOTH };
336
+ if (flags.experiment !== undefined) return { flag: "--exp", command: BOTH };
337
337
  if (flags.results !== undefined) return { flag: "--results", command: BOTH };
338
338
  if (flags.report !== undefined) return { flag: "--report", command: BOTH };
339
339
  if (flags.page !== undefined) return { flag: "--page", command: BOTH };
@@ -468,7 +468,7 @@ function evalsFilterFromExperiment(
468
468
  ): (id: string) => boolean {
469
469
  const patternFilter = makeFilter(patterns);
470
470
  let expFilter: (id: string) => boolean = () => true;
471
- if (Array.isArray(evals)) expFilter = (id) => evals.includes(id) || evals.some((e) => id.startsWith(e + "/"));
471
+ if (Array.isArray(evals)) expFilter = makeFilter(evals);
472
472
  else if (typeof evals === "function") expFilter = evals;
473
473
  return (id) => expFilter(id) && patternFilter(id);
474
474
  }
@@ -566,11 +566,10 @@ async function main(): Promise<void> {
566
566
  }
567
567
 
568
568
  if (command === "view") {
569
- // 位置参数只有一种含义:eval id 前缀(收窄报告槽 Scope)。结果根经 --results 递入,
569
+ // 位置参数只有一种含义:eval id 前缀(收窄有效根)。结果根经 --results 递入,
570
570
  // 单开一份快照经 --snapshot 递入;--report 整槽替换报告槽(与 show --report 吃同一个文件),
571
571
  // --page 定初始页。文件与目录都不进位置参数(docs/feature/reports/view.md「打开与收窄」)。
572
- // --out 与位置参数 / --experiment 的互斥在 buildView 内校验(单点,报错文案含 copySnapshots
573
- // + filter 的下一步),经 exitOnViewUserError 按用法错误退出。
572
+ // --out 接受同一收窄:出站内容即收窄后的有效根(docs/feature/reports/view.md「静态导出」)。
574
573
  let viewInput: { input?: string; patterns: string[] };
575
574
  try {
576
575
  viewInput = resolveViewInput(cwd, positionals, {
@@ -771,6 +770,9 @@ async function main(): Promise<void> {
771
770
  process.exit(1);
772
771
  }
773
772
 
773
+ // environments 查表在 dry-run 与真实运行共用的规划边界完成;缺表项在任何沙箱/agent 花费前穷举失败。
774
+ prepareRunSandboxes(evals, agentRuns, config.sandbox);
775
+
774
776
  if (flags.dry) {
775
777
  // --dry 只按所选 profile 打印计划,不运行、不落盘 —— 三种 profile 各自的展示逻辑
776
778
  // 都在 runner/feedback/{human,agent,ci}.ts 里,这里只负责拼数据、选函数、写流、退出。
@@ -826,7 +828,7 @@ async function main(): Promise<void> {
826
828
  // envelope 展示的"携入"就会和 run.ts 真实调度的"携入"对不上(见 memory 的
827
829
  // live-carry-row-shows-waiting-forever)。
828
830
  const priorResults = flags.force ? undefined : await loadLatestResultsPerEval(join(cwd, ".niceeval"));
829
- const carryPlan = priorResults?.length ? await planCarry(evals, agentRuns, priorResults) : undefined;
831
+ const carryPlan = priorResults?.length ? await planCarry(evals, agentRuns, priorResults, config.sandbox) : undefined;
830
832
  const reusedFailures = (carryPlan?.carriedResults ?? [])
831
833
  .map(failureDetailFromResult)
832
834
  .filter((failure) => failure !== undefined);
@@ -834,8 +836,7 @@ async function main(): Promise<void> {
834
836
  // 无全局默认:并发上限由 sandbox provider 的推荐值决定(多个 agentRun 各有 sandbox 时取
835
837
  // 最小值,最保守的 provider 决定上限)。同一个值既进 RunFeedbackPlan.shape,也传给 runEvals——
836
838
  // 两处必须是同一个数字,dashboard 展示的并发上限不能和真实调度的并发上限对不上。
837
- const sandboxRecs = agentRuns.map((r) => sandboxRecommendedConcurrency(r.sandbox));
838
- const sandboxDefaultConcurrency = sandboxRecs.length > 0 ? Math.min(...sandboxRecs) : 10;
839
+ const sandboxDefaultConcurrency = resolvedSandboxRecommendedConcurrency(evals, agentRuns, config.sandbox);
839
840
  const maxConcurrency =
840
841
  flags.maxConcurrency ??
841
842
  envNumber("NICEEVAL_MAX_CONCURRENCY") ??
package/src/define.ts CHANGED
@@ -55,6 +55,9 @@ export function defineEval(def: EvalDef): EvalDef {
55
55
  if (typeof def.test !== "function") {
56
56
  throw new Error(t("define.evalTestRequired"));
57
57
  }
58
+ if (def.environment !== undefined && def.environment.trim().length === 0) {
59
+ throw new Error(t("define.evalEnvironmentEmpty"));
60
+ }
58
61
  return def;
59
62
  }
60
63
 
package/src/i18n/en.ts CHANGED
@@ -24,6 +24,8 @@ export const en = {
24
24
  "skill.repoAmbiguous": "Repo skill {{source}} contains multiple skills; select which ones to enable with `skills: [...]`. Available: {{available}}.",
25
25
  "skill.repoUnknownSkill": "Repo skill {{source}} (ref: {{ref}}) has no skill named \"{{skill}}\". Available: {{available}}.",
26
26
  "skill.copyFailed": "Could not install skill \"{{name}}\" into {{dest}}:\n{{tail}}",
27
+ "mcp.ambiguousTransport":
28
+ "MCP server \"{{name}}\" specifies both \"command\" and \"url\" — pick one transport: \"command\" for a local stdio server, \"url\" for a remote Streamable HTTP endpoint.",
27
29
  "plugin.marketplaceFailed": "Could not connect {{agent}} marketplace \"{{name}}\" (source: {{source}}, ref: {{ref}}):\n{{tail}}",
28
30
  "plugin.marketplaceVerifyFailed": "Could not read back the registered marketplace list after adding {{agent}} marketplace \"{{name}}\" ({{command}}):\n{{tail}}",
29
31
  "plugin.marketplaceNameMismatch":
@@ -82,15 +84,15 @@ export const en = {
82
84
  " --timing unified timing tree for the attempt (phases + hooks/commands/turns + per-turn OTel)\n" +
83
85
  " --diff[=file] sandbox workspace file-change summary; =file expands one file\n" +
84
86
  " --history per experiment × eval execution timeline (mutually exclusive with --report)\n" +
85
- " --results <dir> pin a results root --experiment <id> one experiment\n" +
86
- " --report <file> custom report --page <id> pick a page (multi-page\n" +
87
- " reports print a page index with copyable commands)\n" +
87
+ " --results <dir> pin a results root --exp <id> one experiment\n" +
88
+ " --report <file> custom report --page <id> pick the initial page (multi-page\n" +
89
+ " reports render it, then list the rest as a page index with copyable commands)\n" +
88
90
  " niceeval list list discovered evals\n" +
89
91
  " niceeval view [eval-id-prefix…] [--out dir] [--port n] [--no-open]\n" +
90
92
  " report pages + evidence rooms; --report <file> swaps in your report\n" +
91
93
  " (same file as show); --page <id> picks the initial page;\n" +
92
94
  " --results <dir> pins a results root; --snapshot <file> opens exactly\n" +
93
- " one snapshot; --experiment <id> one experiment\n" +
95
+ " one snapshot; --exp <id> one experiment\n" +
94
96
  " --out <dir> exports a static site: index.html plus the viewer\n" +
95
97
  " artifacts, ready for any static host\n" +
96
98
  " niceeval sandbox list|enter|history|diff|stop inspect & destroy sandboxes kept by --keep-sandbox\n" +
@@ -107,7 +109,7 @@ export const en = {
107
109
  "cli.show.noResults": "No results found under {{root}}. Run `niceeval exp` first, then `niceeval show`.\n",
108
110
  "cli.show.runDirMissing": "Results directory not found: {{dir}}\n",
109
111
  "cli.show.noEvalMatch": "No results matched: {{patterns}}. Evals with results: {{evals}}\n",
110
- "cli.show.noExperimentMatch": "No experiment matched --experiment {{arg}}. Experiments with results: {{experiments}}\n",
112
+ "cli.show.noExperimentMatch": "No experiment matched --exp {{arg}}. Experiments with results: {{experiments}}\n",
111
113
  "cli.show.historyReportConflict":
112
114
  "`--history` and `--report` are mutually exclusive: both take over the main output. --history is the host's per-attempt execution timeline; for snapshot-level trends, compose exp.snapshots inside your report file instead.\n",
113
115
  "cli.show.evidenceNeedsEval":
@@ -151,6 +153,7 @@ export const en = {
151
153
  "context.turnFailedDefault": "This send returned failed (turn status = failed)",
152
154
  "define.agentNameRequired": "defineAgent requires name.",
153
155
  "define.evalIdRejected": "defineEval does not accept id; ids are derived from file paths.",
156
+ "define.evalEnvironmentEmpty": "defineEval environment must be a non-empty profile id when provided.",
154
157
  "define.evalTestRequired": "defineEval requires an async test(t) function.",
155
158
  "define.experimentAgentRequired": "defineExperiment requires agent.",
156
159
  "define.experimentFlagNotJson": "experiment.flags.{{key}} is not JSON-serializable (functions / undefined / cycles / bigint are not allowed); flags are persisted verbatim into result snapshots and must be plain JSON.",