dsh-eval-guard 0.7.1 → 0.7.3

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 (73) hide show
  1. package/ISSUES.md +235 -0
  2. package/README.md +46 -18
  3. package/assets/neural-cifar/workspace.zip +0 -0
  4. package/assets/neural-cpp/workspace.zip +0 -0
  5. package/assets/neural-fp32/workspace.zip +0 -0
  6. package/assets/neural-pendulum/SPEC.md +41 -38
  7. package/assets/neural-pendulum/TASK.md +146 -32
  8. package/assets/neural-pendulum/sim.cpp +4 -2
  9. package/assets/neural-pendulum/sim_eval +0 -0
  10. package/assets/neural-pendulum/workspace.zip +0 -0
  11. package/eval/README.md +19 -9
  12. package/eval/__pycache__/bomberman.cpython-310.pyc +0 -0
  13. package/eval/__pycache__/commit0.cpython-310.pyc +0 -0
  14. package/eval/__pycache__/common.cpython-310.pyc +0 -0
  15. package/eval/__pycache__/ctf.cpython-310.pyc +0 -0
  16. package/eval/__pycache__/featurebench.cpython-310.pyc +0 -0
  17. package/eval/__pycache__/land.cpython-310.pyc +0 -0
  18. package/eval/__pycache__/neural-cifar.cpython-310.pyc +0 -0
  19. package/eval/__pycache__/neural-cpp.cpython-310.pyc +0 -0
  20. package/eval/__pycache__/neural-fp32.cpython-310.pyc +0 -0
  21. package/eval/__pycache__/neural-pendulum.cpython-310.pyc +0 -0
  22. package/eval/__pycache__/swe-bench-pro.cpython-310.pyc +0 -0
  23. package/eval/__pycache__/tank.cpython-310.pyc +0 -0
  24. package/eval/__pycache__/warehouse.cpython-310.pyc +0 -0
  25. package/eval/bomberman.py +5 -3
  26. package/eval/commit0.py +10 -3
  27. package/eval/common.py +169 -5
  28. package/eval/ctf.py +4 -2
  29. package/eval/featurebench.py +12 -8
  30. package/eval/land.py +4 -2
  31. package/eval/legacy/__pycache__/bomberman.cpython-310.pyc +0 -0
  32. package/eval/legacy/__pycache__/commit0.cpython-310.pyc +0 -0
  33. package/eval/legacy/__pycache__/ctf.cpython-310.pyc +0 -0
  34. package/eval/legacy/__pycache__/featurebench.cpython-310.pyc +0 -0
  35. package/eval/legacy/__pycache__/land.cpython-310.pyc +0 -0
  36. package/eval/legacy/__pycache__/swe_bench_pro.cpython-310.pyc +0 -0
  37. package/eval/legacy/__pycache__/tank.cpython-310.pyc +0 -0
  38. package/eval/legacy/__pycache__/warehouse.cpython-310.pyc +0 -0
  39. package/eval/legacy/bomberman.py +5 -2
  40. package/eval/legacy/ctf.py +5 -2
  41. package/eval/legacy/featurebench.py +55 -15
  42. package/eval/legacy/land.py +6 -3
  43. package/eval/legacy/swe_bench_pro.py +57 -16
  44. package/eval/legacy/tank.py +2 -2
  45. package/eval/legacy/warehouse.py +4 -4
  46. package/eval/neural-cpp.py +9 -6
  47. package/eval/neural-fp32.py +17 -5
  48. package/eval/swe-bench-pro.py +11 -7
  49. package/eval/tank.py +11 -2
  50. package/eval/warehouse.py +17 -1
  51. package/lib/client/index.js +73 -46
  52. package/lib/client/index.js.map +1 -1
  53. package/lib/index.js +894 -301
  54. package/native/dsh-eval-supervisor +0 -0
  55. package/native/supervisor.c +190 -26
  56. package/neural-cifar-eval/README.md +5 -0
  57. package/neural-cifar-eval/__pycache__/cifar_cache.cpython-310.pyc +0 -0
  58. package/neural-cifar-eval/__pycache__/common.cpython-310.pyc +0 -0
  59. package/neural-cifar-eval/__pycache__/fetch_cifar.cpython-310.pyc +0 -0
  60. package/neural-cifar-eval/__pycache__/ops_eval.cpython-310.pyc +0 -0
  61. package/neural-cifar-eval/__pycache__/run_eval.cpython-310.pyc +0 -0
  62. package/neural-cifar-eval/common.py +13 -0
  63. package/neural-cifar-eval/ops_eval.py +17 -5
  64. package/neural-cifar-eval/run_eval.py +42 -32
  65. package/neural-pendulum-eval/README.md +62 -0
  66. package/neural-pendulum-eval/__pycache__/ops_eval.cpython-310.pyc +0 -0
  67. package/neural-pendulum-eval/__pycache__/run_eval.cpython-310.pyc +0 -0
  68. package/neural-pendulum-eval/ops_eval.py +603 -0
  69. package/neural-pendulum-eval/run_eval.py +218 -137
  70. package/neural-pendulum-eval/sim_eval +0 -0
  71. package/package.json +7 -11
  72. package/assets/neural-pendulum/hidden/test_infra.cpp +0 -631
  73. package/assets/neural-pendulum/include/nn/nn.hpp +0 -88
package/lib/index.js CHANGED
@@ -2,9 +2,9 @@ import { createRequire } from "node:module";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
5
6
  import os from "node:os";
6
7
  import { fileURLToPath } from "node:url";
7
- import { execFileSync, spawn } from "node:child_process";
8
8
  import { createHash } from "node:crypto";
9
9
  //#region src/config.ts
10
10
  /**
@@ -145,7 +145,16 @@ const PIP_MIRRORS = [
145
145
  * profile that lacks a plugin simply never mentions that plugin's tools.
146
146
  * @module dsh-eval-guard/policy
147
147
  */
