@gethmy/harness 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -17,14 +17,13 @@ var __export = (target, all) => {
17
17
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
18
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
19
19
  // ../harmony-shared/dist/agentStaleness.js
20
- var AGENT_HEARTBEAT_LIVENESS_MS, AGENT_MILESTONE_LIVENESS_MS, AGENT_SWEEP_DAEMON_MS, AGENT_SWEEP_INTERACTIVE_MS, AGENT_SWEEP_PAUSED_MS, SWEPT_SESSION_WRITE_GRACE_MS, ACTIVE_STATUSES;
20
+ var AGENT_HEARTBEAT_LIVENESS_MS, AGENT_MILESTONE_LIVENESS_MS, AGENT_SWEEP_DAEMON_MS, AGENT_SWEEP_INTERACTIVE_MS, AGENT_SWEEP_PAUSED_MS, ACTIVE_STATUSES;
21
21
  var init_agentStaleness = __esm(() => {
22
22
  AGENT_HEARTBEAT_LIVENESS_MS = 5 * 60 * 1000;
23
23
  AGENT_MILESTONE_LIVENESS_MS = 30 * 60 * 1000;
24
24
  AGENT_SWEEP_DAEMON_MS = 30 * 60 * 1000;
25
25
  AGENT_SWEEP_INTERACTIVE_MS = 2 * 60 * 60 * 1000;
26
26
  AGENT_SWEEP_PAUSED_MS = 4 * 60 * 60 * 1000;
27
- SWEPT_SESSION_WRITE_GRACE_MS = 60 * 60 * 1000;
28
27
  ACTIVE_STATUSES = new Set(["working", "blocked", "waiting"]);
29
28
  });
30
29
  // ../harmony-shared/dist/branchRef.js
@@ -612,13 +611,13 @@ var init_log = __esm(() => {
612
611
 
613
612
  // src/confine-to-repo.ts
614
613
  import { realpathSync } from "node:fs";
615
- import { dirname as dirname2, isAbsolute as isAbsolute2, parse, resolve as resolve2, sep as sep2 } from "node:path";
614
+ import { dirname, isAbsolute, parse, resolve, sep } from "node:path";
616
615
  function isGitMetadata(repoRoot, candidate) {
617
616
  const rel = candidate.startsWith(repoRoot) ? candidate.slice(repoRoot.length) : candidate;
618
617
  return rel.split(/[\\/]/).some((segment) => segment.toLowerCase() === ".git");
619
618
  }
620
619
  function patternEscapes(pattern) {
621
- if (isAbsolute2(pattern))
620
+ if (isAbsolute(pattern))
622
621
  return true;
623
622
  if (/[{}[\]~()!+@]/.test(pattern))
624
623
  return true;
@@ -628,17 +627,17 @@ function pathArgsFor(mode) {
628
627
  return mode === "write" ? { ...READ_PATH_ARGS, ...WRITE_PATH_ARGS } : READ_PATH_ARGS;
629
628
  }
630
629
  function realPathOrNearest(p) {
631
- const abs = isAbsolute2(p) ? p : resolve2(p);
630
+ const abs = isAbsolute(p) ? p : resolve(p);
632
631
  const { root } = parse(abs);
633
632
  let real = root;
634
- for (const part of abs.slice(root.length).split(sep2)) {
633
+ for (const part of abs.slice(root.length).split(sep)) {
635
634
  if (part === "" || part === ".")
636
635
  continue;
637
636
  if (part === "..") {
638
- real = dirname2(real);
637
+ real = dirname(real);
639
638
  continue;
640
639
  }
641
- const next = real.endsWith(sep2) ? real + part : real + sep2 + part;
640
+ const next = real.endsWith(sep) ? real + part : real + sep + part;
642
641
  try {
643
642
  real = realpathSync(next);
644
643
  } catch {
@@ -652,7 +651,7 @@ function isInsideTree(root, target) {
652
651
  const normalizedTarget = realPathOrNearest(target);
653
652
  if (normalizedTarget === normalizedRoot)
654
653
  return true;
655
- return normalizedTarget.startsWith(normalizedRoot + sep2);
654
+ return normalizedTarget.startsWith(normalizedRoot + sep);
656
655
  }
657
656
  function decideConfinedTool(repoRoot, toolName, input, mode = "read") {
658
657
  const pathArgs = pathArgsFor(mode)[toolName];
@@ -678,7 +677,7 @@ function decideConfinedTool(repoRoot, toolName, input, mode = "read") {
678
677
  const value = input[key];
679
678
  if (typeof value !== "string" || value.length === 0)
680
679
  continue;
681
- const candidate = isAbsolute2(value) ? value : `${repoRoot}${sep2}${value}`;
680
+ const candidate = isAbsolute(value) ? value : `${repoRoot}${sep}${value}`;
682
681
  if (isGitMetadata(repoRoot, candidate)) {
683
682
  return {
684
683
  behavior: "deny",
@@ -769,38 +768,38 @@ var init_runner = __esm(() => {
769
768
  });
770
769
 
771
770
  // src/run-containment.ts
772
- import { createHash as createHash2 } from "node:crypto";
773
- import { readFileSync as readFileSync2 } from "node:fs";
771
+ import { createHash } from "node:crypto";
772
+ import { readFileSync } from "node:fs";
774
773
  import { createRequire as createRequire2 } from "node:module";
775
- import { homedir, tmpdir as tmpdir2 } from "node:os";
776
- import { dirname as dirname3, isAbsolute as isAbsolute3, join as join2 } from "node:path";
774
+ import { homedir, tmpdir } from "node:os";
775
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join } from "node:path";
777
776
  import { getConfigDir as getConfigDir2 } from "@gethmy/mcp/src/config.js";
778
777
  function credentialDirectories() {
779
778
  const home = homedir();
780
779
  return [
781
780
  getConfigDir2(),
782
- join2(home, ".claude"),
783
- join2(home, ".claude.json"),
784
- join2(home, ".ssh"),
785
- join2(home, ".gnupg"),
786
- join2(home, ".aws"),
787
- join2(home, ".codex"),
788
- join2(home, ".gemini"),
789
- join2(home, ".config", "gh"),
790
- join2(home, ".config", "gcloud"),
791
- join2(home, ".config", "anthropic"),
792
- join2(home, ".config", "op"),
793
- join2(home, ".docker"),
794
- join2(home, ".kube"),
795
- join2(home, ".netrc"),
796
- join2(home, ".npmrc"),
797
- join2(home, ".git-credentials")
781
+ join(home, ".claude"),
782
+ join(home, ".claude.json"),
783
+ join(home, ".ssh"),
784
+ join(home, ".gnupg"),
785
+ join(home, ".aws"),
786
+ join(home, ".codex"),
787
+ join(home, ".gemini"),
788
+ join(home, ".config", "gh"),
789
+ join(home, ".config", "gcloud"),
790
+ join(home, ".config", "anthropic"),
791
+ join(home, ".config", "op"),
792
+ join(home, ".docker"),
793
+ join(home, ".kube"),
794
+ join(home, ".netrc"),
795
+ join(home, ".npmrc"),
796
+ join(home, ".git-credentials")
798
797
  ];
799
798
  }
800
799
  function writeOnlyDenyPaths() {
801
- const paths = [join2(homedir(), ".gitconfig")];
800
+ const paths = [join(homedir(), ".gitconfig")];
802
801
  const xdg = process.env.XDG_CONFIG_HOME;
803
- paths.push(xdg && isAbsolute3(xdg) ? join2(xdg, "git") : join2(homedir(), ".config", "git"));
802
+ paths.push(xdg && isAbsolute2(xdg) ? join(xdg, "git") : join(homedir(), ".config", "git"));
804
803
  return paths;
805
804
  }
806
805
  function credentialToolDeny() {
@@ -811,13 +810,13 @@ function credentialToolDeny() {
811
810
  }
812
811
  function toolchainCacheDirectories(worktree) {
813
812
  const home = homedir();
814
- const scratch = `harmony-run-${createHash2("sha256").update(worktree).digest("hex").slice(0, 16)}`;
815
- const candidate = process.env.TMPDIR ?? tmpdir2();
816
- const tmpRoot = isAbsolute3(candidate) ? candidate : "/tmp";
813
+ const scratch = `harmony-run-${createHash("sha256").update(worktree).digest("hex").slice(0, 16)}`;
814
+ const candidate = process.env.TMPDIR ?? tmpdir();
815
+ const tmpRoot = isAbsolute2(candidate) ? candidate : "/tmp";
817
816
  return [
818
- join2(home, ".bun", "install", "cache"),
819
- join2(home, ".npm", "_cacache"),
820
- join2(tmpRoot, scratch)
817
+ join(home, ".bun", "install", "cache"),
818
+ join(home, ".npm", "_cacache"),
819
+ join(tmpRoot, scratch)
821
820
  ];
822
821
  }
823
822
  function secretEnvKeysToStrip(parentEnv = process.env) {
@@ -832,10 +831,10 @@ function secretEnvKeysToStrip(parentEnv = process.env) {
832
831
  }
833
832
  function assertNoProjectSandboxOverride(worktree) {
834
833
  for (const name of ["settings.json", "settings.local.json"]) {
835
- const path = join2(worktree, ".claude", name);
834
+ const path = join(worktree, ".claude", name);
836
835
  let raw;
837
836
  try {
838
- raw = readFileSync2(path, "utf-8");
837
+ raw = readFileSync(path, "utf-8");
839
838
  } catch {
840
839
  continue;
841
840
  }
@@ -863,12 +862,17 @@ function containedEnv(parentEnv = process.env) {
863
862
  }
864
863
  return out;
865
864
  }
865
+ function heldTestEnvKeysToStrip(parentEnv = process.env) {
866
+ return [
867
+ ...new Set([...secretEnvKeysToStrip(parentEnv), ...MODEL_CREDENTIAL_KEYS])
868
+ ];
869
+ }
866
870
  function gitMetadataDenyPaths(worktree) {
867
871
  const paths = new Set;
868
- const dotGit = join2(worktree, ".git");
872
+ const dotGit = join(worktree, ".git");
869
873
  const require2 = createRequire2(import.meta.url);
870
874
  const { execFileSync } = require2("node:child_process");
871
- const { statSync: statSync2 } = require2("node:fs");
875
+ const { statSync } = require2("node:fs");
872
876
  const gitDirs = new Set;
873
877
  try {
874
878
  const out = execFileSync("git", [...GIT_NO_HOOKS, "rev-parse", "--git-dir", "--git-common-dir"], { cwd: worktree, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
@@ -877,17 +881,17 @@ function gitMetadataDenyPaths(worktree) {
877
881
  const trimmed = line.trim();
878
882
  if (!trimmed)
879
883
  continue;
880
- gitDirs.add(isAbsolute3(trimmed) ? trimmed : join2(worktree, trimmed));
884
+ gitDirs.add(isAbsolute2(trimmed) ? trimmed : join(worktree, trimmed));
881
885
  }
882
886
  } catch {}
883
887
  gitDirs.add(dotGit);
884
888
  for (const dir of gitDirs) {
885
- paths.add(join2(dir, "config"));
886
- paths.add(join2(dir, "config.worktree"));
887
- paths.add(join2(dir, "hooks"));
889
+ paths.add(join(dir, "config"));
890
+ paths.add(join(dir, "config.worktree"));
891
+ paths.add(join(dir, "hooks"));
888
892
  }
889
893
  try {
890
- if (statSync2(dotGit).isFile())
894
+ if (statSync(dotGit).isFile())
891
895
  paths.add(dotGit);
892
896
  } catch {}
893
897
  return [...paths];
@@ -895,19 +899,19 @@ function gitMetadataDenyPaths(worktree) {
895
899
  function hostPersistencePaths() {
896
900
  const home = homedir();
897
901
  return [
898
- join2(home, ".zshenv"),
899
- join2(home, ".zprofile"),
900
- join2(home, ".zshrc"),
901
- join2(home, ".zlogin"),
902
- join2(home, ".bashrc"),
903
- join2(home, ".bash_profile"),
904
- join2(home, ".bash_login"),
905
- join2(home, ".profile"),
906
- join2(home, ".config", "fish", "config.fish"),
907
- join2(home, ".config", "fish", "conf.d"),
908
- join2(home, "Library", "LaunchAgents"),
909
- join2(home, ".config", "systemd", "user"),
910
- join2(home, ".config", "autostart")
902
+ join(home, ".zshenv"),
903
+ join(home, ".zprofile"),
904
+ join(home, ".zshrc"),
905
+ join(home, ".zlogin"),
906
+ join(home, ".bashrc"),
907
+ join(home, ".bash_profile"),
908
+ join(home, ".bash_login"),
909
+ join(home, ".profile"),
910
+ join(home, ".config", "fish", "config.fish"),
911
+ join(home, ".config", "fish", "conf.d"),
912
+ join(home, "Library", "LaunchAgents"),
913
+ join(home, ".config", "systemd", "user"),
914
+ join(home, ".config", "autostart")
911
915
  ];
912
916
  }
913
917
  function credentialWriteToolDeny(worktree) {
@@ -939,7 +943,7 @@ function implementRunToolPolicy(args) {
939
943
  const value = input[key];
940
944
  if (typeof value !== "string" || value.length === 0)
941
945
  continue;
942
- const target = isAbsolute3(value) ? value : join2(args.worktree, value);
946
+ const target = isAbsolute2(value) ? value : join(args.worktree, value);
943
947
  if (gitMeta.some((p) => isInsideTree(p, target) || p === target)) {
944
948
  return deny(`${toolName} may not touch git metadata. Refused: ${value}`);
945
949
  }
@@ -955,7 +959,7 @@ function implementRunToolPolicy(args) {
955
959
  const value = input[key];
956
960
  if (typeof value !== "string" || value.length === 0)
957
961
  continue;
958
- const target = isAbsolute3(value) ? value : join2(args.worktree, value);
962
+ const target = isAbsolute2(value) ? value : join(args.worktree, value);
959
963
  if (secrets.some((dir) => isInsideTree(dir, target))) {
960
964
  return deny(`${toolName} may not read the credential directories. Refused: ${value}`);
961
965
  }
@@ -965,14 +969,20 @@ function implementRunToolPolicy(args) {
965
969
  return allow;
966
970
  };
967
971
  }
968
- function harmonyMcpServer() {
972
+ function harmonyMcpServer(run) {
969
973
  const require2 = createRequire2(import.meta.url);
970
- const cli = join2(dirname3(require2.resolve("@gethmy/mcp")), "cli.js");
974
+ const cli = join(dirname2(require2.resolve("@gethmy/mcp")), "cli.js");
971
975
  return {
972
976
  harmony: {
973
977
  type: "stdio",
974
978
  command: process.execPath,
975
- args: [cli, "serve"]
979
+ args: [cli, "serve"],
980
+ ...run ? {
981
+ env: {
982
+ HARMONY_AGENT_CARD_ID: run.cardId,
983
+ HARMONY_AGENT_SESSION_ID: run.agentSessionId
984
+ }
985
+ } : {}
976
986
  }
977
987
  };
978
988
  }
@@ -1005,7 +1015,7 @@ function implementRunContainment(args) {
1005
1015
  canUseTool: implementRunToolPolicy(args),
1006
1016
  gateEveryToolCall: true,
1007
1017
  settingSources: args.readOnly === true ? [] : ["project"],
1008
- mcpServers: harmonyMcpServer(),
1018
+ mcpServers: harmonyMcpServer(args.run),
1009
1019
  strictMcpConfig: true,
1010
1020
  stripEnvKeys: secretEnvKeysToStrip(),
1011
1021
  disallowedTools: [
@@ -1029,7 +1039,7 @@ function implementRunContainmentCliArgs(args) {
1029
1039
  containment.disallowedTools.join(",")
1030
1040
  ];
1031
1041
  }
1032
- var SECRET_ENV_PATTERN, KEEP_ENV_KEYS, ProjectSandboxOverrideError, INERT_PROJECT_SETTING_KEYS, WRITE_TOOL_PATHS, READ_TOOL_PATHS, GIT_NO_HOOKS, IMPLEMENT_ALLOWED_DOMAINS;
1042
+ var SECRET_ENV_PATTERN, KEEP_ENV_KEYS, ProjectSandboxOverrideError, INERT_PROJECT_SETTING_KEYS, MODEL_CREDENTIAL_KEYS, WRITE_TOOL_PATHS, READ_TOOL_PATHS, GIT_NO_HOOKS, IMPLEMENT_ALLOWED_DOMAINS;
1033
1043
  var init_run_containment = __esm(() => {
1034
1044
  init_confine_to_repo();
1035
1045
  init_runner();
@@ -1066,6 +1076,11 @@ var init_run_containment = __esm(() => {
1066
1076
  "theme",
1067
1077
  "verbose"
1068
1078
  ]);
1079
+ MODEL_CREDENTIAL_KEYS = [
1080
+ "ANTHROPIC_API_KEY",
1081
+ "ANTHROPIC_AUTH_TOKEN",
1082
+ "CLAUDE_CODE_OAUTH_TOKEN"
1083
+ ];
1069
1084
  WRITE_TOOL_PATHS = {
1070
1085
  Write: ["file_path"],
1071
1086
  Edit: ["file_path"],
@@ -1802,6 +1817,7 @@ init_dist();
1802
1817
 
1803
1818
  // src/exec-types.ts
1804
1819
  var DEFAULT_METRIC_TIMEOUT_MS = 300000;
1820
+ var SANDBOX_STARTUP_GRACE_MS = 60000;
1805
1821
 
1806
1822
  // src/gate-config-error.ts
1807
1823
  init_dist();
@@ -2061,11 +2077,12 @@ function truncate(value, max) {
2061
2077
  init_log();
2062
2078
 
2063
2079
  // src/oracle-collector.ts
2064
- import { createHash } from "node:crypto";
2080
+ import { createHash as createHash2 } from "node:crypto";
2065
2081
  init_log();
2066
2082
 
2067
2083
  // src/oracle.ts
2068
- import { lstatSync, readFileSync, statSync } from "node:fs";
2084
+ import { randomUUID as randomUUID2 } from "node:crypto";
2085
+ import { lstatSync, readFileSync as readFileSync2, realpathSync as realpathSync2, statSync } from "node:fs";
2069
2086
  import {
2070
2087
  chmod,
2071
2088
  lstat,
@@ -2075,26 +2092,159 @@ import {
2075
2092
  rm,
2076
2093
  writeFile
2077
2094
  } from "node:fs/promises";
2078
- import { tmpdir } from "node:os";
2079
- import { dirname, isAbsolute, join, resolve, sep } from "node:path";
2095
+ import { tmpdir as tmpdir2 } from "node:os";
2096
+ import { dirname as dirname3, isAbsolute as isAbsolute3, join as join2, resolve as resolve2, sep as sep2 } from "node:path";
2080
2097
  import { StringDecoder } from "node:string_decoder";
2081
2098
  init_log();
2082
- var TAG4 = "oracle";
2099
+
2100
+ // src/repair-sandbox.ts
2101
+ init_log();
2102
+ import { randomUUID } from "node:crypto";
2103
+ import { promisify } from "node:util";
2104
+ var TAG4 = "repair-sandbox";
2105
+ async function dockerExec(argv, opts) {
2106
+ const { execFile } = await import("node:child_process");
2107
+ return promisify(execFile)("docker", argv, {
2108
+ encoding: "utf-8",
2109
+ ...opts
2110
+ });
2111
+ }
2112
+ var MAX_OUTPUT_BUFFER2 = 20971520;
2113
+ var PROBE_TIMEOUT_MS = 1e4;
2114
+ var SANDBOX_MOUNT = "/repo";
2115
+ var SANDBOX_MEMORY = "4g";
2116
+ var SANDBOX_PIDS = "512";
2117
+ var dockerProbe = null;
2118
+ async function sandboxAvailable() {
2119
+ if (dockerProbe === true)
2120
+ return true;
2121
+ try {
2122
+ await dockerExec(["version", "--format", "{{.Server.Version}}"], {
2123
+ timeout: PROBE_TIMEOUT_MS
2124
+ });
2125
+ dockerProbe = true;
2126
+ } catch {
2127
+ dockerProbe = false;
2128
+ }
2129
+ return dockerProbe;
2130
+ }
2131
+ function __resetSandboxProbe() {
2132
+ dockerProbe = null;
2133
+ }
2134
+ async function removeSandboxContainer(name) {
2135
+ try {
2136
+ await dockerExec(["rm", "--force", name], { timeout: PROBE_TIMEOUT_MS });
2137
+ log.info(TAG4, `removed sandbox container ${name}`);
2138
+ } catch {}
2139
+ }
2140
+ function sandboxRunArgs(image, worktree, command, name, mounts = []) {
2141
+ return [
2142
+ "run",
2143
+ "--rm",
2144
+ ...name ? ["--name", name] : [],
2145
+ "--network=none",
2146
+ ...sandboxHardeningArgs(worktree, mounts),
2147
+ "--entrypoint",
2148
+ command.cmd,
2149
+ image,
2150
+ ...command.args
2151
+ ];
2152
+ }
2153
+ function sandboxHardeningArgs(worktree, mounts = []) {
2154
+ return [
2155
+ "--cap-drop=ALL",
2156
+ "--security-opt=no-new-privileges",
2157
+ `--memory=${SANDBOX_MEMORY}`,
2158
+ `--pids-limit=${SANDBOX_PIDS}`,
2159
+ ...typeof process.getuid === "function" && typeof process.getgid === "function" ? ["--user", `${process.getuid()}:${process.getgid()}`] : [],
2160
+ "--env",
2161
+ "HOME=/tmp",
2162
+ "--volume",
2163
+ `${worktree}:${SANDBOX_MOUNT}`,
2164
+ ...mounts.flatMap((m) => ["--volume", `${m.host}:${m.container}`]),
2165
+ "--workdir",
2166
+ SANDBOX_MOUNT
2167
+ ];
2168
+ }
2169
+ var SANDBOX_DEV_SERVER_BIND = "0.0.0.0";
2170
+ function devServerContainerName(port) {
2171
+ return `harmony-devserver-${port}`;
2172
+ }
2173
+ function devServerSandboxArgs(args) {
2174
+ return [
2175
+ "run",
2176
+ "--rm",
2177
+ "--quiet",
2178
+ "--name",
2179
+ args.name,
2180
+ "--publish",
2181
+ `127.0.0.1:${args.port}:${args.port}`,
2182
+ ...sandboxHardeningArgs(args.worktree),
2183
+ "--entrypoint",
2184
+ args.command.cmd,
2185
+ args.image,
2186
+ ...args.command.args
2187
+ ];
2188
+ }
2189
+ async function runInSandbox(args) {
2190
+ const name = `harmony-repair-${randomUUID()}`;
2191
+ const argv = sandboxRunArgs(args.image, args.worktree, args.command, name);
2192
+ log.info(TAG4, `sandbox: ${args.command.cmd} ${args.command.args.join(" ")} (image ${args.image})`);
2193
+ try {
2194
+ const { stdout } = await dockerExec(argv, {
2195
+ timeout: args.timeoutMs,
2196
+ maxBuffer: MAX_OUTPUT_BUFFER2
2197
+ });
2198
+ return { passed: true, output: stdout ?? "" };
2199
+ } catch (err) {
2200
+ const e = err;
2201
+ const output = `${e.stdout ?? ""}${e.stderr ?? ""}`;
2202
+ if (typeof e.code !== "number") {
2203
+ const timedOut = e.killed === true || e.signal != null;
2204
+ if (timedOut)
2205
+ await removeSandboxContainer(name);
2206
+ return {
2207
+ passed: false,
2208
+ output,
2209
+ sandboxError: timedOut ? `the sandbox timed out after ${args.timeoutMs}ms` : `the sandbox did not run: ${e.message ?? "unknown error"}`
2210
+ };
2211
+ }
2212
+ if (e.code === 125) {
2213
+ return {
2214
+ passed: false,
2215
+ output,
2216
+ sandboxError: `the sandbox could not start (image "${args.image}" missing or unusable)`
2217
+ };
2218
+ }
2219
+ return { passed: false, output };
2220
+ }
2221
+ }
2222
+
2223
+ // src/oracle.ts
2224
+ init_run_containment();
2225
+ var TAG5 = "oracle";
2226
+
2227
+ class OracleSandboxConfigError extends Error {
2228
+ constructor(message) {
2229
+ super(message);
2230
+ this.name = "OracleSandboxConfigError";
2231
+ }
2232
+ }
2083
2233
  async function resolveContained(repoPath, relativePath) {
2084
- if (isAbsolute(relativePath)) {
2234
+ if (isAbsolute3(relativePath)) {
2085
2235
  throw new Error(`refusing to place an oracle at an absolute path: ${relativePath}`);
2086
2236
  }
2087
2237
  if (relativePath === "" || relativePath === ".") {
2088
2238
  throw new Error(`refusing to place an oracle at the empty/self path: "${relativePath}"`);
2089
2239
  }
2090
2240
  const root = await realpath(repoPath);
2091
- const target = resolve(root, relativePath);
2092
- if (target !== root && !target.startsWith(root + sep)) {
2241
+ const target = resolve2(root, relativePath);
2242
+ if (target !== root && !target.startsWith(root + sep2)) {
2093
2243
  throw new Error(`refusing to place an oracle outside the worktree: ${relativePath}`);
2094
2244
  }
2095
2245
  let cursor = root;
2096
2246
  for (const segment of relativePath.split("/")) {
2097
- cursor = resolve(cursor, segment);
2247
+ cursor = resolve2(cursor, segment);
2098
2248
  const stat = await lstat(cursor).catch(() => null);
2099
2249
  if (stat?.isSymbolicLink()) {
2100
2250
  throw new Error(`refusing an oracle path through a symlink component: ${relativePath}`);
@@ -2104,7 +2254,7 @@ async function resolveContained(repoPath, relativePath) {
2104
2254
  }
2105
2255
  async function place(repoPath, oracle) {
2106
2256
  const target = await resolveContained(repoPath, oracle.path);
2107
- await mkdir(dirname(target), { recursive: true });
2257
+ await mkdir(dirname3(target), { recursive: true });
2108
2258
  await writeFile(target, oracle.content, "utf8");
2109
2259
  }
2110
2260
  async function remove(repoPath, oracle) {
@@ -2210,12 +2360,24 @@ function gradeOracleRed(summary, exitCode) {
2210
2360
  function argvPath(path) {
2211
2361
  return path.startsWith("./") ? path : `./${path}`;
2212
2362
  }
2213
- async function runHeldOracle(repoPath, oracle, timeoutMs = DEFAULT_METRIC_TIMEOUT_MS) {
2363
+ async function runHeldOracle(repoPath, oracle, options = {}) {
2364
+ const heldTestTimeoutMs = options.timeoutMs ?? DEFAULT_METRIC_TIMEOUT_MS;
2365
+ const sandbox = options.sandbox?.image.trim() ? options.sandbox : undefined;
2366
+ if (sandbox && !await sandboxAvailable()) {
2367
+ throw new OracleSandboxConfigError(`the held test is configured to run in "${sandbox.image}" but no container runtime answered — ` + "start Docker or unset verification.sandboxImage to run it on the host");
2368
+ }
2214
2369
  const spec = resolveOracleRunnerSpec(oracle);
2215
- const reportDir = await mkdtemp(join(tmpdir(), reportDirPrefix()));
2216
- const reportPath = join(reportDir, spec.report.file);
2370
+ const reportDir = await mkdtemp(join2(tmpdir2(), reportDirPrefix()));
2371
+ const reportPath = join2(reportDir, spec.report.file);
2217
2372
  try {
2218
- return await spawnHeldOracle(spec, repoPath, oracle, reportPath, timeoutMs);
2373
+ return await spawnHeldOracle({
2374
+ spec,
2375
+ repoPath,
2376
+ oracle,
2377
+ reportPath,
2378
+ timeoutMs: sandbox ? heldTestTimeoutMs + SANDBOX_STARTUP_GRACE_MS : heldTestTimeoutMs,
2379
+ sandbox
2380
+ });
2219
2381
  } finally {
2220
2382
  await removeReportDir(reportDir);
2221
2383
  }
@@ -2224,7 +2386,7 @@ function reportDirPrefix() {
2224
2386
  return "harmony-oracle-report-";
2225
2387
  }
2226
2388
  function assertUntampered(reportPath) {
2227
- const dir = statSync(dirname(reportPath));
2389
+ const dir = statSync(dirname3(reportPath));
2228
2390
  if ((dir.mode & 511) !== 448) {
2229
2391
  throw new Error(`the oracle report directory's mode changed to ${(dir.mode & 511).toString(8)} — refusing the report`);
2230
2392
  }
@@ -2236,23 +2398,57 @@ function assertUntampered(reportPath) {
2236
2398
  throw new Error(`the oracle report is not owner-writable (mode ${(file.mode & 511).toString(8)}), so the runner could not have written it last — refusing it`);
2237
2399
  }
2238
2400
  }
2401
+ function assertConsistentWithExit(summary, exitCode) {
2402
+ if (!summary)
2403
+ return;
2404
+ if (summary.failed > 0 && exitCode === 0) {
2405
+ throw new Error(`the oracle report claims ${summary.failed} of ${summary.total} test(s) failed, but the runner exited 0 — ` + "neither allow-listed runner produces that pair, so the report is not the runner's; refusing it");
2406
+ }
2407
+ }
2239
2408
  async function removeReportDir(reportDir) {
2240
2409
  try {
2241
2410
  await chmod(reportDir, 448).catch(() => {});
2242
2411
  await rm(reportDir, { recursive: true, force: true });
2243
2412
  } catch (err) {
2244
- log.warn(TAG4, `Could not remove the oracle report directory ${reportDir} (${err instanceof Error ? err.message : String(err)}) — it may still hold the runner's report, which carries assertion text for vitest. Remove it by hand.`);
2413
+ log.warn(TAG5, `Could not remove the oracle report directory ${reportDir} (${err instanceof Error ? err.message : String(err)}) — it may still hold the runner's report, which carries assertion text for vitest. Remove it by hand.`);
2245
2414
  }
2246
2415
  }
2247
- async function spawnHeldOracle(spec, repoPath, oracle, reportPath, timeoutMs) {
2248
- const { command, args: baseArgs } = spec.argv(argvPath(oracle.path));
2249
- const args = [...baseArgs, ...spec.report.flags(reportPath)];
2416
+ var ORACLE_REPORT_MOUNT = "/harmony-oracle-report";
2417
+ var DOCKER_SELF_FAILURE_CODES = new Set([125, 126, 127]);
2418
+ function heldOracleSandboxArgv(args) {
2419
+ const reportPathInContainer = join2(ORACLE_REPORT_MOUNT, args.reportFile);
2420
+ return {
2421
+ command: "docker",
2422
+ args: sandboxRunArgs(args.image, args.worktree, {
2423
+ cmd: args.runner.command,
2424
+ args: [...args.runner.args, ...args.reportFlags(reportPathInContainer)]
2425
+ }, args.containerName, [{ host: args.reportDir, container: ORACLE_REPORT_MOUNT }]),
2426
+ reportPathInContainer
2427
+ };
2428
+ }
2429
+ async function spawnHeldOracle(args_) {
2430
+ const { spec, repoPath, oracle, reportPath, timeoutMs, sandbox } = args_;
2431
+ const runner = spec.argv(argvPath(oracle.path));
2432
+ const containerName = sandbox ? `harmony-oracle-${randomUUID2()}` : null;
2433
+ const { command, args } = sandbox ? heldOracleSandboxArgv({
2434
+ image: sandbox.image,
2435
+ worktree: realpathSync2(repoPath),
2436
+ reportDir: realpathSync2(dirname3(reportPath)),
2437
+ reportFile: spec.report.file,
2438
+ runner,
2439
+ reportFlags: spec.report.flags,
2440
+ containerName: containerName ?? undefined
2441
+ }) : {
2442
+ command: runner.command,
2443
+ args: [...runner.args, ...spec.report.flags(reportPath)]
2444
+ };
2250
2445
  return await new Promise((settleOk, settleErr) => {
2251
2446
  let child;
2252
2447
  try {
2253
2448
  child = spawnInGroup(command, args, {
2254
2449
  cwd: repoPath,
2255
- stdio: ["ignore", "pipe", "pipe"]
2450
+ stdio: ["ignore", "pipe", "pipe"],
2451
+ stripEnvKeys: heldTestEnvKeysToStrip()
2256
2452
  });
2257
2453
  } catch (err) {
2258
2454
  settleErr(err);
@@ -2284,18 +2480,20 @@ ${output}` : output;
2284
2480
  return verdict;
2285
2481
  };
2286
2482
  let report = null;
2287
- const captureReport = () => {
2483
+ const captureReport = (exitCode) => {
2288
2484
  try {
2289
2485
  assertUntampered(reportPath);
2290
- report = spec.report.parse(readFileSync(reportPath, "utf8"));
2486
+ const parsed = spec.report.parse(readFileSync2(reportPath, "utf8"));
2487
+ assertConsistentWithExit(parsed, exitCode);
2488
+ report = parsed;
2291
2489
  } catch (err) {
2292
2490
  report = null;
2293
2491
  const message = err instanceof Error ? err.message : String(err);
2294
2492
  const absent = err instanceof Error && err.code === "ENOENT";
2295
2493
  if (absent) {
2296
- log.info(TAG4, `No oracle report at ${reportPath} — no verdict.`);
2494
+ log.info(TAG5, `No oracle report at ${reportPath} — no verdict.`);
2297
2495
  } else {
2298
- log.warn(TAG4, `Refusing the oracle report at ${reportPath}: ${message} — the gate will report no verdict.`);
2496
+ log.warn(TAG5, `Refusing the oracle report at ${reportPath}: ${message} — the gate will report no verdict.`);
2299
2497
  }
2300
2498
  }
2301
2499
  };
@@ -2341,6 +2539,10 @@ ${output}` : output;
2341
2539
  settle(new Error(`the held test was terminated by signal ${signal}`));
2342
2540
  return;
2343
2541
  }
2542
+ if (sandbox && DOCKER_SELF_FAILURE_CODES.has(code)) {
2543
+ settle(new OracleSandboxConfigError(`the held test's sandbox ("${sandbox.image}") exited ${code} without running the runner — ` + "the image is missing, unusable, or does not carry the runner; the motor's local log has docker's own message"));
2544
+ return;
2545
+ }
2344
2546
  settle(null, {
2345
2547
  exitCode: code,
2346
2548
  output: finalOutput(),
@@ -2351,7 +2553,7 @@ ${output}` : output;
2351
2553
  child.once("exit", (code, signal) => {
2352
2554
  if (killing)
2353
2555
  return;
2354
- captureReport();
2556
+ captureReport(code);
2355
2557
  if (timer)
2356
2558
  clearTimeout(timer);
2357
2559
  reapGroup(pgid);
@@ -2365,7 +2567,9 @@ ${output}` : output;
2365
2567
  terminateGroup(child, {
2366
2568
  sigintTimeoutMs: ORACLE_SIGINT_GRACE_MS,
2367
2569
  sigtermTimeoutMs: ORACLE_SIGTERM_GRACE_MS
2368
- }).catch(() => {}).then(() => {
2570
+ }).catch(() => {}).then(async () => {
2571
+ if (containerName)
2572
+ await removeSandboxContainer(containerName);
2369
2573
  settle(new Error(`the held test did not finish within ${timeoutMs}ms`));
2370
2574
  });
2371
2575
  }, timeoutMs);
@@ -2373,7 +2577,7 @@ ${output}` : output;
2373
2577
  }
2374
2578
 
2375
2579
  // src/oracle-collector.ts
2376
- var TAG5 = "oracle-collector";
2580
+ var TAG6 = "oracle-collector";
2377
2581
 
2378
2582
  class HeldOracleCollector {
2379
2583
  deps;
@@ -2384,7 +2588,7 @@ class HeldOracleCollector {
2384
2588
  const stageId = this.oracleStageId(context);
2385
2589
  if (!stageId) {
2386
2590
  const reason = `No stage downstream of ${context.stageId} declares an \`oracle_passed\` gate, ` + "so there is no held test for this gate to grade. A red gate needs a later stage that runs the same test green.";
2387
- log.warn(TAG5, `${reason} — blocked (config)`);
2591
+ log.warn(TAG6, `${reason} — blocked (config)`);
2388
2592
  return {
2389
2593
  result: "blocked",
2390
2594
  structured: { reason, ...GATE_CONFIG_ERROR_MARK }
@@ -2392,7 +2596,7 @@ class HeldOracleCollector {
2392
2596
  }
2393
2597
  const oracle = await this.deps.fetchOracle(context.cardId, stageId, this.deps.sessionId);
2394
2598
  if (!oracle) {
2395
- log.info(TAG5, `No oracle held for stage ${stageId} — blocked`);
2599
+ log.info(TAG6, `No oracle held for stage ${stageId} — blocked`);
2396
2600
  return {
2397
2601
  result: "blocked",
2398
2602
  structured: {
@@ -2405,17 +2609,17 @@ class HeldOracleCollector {
2405
2609
  async runHeld(oracle) {
2406
2610
  const identity = {
2407
2611
  oracleId: oracle.id ?? null,
2408
- contentHash: createHash("sha256").update(oracle.content).digest("hex")
2612
+ contentHash: createHash2("sha256").update(oracle.content).digest("hex")
2409
2613
  };
2410
2614
  await this.deps.place(this.deps.repoPath, oracle);
2411
2615
  try {
2412
- const { exitCode, output, report } = await this.deps.run(this.deps.repoPath, oracle);
2616
+ const { exitCode, output, report } = await this.deps.run(this.deps.repoPath, oracle, { sandbox: this.deps.sandbox });
2413
2617
  const logLine = `Oracle run for ${oracle.path} exited ${exitCode}:
2414
2618
  ${output}`;
2415
2619
  if (exitCode === 0) {
2416
- log.info(TAG5, logLine);
2620
+ log.info(TAG6, logLine);
2417
2621
  } else {
2418
- log.warn(TAG5, logLine);
2622
+ log.warn(TAG6, logLine);
2419
2623
  }
2420
2624
  return this.verdict({
2421
2625
  oracle,
@@ -2426,12 +2630,14 @@ ${output}`;
2426
2630
  });
2427
2631
  } catch (err) {
2428
2632
  const message = errText(err);
2429
- log.warn(TAG5, `Oracle run threw: ${message} — blocked`);
2633
+ const configError2 = err instanceof OracleSandboxConfigError;
2634
+ log.warn(TAG6, `Oracle run threw: ${message} — blocked${configError2 ? " (config)" : ""}`);
2430
2635
  return {
2431
2636
  result: "blocked",
2432
2637
  structured: {
2433
2638
  oracle: { path: oracle.path, ...identity },
2434
- error: message
2639
+ error: message,
2640
+ ...configError2 ? { reason: message, ...GATE_CONFIG_ERROR_MARK } : {}
2435
2641
  }
2436
2642
  };
2437
2643
  } finally {
@@ -2443,13 +2649,13 @@ ${output}`;
2443
2649
  await this.deps.remove(this.deps.repoPath, oracle);
2444
2650
  return;
2445
2651
  } catch (err) {
2446
- log.warn(TAG5, `Removing the held test at ${oracle.path} failed (${errText(err)}) — retrying once`);
2652
+ log.warn(TAG6, `Removing the held test at ${oracle.path} failed (${errText(err)}) — retrying once`);
2447
2653
  }
2448
2654
  try {
2449
2655
  await this.deps.remove(this.deps.repoPath, oracle);
2450
- log.info(TAG5, `Held test at ${oracle.path} removed on the second attempt`);
2656
+ log.info(TAG6, `Held test at ${oracle.path} removed on the second attempt`);
2451
2657
  } catch (err) {
2452
- log.error(TAG5, `HELD TEST NOT REMOVED: ${oracle.path} is still in ${this.deps.repoPath} after two attempts (${errText(err)}). ` + "It will be auto-committed by the completion path if it is left there — delete it by hand and check whether it reached a commit.");
2658
+ log.error(TAG6, `HELD TEST NOT REMOVED: ${oracle.path} is still in ${this.deps.repoPath} after two attempts (${errText(err)}). ` + "It will be auto-committed by the completion path if it is left there — delete it by hand and check whether it reached a commit.");
2453
2659
  }
2454
2660
  }
2455
2661
  }
@@ -2492,7 +2698,7 @@ class OracleRedCollector extends HeldOracleCollector {
2492
2698
  const graded = gradeOracleRed(report, exitCode);
2493
2699
  const base = { exitCode, path: oracle.path, ...identity };
2494
2700
  if (graded.outcome === "no_verdict") {
2495
- log.warn(TAG5, `Red gate for ${oracle.path}: ${graded.reason} — blocked`);
2701
+ log.warn(TAG6, `Red gate for ${oracle.path}: ${graded.reason} — blocked`);
2496
2702
  return {
2497
2703
  result: "blocked",
2498
2704
  structured: { oracle: base, reason: graded.reason }
@@ -2516,15 +2722,15 @@ function errText(err) {
2516
2722
  }
2517
2723
 
2518
2724
  // src/verification.ts
2519
- init_log();
2520
2725
  import { execFileSync as execFileSync4, spawn as spawn2 } from "node:child_process";
2726
+ init_log();
2521
2727
 
2522
2728
  // src/pm.ts
2523
2729
  init_log();
2524
2730
  init_run_containment();
2525
2731
  import { execFileSync } from "node:child_process";
2526
2732
  import { existsSync } from "node:fs";
2527
- var TAG6 = "pm";
2733
+ var TAG7 = "pm";
2528
2734
  var cached = null;
2529
2735
  function detectPackageManager() {
2530
2736
  if (cached)
@@ -2546,7 +2752,7 @@ function detectPackageManager() {
2546
2752
  } else {
2547
2753
  cached = "npm";
2548
2754
  }
2549
- log.info(TAG6, `Detected package manager: ${cached}`);
2755
+ log.info(TAG7, `Detected package manager: ${cached}`);
2550
2756
  return cached;
2551
2757
  }
2552
2758
  function installCommand(ignoreScripts = false) {
@@ -2575,7 +2781,7 @@ function spawnRunArgs(script, ...extra) {
2575
2781
  init_log();
2576
2782
  import { execFileSync as execFileSync2 } from "node:child_process";
2577
2783
  import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
2578
- var TAG7 = "project-type";
2784
+ var TAG8 = "project-type";
2579
2785
  var _cache = new Map;
2580
2786
  function _resetCache() {
2581
2787
  _cache.clear();
@@ -2586,7 +2792,7 @@ function detect(dir) {
2586
2792
  return cached2;
2587
2793
  const result = detectUncached(dir);
2588
2794
  _cache.set(dir, result);
2589
- log.info(TAG7, `Detected project type in ${dir}: ${result.kind}`);
2795
+ log.info(TAG8, `Detected project type in ${dir}: ${result.kind}`);
2590
2796
  return result;
2591
2797
  }
2592
2798
  function detectUncached(dir) {
@@ -2680,13 +2886,13 @@ function hasNodeTestScript(dir) {
2680
2886
  const pkg = JSON.parse(readFileSync3(`${dir}/package.json`, "utf-8"));
2681
2887
  script = pkg.scripts?.test;
2682
2888
  } catch (err) {
2683
- log.warn(TAG7, `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`);
2889
+ log.warn(TAG8, `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`);
2684
2890
  return false;
2685
2891
  }
2686
2892
  if (typeof script !== "string" || script.trim().length === 0)
2687
2893
  return false;
2688
2894
  if (NPM_PLACEHOLDER_TEST.test(script)) {
2689
- log.info(TAG7, `package.json 'test' is the npm placeholder — skipping tests`);
2895
+ log.info(TAG8, `package.json 'test' is the npm placeholder — skipping tests`);
2690
2896
  return false;
2691
2897
  }
2692
2898
  return true;
@@ -2697,7 +2903,7 @@ function firstNodeScript(dir, candidates) {
2697
2903
  const pkg = JSON.parse(readFileSync3(`${dir}/package.json`, "utf-8"));
2698
2904
  scripts = pkg.scripts ?? {};
2699
2905
  } catch (err) {
2700
- log.warn(TAG7, `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`);
2906
+ log.warn(TAG8, `Could not read package.json in ${dir}: ${err instanceof Error ? err.message : err}`);
2701
2907
  return null;
2702
2908
  }
2703
2909
  for (const name of candidates) {
@@ -2716,7 +2922,7 @@ function xcodeBuildCommand(pt) {
2716
2922
  return null;
2717
2923
  const scheme = resolveXcodeScheme(pt);
2718
2924
  if (!scheme) {
2719
- log.warn(TAG7, "Could not resolve an Xcode scheme — skipping build (best-effort)");
2925
+ log.warn(TAG8, "Could not resolve an Xcode scheme — skipping build (best-effort)");
2720
2926
  return null;
2721
2927
  }
2722
2928
  const containerFlag = pt.xcodeIsWorkspace ? "-workspace" : "-project";
@@ -2744,7 +2950,7 @@ function resolveXcodeScheme(pt) {
2744
2950
  const schemes = pt.xcodeIsWorkspace ? parsed.workspace?.schemes ?? [] : parsed.project?.schemes ?? [];
2745
2951
  return schemes[0] ?? null;
2746
2952
  } catch (err) {
2747
- log.warn(TAG7, `xcodebuild -list failed: ${err instanceof Error ? err.message : err}`);
2953
+ log.warn(TAG8, `xcodebuild -list failed: ${err instanceof Error ? err.message : err}`);
2748
2954
  return null;
2749
2955
  }
2750
2956
  }
@@ -2753,7 +2959,7 @@ function resolveXcodeScheme(pt) {
2753
2959
  init_log();
2754
2960
  init_run_containment();
2755
2961
  import { execFileSync as execFileSync3 } from "node:child_process";
2756
- var TAG8 = "revert-guard";
2962
+ var TAG9 = "revert-guard";
2757
2963
  var TEST_FILE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
2758
2964
  function isTestFile(path) {
2759
2965
  return TEST_FILE.test(path);
@@ -2768,7 +2974,7 @@ function refetchBase(worktreePath, baseBranch) {
2768
2974
  stdio: "pipe"
2769
2975
  });
2770
2976
  } catch {
2771
- log.warn(TAG8, "Failed to re-fetch base for revert guard — using last fetch");
2977
+ log.warn(TAG9, "Failed to re-fetch base for revert guard — using last fetch");
2772
2978
  }
2773
2979
  }
2774
2980
  function listDeletedFilesAgainstBase(worktreePath, baseBranch) {
@@ -2783,7 +2989,7 @@ function listDeletedFilesAgainstBase(worktreePath, baseBranch) {
2783
2989
  return out.split(`
2784
2990
  `).map((l) => l.trim()).filter((l) => l.length > 0);
2785
2991
  } catch (err) {
2786
- log.warn(TAG8, `Failed to list deleted files: ${err instanceof Error ? err.message : err}`);
2992
+ log.warn(TAG9, `Failed to list deleted files: ${err instanceof Error ? err.message : err}`);
2787
2993
  return [];
2788
2994
  }
2789
2995
  }
@@ -2794,8 +3000,8 @@ function findDeletedTestFiles(worktreePath, baseBranch) {
2794
3000
 
2795
3001
  // src/verification.ts
2796
3002
  init_run_containment();
2797
- var TAG9 = "verification";
2798
- var MAX_OUTPUT_BUFFER2 = 64 * 1024 * 1024;
3003
+ var TAG10 = "verification";
3004
+ var MAX_OUTPUT_BUFFER3 = 64 * 1024 * 1024;
2799
3005
  async function runVerification(worktreePath, config, workerId) {
2800
3006
  const result = {
2801
3007
  passed: true,
@@ -2805,144 +3011,177 @@ async function runVerification(worktreePath, config, workerId) {
2805
3011
  reviewFindings: [],
2806
3012
  revertWarnings: []
2807
3013
  };
3014
+ const sandbox = verificationSandbox(config);
2808
3015
  if (config.verification.revertGuard) {
2809
- log.info(TAG9, `[worker:${workerId}] Checking for reverted merged work...`);
3016
+ log.info(TAG10, `[worker:${workerId}] Checking for reverted merged work...`);
2810
3017
  const deletedTests = findDeletedTestFiles(worktreePath, config.worktree.baseBranch);
2811
3018
  if (deletedTests.length > 0) {
2812
3019
  result.revertWarnings = deletedTests.map((f) => `Branch deletes test file '${f}' relative to current ${config.worktree.baseBranch} — ` + "likely an accidental revert of already-merged work. Restore the test or rebase on current main.");
2813
- log.warn(TAG9, `[worker:${workerId}] Revert guard tripped: ${deletedTests.length} deleted test file(s)`);
3020
+ log.warn(TAG10, `[worker:${workerId}] Revert guard tripped: ${deletedTests.length} deleted test file(s)`);
2814
3021
  result.passed = false;
2815
3022
  } else {
2816
- log.info(TAG9, `[worker:${workerId}] Revert guard passed`);
3023
+ log.info(TAG10, `[worker:${workerId}] Revert guard passed`);
2817
3024
  }
2818
3025
  }
2819
3026
  if (config.verification.build) {
2820
- log.info(TAG9, `[worker:${workerId}] Running build...`);
2821
- result.buildErrors = runBuild(worktreePath, config.verification.timeout);
3027
+ log.info(TAG10, `[worker:${workerId}] Running build...`);
3028
+ result.buildErrors = await runBuild(worktreePath, config.verification.timeout, sandbox);
2822
3029
  if (result.buildErrors.length > 0) {
2823
- log.warn(TAG9, `[worker:${workerId}] Build failed with ${result.buildErrors.length} error(s)`);
3030
+ log.warn(TAG10, `[worker:${workerId}] Build failed with ${result.buildErrors.length} error(s)`);
2824
3031
  result.passed = false;
2825
3032
  } else {
2826
- log.info(TAG9, `[worker:${workerId}] Build passed`);
3033
+ log.info(TAG10, `[worker:${workerId}] Build passed`);
2827
3034
  }
2828
3035
  }
2829
3036
  if (config.verification.test && result.buildErrors.length === 0) {
2830
- log.info(TAG9, `[worker:${workerId}] Running tests...`);
2831
- result.testFailures = runTests(worktreePath, config.verification.testTimeout);
3037
+ log.info(TAG10, `[worker:${workerId}] Running tests...`);
3038
+ result.testFailures = await runTests(worktreePath, config.verification.testTimeout, sandbox);
2832
3039
  if (result.testFailures.length > 0) {
2833
- log.warn(TAG9, `[worker:${workerId}] Tests failed with ${result.testFailures.length} failure(s)`);
3040
+ log.warn(TAG10, `[worker:${workerId}] Tests failed with ${result.testFailures.length} failure(s)`);
2834
3041
  result.passed = false;
2835
3042
  } else {
2836
- log.info(TAG9, `[worker:${workerId}] Tests passed`);
3043
+ log.info(TAG10, `[worker:${workerId}] Tests passed`);
2837
3044
  }
2838
3045
  }
2839
3046
  if (config.verification.lint) {
2840
- log.info(TAG9, `[worker:${workerId}] Running lint...`);
2841
- result.lintWarnings = runLint(worktreePath, config.verification.timeout);
3047
+ log.info(TAG10, `[worker:${workerId}] Running lint...`);
3048
+ result.lintWarnings = await runLint(worktreePath, config.verification.timeout, sandbox);
2842
3049
  if (result.lintWarnings.length > 0) {
2843
- log.warn(TAG9, `[worker:${workerId}] Lint found ${result.lintWarnings.length} issue(s)`);
3050
+ log.warn(TAG10, `[worker:${workerId}] Lint found ${result.lintWarnings.length} issue(s)`);
2844
3051
  } else {
2845
- log.info(TAG9, `[worker:${workerId}] Lint passed`);
3052
+ log.info(TAG10, `[worker:${workerId}] Lint passed`);
2846
3053
  }
2847
3054
  }
2848
3055
  if (config.verification.deepReview) {
2849
- log.info(TAG9, `[worker:${workerId}] Running deep review...`);
3056
+ log.info(TAG10, `[worker:${workerId}] Running deep review...`);
2850
3057
  result.reviewFindings = await runDeepReview(worktreePath, config, workerId);
2851
3058
  if (result.reviewFindings.length > 0) {
2852
- log.warn(TAG9, `[worker:${workerId}] Deep review found ${result.reviewFindings.length} finding(s)`);
3059
+ log.warn(TAG10, `[worker:${workerId}] Deep review found ${result.reviewFindings.length} finding(s)`);
2853
3060
  } else {
2854
- log.info(TAG9, `[worker:${workerId}] Deep review passed`);
3061
+ log.info(TAG10, `[worker:${workerId}] Deep review passed`);
2855
3062
  }
2856
3063
  }
2857
3064
  return result;
2858
3065
  }
2859
- function runBuild(worktreePath, timeout) {
2860
- const command = buildCommand(worktreePath);
2861
- if (!command) {
2862
- log.warn(TAG9, `No known build toolchain for ${worktreePath} — skipping build`);
2863
- return [];
3066
+ function verificationSandbox(config) {
3067
+ const image = config.verification.sandboxImage?.trim();
3068
+ if (!image)
3069
+ return;
3070
+ return { image };
3071
+ }
3072
+ async function execStep(command, args) {
3073
+ const { worktreePath, timeout } = args;
3074
+ const sandbox = args.sandbox?.image.trim() ? args.sandbox : undefined;
3075
+ if (sandbox) {
3076
+ if (!await sandboxAvailable()) {
3077
+ return {
3078
+ ok: false,
3079
+ sandboxError: `verification.sandboxImage is set to "${sandbox.image}" but no container runtime answered — ` + "start Docker or unset the image to verify on the host"
3080
+ };
3081
+ }
3082
+ const result = await runInSandbox({
3083
+ image: sandbox.image,
3084
+ worktree: worktreePath,
3085
+ command,
3086
+ timeoutMs: timeout + SANDBOX_STARTUP_GRACE_MS
3087
+ });
3088
+ if (result.sandboxError) {
3089
+ return { ok: false, sandboxError: result.sandboxError };
3090
+ }
3091
+ if (result.passed)
3092
+ return { ok: true };
3093
+ return { ok: false, err: { stdout: result.output, stderr: "" } };
2864
3094
  }
2865
3095
  try {
2866
3096
  execFileSync4(command.cmd, command.args, {
2867
3097
  cwd: worktreePath,
2868
3098
  timeout,
2869
3099
  stdio: "pipe",
2870
- maxBuffer: MAX_OUTPUT_BUFFER2,
3100
+ maxBuffer: MAX_OUTPUT_BUFFER3,
2871
3101
  env: containedEnv()
2872
3102
  });
2873
- return [];
3103
+ return { ok: true };
2874
3104
  } catch (err) {
2875
- return parseErrorOutput(err);
3105
+ return { ok: false, err };
3106
+ }
3107
+ }
3108
+ async function runBuild(worktreePath, timeout, sandbox) {
3109
+ const command = buildCommand(worktreePath);
3110
+ if (!command) {
3111
+ log.warn(TAG10, `No known build toolchain for ${worktreePath} — skipping build`);
3112
+ return [];
2876
3113
  }
3114
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
3115
+ if (outcome.ok)
3116
+ return [];
3117
+ if (outcome.sandboxError) {
3118
+ log.error(TAG10, `Build not verified: ${outcome.sandboxError}`);
3119
+ return [`Build did not run: ${outcome.sandboxError}`];
3120
+ }
3121
+ return parseErrorOutput(outcome.err);
2877
3122
  }
2878
- function runTests(worktreePath, timeout) {
3123
+ async function runTests(worktreePath, timeout, sandbox) {
2879
3124
  const command = testCommand(worktreePath);
2880
3125
  if (!command) {
2881
- log.warn(TAG9, `No test command for detected toolchain in ${worktreePath} — skipping tests`);
3126
+ log.warn(TAG10, `No test command for detected toolchain in ${worktreePath} — skipping tests`);
2882
3127
  return [];
2883
3128
  }
2884
- try {
2885
- execFileSync4(command.cmd, command.args, {
2886
- cwd: worktreePath,
2887
- timeout,
2888
- stdio: "pipe",
2889
- maxBuffer: MAX_OUTPUT_BUFFER2,
2890
- env: containedEnv()
2891
- });
3129
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
3130
+ if (outcome.ok)
2892
3131
  return [];
2893
- } catch (err) {
2894
- const output = combineOutput(err);
2895
- log.warn(TAG9, `Test run failed:
2896
- ${output.slice(-4000) || "(no output captured)"}`);
2897
- return parseTestFailures(err, timeout);
3132
+ if (outcome.sandboxError) {
3133
+ log.error(TAG10, `Tests not verified: ${outcome.sandboxError}`);
3134
+ return [`Test run did not happen: ${outcome.sandboxError}`];
2898
3135
  }
3136
+ const output = combineOutput(outcome.err);
3137
+ log.warn(TAG10, `Test run failed:
3138
+ ${output.slice(-4000) || "(no output captured)"}`);
3139
+ return parseTestFailures(outcome.err, timeout);
2899
3140
  }
2900
- function runFormatFix(worktreePath, timeout, workerId) {
3141
+ async function runFormatFix(worktreePath, timeout, workerId, sandbox) {
2901
3142
  const command = formatFixCommand(worktreePath);
2902
3143
  if (!command)
2903
3144
  return;
2904
- try {
2905
- execFileSync4(command.cmd, command.args, {
2906
- cwd: worktreePath,
2907
- timeout,
2908
- stdio: "pipe",
2909
- maxBuffer: MAX_OUTPUT_BUFFER2,
2910
- env: containedEnv()
2911
- });
2912
- log.info(TAG9, `[worker:${workerId}] Auto-formatted worktree before commit/push`);
2913
- } catch (err) {
2914
- log.warn(TAG9, `[worker:${workerId}] Auto-format step exited non-zero (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
3145
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
3146
+ if (outcome.ok) {
3147
+ log.info(TAG10, `[worker:${workerId}] Auto-formatted worktree before commit/push`);
3148
+ return;
2915
3149
  }
3150
+ const why = outcome.sandboxError ? outcome.sandboxError : outcome.err instanceof Error ? outcome.err.message : String(outcome.err);
3151
+ log.warn(TAG10, `[worker:${workerId}] Auto-format step did not complete (non-fatal): ${why}`);
2916
3152
  }
2917
- function runLint(worktreePath, timeout) {
3153
+ async function runLint(worktreePath, timeout, sandbox) {
2918
3154
  const command = lintCommand(worktreePath);
2919
3155
  if (!command) {
2920
- log.info(TAG9, `No lint step for detected toolchain in ${worktreePath} — skipping lint`);
3156
+ log.info(TAG10, `No lint step for detected toolchain in ${worktreePath} — skipping lint`);
2921
3157
  return [];
2922
3158
  }
2923
- try {
2924
- execFileSync4(command.cmd, command.args, {
2925
- cwd: worktreePath,
2926
- timeout,
2927
- stdio: "pipe",
2928
- maxBuffer: MAX_OUTPUT_BUFFER2,
2929
- env: containedEnv()
2930
- });
3159
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
3160
+ if (outcome.ok)
2931
3161
  return [];
2932
- } catch (err) {
2933
- return parseErrorOutput(err);
3162
+ if (outcome.sandboxError) {
3163
+ log.error(TAG10, `Lint not verified: ${outcome.sandboxError}`);
3164
+ return [`Lint did not run: ${outcome.sandboxError}`];
2934
3165
  }
3166
+ return parseErrorOutput(outcome.err);
2935
3167
  }
2936
3168
  async function runDeepReview(worktreePath, config, workerId) {
2937
3169
  if (!supportsDevServer(worktreePath)) {
2938
- log.info(TAG9, `[worker:${workerId}] Detected non-web toolchain — skipping deep review`);
3170
+ log.info(TAG10, `[worker:${workerId}] Detected non-web toolchain — skipping deep review`);
2939
3171
  return [];
2940
3172
  }
2941
3173
  const port = config.verification.devServerBasePort + workerId;
2942
3174
  let devServer = null;
3175
+ const launch = devServerLaunch({
3176
+ worktreePath,
3177
+ port,
3178
+ sandbox: verificationSandbox(config)
3179
+ });
2943
3180
  try {
2944
- const [cmd, args] = spawnRunArgs("dev", "--port", String(port));
2945
- devServer = spawn2(cmd, args, {
3181
+ if (launch.containerName) {
3182
+ await removeSandboxContainer(launch.containerName);
3183
+ }
3184
+ devServer = spawn2(launch.cmd, launch.args, {
2946
3185
  cwd: worktreePath,
2947
3186
  stdio: ["ignore", "pipe", "pipe"],
2948
3187
  env: containedEnv()
@@ -2951,7 +3190,7 @@ async function runDeepReview(worktreePath, config, workerId) {
2951
3190
  await waitForDevServer(devServer, 30000);
2952
3191
  await probeDevServer(port);
2953
3192
  } catch (err) {
2954
- log.error(TAG9, `Dev server did not become ready: ${err instanceof Error ? err.message : err}`);
3193
+ log.error(TAG10, `Dev server did not become ready: ${err instanceof Error ? err.message : err}`);
2955
3194
  return [];
2956
3195
  }
2957
3196
  let diff = "";
@@ -2960,7 +3199,7 @@ async function runDeepReview(worktreePath, config, workerId) {
2960
3199
  cwd: worktreePath,
2961
3200
  encoding: "utf-8",
2962
3201
  timeout: 30000,
2963
- maxBuffer: MAX_OUTPUT_BUFFER2
3202
+ maxBuffer: MAX_OUTPUT_BUFFER3
2964
3203
  });
2965
3204
  } catch {
2966
3205
  diff = "(unable to retrieve diff)";
@@ -2994,17 +3233,20 @@ async function runDeepReview(worktreePath, config, workerId) {
2994
3233
  encoding: "utf-8",
2995
3234
  timeout: config.verification.timeout,
2996
3235
  stdio: "pipe",
2997
- maxBuffer: MAX_OUTPUT_BUFFER2,
3236
+ maxBuffer: MAX_OUTPUT_BUFFER3,
2998
3237
  env: containedEnv()
2999
3238
  });
3000
3239
  return parseReviewFindings(output);
3001
3240
  } catch (err) {
3002
- log.error(TAG9, `Deep review failed: ${err instanceof Error ? err.message : err}`);
3241
+ log.error(TAG10, `Deep review failed: ${err instanceof Error ? err.message : err}`);
3003
3242
  return [];
3004
3243
  } finally {
3005
3244
  if (devServer && !devServer.killed) {
3006
3245
  devServer.kill("SIGTERM");
3007
3246
  }
3247
+ if (launch.containerName) {
3248
+ await removeSandboxContainer(launch.containerName);
3249
+ }
3008
3250
  }
3009
3251
  }
3010
3252
  function attemptAutoFix(worktreePath, config, errors) {
@@ -3037,12 +3279,12 @@ function attemptAutoFix(worktreePath, config, errors) {
3037
3279
  "--",
3038
3280
  fixPrompt
3039
3281
  ];
3040
- log.info(TAG9, "Spawning Claude for auto-fix...");
3282
+ log.info(TAG10, "Spawning Claude for auto-fix...");
3041
3283
  execFileSync4("claude", args, {
3042
3284
  cwd: worktreePath,
3043
3285
  timeout: config.verification.timeout,
3044
3286
  stdio: "pipe",
3045
- maxBuffer: MAX_OUTPUT_BUFFER2,
3287
+ maxBuffer: MAX_OUTPUT_BUFFER3,
3046
3288
  env: containedEnv()
3047
3289
  });
3048
3290
  }
@@ -3076,7 +3318,7 @@ async function reportFindings(client, cardId, result, recovery) {
3076
3318
  try {
3077
3319
  await client.createSubtask(cardId, title);
3078
3320
  } catch (err) {
3079
- log.error(TAG9, `Failed to create subtask: ${err instanceof Error ? err.message : err}`);
3321
+ log.error(TAG10, `Failed to create subtask: ${err instanceof Error ? err.message : err}`);
3080
3322
  }
3081
3323
  }));
3082
3324
  if (overflow > 0) {
@@ -3084,7 +3326,7 @@ async function reportFindings(client, cardId, result, recovery) {
3084
3326
  await client.createSubtask(cardId, `...and ${overflow} more issues`);
3085
3327
  } catch {}
3086
3328
  }
3087
- log.info(TAG9, `Reported ${Math.min(items.length, maxSubtasks)} finding(s) as subtasks on card ${cardId}`);
3329
+ log.info(TAG10, `Reported ${Math.min(items.length, maxSubtasks)} finding(s) as subtasks on card ${cardId}`);
3088
3330
  }
3089
3331
  function combineOutput(err) {
3090
3332
  const stderr = err?.stderr?.toString() ?? "";
@@ -3117,7 +3359,7 @@ function parseTestFailures(err, timeout) {
3117
3359
  }
3118
3360
  if (e?.code === "ENOBUFS") {
3119
3361
  return [
3120
- `Test output exceeded the ${MAX_OUTPUT_BUFFER2 / (1024 * 1024)}MB capture limit and the run was killed — ` + "the suite's real result is unknown. Quieten the reporter or raise the limit."
3362
+ `Test output exceeded the ${MAX_OUTPUT_BUFFER3 / (1024 * 1024)}MB capture limit and the run was killed — ` + "the suite's real result is unknown. Quieten the reporter or raise the limit."
3121
3363
  ];
3122
3364
  }
3123
3365
  const combined = combineOutput(err);
@@ -3143,6 +3385,24 @@ class DevServerReadinessError extends Error {
3143
3385
  this.name = "DevServerReadinessError";
3144
3386
  }
3145
3387
  }
3388
+ function devServerLaunch(args) {
3389
+ const [cmd, runArgs] = spawnRunArgs("dev", "--port", String(args.port), ...args.sandbox ? ["--host", SANDBOX_DEV_SERVER_BIND] : []);
3390
+ if (!args.sandbox)
3391
+ return { cmd, args: runArgs };
3392
+ const containerName = devServerContainerName(args.port);
3393
+ return {
3394
+ cmd: "docker",
3395
+ args: devServerSandboxArgs({
3396
+ image: args.sandbox.image,
3397
+ worktree: args.worktreePath,
3398
+ command: { cmd, args: runArgs },
3399
+ port: args.port,
3400
+ name: containerName
3401
+ }),
3402
+ containerName
3403
+ };
3404
+ }
3405
+ var DEV_SERVER_READY = /\bready\b/i;
3146
3406
  function waitForDevServer(proc, timeout) {
3147
3407
  return new Promise((resolve3, reject) => {
3148
3408
  let settled = false;
@@ -3172,7 +3432,7 @@ function waitForDevServer(proc, timeout) {
3172
3432
  }, timeout);
3173
3433
  const onData = (data) => {
3174
3434
  const text = data.toString();
3175
- if (text.includes("ready") || text.includes("localhost") || text.includes("Local:")) {
3435
+ if (DEV_SERVER_READY.test(text) || text.includes("localhost") || text.includes("Local:")) {
3176
3436
  settleResolve();
3177
3437
  }
3178
3438
  };
@@ -3208,7 +3468,7 @@ async function probeDevServer(port, timeoutMs = 5000) {
3208
3468
  }
3209
3469
 
3210
3470
  // src/gate-collectors.ts
3211
- var TAG10 = "gate-collectors";
3471
+ var TAG11 = "gate-collectors";
3212
3472
  async function resolveStageGate(client, card) {
3213
3473
  const currentStage = card.current_stage;
3214
3474
  const playbookId = card.playbook_id;
@@ -3226,7 +3486,7 @@ async function resolveStageGate(client, card) {
3226
3486
  return null;
3227
3487
  return { stage: resolution.stage, gate };
3228
3488
  } catch (err) {
3229
- log.warn(TAG10, `resolveStageGate failed for stage "${currentStage}": ${err instanceof Error ? err.message : err}`);
3489
+ log.warn(TAG11, `resolveStageGate failed for stage "${currentStage}": ${err instanceof Error ? err.message : err}`);
3230
3490
  return null;
3231
3491
  }
3232
3492
  }
@@ -3261,8 +3521,8 @@ class BuildGreenCollector {
3261
3521
  async collect(_context) {
3262
3522
  const doBuild = this.deps.runBuild ?? runBuild;
3263
3523
  const doLint = this.deps.runLint ?? runLint;
3264
- const buildErrors = doBuild(this.deps.worktreePath, this.deps.buildTimeout);
3265
- const lintWarnings = doLint(this.deps.worktreePath, this.deps.lintTimeout);
3524
+ const buildErrors = await doBuild(this.deps.worktreePath, this.deps.buildTimeout, this.deps.sandbox);
3525
+ const lintWarnings = await doLint(this.deps.worktreePath, this.deps.lintTimeout, this.deps.sandbox);
3266
3526
  const buildPassed = buildErrors.length === 0;
3267
3527
  const lintPassed = lintWarnings.length === 0;
3268
3528
  const result = buildPassed ? "passed" : "failed";
@@ -3358,7 +3618,7 @@ function buildGateCollectorRegistry(deps) {
3358
3618
  async function collectGateEvidence(registry, context) {
3359
3619
  const collector = registry[context.gate.kind];
3360
3620
  if (!collector) {
3361
- log.info(TAG10, `No collector for gate kind "${context.gate.kind}" — reporting blocked`);
3621
+ log.info(TAG11, `No collector for gate kind "${context.gate.kind}" — reporting blocked`);
3362
3622
  return {
3363
3623
  result: "blocked",
3364
3624
  structured: {
@@ -3370,14 +3630,14 @@ async function collectGateEvidence(registry, context) {
3370
3630
  return await collector.collect(context);
3371
3631
  } catch (err) {
3372
3632
  const msg = err instanceof Error ? err.message : String(err);
3373
- log.warn(TAG10, `Collector for "${context.gate.kind}" threw: ${msg} — reporting blocked`);
3633
+ log.warn(TAG11, `Collector for "${context.gate.kind}" threw: ${msg} — reporting blocked`);
3374
3634
  return { result: "blocked", structured: { error: msg } };
3375
3635
  }
3376
3636
  }
3377
3637
 
3378
3638
  // src/harmony-client.ts
3379
3639
  init_log();
3380
- var TAG11 = "harmony-client";
3640
+ var TAG12 = "harmony-client";
3381
3641
  function readClientConfig(env) {
3382
3642
  const apiUrl = env.HARMONY_API_URL?.trim();
3383
3643
  const apiKey = env.HARMONY_API_KEY?.trim();
@@ -3429,7 +3689,7 @@ class HarmonyClient {
3429
3689
  purpose: "gate_evaluation"
3430
3690
  });
3431
3691
  if (!response.ok) {
3432
- log.warn(TAG11, `Oracle fetch for stage ${stageId} returned ${response.status} — no oracle read, the gate will report blocked`);
3692
+ log.warn(TAG12, `Oracle fetch for stage ${stageId} returned ${response.status} — no oracle read, the gate will report blocked`);
3433
3693
  return null;
3434
3694
  }
3435
3695
  const body = await response.json();
@@ -3513,7 +3773,7 @@ function relayAgentEvent(draft) {
3513
3773
  // src/stage-cli.ts
3514
3774
  init_dist();
3515
3775
  init_runner();
3516
- var STAGE_RUN_USAGE = "usage: harmony-harness stage run --card <id> --stage <id> --workspace <id> --repo <path> --session <id> [--metrics <json-path>]";
3776
+ var STAGE_RUN_USAGE = "usage: harmony-harness stage run --card <id> --stage <id> --workspace <id> --repo <path> --session <id> [--metrics <json-path>] [--sandbox-image <image>]";
3517
3777
  function readFlag(argv, name) {
3518
3778
  const index = argv.indexOf(`--${name}`);
3519
3779
  if (index === -1)
@@ -3538,7 +3798,7 @@ function parseStageRunArgs(argv) {
3538
3798
  ["repoPath", "repo"],
3539
3799
  ["sessionId", "session"]
3540
3800
  ];
3541
- const args = { metricsPath: null };
3801
+ const args = { metricsPath: null, sandboxImage: null };
3542
3802
  for (const [field, flag] of fields) {
3543
3803
  const value = readFlag(argv, flag);
3544
3804
  if (value === null) {
@@ -3553,6 +3813,16 @@ function parseStageRunArgs(argv) {
3553
3813
  }
3554
3814
  args.metricsPath = metricsPath;
3555
3815
  }
3816
+ if (argv.includes("--sandbox-image")) {
3817
+ const sandboxImage = readFlag(argv, "sandbox-image");
3818
+ if (sandboxImage === null) {
3819
+ return {
3820
+ ok: false,
3821
+ message: "missing value for --sandbox-image <image>"
3822
+ };
3823
+ }
3824
+ args.sandboxImage = sandboxImage;
3825
+ }
3556
3826
  return { ok: true, args };
3557
3827
  }
3558
3828
  function parseMetricsAllowlist(raw, sourcePath) {
@@ -3713,7 +3983,7 @@ async function runStage(request, deps) {
3713
3983
  }
3714
3984
 
3715
3985
  // src/cli.ts
3716
- var TAG12 = "cli";
3986
+ var TAG13 = "cli";
3717
3987
  var GATE_VERIFICATION_TIMEOUT_MS = 600000;
3718
3988
  var SHUTDOWN_HARD_EXIT_MS = 15000;
3719
3989
  var activeRunner = null;
@@ -3744,12 +4014,12 @@ async function runRole(request, prompt, emit2) {
3744
4014
  });
3745
4015
  const runner = new SdkAgentRunner(launch.config);
3746
4016
  activeRunner = runner;
3747
- log.info(TAG12, `Running stage ${request.stageId} as role ${launch.role ?? "(none — fail-closed)"}`);
4017
+ log.info(TAG13, `Running stage ${request.stageId} as role ${launch.role ?? "(none — fail-closed)"}`);
3748
4018
  const timeoutMs = stageTimeoutMs(process.env);
3749
4019
  let timedOut = false;
3750
4020
  const clock = timeoutMs > 0 ? setTimeout(() => {
3751
4021
  timedOut = true;
3752
- log.warn(TAG12, `stage ${request.stageId} exceeded ${timeoutMs}ms — stopping the subagent`);
4022
+ log.warn(TAG13, `stage ${request.stageId} exceeded ${timeoutMs}ms — stopping the subagent`);
3753
4023
  runner.stop("timeout");
3754
4024
  }, timeoutMs) : null;
3755
4025
  clock?.unref?.();
@@ -3765,9 +4035,9 @@ async function runRole(request, prompt, emit2) {
3765
4035
  if (relayed)
3766
4036
  emit2(relayed);
3767
4037
  if (event.kind === "error") {
3768
- log.warn(TAG12, `subagent error: ${event.payload.message}`);
4038
+ log.warn(TAG13, `subagent error: ${event.payload.message}`);
3769
4039
  } else {
3770
- log.event(TAG12, `subagent ${event.kind}`);
4040
+ log.event(TAG13, `subagent ${event.kind}`);
3771
4041
  }
3772
4042
  }
3773
4043
  } finally {
@@ -3795,7 +4065,16 @@ ${STAGE_RUN_USAGE}
3795
4065
  `);
3796
4066
  process.exit(2);
3797
4067
  }
3798
- const { cardId, stageId, workspaceId, repoPath, sessionId, metricsPath } = parsed.args;
4068
+ const {
4069
+ cardId,
4070
+ stageId,
4071
+ workspaceId,
4072
+ repoPath,
4073
+ sessionId,
4074
+ metricsPath,
4075
+ sandboxImage
4076
+ } = parsed.args;
4077
+ const gateSandbox = sandboxImage?.trim() ? { image: sandboxImage.trim() } : undefined;
3799
4078
  let driverMetrics = {};
3800
4079
  if (metricsPath !== null) {
3801
4080
  const { readFileSync: readFileSync4 } = await import("node:fs");
@@ -3838,7 +4117,8 @@ ${STAGE_RUN_USAGE}
3838
4117
  build: {
3839
4118
  worktreePath: req.repoPath,
3840
4119
  buildTimeout: GATE_VERIFICATION_TIMEOUT_MS,
3841
- lintTimeout: GATE_VERIFICATION_TIMEOUT_MS
4120
+ lintTimeout: GATE_VERIFICATION_TIMEOUT_MS,
4121
+ sandbox: gateSandbox
3842
4122
  },
3843
4123
  command: {
3844
4124
  worktreePath: req.repoPath,
@@ -3847,6 +4127,7 @@ ${STAGE_RUN_USAGE}
3847
4127
  oracle: {
3848
4128
  repoPath: req.repoPath,
3849
4129
  sessionId: req.sessionId,
4130
+ sandbox: gateSandbox,
3850
4131
  targetStageId: oracleTargetStageId,
3851
4132
  fetchOracle: (oracleCardId, oracleStageId, oracleSessionId) => client.fetchOracle(oracleCardId, oracleStageId, oracleSessionId),
3852
4133
  place,