@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 +376 -219
- package/dist/index.js +293 -146
- package/package.json +2 -2
- package/src/cli.ts +34 -2
- package/src/exec-types.ts +33 -4
- package/src/git-pr.ts +53 -5
- package/src/oracle-collector.ts +38 -8
- package/src/oracle.ts +464 -53
- package/src/repair-sandbox.test.ts +166 -1
- package/src/repair-sandbox.ts +203 -8
- package/src/run-containment.ts +191 -37
- package/src/stage-cli.ts +32 -8
- package/src/verification.ts +200 -21
- package/src/worktree.ts +19 -2
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,
|
|
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
|
|
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 (
|
|
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 =
|
|
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(
|
|
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 =
|
|
637
|
+
real = dirname(real);
|
|
639
638
|
continue;
|
|
640
639
|
}
|
|
641
|
-
const next = real.endsWith(
|
|
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 +
|
|
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 =
|
|
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
|
|
773
|
-
import { readFileSync
|
|
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
|
|
776
|
-
import { dirname as
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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 = [
|
|
800
|
+
const paths = [join(homedir(), ".gitconfig")];
|
|
802
801
|
const xdg = process.env.XDG_CONFIG_HOME;
|
|
803
|
-
paths.push(xdg &&
|
|
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-${
|
|
815
|
-
const candidate = process.env.TMPDIR ??
|
|
816
|
-
const tmpRoot =
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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 =
|
|
834
|
+
const path = join(worktree, ".claude", name);
|
|
836
835
|
let raw;
|
|
837
836
|
try {
|
|
838
|
-
raw =
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
886
|
-
paths.add(
|
|
887
|
-
paths.add(
|
|
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 (
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
2093
|
-
if (target !== root && !target.startsWith(root +
|
|
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 =
|
|
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(
|
|
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,
|
|
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(
|
|
2217
|
-
const reportPath =
|
|
2370
|
+
const reportDir = await mkdtemp(join2(tmpdir2(), reportDirPrefix()));
|
|
2371
|
+
const reportPath = join2(reportDir, spec.report.file);
|
|
2218
2372
|
try {
|
|
2219
|
-
return await spawnHeldOracle(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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
|
-
|
|
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(
|
|
2494
|
+
log.info(TAG5, `No oracle report at ${reportPath} — no verdict.`);
|
|
2298
2495
|
} else {
|
|
2299
|
-
log.warn(
|
|
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
|
|
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(
|
|
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(
|
|
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:
|
|
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(
|
|
2620
|
+
log.info(TAG6, logLine);
|
|
2418
2621
|
} else {
|
|
2419
|
-
log.warn(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
2656
|
+
log.info(TAG6, `Held test at ${oracle.path} removed on the second attempt`);
|
|
2452
2657
|
} catch (err) {
|
|
2453
|
-
log.error(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
3069
|
-
|
|
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 (
|
|
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 {
|
|
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,
|