148
- /** DSH-first default section. Only names that exist in the registry are surfaced. */
148
+ /**
149
+ * DSH-first default section. Only names that exist in the registry are surfaced
150
+ * in prompts; the enforcement set keeps every name so a tool installed later
151
+ * cannot slip through with an empty user document.
152
+ *
153
+ * The last block covers tools that can otherwise escape a one-shot evaluation:
154
+ * goal rounds and schedules keep working after the wall-clock deadline, and the
155
+ * drawio/canvas tools write host files directly (bypassing the fs sandbox) and
156
+ * can cross workspace boundaries.
157
+ */
149
158
  const BASE_BLACKLIST = [
150
159
  "web_search",
151
160
  "web_fetch",
@@ -181,7 +190,20 @@ const BASE_BLACKLIST = [
181
190
  "cordis_inspect_list",
182
191
  "cordis_inspect_query",
183
192
  "exit_plan_mode",
184
- "ask_user_question"
193
+ "ask_user_question",
194
+ "create_goal",
195
+ "get_goal",
196
+ "update_goal",
197
+ "schedule_create",
198
+ "schedule_list",
199
+ "schedule_update",
200
+ "schedule_delete",
201
+ "drawio_edit",
202
+ "drawio_render",
203
+ "drawio_template",
204
+ "drawio_validate",
205
+ "canvas_state",
206
+ "view_canvas"
185
207
  ];
186
208
  /** Third-party tools that reach an external service, subagent, or agent channel. */
187
209
  const PLUGIN_BLACKLIST_PATTERNS = [
@@ -197,7 +219,12 @@ const PLUGIN_BLACKLIST_PATTERNS = [
197
219
  /^computer_/,
198
220
  /^ssh_/,
199
221
  /^e2b_/,
200
- /^mcp_/
222
+ /^mcp_/,
223
+ /^drawio_/,
224
+ /^schedule_/,
225
+ /^(?:create|get|update)_goal$/,
226
+ /^canvas_/,
227
+ /^view_canvas$/
201
228
  ];
202
229
  /** Parse one blacklist document into ordered tool names (comments removed). */
203
230
  function parseBlacklist(text) {
@@ -229,11 +256,13 @@ function generateDefaultBlacklist(installed) {
229
256
  lines.push(...plugin);
230
257
  return lines.join("\n");
231
258
  }
232
- /** Whether a name belongs to the always-present DSH base section. */
233
- function isBaseBlacklistName(name) {
234
- return BASE_BLACKLIST.includes(name);
235
- }
236
- /** Parse the forbidden-paths document. Invalid lines are ignored. */
259
+ /**
260
+ * Parse the forbidden-paths document. Invalid lines are ignored.
261
+ *
262
+ * A line containing a glob character is treated as the prefix before the first
263
+ * wildcard, so a line like /home/<anything>/secret means "everything under
264
+ * /home"; this is deliberately conservative (never narrower than written).
265
+ */
237
266
  function parsePathRules(text) {
238
267
  const rules = [];
239
268
  for (const raw of text.split(/\r?\n/)) {
@@ -245,8 +274,10 @@ function parsePathRules(text) {
245
274
  line = line.slice(1).trim();
246
275
  }
247
276
  if (!line.startsWith("/")) continue;
277
+ const wildcard = line.search(/[*?]/);
278
+ if (wildcard >= 0) line = line.slice(0, wildcard);
248
279
  if (line.length > 1 && line.endsWith("/")) line = line.slice(0, -1);
249
- if (line.includes("*") || line.includes("?")) continue;
280
+ if (line === "") line = "/";
250
281
  rules.push({
251
282
  allow,
252
283
  path: path.normalize(line)
@@ -309,12 +340,30 @@ function applyRules(real, rules, base) {
309
340
  }
310
341
  /** Decide one absolute path for read or write access. */
311
342
  function checkPath(real, workspace, write, rules) {
312
- if (under$1(real, workspace)) return true;
313
- if (isNoTraceDevice(real) && !write) return true;
314
- if (write) return isNoTraceDevice(real);
315
- return applyRules(real, rules, baseReadAllowed(real, workspace));
343
+ const inWorkspace = under$1(real, workspace);
344
+ if (!write) {
345
+ if (inWorkspace) return true;
346
+ if (isNoTraceDevice(real)) return true;
347
+ return applyRules(real, rules, baseReadAllowed(real, workspace));
348
+ }
349
+ const base = inWorkspace || isNoTraceDevice(real);
350
+ if (!base) return false;
351
+ return applyRules(real, rules, base) && base;
316
352
  }
317
- /** Path-bearing argument keys audited for DSH-built-in and dsh-ops tools. */
353
+ /** Keys whose value is task content, not another path to audit. */
354
+ const CONTENT_KEYS = /* @__PURE__ */ new Set([
355
+ "content",
356
+ "old_string",
357
+ "new_string",
358
+ "text",
359
+ "code",
360
+ "prompt",
361
+ "description",
362
+ "justification",
363
+ "new_source",
364
+ "file_text"
365
+ ]);
366
+ /** Path-bearing argument keys audited for DSH-built-in and installed tools. */
318
367
  const PATH_KEYS = /* @__PURE__ */ new Set([
319
368
  "path",
320
369
  "file",
@@ -330,7 +379,16 @@ const PATH_KEYS = /* @__PURE__ */ new Set([
330
379
  "target",
331
380
  "input",
332
381
  "output",
333
- "script_path"
382
+ "script_path",
383
+ "source_path",
384
+ "source_paths",
385
+ "file_paths",
386
+ "paths",
387
+ "input_path",
388
+ "output_path",
389
+ "image",
390
+ "images",
391
+ "out"
334
392
  ]);
335
393
  /** Tools whose path arguments are read-only accesses. */
336
394
  const READ_TOOLS = /* @__PURE__ */ new Set([
@@ -342,14 +400,18 @@ const READ_TOOLS = /* @__PURE__ */ new Set([
342
400
  "load_workspace_dependencies",
343
401
  "ops_inspect_local_file",
344
402
  "ops_grep",
345
- "ops_glob"
403
+ "ops_glob",
404
+ "drawio_validate"
346
405
  ]);
347
406
  /** Tools whose path arguments are mutations. */
348
407
  const WRITE_TOOLS = /* @__PURE__ */ new Set([
349
408
  "write",
350
409
  "edit",
351
410
  "str_replace_editor",
352
- "ops_replace"
411
+ "ops_replace",
412
+ "drawio_edit",
413
+ "drawio_render",
414
+ "drawio_template"
353
415
  ]);
354
416
  /** Tools we deliberately do not inspect: the process sandbox owns their effects. */
355
417
  const PROCESS_TOOLS = /* @__PURE__ */ new Set([
@@ -365,18 +427,24 @@ const PROCESS_TOOLS = /* @__PURE__ */ new Set([
365
427
  "ops_job_list",
366
428
  "ops_job_kill"
367
429
  ]);
430
+ /** Whether one tool name takes file paths this module knows how to audit. */
431
+ function isAuditedTool(name) {
432
+ return READ_TOOLS.has(name) || WRITE_TOOLS.has(name);
433
+ }
368
434
  /** Collect every path-looking string under a known path key. */
369
435
  function collectPathArgs(value, out = []) {
370
436
  if (Array.isArray(value)) {
371
437
  for (const item of value) collectPathArgs(item, out);
372
438
  return out;
373
439
  }
374
- if (value !== null && typeof value === "object") for (const [key, child] of Object.entries(value)) if (PATH_KEYS.has(key)) {
375
- if (typeof child === "string") out.push(child);
376
- else if (Array.isArray(child)) for (const item of child) if (typeof item === "string") out.push(item);
377
- else collectPathArgs(item, out);
378
- else collectPathArgs(child, out);
379
- } else collectPathArgs(child, out);
440
+ if (value !== null && typeof value === "object") {
441
+ for (const [key, child] of Object.entries(value)) if (PATH_KEYS.has(key)) {
442
+ if (typeof child === "string") out.push(child);
443
+ else if (Array.isArray(child)) for (const item of child) if (typeof item === "string") out.push(item);
444
+ else collectPathArgs(item, out);
445
+ else collectPathArgs(child, out);
446
+ } else if (!CONTENT_KEYS.has(key)) collectPathArgs(child, out);
447
+ }
380
448
  return out;
381
449
  }
382
450
  /** Audit the path arguments of one DSH/ops tool call. Non-targeted tools pass. */
@@ -386,12 +454,12 @@ function auditToolCall(name, args, workspace, rules) {
386
454
  tool: name,
387
455
  write: false
388
456
  };
389
- if (!READ_TOOLS.has(name) && !WRITE_TOOLS.has(name)) return {
457
+ if (!isAuditedTool(name)) return {
390
458
  allow: true,
391
459
  tool: name,
392
460
  write: false
393
461
  };
394
- const write = WRITE_TOOLS.has(name);
462
+ const write = name === "str_replace_editor" && (args !== null && typeof args === "object" && !Array.isArray(args) ? args : void 0)?.["command"] === "view" ? false : WRITE_TOOLS.has(name);
395
463
  const paths = collectPathArgs(args);
396
464
  for (const target of paths) {
397
465
  const real = resolveCheckPath(target, workspace);
@@ -400,7 +468,7 @@ function auditToolCall(name, args, workspace, rules) {
400
468
  tool: name,
401
469
  path: real,
402
470
  write,
403
- reason: write ? "只允许修改当前工作区;该写入已被阻止。" : "只允许读取当前工作区与系统路径;该读取已被阻止。"
471
+ reason: write ? "只允许修改当前工作区;该写入已被阻止。" : "只允许读取当前工作区与系统只读文件;该读取已被阻止。"
404
472
  };
405
473
  }
406
474
  return {
@@ -427,6 +495,35 @@ function packageRoot() {
427
495
  function supervisorPath() {
428
496
  return path.join(packageRoot(), "native", "dsh-eval-supervisor");
429
497
  }
498
+ /**
499
+ * Probe the two host capabilities the guard depends on: a usable bwrap binary
500
+ * and unprivileged user namespaces. Returns a human-readable failure, or
501
+ * undefined when confinement is expected to work.
502
+ */
503
+ function probeConfinement() {
504
+ const version = spawnSync("bwrap", ["--version"], {
505
+ timeout: 2e3,
506
+ encoding: "utf8"
507
+ });
508
+ if (version.error !== void 0 || version.status !== 0) return "bubblewrap (bwrap) is unavailable; refusing to enable without process confinement";
509
+ const probe = spawnSync("bwrap", [
510
+ "--unshare-all",
511
+ "--die-with-parent",
512
+ "--ro-bind",
513
+ "/",
514
+ "/",
515
+ "--dev",
516
+ "/dev",
517
+ "--proc",
518
+ "/proc",
519
+ "--",
520
+ "/bin/true"
521
+ ], {
522
+ timeout: 5e3,
523
+ encoding: "utf8"
524
+ });
525
+ if (probe.error !== void 0 || probe.status !== 0) return "bubblewrap cannot create an unprivileged user namespace on this host; refusing to enable without process confinement";
526
+ }
430
527
  /** Launcher-provided facts; nothing here is a fixed install path. */
431
528
  let runtimeHome;
432
529
  let runtimeProfileDir;
@@ -481,6 +578,24 @@ const SYSTEM_READ_FILES = [
481
578
  "/run/utmpx",
482
579
  "/var/log/wtmp"
483
580
  ];
581
+ /** Host environment variables that are safe to expose to a confined command. */
582
+ const SAFE_ENV_KEYS = [
583
+ "USER",
584
+ "LOGNAME",
585
+ "SHELL",
586
+ "TZ",
587
+ "SSL_CERT_FILE",
588
+ "SSL_CERT_DIR",
589
+ "REQUESTS_CA_BUNDLE",
590
+ "CURL_CA_BUNDLE",
591
+ "NODE_EXTRA_CA_CERTS",
592
+ "GIT_SSL_CAINFO"
593
+ ];
594
+ /** Whether `root` is `target` or an ancestor of it. */
595
+ function containsPath(root, target) {
596
+ const relative = path.relative(root, target);
597
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
598
+ }
484
599
  /** Python stdlib/venv directories that may live next to a workspace. */
485
600
  function pythonRuntimeDirs(workspace) {
486
601
  const dirs = /* @__PURE__ */ new Set();
@@ -508,14 +623,39 @@ function ancestorsOf(target) {
508
623
  return out;
509
624
  }
510
625
  /** Resolve one package directory from the host launch world. */
626
+ /** Likely roots for the running DSH installation (app, checkout, or profile). */
627
+ function runtimeRoots() {
628
+ const roots = /* @__PURE__ */ new Set();
629
+ const starts = [fileURLToPath(new URL("..", import.meta.url)), process.cwd()];
630
+ if (process.argv[1] !== void 0) starts.push(path.dirname(process.argv[1]));
631
+ if (process.env.DSH_CHECKOUT !== void 0 && process.env.DSH_CHECKOUT !== "") starts.push(process.env.DSH_CHECKOUT);
632
+ for (const start of starts) {
633
+ let current = path.resolve(start);
634
+ for (let depth = 0; depth < 8; depth += 1) {
635
+ roots.add(current);
636
+ const parent = path.dirname(current);
637
+ if (parent === current) break;
638
+ current = parent;
639
+ }
640
+ }
641
+ return [...roots];
642
+ }
643
+ /** Checkout-relative guesses for DSH source layouts. */
644
+ const CHECKOUT_PATHS = {
645
+ "@deepseek-ai/dsh-ptc-runtime-node": ["packages/ptc-runtime/ptc-runtime-node"],
646
+ "@deepseek-ai/dsh-subprocess": ["packages/subprocess/subprocess"]
647
+ };
511
648
  function packageDir(name) {
512
649
  const candidates = [];
513
- try {
514
- const require = createRequire(path.join(process.cwd(), "noop.js"));
515
- candidates.push(path.dirname(require.resolve(name + "/package.json")));
516
- } catch {}
517
- if (name === "@deepseek-ai/dsh-ptc-runtime-node") candidates.push(path.join(process.cwd(), "packages", "ptc-runtime", "ptc-runtime-node"));
518
- if (name === "@deepseek-ai/dsh-subprocess") candidates.push(path.join(process.cwd(), "packages", "subprocess", "subprocess"));
650
+ const roots = runtimeRoots();
651
+ for (const root of roots) {
652
+ try {
653
+ const require = createRequire(path.join(root, "noop.js"));
654
+ candidates.push(path.dirname(require.resolve(name + "/package.json")));
655
+ } catch {}
656
+ candidates.push(path.join(root, "node_modules", name));
657
+ for (const relative of CHECKOUT_PATHS[name] ?? []) candidates.push(path.join(root, relative));
658
+ }
519
659
  for (const modules of profileNodeModules()) candidates.push(path.join(modules, name));
520
660
  for (const candidate of candidates) try {
521
661
  if (fs.statSync(path.join(candidate, "package.json")).isFile()) return fs.realpathSync.native(candidate);
@@ -650,6 +790,7 @@ function writePolicy(workspace, rules) {
650
790
  }
651
791
  for (const rule of rules) lines.push(line("rule", (rule.allow ? "allow " : "deny ") + rule.path));
652
792
  if (!(workspace === "/tmp" || workspace.startsWith("/tmp/"))) lines.push(line("tmpbind", path.join(workspace, ".tmp")));
793
+ else lines.push(line("tmpfs", "/tmp"));
653
794
  fs.writeFileSync(file, lines.join("\n") + "\n", {
654
795
  encoding: "utf8",
655
796
  mode: 384
@@ -657,7 +798,7 @@ function writePolicy(workspace, rules) {
657
798
  return file;
658
799
  }
659
800
  /** Build the bwrap profile arguments for one workspace. */
660
- function bwrapArgs(workspace, cwd, rules, runtime) {
801
+ function bwrapArgs(workspace, rules, runtime) {
661
802
  const args = [
662
803
  "--unshare-all",
663
804
  "--die-with-parent",
@@ -681,7 +822,6 @@ function bwrapArgs(workspace, cwd, rules, runtime) {
681
822
  args.push("--dir", "/var/log");
682
823
  }
683
824
  for (const file of systemFiles) args.push("--ro-bind", file, file);
684
- args.push("--bind", workspace, workspace);
685
825
  const tmp = path.join(workspace, ".tmp");
686
826
  ensureDir(tmp);
687
827
  const underHostTmp = workspace === "/tmp" || workspace.startsWith("/tmp/");
@@ -689,7 +829,9 @@ function bwrapArgs(workspace, cwd, rules, runtime) {
689
829
  if (underHostTmp) {
690
830
  args.push("--tmpfs", "/tmp");
691
831
  tmpdirForChild = tmp;
692
- } else args.push("--bind", tmp, "/tmp");
832
+ }
833
+ args.push("--bind", workspace, workspace);
834
+ if (!underHostTmp) args.push("--bind", tmp, "/tmp");
693
835
  const supervisorFile = supervisorPath();
694
836
  if (fs.existsSync(supervisorFile)) args.push("--ro-bind", supervisorFile, supervisorFile);
695
837
  args.push("--ro-bind", runtime.node, runtime.node);
@@ -712,7 +854,12 @@ function bwrapArgs(workspace, cwd, rules, runtime) {
712
854
  if (!rule.allow) continue;
713
855
  try {
714
856
  const real = fs.realpathSync.native(rule.path);
715
- if (real !== workspace && !real.startsWith(workspace + "/")) args.push("--ro-bind", real, real);
857
+ if (containsPath(real, workspace) || containsPath(workspace, real)) continue;
858
+ if (containsPath(real, guardRoot()) || containsPath(guardRoot(), real)) continue;
859
+ if (containsPath(real, dshHome()) || containsPath(dshHome(), real)) continue;
860
+ if (real === "/tmp" || real.startsWith("/tmp/")) continue;
861
+ if (SYSTEM_READ_DIRS.some((system) => real === system || real.startsWith(system + "/"))) continue;
862
+ args.push("--ro-bind", real, real);
716
863
  } catch {}
717
864
  }
718
865
  const home = path.join(workspace, ".home");
@@ -725,21 +872,11 @@ function bwrapArgs(workspace, cwd, rules, runtime) {
725
872
  config,
726
873
  data
727
874
  ]) ensureDir(dir);
728
- for (const name of [
729
- "LD_LIBRARY_PATH",
730
- "PYTHONPATH",
731
- "MVCAM_SDK_PATH",
732
- "AMENT_PREFIX_PATH",
733
- "COLCON_PREFIX_PATH",
734
- "CMAKE_PREFIX_PATH",
735
- "ROS_DISTRO",
736
- "ROS_VERSION",
737
- "ROS_PYTHON_VERSION",
738
- "NODE_OPTIONS",
739
- "NODE_PATH",
740
- "PYTHONSTARTUP",
741
- "PYTHONHOME"
742
- ]) args.push("--unsetenv", name);
875
+ args.push("--clearenv");
876
+ for (const name of SAFE_ENV_KEYS) {
877
+ const value = process.env[name];
878
+ if (value !== void 0 && value !== "") args.push("--setenv", name, value);
879
+ }
743
880
  args.push("--setenv", "PATH", "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin");
744
881
  args.push("--setenv", "HOME", home);
745
882
  args.push("--setenv", "TMPDIR", tmpdirForChild);
@@ -756,7 +893,8 @@ function bwrapArgs(workspace, cwd, rules, runtime) {
756
893
  * Wrap one argv in the strict sandbox.
757
894
  * @param argv - the exact command the consumer was about to spawn.
758
895
  * @param workspace - session cwd.
759
- * @param cwd - working directory the consumer will use (same as workspace in practice).
896
+ * @param cwd - explicit working directory override; the empty string lets the
897
+ * supervisor use its inherited cwd when that cwd lies inside the workspace.
760
898
  * @param rules - ordered forbidden-path rules.
761
899
  * @param auditFile - host-side audit path the supervisor appends to.
762
900
  */
@@ -771,6 +909,7 @@ function wrapSandbox(argv, workspace, cwd, rules, auditFile) {
771
909
  ensureDir(path.join(workspace, ".config"));
772
910
  ensureDir(path.join(workspace, ".local", "share"));
773
911
  const policyFile = writePolicy(workspace, rules);
912
+ const args = bwrapArgs(workspace, rules, runtimeFiles());
774
913
  return {
775
914
  argv: [
776
915
  supervisor,
@@ -778,9 +917,10 @@ function wrapSandbox(argv, workspace, cwd, rules, auditFile) {
778
917
  policyFile,
779
918
  "--audit",
780
919
  auditFile,
920
+ ...cwd === "" ? [] : ["--cwd", cwd],
781
921
  "--",
782
922
  "bwrap",
783
- ...bwrapArgs(workspace, cwd, rules, runtimeFiles()),
923
+ ...args,
784
924
  "--",
785
925
  ...argv
786
926
  ],
@@ -795,6 +935,39 @@ function wrapSandbox(argv, workspace, cwd, rules, auditFile) {
795
935
  * tool policy, wall-clock limit, run records, and audit events.
796
936
  * @module dsh-eval-guard/guard
797
937
  */
938
+ /** The cwd a session is confined to, or undefined when the header has none. */
939
+ function sessionCwd(session) {
940
+ const cwd = session?.header?.cwd;
941
+ return typeof cwd === "string" && cwd !== "" ? cwd : void 0;
942
+ }
943
+ /**
944
+ * Whether a session is a subagent child.
945
+ *
946
+ * DSH uses `origin: 'subagent'` and/or `delegationDepth > 0` for subagents.
947
+ * `parentSession` alone is NOT a subagent marker: a user-visible fork of a
948
+ * normal conversation also sets `parentSession` (seed lineage), and treating
949
+ * that as a subagent would leave the whole fork unguarded.
950
+ */
951
+ function sessionIsSubagent(session) {
952
+ const header = session?.header;
953
+ if (header === void 0) return false;
954
+ if (header.origin === "subagent") return true;
955
+ return typeof header.delegationDepth === "number" && header.delegationDepth > 0;
956
+ }
957
+ /** The session this one was forked/delegated from, if any. */
958
+ function sessionParent(session) {
959
+ const parent = session?.header?.parentSession;
960
+ return typeof parent === "string" && parent !== "" ? parent : void 0;
961
+ }
962
+ /** Display title for the enable warning; DSH's header has none, so fall back. */
963
+ function sessionTitle(session) {
964
+ const title = session?.header?.title;
965
+ if (typeof title === "string" && title !== "") return title;
966
+ const cwd = sessionCwd(session);
967
+ return cwd === void 0 ? void 0 : path.basename(cwd);
968
+ }
969
+ /** How long an idle session keeps its deadline alive before it is settled. */
970
+ const IDLE_GRACE_MS = 5e3;
798
971
  /** Event ring + run audit writer. */
799
972
  var Guard = class {
800
973
  ctx;
@@ -811,6 +984,7 @@ var Guard = class {
811
984
  approvalPrev = /* @__PURE__ */ new Map();
812
985
  deadlines = /* @__PURE__ */ new Map();
813
986
  startedAt = /* @__PURE__ */ new Map();
987
+ lastActivity = /* @__PURE__ */ new Map();
814
988
  runs = /* @__PURE__ */ new Map();
815
989
  sessionRuns = /* @__PURE__ */ new Map();
816
990
  offsets = /* @__PURE__ */ new Map();
@@ -818,6 +992,8 @@ var Guard = class {
818
992
  toolCache = [];
819
993
  disposers = [];
820
994
  task;
995
+ lastError;
996
+ enableRetryAt = 0;
821
997
  /** Optional hook invoked when any run binds to an agent. */
822
998
  onRunAssociated;
823
999
  /** Optional automatic task binding for every main conversation. */
@@ -892,13 +1068,21 @@ var Guard = class {
892
1068
  if (!this.rawConfig) return;
893
1069
  const next = this.configuration;
894
1070
  const wasEnabled = this.enabled;
895
- if (!wasEnabled && next.enabled) this.enable();
896
- else if (wasEnabled && !next.enabled) this.disable();
1071
+ if (!wasEnabled && next.enabled) {
1072
+ if (Date.now() < this.enableRetryAt) return;
1073
+ this.enable().catch((error) => {
1074
+ this.failEnable(error instanceof Error ? error.message : String(error));
1075
+ });
1076
+ } else if (wasEnabled && !next.enabled) this.disable();
897
1077
  }
898
1078
  /** Whether the guard is active. */
899
1079
  get active() {
900
1080
  return this.enabled;
901
1081
  }
1082
+ /** Why the guard refused to enable, if it did. */
1083
+ get lastFailure() {
1084
+ return this.lastError;
1085
+ }
902
1086
  /** Installed tool names currently visible in this deployment. */
903
1087
  refreshTools() {
904
1088
  try {
@@ -928,31 +1112,31 @@ var Guard = class {
928
1112
  pathRules() {
929
1113
  return parsePathRules(this.configuration.forbiddenPaths);
930
1114
  }
931
- /** Live conversations for the enable warning. */
1115
+ /** Jobs owned by one session (unowned/global jobs must never be attributed). */
1116
+ ownJobs(sessionId) {
1117
+ return (this.services.jobs?.list(sessionId) ?? []).filter((job) => job.owner === sessionId).filter((job) => job.status === "running" || job.status === "stopping");
1118
+ }
1119
+ /** Live main conversations for the enable warning. */
932
1120
  runningSessions() {
933
1121
  const out = [];
934
1122
  for (const agent of this.services.agents.list()) {
935
- const jobs = this.services.jobs?.list(agent.id) ?? [];
1123
+ if (!this.isMain(agent.session)) continue;
1124
+ const jobs = this.ownJobs(agent.id);
936
1125
  const running = agent.status === "running";
937
- const jobCount = jobs.filter((job) => job.status === "running" || job.status === "stopping").length;
938
- if (!running && jobCount === 0) continue;
1126
+ if (!running && jobs.length === 0) continue;
939
1127
  out.push({
940
1128
  sessionId: agent.id,
941
- title: agent.session.header?.meta?.title ?? agent.id,
942
- cwd: agent.session.header?.meta?.cwd ?? "",
1129
+ title: sessionTitle(agent.session) ?? agent.id,
1130
+ cwd: sessionCwd(agent.session) ?? "",
943
1131
  running,
944
- jobs: jobCount
1132
+ jobs: jobs.length
945
1133
  });
946
1134
  }
947
1135
  return out;
948
1136
  }
949
- /** Whether a session is a user main conversation (not a subagent or plugin child). */
1137
+ /** Whether a session is a user conversation (not a subagent/plugin child). */
950
1138
  isMain(session) {
951
- if (session === void 0) return false;
952
- const meta = session.header?.meta;
953
- if (meta?.origin === "subagent") return false;
954
- if (meta?.parentSession !== void 0 || meta?.delegationDepth !== void 0) return false;
955
- return true;
1139
+ return session !== void 0 && !sessionIsSubagent(session);
956
1140
  }
957
1141
  /** Approvals are auto-rejected while the guard is active. */
958
1142
  forceApproval(session) {
@@ -960,8 +1144,10 @@ var Guard = class {
960
1144
  if (approval === void 0) return;
961
1145
  const agent = this.services.agents.get(session.id);
962
1146
  if (agent === void 0) return;
963
- if (!this.approvalPrev.has(session.id)) this.approvalPrev.set(session.id, approval.effectivePolicy(session));
964
- approval.setPolicy(agent, "never");
1147
+ try {
1148
+ if (!this.approvalPrev.has(session.id)) this.approvalPrev.set(session.id, approval.effectivePolicy(session));
1149
+ approval.setPolicy(agent, "never");
1150
+ } catch {}
965
1151
  }
966
1152
  /** Restore the policy that existed before the guard touched this session. */
967
1153
  restoreApproval(sessionId) {
@@ -972,19 +1158,36 @@ var Guard = class {
972
1158
  const approval = this.services.approval;
973
1159
  if (agent !== void 0 && approval !== void 0) approval.setPolicy(agent, previous);
974
1160
  }
1161
+ /** Record an enable-time refusal and back off before retrying. */
1162
+ failEnable(error) {
1163
+ this.lastError = error;
1164
+ this.enableRetryAt = Date.now() + 5e3;
1165
+ this.record({
1166
+ kind: "notice",
1167
+ detail: "评测守护启用失败:" + error
1168
+ });
1169
+ try {
1170
+ this.ctx.logger.warn("eval-guard: %s", error);
1171
+ } catch {}
1172
+ return {
1173
+ ok: false,
1174
+ error
1175
+ };
1176
+ }
975
1177
  /** Turn the guard on. */
976
1178
  async enable() {
977
1179
  if (this.enabled) return { ok: true };
978
1180
  const sandbox = this.services.sandbox;
979
1181
  const policy = this.services.sandboxPolicy;
980
- if (sandbox === void 0 || policy === void 0) return {
981
- ok: false,
982
- error: "sandbox service is unavailable; refusing to enable without process confinement"
983
- };
984
- if (!fs.existsSync(supervisorPath())) return {
985
- ok: false,
986
- error: "native supervisor is not built; run npm run build:native"
987
- };
1182
+ if (sandbox === void 0 || policy === void 0) return this.failEnable("sandbox service is unavailable; refusing to enable without process confinement");
1183
+ if (!fs.existsSync(supervisorPath())) return this.failEnable("native supervisor is not built; run npm run build:native");
1184
+ try {
1185
+ fs.accessSync(supervisorPath(), fs.constants.X_OK);
1186
+ } catch {
1187
+ return this.failEnable("native supervisor is not executable; run npm run build:native");
1188
+ }
1189
+ const probe = probeConfinement();
1190
+ if (probe !== void 0) return this.failEnable(probe);
988
1191
  this.sandboxObject = sandbox;
989
1192
  this.policyObject = policy;
990
1193
  this.origConfine = sandbox.confine.bind(sandbox);
@@ -994,12 +1197,12 @@ var Guard = class {
994
1197
  if (!guard.enabled) return guard.origConfine(argv, executionPolicy, signal);
995
1198
  const sessionId = typeof executionPolicy["sessionId"] === "string" ? executionPolicy["sessionId"] : void 0;
996
1199
  const session = sessionId === void 0 ? void 0 : guard.services.sessions.get(sessionId);
997
- if (session === void 0 || !guard.isMain(session)) return guard.origConfine(argv, executionPolicy, signal);
998
- const workspace = typeof executionPolicy["workspaceRoot"] === "string" ? executionPolicy["workspaceRoot"] : session.header?.meta?.cwd;
999
- if (workspace === void 0 || workspace === "") throw new Error("dsh-eval-guard: session has no workspace");
1200
+ if (session === void 0) return guard.origConfine(argv, executionPolicy, signal);
1201
+ const workspace = sessionCwd(session);
1202
+ if (workspace === void 0) throw new Error("dsh-eval-guard: session has no workspace");
1000
1203
  const audit = guard.auditFileFor(session.id);
1001
1204
  return {
1002
- argv: wrapSandbox(argv, workspace, workspace, guard.pathRules(), audit).argv,
1205
+ argv: wrapSandbox(argv, workspace, "", guard.pathRules(), audit).argv,
1003
1206
  enforcement: "full",
1004
1207
  denialSignatures: [
1005
1208
  "operation not permitted",
@@ -1012,29 +1215,60 @@ var Guard = class {
1012
1215
  policy.resolve = function resolveHook(request = {}) {
1013
1216
  const resolved = guard.origResolve(request);
1014
1217
  if (!guard.enabled) return resolved;
1015
- const session = request["session"];
1016
- if (session === void 0 || !guard.isMain(session)) return resolved;
1218
+ if (request["session"] === void 0) return resolved;
1017
1219
  return {
1018
1220
  ...resolved,
1019
1221
  mode: "workspace-write"
1020
1222
  };
1021
1223
  };
1022
- this.enabled = true;
1023
- for (const agent of this.services.agents.list()) if (this.isMain(agent.session)) this.forceApproval(agent.session);
1024
- this.record({
1025
- kind: "notice",
1026
- detail: "评测守护已启用。"
1027
- });
1028
- return { ok: true };
1224
+ try {
1225
+ for (const agent of this.services.agents.list()) if (this.isMain(agent.session)) this.forceApproval(agent.session);
1226
+ try {
1227
+ const runtime = runtimeFiles();
1228
+ if (runtime.ptcProcess === void 0 || runtime.ptcControl === void 0) this.record({
1229
+ kind: "notice",
1230
+ detail: "未找到 run_code 运行时(PTC)文件;沙箱内 run_code 可能不可用。"
1231
+ });
1232
+ } catch {
1233
+ this.record({
1234
+ kind: "notice",
1235
+ detail: "无法读取 run_code 运行时(PTC)文件;沙箱内 run_code 可能不可用。"
1236
+ });
1237
+ }
1238
+ this.enabled = true;
1239
+ this.lastError = void 0;
1240
+ this.enableRetryAt = 0;
1241
+ this.record({
1242
+ kind: "notice",
1243
+ detail: "评测守护已启用。"
1244
+ });
1245
+ return { ok: true };
1246
+ } catch (error) {
1247
+ this.enabled = false;
1248
+ this.restoreHooks();
1249
+ return this.failEnable(error instanceof Error ? error.message : String(error));
1250
+ }
1251
+ }
1252
+ /** Restore the original sandbox/policy methods (idempotent). */
1253
+ restoreHooks() {
1254
+ if (this.sandboxObject !== void 0 && this.origConfine !== void 0) this.sandboxObject.confine = this.origConfine;
1255
+ if (this.policyObject !== void 0 && this.origResolve !== void 0) this.policyObject.resolve = this.origResolve;
1256
+ this.sandboxObject = void 0;
1257
+ this.policyObject = void 0;
1258
+ this.origConfine = void 0;
1259
+ this.origResolve = void 0;
1029
1260
  }
1030
1261
  /** Turn the guard off without touching already-running confined processes. */
1031
1262
  disable() {
1263
+ this.enableRetryAt = 0;
1032
1264
  if (!this.enabled) return;
1033
1265
  this.enabled = false;
1034
- if (this.sandboxObject !== void 0 && this.origConfine !== void 0) this.sandboxObject.confine = this.origConfine;
1035
- if (this.policyObject !== void 0 && this.origResolve !== void 0) this.policyObject.resolve = this.origResolve;
1036
- for (const sessionId of [...this.approvalPrev.keys()]) this.restoreApproval(sessionId);
1266
+ this.restoreHooks();
1267
+ for (const sessionId of [...this.approvalPrev.keys()]) try {
1268
+ this.restoreApproval(sessionId);
1269
+ } catch {}
1037
1270
  this.deadlines.clear();
1271
+ this.lastActivity.clear();
1038
1272
  this.record({
1039
1273
  kind: "notice",
1040
1274
  detail: "评测守护已停用。"
@@ -1045,24 +1279,31 @@ var Guard = class {
1045
1279
  const config = this.configuration;
1046
1280
  if (!config.timeLimitEnabled) return;
1047
1281
  this.deadlines.set(sessionId, Date.now() + config.timeLimitMinutes * 6e4);
1282
+ this.lastActivity.set(sessionId, Date.now());
1048
1283
  }
1049
1284
  /** A new main agent appeared. */
1050
1285
  onAgentCreated(agent) {
1051
1286
  if (!this.isMain(agent.session)) return;
1052
1287
  this.knownAgents.add(agent.id);
1053
1288
  if (this.enabled) this.forceApproval(agent.session);
1054
- const cwd = agent.session.header?.meta?.cwd;
1055
- if (cwd !== void 0) {
1056
- if (this.enabled) try {
1289
+ const cwd = sessionCwd(agent.session);
1290
+ if (cwd !== void 0 && this.enabled) {
1291
+ const existingRunId = this.sessionRuns.get(agent.id);
1292
+ const existing = existingRunId === void 0 ? void 0 : this.runs.get(existingRunId);
1293
+ if (existing !== void 0 && (existing.status === "active" || existing.status === "prepared")) return;
1294
+ try {
1057
1295
  this.onAutoRun?.(agent, cwd);
1058
1296
  } catch {}
1059
1297
  }
1060
1298
  }
1061
1299
  /** An agent went away. */
1062
1300
  onAgentDisposed(agent) {
1063
- this.restoreApproval(agent.id);
1301
+ try {
1302
+ this.restoreApproval(agent.id);
1303
+ } catch {}
1064
1304
  this.deadlines.delete(agent.id);
1065
1305
  this.startedAt.delete(agent.id);
1306
+ this.lastActivity.delete(agent.id);
1066
1307
  const runId = this.sessionRuns.get(agent.id);
1067
1308
  if (runId !== void 0) {
1068
1309
  const run = this.runs.get(runId);
@@ -1087,7 +1328,7 @@ var Guard = class {
1087
1328
  run.status = "active";
1088
1329
  run.provider = agent.options?.provider;
1089
1330
  run.model = agent.options?.model;
1090
- run.title = agent.session.header?.meta?.title;
1331
+ run.title = sessionTitle(agent.session);
1091
1332
  this.sessionRuns.set(agent.id, runId);
1092
1333
  this.writeRun(run);
1093
1334
  this.writeUsage(runId, this.zeroUsage(run.startedAt));
@@ -1098,8 +1339,10 @@ var Guard = class {
1098
1339
  /** Session event intake. */
1099
1340
  onSessionEvent(session, event) {
1100
1341
  if (!this.enabled || session === void 0 || !this.isMain(session)) return;
1342
+ this.lastActivity.set(session.id, Date.now());
1101
1343
  const runId = this.sessionRuns.get(session.id);
1102
1344
  const run = runId === void 0 ? void 0 : this.runs.get(runId);
1345
+ const activeRun = run !== void 0 && run.status === "active";
1103
1346
  if (event.type === "user/message") {
1104
1347
  if ((event.data?.source)?.kind === "user") {
1105
1348
  this.startedAt.set(session.id, Date.now());
@@ -1108,44 +1351,51 @@ var Guard = class {
1108
1351
  }
1109
1352
  } else if (event.type === "assistant/message") {
1110
1353
  const usage = event.data?.usage;
1111
- if (usage !== void 0 && runId !== void 0) this.addUsage(runId, usage);
1354
+ if (usage !== void 0 && runId !== void 0 && activeRun) this.addUsage(runId, usage);
1112
1355
  } else if (event.type === "tool/call") {
1113
- if (runId !== void 0) this.addToolCall(runId);
1356
+ if (runId !== void 0 && activeRun) this.addToolCall(runId);
1114
1357
  } else if (event.type === "turn/end") {
1115
- if (runId !== void 0) this.addTurn(runId);
1358
+ if (runId !== void 0 && activeRun) this.addTurn(runId);
1116
1359
  this.maybeComplete(session.id);
1117
1360
  } else if (event.type === "turn/start") {
1118
1361
  if (run !== void 0 && run.startedAt === null) run.startedAt = Date.now();
1119
1362
  }
1120
1363
  }
1121
- /** Clear a turn deadline once no work remains for that session. */
1364
+ /** Clear a turn deadline once no work remains for that session (with grace). */
1122
1365
  maybeComplete(sessionId) {
1123
- const agent = this.services.agents.get(sessionId);
1124
- const jobs = (this.services.jobs?.list(sessionId) ?? []).filter((job) => job.status === "running" || job.status === "stopping");
1125
- if (agent?.status === "running" || jobs.length > 0) return;
1366
+ if (this.services.agents.get(sessionId)?.status === "running" || this.ownJobs(sessionId).length > 0) return;
1367
+ const last = this.lastActivity.get(sessionId) ?? 0;
1368
+ if (Date.now() - last < IDLE_GRACE_MS) return;
1126
1369
  this.deadlines.delete(sessionId);
1127
1370
  }
1128
1371
  /** One-second clock: expire deadlines and tail supervisor audits. */
1129
1372
  tick() {
1130
- this.syncConfig();
1131
- if (!this.enabled) return;
1132
- const now = Date.now();
1133
- for (const [sessionId, deadline] of [...this.deadlines]) {
1134
- if (deadline > now) {
1135
- const agent = this.services.agents.get(sessionId);
1136
- const ownJobs = (this.services.jobs?.list(sessionId) ?? []).some((job) => job.status === "running" || job.status === "stopping");
1137
- const descendantRunning = this.descendants(sessionId).some((id) => {
1138
- if (this.services.agents.get(id)?.status === "running") return true;
1139
- return (this.services.jobs?.list(id) ?? []).some((job) => job.status === "running" || job.status === "stopping");
1140
- });
1141
- if (agent?.status !== "running" && !ownJobs && !descendantRunning) this.deadlines.delete(sessionId);
1142
- continue;
1373
+ try {
1374
+ this.syncConfig();
1375
+ if (!this.enabled) return;
1376
+ const now = Date.now();
1377
+ for (const [sessionId, deadline] of [...this.deadlines]) {
1378
+ if (deadline > now) {
1379
+ const agent = this.services.agents.get(sessionId);
1380
+ const ownJobs = this.ownJobs(sessionId).length > 0;
1381
+ const last = this.lastActivity.get(sessionId) ?? 0;
1382
+ const descendantRunning = this.descendants(sessionId).some((id) => {
1383
+ if (this.services.agents.get(id)?.status === "running") return true;
1384
+ return this.ownJobs(id).length > 0;
1385
+ });
1386
+ if (agent?.status !== "running" && !ownJobs && !descendantRunning && now - last >= IDLE_GRACE_MS) this.deadlines.delete(sessionId);
1387
+ continue;
1388
+ }
1389
+ this.deadlines.delete(sessionId);
1390
+ this.expire(sessionId);
1143
1391
  }
1144
- this.deadlines.delete(sessionId);
1145
- this.expire(sessionId);
1392
+ this.tailAudits();
1393
+ this.cleanupTemp();
1394
+ } catch (error) {
1395
+ try {
1396
+ this.ctx.logger.warn("eval-guard: tick failed: %s", error instanceof Error ? error.message : String(error));
1397
+ } catch {}
1146
1398
  }
1147
- this.tailAudits();
1148
- this.cleanupTemp();
1149
1399
  }
1150
1400
  /** Kill one session's turn, descendants, and jobs. */
1151
1401
  expire(sessionId) {
@@ -1159,9 +1409,11 @@ var Guard = class {
1159
1409
  reason
1160
1410
  });
1161
1411
  } catch {}
1162
- for (const job of this.services.jobs?.list(id) ?? []) if (job.status === "running" || job.status === "stopping") try {
1412
+ for (const job of this.ownJobs(id)) try {
1163
1413
  this.services.jobs?.kill(job.id, id, "评测时间上限");
1164
1414
  } catch {}
1415
+ this.deadlines.delete(id);
1416
+ this.lastActivity.delete(id);
1165
1417
  }
1166
1418
  const runId = this.sessionRuns.get(sessionId);
1167
1419
  if (runId !== void 0) {
@@ -1176,8 +1428,10 @@ var Guard = class {
1176
1428
  sessionId,
1177
1429
  detail: reason
1178
1430
  });
1431
+ return;
1179
1432
  }
1180
- } else this.record({
1433
+ }
1434
+ this.record({
1181
1435
  kind: "timeout",
1182
1436
  sessionId,
1183
1437
  detail: reason
@@ -1185,10 +1439,10 @@ var Guard = class {
1185
1439
  }
1186
1440
  /** Direct and transitive subagent sessions of one main session. */
1187
1441
  descendants(sessionId) {
1188
- const all = this.services.agents.list();
1189
1442
  const children = /* @__PURE__ */ new Map();
1190
- for (const agent of all) {
1191
- const parent = agent.session.header?.meta?.parentSession;
1443
+ for (const agent of this.services.agents.list()) {
1444
+ if (!sessionIsSubagent(agent.session)) continue;
1445
+ const parent = sessionParent(agent.session);
1192
1446
  if (parent === void 0) continue;
1193
1447
  const list = children.get(parent) ?? [];
1194
1448
  list.push(agent.id);
@@ -1208,7 +1462,7 @@ var Guard = class {
1208
1462
  checkTool(exec) {
1209
1463
  if (!this.enabled) return void 0;
1210
1464
  const agent = exec.agent;
1211
- if (agent === void 0 || !this.isMain(agent.session)) return void 0;
1465
+ if (agent === void 0) return void 0;
1212
1466
  const name = String(exec.name);
1213
1467
  if (WAIT_TOOLS.includes(name)) {
1214
1468
  this.record({
@@ -1228,8 +1482,17 @@ var Guard = class {
1228
1482
  });
1229
1483
  return "该工具在本次评测的黑名单中,调用已被阻止。";
1230
1484
  }
1231
- const workspace = agent.session.header?.meta?.cwd;
1232
- if (workspace === void 0 || workspace === "") return void 0;
1485
+ const workspace = sessionCwd(agent.session);
1486
+ if (workspace === void 0) {
1487
+ if (!isAuditedTool(name)) return void 0;
1488
+ this.record({
1489
+ kind: "path-denied",
1490
+ sessionId: agent.id,
1491
+ tool: name,
1492
+ detail: "会话没有工作区,无法审计路径。"
1493
+ });
1494
+ return "评测守护:无法确定会话工作区,文件类工具已被阻止。";
1495
+ }
1233
1496
  const decision = auditToolCall(name, exec.arguments, workspace, this.pathRules());
1234
1497
  if (!decision.allow) {
1235
1498
  this.record({
@@ -1420,13 +1683,7 @@ var Guard = class {
1420
1683
  }
1421
1684
  /** Build the prompt for one task with only installed tools listed. */
1422
1685
  promptFor(workspacePath, source, task = this.task) {
1423
- const present = new Set(this.toolCache);
1424
- const blacklist = parseBlacklist(this.blacklistText()).filter((name) => isBaseBlacklistName(name) || present.has(name));
1425
- return task.prompt(blacklist, {
1426
- source,
1427
- workspacePath,
1428
- minutes: this.configuration.timeLimitMinutes
1429
- });
1686
+ return task.prompt({ minutes: this.configuration.timeLimitMinutes });
1430
1687
  }
1431
1688
  /** The task definition. */
1432
1689
  get definition() {
@@ -1440,6 +1697,45 @@ var Guard = class {
1440
1697
  //#endregion
1441
1698
  //#region src/jobs.ts
1442
1699
  /** Persistent background jobs for task setup and single-result evaluation. */
1700
+ /**
1701
+ * The only host environment variables a setup/evaluation child inherits.
1702
+ *
1703
+ * Evaluation children execute agent-authored build scripts and binaries, so
1704
+ * the ambient DSH environment (credentials, session secrets, proxy settings)
1705
+ * must not leak into them. Everything the task needs is passed explicitly via
1706
+ * the EVAL_* variables in StartOptions.env.
1707
+ */
1708
+ function baseEnvironment(source = process.env) {
1709
+ const keep = [
1710
+ "PATH",
1711
+ "HOME",
1712
+ "USER",
1713
+ "LOGNAME",
1714
+ "SHELL",
1715
+ "LANG",
1716
+ "LANGUAGE",
1717
+ "LC_ALL",
1718
+ "LC_CTYPE",
1719
+ "LC_MESSAGES",
1720
+ "TZ",
1721
+ "TERM",
1722
+ "TMPDIR",
1723
+ "TMP",
1724
+ "TEMP",
1725
+ "SSL_CERT_FILE",
1726
+ "SSL_CERT_DIR",
1727
+ "GIT_SSL_CAINFO",
1728
+ "REQUESTS_CA_BUNDLE",
1729
+ "CURL_CA_BUNDLE",
1730
+ "NODE_EXTRA_CA_CERTS"
1731
+ ];
1732
+ const env = {};
1733
+ for (const key of keep) {
1734
+ const value = source[key];
1735
+ if (value !== void 0) env[key] = value;
1736
+ }
1737
+ return env;
1738
+ }
1443
1739
  function now() {
1444
1740
  return Date.now();
1445
1741
  }
@@ -1461,15 +1757,65 @@ var JobManager = class {
1461
1757
  try {
1462
1758
  const stored = JSON.parse(fs.readFileSync(file, "utf8"));
1463
1759
  if (stored.status === "running" || stored.status === "queued") {
1760
+ if (typeof stored.pid === "number" && stored.pid > 0 && this.stillOurProcess(stored)) try {
1761
+ process.kill(-stored.pid, "SIGTERM");
1762
+ } catch {
1763
+ try {
1764
+ process.kill(stored.pid, "SIGTERM");
1765
+ } catch {}
1766
+ }
1464
1767
  stored.status = "interrupted";
1465
1768
  stored.error = "服务重启,任务已中断";
1466
1769
  stored.endedAt = now();
1467
1770
  this.write(stored);
1771
+ if (stored.kind === "install" && stored.cleanupWorkspace !== void 0) {
1772
+ this.wipeInstallWorkspace(stored.cleanupWorkspace);
1773
+ if (stored.cifarToken !== void 0) this.cleanupCifarLock(stored.cifarToken);
1774
+ }
1468
1775
  }
1469
1776
  this.jobs.set(stored.id, stored);
1470
1777
  } catch {}
1471
1778
  }
1472
1779
  }
1780
+ /** Remove every top-level entry an interrupted install could have created. */
1781
+ wipeInstallWorkspace(workspacePath) {
1782
+ try {
1783
+ for (const entry of fs.readdirSync(workspacePath)) fs.rmSync(path.join(workspacePath, entry), {
1784
+ recursive: true,
1785
+ force: true
1786
+ });
1787
+ } catch {}
1788
+ }
1789
+ /** Remove the CIFAR lock only when it carries this install's token. */
1790
+ cleanupCifarLock(token) {
1791
+ try {
1792
+ const lock = path.join(guardRoot(), "cache", "cifar10", ".download.lock");
1793
+ if (fs.existsSync(lock) && fs.readFileSync(lock, "utf8").includes("token=" + token)) fs.rmSync(lock, { force: true });
1794
+ } catch {}
1795
+ }
1796
+ /** Read the Linux process start time from /proc/<pid>/stat (field 22). */
1797
+ processStartTime(pid) {
1798
+ try {
1799
+ const stat = fs.readFileSync("/proc/" + String(pid) + "/stat", "utf8");
1800
+ const end = stat.lastIndexOf(")");
1801
+ if (end < 0) return void 0;
1802
+ return stat.slice(end + 2).trim().split(/\s+/)[19];
1803
+ } catch {
1804
+ return;
1805
+ }
1806
+ }
1807
+ /** Whether a persisted PID still names the process this job started. */
1808
+ stillOurProcess(stored) {
1809
+ if (stored.pidStartTime !== void 0 && this.processStartTime(stored.pid) !== stored.pidStartTime) return false;
1810
+ if (stored.pidStartTime !== void 0) return true;
1811
+ try {
1812
+ const argv = fs.readFileSync("/proc/" + String(stored.pid) + "/cmdline", "utf8").split("\0").filter(Boolean);
1813
+ if (argv.length === 0) return false;
1814
+ return path.basename(argv[0]) === path.basename(stored.command[0] ?? "");
1815
+ } catch {
1816
+ return false;
1817
+ }
1818
+ }
1473
1819
  dir(idValue) {
1474
1820
  return path.join(this.root, idValue);
1475
1821
  }
@@ -1497,7 +1843,9 @@ var JobManager = class {
1497
1843
  command: options.command,
1498
1844
  cwd: options.cwd,
1499
1845
  logFile,
1500
- resultFile: path.join(directory, "result.json")
1846
+ resultFile: path.join(directory, "result.json"),
1847
+ ...options.cleanupWorkspace === void 0 ? {} : { cleanupWorkspace: options.cleanupWorkspace },
1848
+ ...options.cifarToken === void 0 ? {} : { cifarToken: options.cifarToken }
1501
1849
  };
1502
1850
  this.jobs.set(jobId, job);
1503
1851
  this.write(job);
@@ -1506,7 +1854,7 @@ var JobManager = class {
1506
1854
  child = spawn(options.command[0], options.command.slice(1), {
1507
1855
  cwd: options.cwd,
1508
1856
  env: {
1509
- ...process.env,
1857
+ ...baseEnvironment(),
1510
1858
  ...options.env ?? {},
1511
1859
  PYTHONUNBUFFERED: "1",
1512
1860
  EVAL_JOB_DIR: directory,
@@ -1528,6 +1876,11 @@ var JobManager = class {
1528
1876
  return this.info(job);
1529
1877
  }
1530
1878
  this.children.set(jobId, child);
1879
+ if (child.pid !== void 0) {
1880
+ job.pid = child.pid;
1881
+ job.pidStartTime = this.processStartTime(child.pid);
1882
+ this.write(job);
1883
+ }
1531
1884
  if (options.onCancel !== void 0) this.cancelHandlers.set(jobId, options.onCancel);
1532
1885
  const append = (chunk) => {
1533
1886
  try {
@@ -1536,17 +1889,29 @@ var JobManager = class {
1536
1889
  };
1537
1890
  child.stdout?.on("data", (chunk) => append(chunk));
1538
1891
  child.stderr?.on("data", (chunk) => append(chunk));
1539
- child.on("error", (error) => {
1892
+ let failureNotified = false;
1893
+ const fail = (error) => {
1894
+ if (job.status !== "running" && job.status !== "queued") return;
1540
1895
  job.status = "failed";
1541
- job.error = error.message;
1896
+ job.error = error;
1542
1897
  job.endedAt = now();
1543
1898
  job.updatedAt = now();
1544
1899
  this.write(job);
1900
+ if (failureNotified) return;
1901
+ failureNotified = true;
1902
+ try {
1903
+ options.onFailure?.(this.detail(jobId));
1904
+ } catch {}
1905
+ };
1906
+ child.on("error", (error) => {
1907
+ this.children.delete(jobId);
1908
+ this.cancelHandlers.delete(jobId);
1909
+ fail(error.message);
1545
1910
  });
1546
1911
  child.on("exit", (code, signal) => {
1547
1912
  this.children.delete(jobId);
1548
1913
  this.cancelHandlers.delete(jobId);
1549
- if (job.status === "cancelled") return;
1914
+ if (job.status === "cancelled" || job.status === "failed") return;
1550
1915
  if (code === 0) try {
1551
1916
  const extra = options.onSuccess?.(this.detail(jobId));
1552
1917
  if (extra !== void 0) {
@@ -1564,17 +1929,14 @@ var JobManager = class {
1564
1929
  } catch (error) {
1565
1930
  job.status = "failed";
1566
1931
  job.error = error instanceof Error ? error.message : String(error);
1567
- try {
1568
- options.onFailure?.(this.detail(jobId));
1569
- } catch {}
1570
- }
1571
- else {
1572
- job.status = "failed";
1573
- job.error = signal !== null ? "进程被信号 " + signal + " 终止" : "退出码 " + String(code);
1574
- try {
1575
- options.onFailure?.(this.detail(jobId));
1576
- } catch {}
1932
+ if (!failureNotified) {
1933
+ failureNotified = true;
1934
+ try {
1935
+ options.onFailure?.(this.detail(jobId));
1936
+ } catch {}
1937
+ }
1577
1938
  }
1939
+ else fail(signal !== null ? "进程被信号 " + signal + " 终止" : "退出码 " + String(code));
1578
1940
  job.endedAt = now();
1579
1941
  job.updatedAt = now();
1580
1942
  this.write(job);
@@ -1802,7 +2164,7 @@ const TASKS = [
1802
2164
  {
1803
2165
  id: NEURAL_PENDULUM_TASK,
1804
2166
  label: NEURAL_PENDULUM_TASK_LABEL,
1805
- description: "从零实现纯 C++17 神经网络基础设施,用三个关节角度控制预编译三节摆起摆并稳定保持;评测固定初始状态,统计 t_can。",
2167
+ description: "从零实现纯 C++17 神经网络基础设施(算子数值以评测侧 PyTorch 参考校验),用三个关节角度控制预编译三节摆起摆并稳定保持;评测固定初始状态,统计 t_can。",
1806
2168
  timeLimitMinutes: 1440,
1807
2169
  timeLimitEnabled: false,
1808
2170
  needsSetup: true
@@ -1817,14 +2179,14 @@ const TANK_DEFINITION = {
1817
2179
  timeLimitMinutes: 90,
1818
2180
  timeLimitEnabled: true,
1819
2181
  needsSetup: false,
1820
- prompt() {
2182
+ prompt({ minutes }) {
1821
2183
  return [
1822
2184
  "按照要求编写尽可能强的坦克大战 bot。你的对手是其他顶尖 AI 模型写的 bot,它们可能主动设计战术。基准是一个中等强度、思路常规的 bot,它在正式比赛中是 0 分线;战胜基准不代表足够强,正式对手远强于基准。正式赛是 1 对 1 循环赛,用 Bradley-Terry 模型拟合实力值。",
1823
2185
  "",
1824
2186
  "解压 workspace.zip,阅读 README.md 和 RULES.md,然后开始。",
1825
2187
  "",
1826
- "比赛是连续 90 分钟的单次任务:",
1827
- "- 你有 90 分钟修改 bot,从对话开始计时;到 90 分钟强制停止;",
2188
+ "比赛是连续 " + minutes + " 分钟的单次任务:",
2189
+ "- 你有 " + minutes + " 分钟修改 bot,从对话开始计时;到 " + minutes + " 分钟强制停止;",
1828
2190
  "- 最终只看 bot 的强度,提前交卷没有任何好处;觉得没什么可改了,先换一个思路再试;",
1829
2191
  "- 到点时 workspace/submission/bot.cpp 是什么就交什么:不要让它长时间停在编译不过的状态;",
1830
2192
  "- 允许使用本地工具、对战和复盘;禁止联网,禁止求助其他模型(包括启动子代理:subagent、Task / Agent 工具、后台并行代理之类),除主办方发给你的材料外禁止查看其他选手的资料;",
@@ -1846,7 +2208,7 @@ const WAREHOUSE_DEFINITION = {
1846
2208
  timeLimitMinutes: 90,
1847
2209
  timeLimitEnabled: true,
1848
2210
  needsSetup: false,
1849
- prompt() {
2211
+ prompt({ minutes }) {
1850
2212
  return [
1851
2213
  "这是一场 AI 编程赛。你接手了一个代码混乱的老系统:src/ 里是一个仓储机器人调度仿真系统,功能正确,但运行很慢。你的任务:在输出完全不变的前提下,让它尽可能快。",
1852
2214
  "",
@@ -1855,7 +2217,7 @@ const WAREHOUSE_DEFINITION = {
1855
2217
  "要点(详见 TASK.md):",
1856
2218
  "- 只能修改 src/;编译命令固定为 g++ -O2 -std=c++20 src/*.cpp,只用 C++ 标准库;",
1857
2219
  "- 对任何合法输入(以 tools/check_input.py --full 为准),输出都必须与基线逐字节相同;SPEC.md 是过时的旧文档,一切以基线代码的实际行为为准;",
1858
- "- 本次是连续 90 分钟的单次任务;clock.py 从解压那一刻开始计算,实际计时从对话开始计算,它只能作为参考,请尽快解压;",
2220
+ "- 本次是连续 " + minutes + " 分钟的单次任务;clock.py 从解压那一刻开始计算,实际计时从对话开始计算,它只能作为参考,请尽快解压;",
1859
2221
  "- 只能读写当前文件夹里的文件;可以使用本环境的所有工具,但不能联网搜索;",
1860
2222
  "- 不要向我提问,也不要等待我确认,直到完成为止。",
1861
2223
  "",
@@ -1872,11 +2234,11 @@ const LAND_DEFINITION = {
1872
2234
  timeLimitMinutes: 90,
1873
2235
  timeLimitEnabled: true,
1874
2236
  needsSetup: false,
1875
- prompt() {
2237
+ prompt({ minutes }) {
1876
2238
  return [
1877
2239
  "这是一场正式的 AI 编程对抗赛,你的对手是其他顶尖 AI 模型写的 bot,它们会主动切断你、抢你的地。比赛结果会公开排名,请拿出你的最高水平。",
1878
2240
  "",
1879
- "请解压当前目录的 workspace.zip,进入 workspace 文件夹,按 TASK.md 的要求编写一个尽可能强的圈地对战 bot,保存为 workspace/submission/bot.cpp。时间预算约 60 分钟,请自己把握进度,做完就可以结束;超过 90 分钟会被强制停止。只能读写当前文件夹里的文件,不能联网搜索,不要向我提问。"
2241
+ "请解压当前目录的 workspace.zip,进入 workspace 文件夹,按 TASK.md 的要求编写一个尽可能强的圈地对战 bot,保存为 workspace/submission/bot.cpp。时间预算约 " + Math.min(60, minutes) + " 分钟,请自己把握进度,做完就可以结束;超过 " + minutes + " 分钟会被强制停止。只能读写当前文件夹里的文件,不能联网搜索,不要向我提问。"
1880
2242
  ].join("\n");
1881
2243
  }
1882
2244
  };
@@ -1889,8 +2251,8 @@ const BOMBERMAN_DEFINITION = {
1889
2251
  timeLimitMinutes: 90,
1890
2252
  timeLimitEnabled: true,
1891
2253
  needsSetup: false,
1892
- prompt() {
1893
- return ["按照要求编写尽可能强bot,你的对手是全球其他顶尖ai,可能主动设计战术。基准只是很弱的bot,它在正式比赛为0分线。正式赛会对战多轮,用Bradley-Terry模型拟合实力值。", "解压 workspace.zip,阅读 README.md 和 RULES.md,编写支持2–10人的炸弹人 bot。尽量60分钟完成,90分钟必须交卷。允许本地工具、对战和复盘,禁止联网、调用其他AI或查看其他选手资料。提交 submission/bot.cpp,简述策略。"].join("\n");
2254
+ prompt({ minutes }) {
2255
+ return ["按照要求编写尽可能强bot,你的对手是全球其他顶尖ai,可能主动设计战术。基准只是很弱的bot,它在正式比赛为0分线。正式赛会对战多轮,用Bradley-Terry模型拟合实力值。", "解压 workspace.zip,阅读 README.md 和 RULES.md,编写支持2–10人的炸弹人 bot。尽量" + Math.min(60, minutes) + "分钟完成," + minutes + "分钟必须交卷。允许本地工具、对战和复盘,禁止联网、调用其他AI或查看其他选手资料。提交 submission/bot.cpp,简述策略。"].join("\n");
1894
2256
  }
1895
2257
  };
1896
2258
  /** CTF bot task: original participant prompt, unchanged. */
@@ -1902,28 +2264,17 @@ const CTF_DEFINITION = {
1902
2264
  timeLimitMinutes: 90,
1903
2265
  timeLimitEnabled: true,
1904
2266
  needsSetup: false,
1905
- prompt() {
1906
- return ["按照要求编写尽可能强的 bot,你的对手是全球其他顶尖 AI,可能主动设计战术。基准是一个中等强度、思路常规的 bot,它在正式比赛是 0 分线;战胜基准不代表足够强,正式对手远强于基准。正式赛会对战多轮,用 Bradley-Terry 模型拟合实力值。", "解压 workspace.zip,阅读 README.md 和 RULES.md,编写支持 2–15 方的夺旗 bot。你有 60 分钟,只看最终 bot 的强度,提前交卷不加分,请充分利用时间持续改进;可以适当超时,但到 90 分钟会强制交卷。允许本地工具、对战和复盘,禁止联网、调用其他 AI 或查看其他选手资料。提交 workspace/submission/bot.cpp,简述策略。"].join("\n");
2267
+ prompt({ minutes }) {
2268
+ return ["按照要求编写尽可能强的 bot,你的对手是全球其他顶尖 AI,可能主动设计战术。基准是一个中等强度、思路常规的 bot,它在正式比赛是 0 分线;战胜基准不代表足够强,正式对手远强于基准。正式赛会对战多轮,用 Bradley-Terry 模型拟合实力值。", "解压 workspace.zip,阅读 README.md 和 RULES.md,编写支持 2–15 方的夺旗 bot。你有 " + Math.min(60, minutes) + " 分钟,只看最终 bot 的强度,提前交卷不加分,请充分利用时间持续改进;可以适当超时,但到 " + minutes + " 分钟会强制交卷。允许本地工具、对战和复盘,禁止联网、调用其他 AI 或查看其他选手资料。提交 workspace/submission/bot.cpp,简述策略。"].join("\n");
1907
2269
  }
1908
2270
  };
1909
- /** Run the task's host-side setup script with network access. */
1910
- function runSetup(workspacePath) {
1911
- execFileSync("bash", ["setup.sh"], {
1912
- cwd: workspacePath,
1913
- stdio: "inherit",
1914
- env: {
1915
- ...process.env,
1916
- GIT_TERMINAL_PROMPT: "0"
1917
- }
1918
- });
1919
- }
1920
2271
  /** Commit0 single-library task. */
1921
2272
  const COMMIT0_DEFINITION = {
1922
2273
  id: COMMIT0_TASK,
1923
2274
  label: COMMIT0_TASK_LABEL,
1924
2275
  description: TASKS[5].description,
1925
2276
  asset: path.join(packageRoot(), "assets", "commit0", "workspace.zip"),
1926
- prepare: runSetup,
2277
+ testMaterial: ["tests", "pytest.ini"],
1927
2278
  timeLimitMinutes: 480,
1928
2279
  timeLimitEnabled: false,
1929
2280
  needsSetup: true,
@@ -1954,7 +2305,11 @@ const SWE_BENCH_PRO_DEFINITION = {
1954
2305
  label: SWE_BENCH_PRO_TASK_LABEL,
1955
2306
  description: TASKS[6].description,
1956
2307
  asset: path.join(packageRoot(), "assets", "swe-bench-pro", "workspace.zip"),
1957
- prepare: runSetup,
2308
+ testMaterial: [
2309
+ "test",
2310
+ "pytest.ini",
2311
+ "conftest.py"
2312
+ ],
1958
2313
  timeLimitMinutes: 1440,
1959
2314
  timeLimitEnabled: false,
1960
2315
  needsSetup: true,
@@ -1989,7 +2344,7 @@ const FEATUREBENCH_DEFINITION = {
1989
2344
  label: FEATUREBENCH_TASK_LABEL,
1990
2345
  description: TASKS[7].description,
1991
2346
  asset: path.join(packageRoot(), "assets", "featurebench", "workspace.zip"),
1992
- prepare: runSetup,
2347
+ testMaterial: ["tests", "conftest.py"],
1993
2348
  timeLimitMinutes: 1440,
1994
2349
  timeLimitEnabled: false,
1995
2350
  needsSetup: true,
@@ -2123,16 +2478,16 @@ const NEURAL_PENDULUM_DEFINITION = {
2123
2478
  return [
2124
2479
  "你是一名控制与神经网络工程师。任务:从零实现纯 C++17、仅 CPU、fp32 的神经网络基础设施,并用它训练一个控制器,控制预编译的三节摆从接近全向下起摆,使三根杆全部进入竖直向上容差并尽可能长时间保持。",
2125
2480
  "",
2126
- "请先完整阅读 TASK.md;它是权威规则。硬约束:仅 C++17 标准库、仅 CPU、fp32;不使用 Python/OpenCL/CUDA/BLAS/Eigen/第三方库;必须从提交源码构建出 build/control 与 build/libnn.a;基础设施需实现工作区公开头文件 include/nn/nn.hpp 的契约,隐藏测试由评测侧独立编译链接。",
2481
+ "请先完整阅读 TASK.md;它是权威规则。硬约束:仅 C++17 标准库、仅 CPU、fp32;不使用 Python/OpenCL/CUDA/BLAS/Eigen/第三方库;必须从提交源码构建出 build/nn_task 与 build/control。",
2127
2482
  "",
2128
2483
  "环境细节:joint1 与 joint3 驱动,joint2 被动;控制频率 100 Hz;每回合最多 3000 步(30 s),达到上限后继续 step 返回 {\"error\":\"episode_finished\"},必须 reset。环境只返回三个关节角度(弧度,不除 pi),不返回 reward/loss/cost/done;环境不提供杆长、质量、惯量、角速度或 tip。训练 reset 带小范围随机初始角度和角速度;评测使用固定初始状态,无随机扰动。",
2129
2484
  "",
2130
2485
  "优化目标:从接近全向下起摆,使三根杆全部进入 0.05 rad 的竖直向上容差,并尽可能长时间保持;评测指标为 t_can。损失函数、奖励、优化器、网络结构和训练方法由你自行设计,不规定任何训练方案。",
2131
2486
  "",
2132
- "评测会从干净副本重新构建提交源码,编译并运行宿主侧隐藏基础设施测试,然后在固定初始状态上驱动 build/control,统计连续全向上保持时间 t_can。不要把任何未提交的二进制、临时构建产物或外部进程当作交付依赖。",
2487
+ "评测会从干净副本重新构建提交源码,先运行与 Neural-FP32/Neural-CIFAR 相同的算子数值评测:评测侧用 PyTorch 在相同数据上计算参考值,逐操作比较加权平均相对误差并对照阈值,任一算子不可执行、崩溃、超时、输出缺失或误差超阈值即判为基础设施失败;然后在固定初始状态上驱动 build/control,统计连续全向上保持时间 t_can。不要把任何未提交的二进制、临时构建产物或外部进程当作交付依赖。",
2133
2488
  "使用宿主工具 eval_submit 提交当前源码快照,使用 eval_test 请求宿主评测最新提交。提交快照、事件、测试结果和最终分数全部保存在工作区之外的宿主目录;不要尝试读取或修改这些数据,也不要依赖任何工作区内的测试结果文件。最终评测使用最新提交快照,按宿主事件时间积分计算分数。",
2134
2489
  "",
2135
- "工作方式:先完成最小可构建的基础设施和 control 协议;再持续做控制/训练实验并记录可复现结果;最终保证工作区从零构建、隐藏基础设施测试通过、control 可被评测驱动,并留下 NOTES.md/RESULTS.md。",
2490
+ "工作方式:先完成最小可构建的基础设施和 control 协议;再持续做控制/训练实验并记录可复现结果;最终保证工作区从零构建、build/nn_task 算子数值评测通过、control 可被评测驱动,并留下 NOTES.md/RESULTS.md。",
2136
2491
  "",
2137
2492
  "不要向我提问,也不要等待确认,独立完成。"
2138
2493
  ].join("\n");
@@ -2153,7 +2508,47 @@ const TASK_DEFINITIONS = {
2153
2508
  [NEURAL_CIFAR_TASK]: NEURAL_CIFAR_DEFINITION,
2154
2509
  [NEURAL_PENDULUM_TASK]: NEURAL_PENDULUM_DEFINITION
2155
2510
  };
2156
- var TaskService = class {
2511
+ /** SHA-256 of one file. */
2512
+ function sha256File(file) {
2513
+ const hash = createHash("sha256");
2514
+ hash.update(fs.readFileSync(file));
2515
+ return hash.digest("hex");
2516
+ }
2517
+ /** Order-stable SHA-256 of a directory tree (relative paths + contents). */
2518
+ function sha256Tree(dir) {
2519
+ const hash = createHash("sha256");
2520
+ const walk = (current) => {
2521
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
2522
+ const full = path.join(current, entry.name);
2523
+ hash.update(path.relative(dir, full));
2524
+ if (entry.isDirectory()) walk(full);
2525
+ else if (entry.isFile()) hash.update(fs.readFileSync(full));
2526
+ else if (entry.isSymbolicLink()) hash.update("link:" + fs.readlinkSync(full));
2527
+ }
2528
+ };
2529
+ walk(dir);
2530
+ return hash.digest("hex");
2531
+ }
2532
+ /** The venv site-packages directories that exist under one workspace venv. */
2533
+ function sitePackageDirs(venv) {
2534
+ const out = [];
2535
+ for (const lib of ["lib", "lib64"]) {
2536
+ const base = path.join(venv, lib);
2537
+ let entries = [];
2538
+ try {
2539
+ entries = fs.readdirSync(base, { withFileTypes: true });
2540
+ } catch {
2541
+ continue;
2542
+ }
2543
+ for (const entry of entries) {
2544
+ if (!entry.isDirectory() || !entry.name.startsWith("python")) continue;
2545
+ const site = path.join(base, entry.name, "site-packages");
2546
+ if (fs.existsSync(site)) out.push(site);
2547
+ }
2548
+ }
2549
+ return out;
2550
+ }
2551
+ var TaskService = class TaskService {
2157
2552
  guard;
2158
2553
  registry;
2159
2554
  jobs;
@@ -2196,7 +2591,14 @@ var TaskService = class {
2196
2591
  ".submissions",
2197
2592
  "node_modules",
2198
2593
  "__pycache__",
2199
- ".venv"
2594
+ ".venv",
2595
+ ".tmp",
2596
+ ".home",
2597
+ ".cache",
2598
+ ".config",
2599
+ ".local",
2600
+ "data",
2601
+ "work"
2200
2602
  ]);
2201
2603
  let count = 0;
2202
2604
  const walk = (dir) => {
@@ -2265,13 +2667,85 @@ var TaskService = class {
2265
2667
  files
2266
2668
  };
2267
2669
  }
2268
- async runHostEval(workspaceId) {
2670
+ /** Map an evaluator result that has no explicit stage (older evaluator builds). */
2671
+ inferStage(value, error) {
2672
+ if (value.infra_ok === true) return value.success === true ? "completed" : "scoring";
2673
+ const text = error.toLowerCase();
2674
+ if (text.includes("cmake") || text.includes("build")) return "compile";
2675
+ if (text.includes("infrastructure") || text.includes("operator") || text.includes("mre")) return "infra";
2676
+ if (text.includes("controller") || text.includes("torque")) return "controller";
2677
+ return "simulation";
2678
+ }
2679
+ /** The target hold time used by the neural-pendulum scoring integral. */
2680
+ static FINAL_T_TGT = 10;
2681
+ /** One test event's instantaneous score, mirroring the task specification. */
2682
+ static eventScore(event) {
2683
+ const stage = String(event["stage"] ?? "");
2684
+ if (stage !== "scoring" && stage !== "completed" && event["valid"] !== true) return -1;
2685
+ const value = Number(event["t_can"] ?? 0);
2686
+ if (!Number.isFinite(value)) return -1;
2687
+ return (Math.min(Math.max(value, 0), TaskService.FINAL_T_TGT) - TaskService.FINAL_T_TGT) / TaskService.FINAL_T_TGT;
2688
+ }
2689
+ /**
2690
+ * Host-side authoritative final score: integral of the per-test score over
2691
+ * time, applying every test event at its REQUEST time and stopping at the
2692
+ * first completed request. Mirrors neural-pendulum-eval/run_eval.py, but the
2693
+ * host owns it because only the host knows the request timestamp and the
2694
+ * current test's outcome before the next test arrives.
2695
+ */
2696
+ static finalScoreFromEvents(startedAt, events, now) {
2697
+ const tests = events.filter((item) => (item["event"] ?? item["type"]) === "test");
2698
+ if (tests.length === 0) return -1 * Math.max(0, now - startedAt);
2699
+ let doneAt;
2700
+ let previous = startedAt;
2701
+ let current = -1;
2702
+ let total = 0;
2703
+ for (const item of tests) {
2704
+ const raw = Number(item["time"] ?? item["at"] ?? previous);
2705
+ const at = Number.isFinite(raw) ? Math.max(raw, previous) : previous;
2706
+ total += current * (at - previous);
2707
+ current = TaskService.eventScore(item);
2708
+ previous = at;
2709
+ if (item["completed"] === true) {
2710
+ doneAt = at;
2711
+ break;
2712
+ }
2713
+ }
2714
+ total += current * Math.max(0, (doneAt ?? now) - previous);
2715
+ return total;
2716
+ }
2717
+ /** Read host test events; malformed lines are ignored. */
2718
+ readHostEvents(workspaceId) {
2719
+ const file = this.hostEvents(workspaceId);
2720
+ if (!fs.existsSync(file)) return [];
2721
+ const out = [];
2722
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
2723
+ if (line.trim() === "") continue;
2724
+ try {
2725
+ const value = JSON.parse(line);
2726
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) out.push(value);
2727
+ } catch {}
2728
+ }
2729
+ return out;
2730
+ }
2731
+ /** The task start time recorded at first submission, falling back to the run creation time. */
2732
+ hostStartedAt(workspaceId, run) {
2733
+ try {
2734
+ const meta = JSON.parse(fs.readFileSync(path.join(this.hostDir(workspaceId), "meta.json"), "utf8"));
2735
+ const value = Number(meta.started_at);
2736
+ if (Number.isFinite(value) && value > 0) return value;
2737
+ } catch {}
2738
+ return run.createdAt / 1e3;
2739
+ }
2740
+ /** @param startedAtSeconds - task start in epoch seconds (from host meta). */
2741
+ async runHostEval(workspaceId, startedAtSeconds) {
2269
2742
  const latest = this.hostLatestSubmission(workspaceId);
2270
2743
  if (latest === void 0) return {
2271
2744
  stage: "no_submission",
2272
2745
  t_can: 0,
2273
2746
  completed: false,
2274
- error: ""
2747
+ error: "",
2748
+ final_score: 0
2275
2749
  };
2276
2750
  const temp = fs.mkdtempSync(path.join(guardRoot(), "pend-eval-"));
2277
2751
  try {
@@ -2281,16 +2755,14 @@ var TaskService = class {
2281
2755
  const events = this.hostEvents(workspaceId);
2282
2756
  fs.writeFileSync(path.join(temp, ".eval", "events.jsonl"), fs.existsSync(events) ? fs.readFileSync(events) : "", "utf8");
2283
2757
  fs.writeFileSync(path.join(temp, ".eval", "meta.json"), JSON.stringify({
2284
- started_at: Date.now() / 1e3,
2758
+ started_at: startedAtSeconds,
2285
2759
  t_tgt: 10
2286
2760
  }), "utf8");
2287
2761
  const resultFile = path.join(temp, "result.json");
2288
2762
  const script = path.join(packageRoot(), "neural-pendulum-eval", "run_eval.py");
2289
2763
  const env = {
2290
- ...process.env,
2291
- EVAL_RESULT_FILE: resultFile,
2292
- PYTHONPATH: "",
2293
- PYTHONHOME: ""
2764
+ ...baseEnvironment(),
2765
+ EVAL_RESULT_FILE: resultFile
2294
2766
  };
2295
2767
  const child = spawn("python3", [
2296
2768
  "-X",
@@ -2298,35 +2770,61 @@ var TaskService = class {
2298
2770
  script,
2299
2771
  "--workspace",
2300
2772
  temp
2301
- ], { env });
2773
+ ], {
2774
+ env,
2775
+ detached: true
2776
+ });
2302
2777
  let stderr = "";
2303
2778
  child.stderr?.on("data", (chunk) => {
2304
2779
  stderr += String(chunk);
2305
2780
  });
2781
+ let timedOut = false;
2306
2782
  await new Promise((resolve, reject) => {
2307
- child.on("error", reject);
2308
- child.on("close", () => resolve());
2783
+ const timer = setTimeout(() => {
2784
+ timedOut = true;
2785
+ try {
2786
+ if (child.pid !== void 0) process.kill(-child.pid, "SIGKILL");
2787
+ } catch {}
2788
+ try {
2789
+ child.kill("SIGKILL");
2790
+ } catch {}
2791
+ resolve();
2792
+ }, 12e5);
2793
+ child.on("error", (error) => {
2794
+ clearTimeout(timer);
2795
+ reject(error);
2796
+ });
2797
+ child.on("close", () => {
2798
+ clearTimeout(timer);
2799
+ resolve();
2800
+ });
2309
2801
  });
2802
+ if (timedOut) return {
2803
+ stage: "infra",
2804
+ t_can: 0,
2805
+ completed: false,
2806
+ error: "evaluation timed out after 20 minutes",
2807
+ final_score: 0
2808
+ };
2310
2809
  if (!fs.existsSync(resultFile)) return {
2311
- stage: "controller",
2810
+ stage: "infra",
2312
2811
  t_can: 0,
2313
2812
  completed: false,
2314
- error: stderr || "evaluator failed"
2813
+ error: stderr || "evaluator failed",
2814
+ final_score: 0
2315
2815
  };
2316
2816
  const value = JSON.parse(fs.readFileSync(resultFile, "utf8"));
2317
2817
  const error = String(value.error ?? "");
2318
- let stage = "controller";
2319
- if (error.includes("cmake") || error.includes("build")) stage = "compile";
2320
- else if (error.includes("selftest") || value.infra_ok === false) stage = "infra";
2321
- else if (error.includes("controller")) stage = "controller";
2322
- else if (value.success === true) stage = "completed";
2323
- else if (value.infra_ok === true) stage = "scoring";
2324
- else stage = "simulation";
2818
+ const rawStage = String(value.stage ?? "");
2819
+ const stage = rawStage !== "" ? rawStage : this.inferStage(value, error);
2820
+ const tCan = Number(value.t_can ?? 0);
2821
+ const finalScore = Number(value.final_score ?? 0);
2325
2822
  return {
2326
2823
  stage,
2327
- t_can: Number(value.t_can ?? 0),
2328
- completed: value.success === true,
2329
- error
2824
+ t_can: Number.isFinite(tCan) ? tCan : 0,
2825
+ completed: stage === "completed" && value.success === true,
2826
+ error,
2827
+ final_score: Number.isFinite(finalScore) ? finalScore : 0
2330
2828
  };
2331
2829
  } finally {
2332
2830
  fs.rmSync(temp, {
@@ -2343,11 +2841,12 @@ var TaskService = class {
2343
2841
  stage: "completed",
2344
2842
  error: "task already completed"
2345
2843
  };
2844
+ const requestedAt = Date.now() / 1e3;
2346
2845
  const latest = this.hostLatestSubmission(run.workspaceId);
2347
2846
  if (latest === void 0) {
2348
2847
  this.appendHostEvent(run.workspaceId, {
2349
2848
  event: "test",
2350
- time: Date.now() / 1e3,
2849
+ time: requestedAt,
2351
2850
  submission_id: "",
2352
2851
  stage: "no_submission",
2353
2852
  t_can: 0,
@@ -2362,20 +2861,29 @@ var TaskService = class {
2362
2861
  error: ""
2363
2862
  };
2364
2863
  }
2365
- const value = await this.runHostEval(run.workspaceId);
2864
+ const startedAt = this.hostStartedAt(run.workspaceId, run);
2865
+ const value = await this.runHostEval(run.workspaceId, startedAt);
2366
2866
  const stage = String(value.stage ?? "simulation");
2367
2867
  const tCan = Number(value.t_can ?? 0);
2368
- const completed = stage === "completed" && Number.isFinite(tCan);
2369
- this.appendHostEvent(run.workspaceId, {
2868
+ const completed = stage === "completed" && Number.isFinite(tCan) && tCan >= 10;
2869
+ const event = {
2370
2870
  event: "test",
2371
- time: Date.now() / 1e3,
2871
+ time: requestedAt,
2372
2872
  submission_id: latest.id,
2373
2873
  stage,
2374
2874
  t_can: tCan,
2375
2875
  completed,
2376
2876
  error: String(value.error ?? "")
2377
- });
2378
- if (completed) fs.writeFileSync(done, String(Date.now() / 1e3), "utf8");
2877
+ };
2878
+ const finalScore = TaskService.finalScoreFromEvents(startedAt, [...this.readHostEvents(run.workspaceId), event], Date.now() / 1e3);
2879
+ event["final_score"] = finalScore;
2880
+ this.appendHostEvent(run.workspaceId, event);
2881
+ if (completed) fs.writeFileSync(done, JSON.stringify({
2882
+ at: requestedAt,
2883
+ stage,
2884
+ t_can: tCan,
2885
+ final_score: finalScore
2886
+ }) + "\n", "utf8");
2379
2887
  return {
2380
2888
  ok: true,
2381
2889
  stage,
@@ -2386,17 +2894,28 @@ var TaskService = class {
2386
2894
  }
2387
2895
  /** Build a temporary workspace containing the host-side authoritative state. */
2388
2896
  stageHostWorkspace(workspaceId) {
2389
- if (this.hostLatestSubmission(workspaceId) === void 0) return void 0;
2897
+ const latest = this.hostLatestSubmission(workspaceId);
2898
+ if (latest === void 0) return void 0;
2390
2899
  const temp = fs.mkdtempSync(path.join(guardRoot(), "pend-eval-view-"));
2391
2900
  const state = path.join(temp, ".eval");
2392
2901
  fs.mkdirSync(path.join(state, "submissions"), { recursive: true });
2393
- const root = path.join(this.hostDir(workspaceId), "submissions");
2394
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) if (entry.isDirectory()) fs.cpSync(path.join(root, entry.name), path.join(state, "submissions", entry.name), { recursive: true });
2902
+ fs.cpSync(latest.path, path.join(state, "submissions", latest.id), { recursive: true });
2395
2903
  const events = this.hostEvents(workspaceId);
2396
2904
  fs.writeFileSync(path.join(state, "events.jsonl"), fs.existsSync(events) ? fs.readFileSync(events) : "", "utf8");
2397
2905
  const meta = path.join(this.hostDir(workspaceId), "meta.json");
2906
+ let fallbackStart = Date.now() / 1e3;
2907
+ try {
2908
+ for (const line of (fs.existsSync(events) ? fs.readFileSync(events, "utf8") : "").split("\n")) {
2909
+ if (line.trim() === "") continue;
2910
+ const time = Number(JSON.parse(line).time);
2911
+ if (Number.isFinite(time) && time > 0) {
2912
+ fallbackStart = time;
2913
+ break;
2914
+ }
2915
+ }
2916
+ } catch {}
2398
2917
  fs.writeFileSync(path.join(state, "meta.json"), fs.existsSync(meta) ? fs.readFileSync(meta) : JSON.stringify({
2399
- started_at: Date.now() / 1e3,
2918
+ started_at: fallbackStart,
2400
2919
  t_tgt: 10
2401
2920
  }), "utf8");
2402
2921
  return temp;
@@ -2455,6 +2974,67 @@ var TaskService = class {
2455
2974
  if (index === "custom") return (options.customPipUrl ?? "").trim() || "https://pypi.org/simple";
2456
2975
  return PIP_MIRRORS.find((item) => item.id === index)?.url ?? "https://pypi.org/simple";
2457
2976
  }
2977
+ /** Host-side pristine material for one workspace (tests, venv manifest). */
2978
+ baselineDir(workspaceId) {
2979
+ return path.join(guardRoot(), "task-baseline", workspaceId.replace(/[^A-Za-z0-9_.-]/g, "_"));
2980
+ }
2981
+ /**
2982
+ * Snapshot the test material a benchmark task ships with, so an agent cannot
2983
+ * pass by editing or weakening the tests. Runs once after a successful
2984
+ * install; the evaluator restores it before scoring.
2985
+ */
2986
+ snapshotTestMaterial(definition, workspaceId, workspacePath) {
2987
+ const names = definition.testMaterial ?? [];
2988
+ if (names.length === 0) return;
2989
+ const target = this.baselineDir(workspaceId);
2990
+ fs.mkdirSync(target, { recursive: true });
2991
+ for (const name of names) {
2992
+ const source = path.join(workspacePath, name);
2993
+ if (!fs.existsSync(source)) continue;
2994
+ const destination = path.join(target, name);
2995
+ fs.rmSync(destination, {
2996
+ recursive: true,
2997
+ force: true
2998
+ });
2999
+ fs.cpSync(source, destination, {
3000
+ recursive: true,
3001
+ dereference: false
3002
+ });
3003
+ }
3004
+ fs.writeFileSync(path.join(target, "manifest.json"), JSON.stringify({
3005
+ task: definition.id,
3006
+ names
3007
+ }, null, 2) + "\n", "utf8");
3008
+ }
3009
+ /**
3010
+ * Record a fingerprint of the venv's control plane (python target, pyvenv.cfg,
3011
+ * .pth files, pytest packages). The evaluator refuses to trust a venv whose
3012
+ * fingerprint no longer matches, which closes the "replace bin/python or
3013
+ * patch pytest" forgery path.
3014
+ */
3015
+ snapshotVenv(workspaceId, workspacePath) {
3016
+ const venv = path.join(workspacePath, ".venv");
3017
+ if (!fs.existsSync(venv)) return;
3018
+ const target = this.baselineDir(workspaceId);
3019
+ fs.mkdirSync(target, { recursive: true });
3020
+ const manifest = { files: {} };
3021
+ const python = path.join(venv, "bin", "python");
3022
+ try {
3023
+ manifest.python = fs.realpathSync.native(python);
3024
+ } catch {}
3025
+ const cfg = path.join(venv, "pyvenv.cfg");
3026
+ if (fs.existsSync(cfg)) manifest.files["pyvenv.cfg"] = sha256File(cfg);
3027
+ for (const site of sitePackageDirs(venv)) {
3028
+ for (const entry of fs.readdirSync(site, { withFileTypes: true })) {
3029
+ if (!entry.isFile() || !entry.name.endsWith(".pth")) continue;
3030
+ const file = path.join(site, entry.name);
3031
+ manifest.files[path.relative(venv, file)] = sha256File(file);
3032
+ }
3033
+ const pytest = path.join(site, "pytest");
3034
+ if (fs.existsSync(pytest)) manifest.pytest = sha256Tree(pytest);
3035
+ }
3036
+ fs.writeFileSync(path.join(target, "venv.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8");
3037
+ }
2458
3038
  /** Check one workspace before install. */
2459
3039
  check(workspaceId) {
2460
3040
  const workspace = this.registry.get(workspaceId);
@@ -2548,7 +3128,7 @@ var TaskService = class {
2548
3128
  }
2549
3129
  /** Remove the shared CIFAR lock only when it belongs to this install. */
2550
3130
  cleanupCifarLock(token) {
2551
- const lock = path.join(os.homedir(), ".dsh", "eval-guard", "cache", "cifar10", ".download.lock");
3131
+ const lock = path.join(dshHome(), "eval-guard", "cache", "cifar10", ".download.lock");
2552
3132
  try {
2553
3133
  if (!fs.existsSync(lock)) return;
2554
3134
  if (fs.readFileSync(lock, "utf8").includes("token=" + token)) fs.rmSync(lock, { force: true });
@@ -2613,10 +3193,16 @@ var TaskService = class {
2613
3193
  command,
2614
3194
  cwd: checked.path,
2615
3195
  env,
3196
+ cleanupWorkspace: checked.path,
3197
+ cifarToken: installToken,
2616
3198
  onCancel: cleanupInstall,
2617
3199
  onFailure: cleanupInstall,
2618
3200
  onSuccess: () => {
2619
3201
  if (definition.needsSetup) this.cleanupWorkspace(checked.path);
3202
+ try {
3203
+ this.snapshotTestMaterial(definition, workspaceId, checked.path);
3204
+ this.snapshotVenv(workspaceId, checked.path);
3205
+ } catch {}
2620
3206
  return { prompt: this.promptNotice(this.guard.promptFor(checked.path, this.materialZip(taskId), definition)) };
2621
3207
  }
2622
3208
  });
@@ -2646,30 +3232,53 @@ var TaskService = class {
2646
3232
  ok: false,
2647
3233
  error: "评测脚本不存在:" + script
2648
3234
  };
2649
- const evalWorkspace = (taskId === "neural-pendulum" ? this.stageHostWorkspace(workspaceId) : void 0) ?? checked.path;
3235
+ const hostView = taskId === "neural-pendulum" ? this.stageHostWorkspace(workspaceId) : void 0;
3236
+ const evalWorkspace = hostView ?? checked.path;
3237
+ const cleanupHostView = () => {
3238
+ if (hostView === void 0) return;
3239
+ try {
3240
+ fs.rmSync(hostView, {
3241
+ recursive: true,
3242
+ force: true
3243
+ });
3244
+ } catch {}
3245
+ };
3246
+ const baseline = this.baselineDir(workspaceId);
3247
+ const baselineEnv = fs.existsSync(baseline) ? { EVAL_BASELINE_DIR: baseline } : {};
3248
+ const job = this.jobs.start({
3249
+ kind: "eval",
3250
+ task: taskId,
3251
+ workspaceId,
3252
+ command: [
3253
+ "python3",
3254
+ "-X",
3255
+ "utf8",
3256
+ script,
3257
+ "--workspace",
3258
+ evalWorkspace
3259
+ ],
3260
+ cwd: packageRoot(),
3261
+ env: {
3262
+ PYTHONPATH: "",
3263
+ PYTHONHOME: "",
3264
+ EVAL_CIFAR_PROXY: this.proxyValue(this.guard.configuration.cifarProxyUrl),
3265
+ EVAL_CIFAR_URL: this.cifarUrl(this.guard.configuration),
3266
+ ...baselineEnv
3267
+ },
3268
+ onSuccess: cleanupHostView,
3269
+ onCancel: cleanupHostView,
3270
+ onFailure: cleanupHostView
3271
+ });
3272
+ if (job.status === "failed") {
3273
+ cleanupHostView();
3274
+ return {
3275
+ ok: false,
3276
+ error: job.error ?? "评测启动失败"
3277
+ };
3278
+ }
2650
3279
  return {
2651
3280
  ok: true,
2652
- jobId: this.jobs.start({
2653
- kind: "eval",
2654
- task: taskId,
2655
- workspaceId,
2656
- command: [
2657
- "python3",
2658
- "-X",
2659
- "utf8",
2660
- script,
2661
- "--workspace",
2662
- evalWorkspace
2663
- ],
2664
- cwd: packageRoot(),
2665
- env: {
2666
- PYTHONPATH: "",
2667
- PYTHONHOME: "",
2668
- EVAL_CIFAR_PROXY: this.proxyValue(this.guard.configuration.cifarProxyUrl),
2669
- EVAL_CIFAR_URL: this.cifarUrl(this.guard.configuration),
2670
- EVAL_SUBMISSIONS_ROOT: path.join(guardRoot(), "submissions", workspaceId.replace(/[^A-Za-z0-9_.-]/g, "_"))
2671
- }
2672
- }).id
3281
+ jobId: job.id
2673
3282
  };
2674
3283
  }
2675
3284
  /** Add the optional shared-machine notice to a task prompt. */
@@ -2695,54 +3304,6 @@ var TaskService = class {
2695
3304
  if (stale) fs.copyFileSync(source, target);
2696
3305
  return target;
2697
3306
  }
2698
- /** Install the selected task pack into an empty workspace. */
2699
- install(workspaceId, taskId = TANK_TASK, promptOverride) {
2700
- const definition = TASK_DEFINITIONS[taskId];
2701
- if (definition === void 0) return {
2702
- ok: false,
2703
- workspaceId,
2704
- error: "未知任务:" + taskId
2705
- };
2706
- const checked = this.check(workspaceId);
2707
- if (!checked.ok) return {
2708
- ok: false,
2709
- error: checked.error,
2710
- workspaceId
2711
- };
2712
- if (!checked.empty) return {
2713
- ok: false,
2714
- workspaceId,
2715
- workspacePath: checked.path,
2716
- error: "工作区不是空的;未执行任何操作。请换一个空工作区,或按手动通道处理。"
2717
- };
2718
- if (!this.guard.active) return {
2719
- ok: false,
2720
- workspaceId,
2721
- workspacePath: checked.path,
2722
- error: "请先启用评测守护,再安装评测任务。"
2723
- };
2724
- try {
2725
- fs.copyFileSync(this.materialZip(taskId), path.join(checked.path, "workspace.zip"));
2726
- if (definition.needsSetup) this.extractMaterial(checked.path);
2727
- if (definition.prepare !== void 0) definition.prepare(checked.path);
2728
- if (definition.needsSetup) this.cleanupWorkspace(checked.path);
2729
- } catch (error) {
2730
- if (checked.empty) this.wipeInstallWorkspace(checked.path);
2731
- return {
2732
- ok: false,
2733
- workspaceId,
2734
- workspacePath: checked.path,
2735
- error: "任务准备失败:" + (error instanceof Error ? error.message : String(error))
2736
- };
2737
- }
2738
- const prompt = this.promptNotice(promptOverride ?? this.guard.promptFor(checked.path, this.materialZip(taskId), definition));
2739
- return {
2740
- ok: true,
2741
- workspaceId,
2742
- workspacePath: checked.path,
2743
- prompt
2744
- };
2745
- }
2746
3307
  /** The prompt for manual copying, without installing anything. */
2747
3308
  prompt(workspaceId, taskId = TANK_TASK) {
2748
3309
  const definition = TASK_DEFINITIONS[taskId];
@@ -2794,6 +3355,14 @@ function under(target, root) {
2794
3355
  const relative = path.relative(root, target);
2795
3356
  return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
2796
3357
  }
3358
+ /** Resolve symlinks where possible; keep the lexical path when it does not exist. */
3359
+ function realOrSelf(target) {
3360
+ try {
3361
+ return fs.realpathSync.native(target);
3362
+ } catch {
3363
+ return path.resolve(target);
3364
+ }
3365
+ }
2797
3366
  /**
2798
3367
  * Register every route this plugin owns.
2799
3368
  * @param ctx - host context.
@@ -2818,6 +3387,7 @@ function registerRoutes(ctx, guard, tasks) {
2818
3387
  fetch: async () => {
2819
3388
  return json({
2820
3389
  enabled: guard.active,
3390
+ ...guard.lastFailure === void 0 ? {} : { error: guard.lastFailure },
2821
3391
  running: guard.runningSessions(),
2822
3392
  tasks: [...TASKS],
2823
3393
  defaultBlacklist: guard.defaultBlacklist(),
@@ -2964,15 +3534,25 @@ function registerRoutes(ctx, guard, tasks) {
2964
3534
  }
2965
3535
  const target = typeof body.path === "string" ? path.resolve(body.path) : "";
2966
3536
  if (target === "") return json({ error: "path is required" }, 400);
2967
- const material = path.resolve(guardRoot(), "material");
2968
- const workspaces = child.get("workspaceRegistry")?.list?.() ?? [];
2969
- if (!(under(target, material) || workspaces.some((workspace) => under(target, workspace.path)))) return json({ error: "path is not revealable" }, 403);
2970
3537
  if (!fs.existsSync(target)) return json({ error: "path does not exist" }, 404);
3538
+ const real = realOrSelf(target);
3539
+ const material = realOrSelf(path.join(guardRoot(), "material"));
3540
+ const workspaces = child.get("workspaceRegistry")?.list?.() ?? [];
3541
+ if (!(under(real, material) || workspaces.some((workspace) => under(real, realOrSelf(workspace.path))))) return json({ error: "path is not revealable" }, 403);
2971
3542
  try {
2972
- spawn("xdg-open", [target], {
2973
- detached: true,
2974
- stdio: "ignore"
2975
- }).unref();
3543
+ await new Promise((resolve, reject) => {
3544
+ const childProcess = spawn("xdg-open", [real], {
3545
+ detached: true,
3546
+ stdio: "ignore"
3547
+ });
3548
+ childProcess.once("spawn", () => {
3549
+ childProcess.unref();
3550
+ resolve();
3551
+ });
3552
+ childProcess.once("error", (error) => {
3553
+ reject(error);
3554
+ });
3555
+ });
2976
3556
  } catch (error) {
2977
3557
  return json({ error: error instanceof Error ? error.message : String(error) }, 500);
2978
3558
  }
@@ -3008,12 +3588,11 @@ function resolve(guard, exec) {
3008
3588
  const run = guard.runForSession(exec.agent?.id);
3009
3589
  if (run === void 0) throw new Error("eval task run is not active for this session");
3010
3590
  if (run.task !== "neural-pendulum") throw new Error("this tool is only available for the neural-pendulum task");
3591
+ if (run.status !== "active") throw new Error("eval task run is not active (status: " + run.status + ")");
3011
3592
  return run;
3012
3593
  }
3013
3594
  /** Install the task tools into one agent's own scoped registry. */
3014
3595
  function registerAgentSubmissionTools(agent, guard, tasks) {
3015
- const tools = agent.ctx?.tools;
3016
- if (tools === void 0) return;
3017
3596
  const submit = {
3018
3597
  name: "eval_submit",
3019
3598
  description: "Submit the current workspace source as a new immutable snapshot for the active Neural-Pendulum run. Submission itself does not change the score.",
@@ -3039,11 +3618,25 @@ function registerAgentSubmissionTools(agent, guard, tasks) {
3039
3618
  schema: OUTPUT_SCHEMA,
3040
3619
  render
3041
3620
  },
3042
- timeoutMs: 9e5,
3621
+ timeoutMs: 132e4,
3043
3622
  execute: async (_args, exec) => tasks.hostTest(resolve(guard, exec))
3044
3623
  };
3045
- tools.register(submit);
3046
- tools.register(test);
3624
+ let registered = false;
3625
+ const install = (tools) => {
3626
+ if (tools === void 0 || registered) return;
3627
+ registered = true;
3628
+ tools.register(submit);
3629
+ tools.register(test);
3630
+ };
3631
+ const ctx = agent.ctx;
3632
+ if (ctx === void 0) return;
3633
+ if (ctx.tools !== void 0) {
3634
+ install(ctx.tools);
3635
+ return;
3636
+ }
3637
+ if (typeof ctx.inject === "function") ctx.inject(["tools"], (child) => {
3638
+ install(child.get?.("tools") ?? child.tools);
3639
+ });
3047
3640
  }
3048
3641
  //#endregion
3049
3642
  //#region src/index.ts