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