@gethmy/harness 1.6.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"],
@@ -2062,11 +2077,12 @@ function truncate(value, max) {
2062
2077
  init_log();
2063
2078
 
2064
2079
  // src/oracle-collector.ts
2065
- import { createHash } from "node:crypto";
2080
+ import { createHash as createHash2 } from "node:crypto";
2066
2081
  init_log();
2067
2082
 
2068
2083
  // src/oracle.ts
2069
- 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";
2070
2086
  import {
2071
2087
  chmod,
2072
2088
  lstat,
@@ -2076,26 +2092,159 @@ import {
2076
2092
  rm,
2077
2093
  writeFile
2078
2094
  } from "node:fs/promises";
2079
- import { tmpdir } from "node:os";
2080
- 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";
2081
2097
  import { StringDecoder } from "node:string_decoder";
2082
2098
  init_log();
2083
- 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
+ }
2084
2233
  async function resolveContained(repoPath, relativePath) {
2085
- if (isAbsolute(relativePath)) {
2234
+ if (isAbsolute3(relativePath)) {
2086
2235
  throw new Error(`refusing to place an oracle at an absolute path: ${relativePath}`);
2087
2236
  }
2088
2237
  if (relativePath === "" || relativePath === ".") {
2089
2238
  throw new Error(`refusing to place an oracle at the empty/self path: "${relativePath}"`);
2090
2239
  }
2091
2240
  const root = await realpath(repoPath);
2092
- const target = resolve(root, relativePath);
2093
- if (target !== root && !target.startsWith(root + sep)) {
2241
+ const target = resolve2(root, relativePath);
2242
+ if (target !== root && !target.startsWith(root + sep2)) {
2094
2243
  throw new Error(`refusing to place an oracle outside the worktree: ${relativePath}`);
2095
2244
  }
2096
2245
  let cursor = root;
2097
2246
  for (const segment of relativePath.split("/")) {
2098
- cursor = resolve(cursor, segment);
2247
+ cursor = resolve2(cursor, segment);
2099
2248
  const stat = await lstat(cursor).catch(() => null);
2100
2249
  if (stat?.isSymbolicLink()) {
2101
2250
  throw new Error(`refusing an oracle path through a symlink component: ${relativePath}`);
@@ -2105,7 +2254,7 @@ async function resolveContained(repoPath, relativePath) {
2105
2254
  }
2106
2255
  async function place(repoPath, oracle) {
2107
2256
  const target = await resolveContained(repoPath, oracle.path);
2108
- await mkdir(dirname(target), { recursive: true });
2257
+ await mkdir(dirname3(target), { recursive: true });
2109
2258
  await writeFile(target, oracle.content, "utf8");
2110
2259
  }
2111
2260
  async function remove(repoPath, oracle) {
@@ -2211,12 +2360,24 @@ function gradeOracleRed(summary, exitCode) {
2211
2360
  function argvPath(path) {
2212
2361
  return path.startsWith("./") ? path : `./${path}`;
2213
2362
  }
2214
- 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
+ }
2215
2369
  const spec = resolveOracleRunnerSpec(oracle);
2216
- const reportDir = await mkdtemp(join(tmpdir(), reportDirPrefix()));
2217
- const reportPath = join(reportDir, spec.report.file);
2370
+ const reportDir = await mkdtemp(join2(tmpdir2(), reportDirPrefix()));
2371
+ const reportPath = join2(reportDir, spec.report.file);
2218
2372
  try {
2219
- 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
+ });
2220
2381
  } finally {
2221
2382
  await removeReportDir(reportDir);
2222
2383
  }
@@ -2225,7 +2386,7 @@ function reportDirPrefix() {
2225
2386
  return "harmony-oracle-report-";
2226
2387
  }
2227
2388
  function assertUntampered(reportPath) {
2228
- const dir = statSync(dirname(reportPath));
2389
+ const dir = statSync(dirname3(reportPath));
2229
2390
  if ((dir.mode & 511) !== 448) {
2230
2391
  throw new Error(`the oracle report directory's mode changed to ${(dir.mode & 511).toString(8)} — refusing the report`);
2231
2392
  }
@@ -2237,23 +2398,57 @@ function assertUntampered(reportPath) {
2237
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`);
2238
2399
  }
2239
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
+ }
2240
2408
  async function removeReportDir(reportDir) {
2241
2409
  try {
2242
2410
  await chmod(reportDir, 448).catch(() => {});
2243
2411
  await rm(reportDir, { recursive: true, force: true });
2244
2412
  } catch (err) {
2245
- 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.`);
2246
2414
  }
2247
2415
  }
2248
- async function spawnHeldOracle(spec, repoPath, oracle, reportPath, timeoutMs) {
2249
- const { command, args: baseArgs } = spec.argv(argvPath(oracle.path));
2250
- 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
+ };
2251
2445
  return await new Promise((settleOk, settleErr) => {
2252
2446
  let child;
2253
2447
  try {
2254
2448
  child = spawnInGroup(command, args, {
2255
2449
  cwd: repoPath,
2256
- stdio: ["ignore", "pipe", "pipe"]
2450
+ stdio: ["ignore", "pipe", "pipe"],
2451
+ stripEnvKeys: heldTestEnvKeysToStrip()
2257
2452
  });
2258
2453
  } catch (err) {
2259
2454
  settleErr(err);
@@ -2285,18 +2480,20 @@ ${output}` : output;
2285
2480
  return verdict;
2286
2481
  };
2287
2482
  let report = null;
2288
- const captureReport = () => {
2483
+ const captureReport = (exitCode) => {
2289
2484
  try {
2290
2485
  assertUntampered(reportPath);
2291
- report = spec.report.parse(readFileSync(reportPath, "utf8"));
2486
+ const parsed = spec.report.parse(readFileSync2(reportPath, "utf8"));
2487
+ assertConsistentWithExit(parsed, exitCode);
2488
+ report = parsed;
2292
2489
  } catch (err) {
2293
2490
  report = null;
2294
2491
  const message = err instanceof Error ? err.message : String(err);
2295
2492
  const absent = err instanceof Error && err.code === "ENOENT";
2296
2493
  if (absent) {
2297
- log.info(TAG4, `No oracle report at ${reportPath} — no verdict.`);
2494
+ log.info(TAG5, `No oracle report at ${reportPath} — no verdict.`);
2298
2495
  } else {
2299
- 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.`);
2300
2497
  }
2301
2498
  }
2302
2499
  };
@@ -2342,6 +2539,10 @@ ${output}` : output;
2342
2539
  settle(new Error(`the held test was terminated by signal ${signal}`));
2343
2540
  return;
2344
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
+ }
2345
2546
  settle(null, {
2346
2547
  exitCode: code,
2347
2548
  output: finalOutput(),
@@ -2352,7 +2553,7 @@ ${output}` : output;
2352
2553
  child.once("exit", (code, signal) => {
2353
2554
  if (killing)
2354
2555
  return;
2355
- captureReport();
2556
+ captureReport(code);
2356
2557
  if (timer)
2357
2558
  clearTimeout(timer);
2358
2559
  reapGroup(pgid);
@@ -2366,7 +2567,9 @@ ${output}` : output;
2366
2567
  terminateGroup(child, {
2367
2568
  sigintTimeoutMs: ORACLE_SIGINT_GRACE_MS,
2368
2569
  sigtermTimeoutMs: ORACLE_SIGTERM_GRACE_MS
2369
- }).catch(() => {}).then(() => {
2570
+ }).catch(() => {}).then(async () => {
2571
+ if (containerName)
2572
+ await removeSandboxContainer(containerName);
2370
2573
  settle(new Error(`the held test did not finish within ${timeoutMs}ms`));
2371
2574
  });
2372
2575
  }, timeoutMs);
@@ -2374,7 +2577,7 @@ ${output}` : output;
2374
2577
  }
2375
2578
 
2376
2579
  // src/oracle-collector.ts
2377
- var TAG5 = "oracle-collector";
2580
+ var TAG6 = "oracle-collector";
2378
2581
 
2379
2582
  class HeldOracleCollector {
2380
2583
  deps;
@@ -2385,7 +2588,7 @@ class HeldOracleCollector {
2385
2588
  const stageId = this.oracleStageId(context);
2386
2589
  if (!stageId) {
2387
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.";
2388
- log.warn(TAG5, `${reason} — blocked (config)`);
2591
+ log.warn(TAG6, `${reason} — blocked (config)`);
2389
2592
  return {
2390
2593
  result: "blocked",
2391
2594
  structured: { reason, ...GATE_CONFIG_ERROR_MARK }
@@ -2393,7 +2596,7 @@ class HeldOracleCollector {
2393
2596
  }
2394
2597
  const oracle = await this.deps.fetchOracle(context.cardId, stageId, this.deps.sessionId);
2395
2598
  if (!oracle) {
2396
- log.info(TAG5, `No oracle held for stage ${stageId} — blocked`);
2599
+ log.info(TAG6, `No oracle held for stage ${stageId} — blocked`);
2397
2600
  return {
2398
2601
  result: "blocked",
2399
2602
  structured: {
@@ -2406,17 +2609,17 @@ class HeldOracleCollector {
2406
2609
  async runHeld(oracle) {
2407
2610
  const identity = {
2408
2611
  oracleId: oracle.id ?? null,
2409
- contentHash: createHash("sha256").update(oracle.content).digest("hex")
2612
+ contentHash: createHash2("sha256").update(oracle.content).digest("hex")
2410
2613
  };
2411
2614
  await this.deps.place(this.deps.repoPath, oracle);
2412
2615
  try {
2413
- 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 });
2414
2617
  const logLine = `Oracle run for ${oracle.path} exited ${exitCode}:
2415
2618
  ${output}`;
2416
2619
  if (exitCode === 0) {
2417
- log.info(TAG5, logLine);
2620
+ log.info(TAG6, logLine);
2418
2621
  } else {
2419
- log.warn(TAG5, logLine);
2622
+ log.warn(TAG6, logLine);
2420
2623
  }
2421
2624
  return this.verdict({
2422
2625
  oracle,
@@ -2427,12 +2630,14 @@ ${output}`;
2427
2630
  });
2428
2631
  } catch (err) {
2429
2632
  const message = errText(err);
2430
- 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)" : ""}`);
2431
2635
  return {
2432
2636
  result: "blocked",
2433
2637
  structured: {
2434
2638
  oracle: { path: oracle.path, ...identity },
2435
- error: message
2639
+ error: message,
2640
+ ...configError2 ? { reason: message, ...GATE_CONFIG_ERROR_MARK } : {}
2436
2641
  }
2437
2642
  };
2438
2643
  } finally {
@@ -2444,13 +2649,13 @@ ${output}`;
2444
2649
  await this.deps.remove(this.deps.repoPath, oracle);
2445
2650
  return;
2446
2651
  } catch (err) {
2447
- 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`);
2448
2653
  }
2449
2654
  try {
2450
2655
  await this.deps.remove(this.deps.repoPath, oracle);
2451
- 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`);
2452
2657
  } catch (err) {
2453
- 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.");
2454
2659
  }
2455
2660
  }
2456
2661
  }
@@ -2493,7 +2698,7 @@ class OracleRedCollector extends HeldOracleCollector {
2493
2698
  const graded = gradeOracleRed(report, exitCode);
2494
2699
  const base = { exitCode, path: oracle.path, ...identity };
2495
2700
  if (graded.outcome === "no_verdict") {
2496
- log.warn(TAG5, `Red gate for ${oracle.path}: ${graded.reason} — blocked`);
2701
+ log.warn(TAG6, `Red gate for ${oracle.path}: ${graded.reason} — blocked`);
2497
2702
  return {
2498
2703
  result: "blocked",
2499
2704
  structured: { oracle: base, reason: graded.reason }
@@ -2525,7 +2730,7 @@ init_log();
2525
2730
  init_run_containment();
2526
2731
  import { execFileSync } from "node:child_process";
2527
2732
  import { existsSync } from "node:fs";
2528
- var TAG6 = "pm";
2733
+ var TAG7 = "pm";
2529
2734
  var cached = null;
2530
2735
  function detectPackageManager() {
2531
2736
  if (cached)
@@ -2547,7 +2752,7 @@ function detectPackageManager() {
2547
2752
  } else {
2548
2753
  cached = "npm";
2549
2754
  }
2550
- log.info(TAG6, `Detected package manager: ${cached}`);
2755
+ log.info(TAG7, `Detected package manager: ${cached}`);
2551
2756
  return cached;
2552
2757
  }
2553
2758
  function installCommand(ignoreScripts = false) {
@@ -2576,7 +2781,7 @@ function spawnRunArgs(script, ...extra) {
2576
2781
  init_log();
2577
2782
  import { execFileSync as execFileSync2 } from "node:child_process";
2578
2783
  import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
2579
- var TAG7 = "project-type";
2784
+ var TAG8 = "project-type";
2580
2785
  var _cache = new Map;
2581
2786
  function _resetCache() {
2582
2787
  _cache.clear();
@@ -2587,7 +2792,7 @@ function detect(dir) {
2587
2792
  return cached2;
2588
2793
  const result = detectUncached(dir);
2589
2794
  _cache.set(dir, result);
2590
- log.info(TAG7, `Detected project type in ${dir}: ${result.kind}`);
2795
+ log.info(TAG8, `Detected project type in ${dir}: ${result.kind}`);
2591
2796
  return result;
2592
2797
  }
2593
2798
  function detectUncached(dir) {
@@ -2681,13 +2886,13 @@ function hasNodeTestScript(dir) {
2681
2886
  const pkg = JSON.parse(readFileSync3(`${dir}/package.json`, "utf-8"));
2682
2887
  script = pkg.scripts?.test;
2683
2888
  } catch (err) {
2684
- 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}`);
2685
2890
  return false;
2686
2891
  }
2687
2892
  if (typeof script !== "string" || script.trim().length === 0)
2688
2893
  return false;
2689
2894
  if (NPM_PLACEHOLDER_TEST.test(script)) {
2690
- 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`);
2691
2896
  return false;
2692
2897
  }
2693
2898
  return true;
@@ -2698,7 +2903,7 @@ function firstNodeScript(dir, candidates) {
2698
2903
  const pkg = JSON.parse(readFileSync3(`${dir}/package.json`, "utf-8"));
2699
2904
  scripts = pkg.scripts ?? {};
2700
2905
  } catch (err) {
2701
- 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}`);
2702
2907
  return null;
2703
2908
  }
2704
2909
  for (const name of candidates) {
@@ -2717,7 +2922,7 @@ function xcodeBuildCommand(pt) {
2717
2922
  return null;
2718
2923
  const scheme = resolveXcodeScheme(pt);
2719
2924
  if (!scheme) {
2720
- 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)");
2721
2926
  return null;
2722
2927
  }
2723
2928
  const containerFlag = pt.xcodeIsWorkspace ? "-workspace" : "-project";
@@ -2745,108 +2950,11 @@ function resolveXcodeScheme(pt) {
2745
2950
  const schemes = pt.xcodeIsWorkspace ? parsed.workspace?.schemes ?? [] : parsed.project?.schemes ?? [];
2746
2951
  return schemes[0] ?? null;
2747
2952
  } catch (err) {
2748
- 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}`);
2749
2954
  return null;
2750
2955
  }
2751
2956
  }
2752
2957
 
2753
- // src/repair-sandbox.ts
2754
- init_log();
2755
- import { randomUUID } from "node:crypto";
2756
- import { promisify } from "node:util";
2757
- var TAG8 = "repair-sandbox";
2758
- async function dockerExec(argv, opts) {
2759
- const { execFile } = await import("node:child_process");
2760
- return promisify(execFile)("docker", argv, {
2761
- encoding: "utf-8",
2762
- ...opts
2763
- });
2764
- }
2765
- var MAX_OUTPUT_BUFFER2 = 20971520;
2766
- var PROBE_TIMEOUT_MS = 1e4;
2767
- var SANDBOX_MOUNT = "/repo";
2768
- var SANDBOX_MEMORY = "4g";
2769
- var SANDBOX_PIDS = "512";
2770
- var dockerProbe = null;
2771
- async function sandboxAvailable() {
2772
- if (dockerProbe === true)
2773
- return true;
2774
- try {
2775
- await dockerExec(["version", "--format", "{{.Server.Version}}"], {
2776
- timeout: PROBE_TIMEOUT_MS
2777
- });
2778
- dockerProbe = true;
2779
- } catch {
2780
- dockerProbe = false;
2781
- }
2782
- return dockerProbe;
2783
- }
2784
- function __resetSandboxProbe() {
2785
- dockerProbe = null;
2786
- }
2787
- async function removeContainer(name) {
2788
- try {
2789
- await dockerExec(["rm", "--force", name], { timeout: PROBE_TIMEOUT_MS });
2790
- log.warn(TAG8, `removed the timed-out sandbox container ${name}`);
2791
- } catch {}
2792
- }
2793
- function sandboxRunArgs(image, worktree, command, name) {
2794
- return [
2795
- "run",
2796
- "--rm",
2797
- ...name ? ["--name", name] : [],
2798
- "--network=none",
2799
- "--cap-drop=ALL",
2800
- "--security-opt=no-new-privileges",
2801
- `--memory=${SANDBOX_MEMORY}`,
2802
- `--pids-limit=${SANDBOX_PIDS}`,
2803
- ...typeof process.getuid === "function" && typeof process.getgid === "function" ? ["--user", `${process.getuid()}:${process.getgid()}`] : [],
2804
- "--env",
2805
- "HOME=/tmp",
2806
- "--volume",
2807
- `${worktree}:${SANDBOX_MOUNT}`,
2808
- "--workdir",
2809
- SANDBOX_MOUNT,
2810
- "--entrypoint",
2811
- command.cmd,
2812
- image,
2813
- ...command.args
2814
- ];
2815
- }
2816
- async function runInSandbox(args) {
2817
- const name = `harmony-repair-${randomUUID()}`;
2818
- const argv = sandboxRunArgs(args.image, args.worktree, args.command, name);
2819
- log.info(TAG8, `sandbox: ${args.command.cmd} ${args.command.args.join(" ")} (image ${args.image})`);
2820
- try {
2821
- const { stdout } = await dockerExec(argv, {
2822
- timeout: args.timeoutMs,
2823
- maxBuffer: MAX_OUTPUT_BUFFER2
2824
- });
2825
- return { passed: true, output: stdout ?? "" };
2826
- } catch (err) {
2827
- const e = err;
2828
- const output = `${e.stdout ?? ""}${e.stderr ?? ""}`;
2829
- if (typeof e.code !== "number") {
2830
- const timedOut = e.killed === true || e.signal != null;
2831
- if (timedOut)
2832
- await removeContainer(name);
2833
- return {
2834
- passed: false,
2835
- output,
2836
- sandboxError: timedOut ? `the sandbox timed out after ${args.timeoutMs}ms` : `the sandbox did not run: ${e.message ?? "unknown error"}`
2837
- };
2838
- }
2839
- if (e.code === 125) {
2840
- return {
2841
- passed: false,
2842
- output,
2843
- sandboxError: `the sandbox could not start (image "${args.image}" missing or unusable)`
2844
- };
2845
- }
2846
- return { passed: false, output };
2847
- }
2848
- }
2849
-
2850
2958
  // src/revert-guard.ts
2851
2959
  init_log();
2852
2960
  init_run_containment();
@@ -3064,9 +3172,16 @@ async function runDeepReview(worktreePath, config, workerId) {
3064
3172
  }
3065
3173
  const port = config.verification.devServerBasePort + workerId;
3066
3174
  let devServer = null;
3175
+ const launch = devServerLaunch({
3176
+ worktreePath,
3177
+ port,
3178
+ sandbox: verificationSandbox(config)
3179
+ });
3067
3180
  try {
3068
- const [cmd, args] = spawnRunArgs("dev", "--port", String(port));
3069
- devServer = spawn2(cmd, args, {
3181
+ if (launch.containerName) {
3182
+ await removeSandboxContainer(launch.containerName);
3183
+ }
3184
+ devServer = spawn2(launch.cmd, launch.args, {
3070
3185
  cwd: worktreePath,
3071
3186
  stdio: ["ignore", "pipe", "pipe"],
3072
3187
  env: containedEnv()
@@ -3129,6 +3244,9 @@ async function runDeepReview(worktreePath, config, workerId) {
3129
3244
  if (devServer && !devServer.killed) {
3130
3245
  devServer.kill("SIGTERM");
3131
3246
  }
3247
+ if (launch.containerName) {
3248
+ await removeSandboxContainer(launch.containerName);
3249
+ }
3132
3250
  }
3133
3251
  }
3134
3252
  function attemptAutoFix(worktreePath, config, errors) {
@@ -3267,6 +3385,24 @@ class DevServerReadinessError extends Error {
3267
3385
  this.name = "DevServerReadinessError";
3268
3386
  }
3269
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;
3270
3406
  function waitForDevServer(proc, timeout) {
3271
3407
  return new Promise((resolve3, reject) => {
3272
3408
  let settled = false;
@@ -3296,7 +3432,7 @@ function waitForDevServer(proc, timeout) {
3296
3432
  }, timeout);
3297
3433
  const onData = (data) => {
3298
3434
  const text = data.toString();
3299
- if (text.includes("ready") || text.includes("localhost") || text.includes("Local:")) {
3435
+ if (DEV_SERVER_READY.test(text) || text.includes("localhost") || text.includes("Local:")) {
3300
3436
  settleResolve();
3301
3437
  }
3302
3438
  };
@@ -3637,7 +3773,7 @@ function relayAgentEvent(draft) {
3637
3773
  // src/stage-cli.ts
3638
3774
  init_dist();
3639
3775
  init_runner();
3640
- 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>]";
3641
3777
  function readFlag(argv, name) {
3642
3778
  const index = argv.indexOf(`--${name}`);
3643
3779
  if (index === -1)
@@ -3662,7 +3798,7 @@ function parseStageRunArgs(argv) {
3662
3798
  ["repoPath", "repo"],
3663
3799
  ["sessionId", "session"]
3664
3800
  ];
3665
- const args = { metricsPath: null };
3801
+ const args = { metricsPath: null, sandboxImage: null };
3666
3802
  for (const [field, flag] of fields) {
3667
3803
  const value = readFlag(argv, flag);
3668
3804
  if (value === null) {
@@ -3677,6 +3813,16 @@ function parseStageRunArgs(argv) {
3677
3813
  }
3678
3814
  args.metricsPath = metricsPath;
3679
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
+ }
3680
3826
  return { ok: true, args };
3681
3827
  }
3682
3828
  function parseMetricsAllowlist(raw, sourcePath) {
@@ -3919,7 +4065,16 @@ ${STAGE_RUN_USAGE}
3919
4065
  `);
3920
4066
  process.exit(2);
3921
4067
  }
3922
- 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;
3923
4078
  let driverMetrics = {};
3924
4079
  if (metricsPath !== null) {
3925
4080
  const { readFileSync: readFileSync4 } = await import("node:fs");
@@ -3962,7 +4117,8 @@ ${STAGE_RUN_USAGE}
3962
4117
  build: {
3963
4118
  worktreePath: req.repoPath,
3964
4119
  buildTimeout: GATE_VERIFICATION_TIMEOUT_MS,
3965
- lintTimeout: GATE_VERIFICATION_TIMEOUT_MS
4120
+ lintTimeout: GATE_VERIFICATION_TIMEOUT_MS,
4121
+ sandbox: gateSandbox
3966
4122
  },
3967
4123
  command: {
3968
4124
  worktreePath: req.repoPath,
@@ -3971,6 +4127,7 @@ ${STAGE_RUN_USAGE}
3971
4127
  oracle: {
3972
4128
  repoPath: req.repoPath,
3973
4129
  sessionId: req.sessionId,
4130
+ sandbox: gateSandbox,
3974
4131
  targetStageId: oracleTargetStageId,
3975
4132
  fetchOracle: (oracleCardId, oracleStageId, oracleSessionId) => client.fetchOracle(oracleCardId, oracleStageId, oracleSessionId),
3976
4133
  place,