nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21
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/README.md +86 -480
- package/bin/nomarmy.mjs +1081 -185
- package/docker/Dockerfile +2 -2
- package/docker/Dockerfile.go +6 -4
- package/docker/Dockerfile.rust +17 -2
- package/harnesses/_template/README.md +27 -0
- package/harnesses/_template/harness.yml +26 -0
- package/harnesses/browser-playwright/README.md +35 -0
- package/harnesses/browser-playwright/fixture/package.json +1 -0
- package/harnesses/browser-playwright/fixture/page.html +1 -0
- package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
- package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
- package/harnesses/browser-playwright/harness.yml +18 -0
- package/harnesses/go/README.md +45 -0
- package/harnesses/go/harness.yml +14 -0
- package/harnesses/mock-oidc/README.md +31 -0
- package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
- package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
- package/harnesses/mock-oidc/harness.yml +19 -0
- package/harnesses/node/README.md +53 -0
- package/harnesses/node/harness.yml +18 -0
- package/harnesses/python/README.md +46 -0
- package/harnesses/python/harness.yml +16 -0
- package/harnesses/rust/README.md +45 -0
- package/harnesses/rust/harness.yml +13 -0
- package/install.sh +29 -9
- package/lib/admission.mjs +178 -30
- package/lib/agents.mjs +8 -6
- package/lib/army.mjs +25 -10
- package/lib/codex-link.mjs +37 -0
- package/lib/config.mjs +15 -0
- package/lib/connect.mjs +232 -19
- package/lib/continue-from.mjs +103 -0
- package/lib/coordinator-instructions.mjs +5 -1
- package/lib/diff-checks.mjs +114 -0
- package/lib/dispatch-schema.mjs +14 -12
- package/lib/doctor.mjs +98 -9
- package/lib/egress-proxy.mjs +116 -0
- package/lib/execute.mjs +241 -33
- package/lib/git-record.mjs +27 -3
- package/lib/harness-schema.mjs +61 -0
- package/lib/harnesses.mjs +99 -0
- package/lib/health.mjs +162 -18
- package/lib/install-freshness.mjs +114 -0
- package/lib/jev-checks.mjs +110 -0
- package/lib/job-format.mjs +54 -0
- package/lib/judge.mjs +130 -0
- package/lib/limits.mjs +77 -0
- package/lib/model-probe.mjs +61 -0
- package/lib/mutation.mjs +159 -0
- package/lib/notify.mjs +30 -3
- package/lib/openclaw-install.mjs +122 -0
- package/lib/openclaw-path.mjs +28 -0
- package/lib/openclaw-run.mjs +74 -12
- package/lib/openclaw-runtime-health.mjs +56 -0
- package/lib/outcome.mjs +21 -2
- package/lib/outcomes.mjs +6 -0
- package/lib/path-utils.mjs +4 -0
- package/lib/podman-health.mjs +41 -0
- package/lib/process.mjs +4 -1
- package/lib/propose.mjs +10 -11
- package/lib/refusal-retry.mjs +16 -0
- package/lib/registry-python.mjs +98 -0
- package/lib/registry-secrets.mjs +140 -0
- package/lib/repo-query.mjs +13 -7
- package/lib/runs.mjs +7 -1
- package/lib/same-path.mjs +14 -0
- package/lib/sandbox-images.mjs +499 -83
- package/lib/sandbox-vm.mjs +32 -0
- package/lib/scan.mjs +5 -1
- package/lib/schema.mjs +20 -11
- package/lib/scout.mjs +21 -3
- package/lib/server-context.mjs +21 -1
- package/lib/setup-steps.mjs +55 -0
- package/lib/share.mjs +82 -0
- package/lib/stale-sessions.mjs +60 -0
- package/lib/stats.mjs +315 -0
- package/lib/statusline.mjs +32 -6
- package/lib/subscription-setup.mjs +13 -0
- package/lib/suggestions.mjs +153 -0
- package/lib/thinking.mjs +23 -0
- package/lib/transcript.mjs +30 -5
- package/lib/usage-limits.mjs +329 -0
- package/lib/user-config.mjs +106 -0
- package/lib/validators.mjs +220 -0
- package/lib/verification-artifacts.mjs +46 -0
- package/lib/verification-flow.mjs +52 -7
- package/lib/verification-network.mjs +66 -0
- package/lib/verify.mjs +338 -85
- package/lib/worker-prompt.mjs +5 -2
- package/lib/wsl-cli.mjs +152 -0
- package/lib/wsl.mjs +230 -0
- package/lib/zod-issues.mjs +15 -0
- package/mcp/server.mjs +165 -34
- package/package.json +7 -5
- package/playbooks/feature.md +8 -5
- package/scripts/configure-openclaw.sh +4 -2
- package/scripts/generate-harness-docs.mjs +42 -0
- package/scripts/install-openclaw.mjs +23 -0
- package/scripts/lib.sh +9 -2
- package/scripts/select-model.mjs +12 -5
- package/scripts/start-inference.sh +2 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Optional semantic validators: judgments nomArmy's mechanical checks can't
|
|
2
|
+
// make, from a model the operator configures with their own key. Today one:
|
|
3
|
+
// Jev, TypeSafe's System One model (https://docs.typesafe.ai).
|
|
4
|
+
//
|
|
5
|
+
// They only ever add review flags. Diffs and reports are written by the
|
|
6
|
+
// worker being judged, and a model can be talked into a verdict (Jev's own
|
|
7
|
+
// docs list prompt injection as a weakness), so a validator's answer never
|
|
8
|
+
// passes a check, clears a flag or allows a commit. Its worst case is a flag
|
|
9
|
+
// it failed to raise.
|
|
10
|
+
//
|
|
11
|
+
// Off by default, and it sends excerpts of your code to TypeSafe.
|
|
12
|
+
//
|
|
13
|
+
// ~/.config/nomarmy/validators.yml:
|
|
14
|
+
// jev:
|
|
15
|
+
// enabled: true
|
|
16
|
+
// key_file: ~/.config/nomarmy/secrets/typesafe.key # or key_env: NAME
|
|
17
|
+
// model: jev-latest
|
|
18
|
+
// checks: [scout-citations, report-claims]
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import YAML from "yaml";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import { globalConfigDir } from "./army.mjs";
|
|
26
|
+
|
|
27
|
+
export const VALIDATORS_FILE = "validators.yml";
|
|
28
|
+
export const JEV_CHECKS = Object.freeze(["scout-citations", "report-claims"]);
|
|
29
|
+
export const JEV_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
|
|
30
|
+
|
|
31
|
+
const jevSchema = z.object({
|
|
32
|
+
enabled: z.boolean().default(true),
|
|
33
|
+
key_file: z.string().min(1).optional(),
|
|
34
|
+
key_env: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, "must be an environment variable NAME, never the key itself").optional(),
|
|
35
|
+
model: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/).default("jev-latest"),
|
|
36
|
+
checks: z.array(z.enum(JEV_CHECKS)).default([...JEV_CHECKS]),
|
|
37
|
+
}).strict().refine((j) => j.key_file || j.key_env, "needs key_file or key_env");
|
|
38
|
+
|
|
39
|
+
// A model judge (lib/judge.mjs): an agent from agents.yml and a model.
|
|
40
|
+
export const JUDGE_CHECK_NAMES = Object.freeze(["acceptance", "report-claims", "test-weakening"]);
|
|
41
|
+
const judgeSchema = z.object({
|
|
42
|
+
enabled: z.boolean().default(true),
|
|
43
|
+
agent: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, "must name an agent from agents.yml"),
|
|
44
|
+
model: z.string().regex(/^\S{1,200}$/, "must be a model id"),
|
|
45
|
+
checks: z.array(z.enum(JUDGE_CHECK_NAMES)).default([...JUDGE_CHECK_NAMES]),
|
|
46
|
+
// A judge on an agent whose tools run on this machine (the Claude CLI)
|
|
47
|
+
// reads worker-written text with those tools available: explicit consent.
|
|
48
|
+
host_tools: z.boolean().default(false),
|
|
49
|
+
}).strict();
|
|
50
|
+
|
|
51
|
+
const validatorsSchema = z.object({ jev: jevSchema.optional(), judge: judgeSchema.optional() }).strict();
|
|
52
|
+
|
|
53
|
+
export function validatorsPath(configDir = globalConfigDir()) {
|
|
54
|
+
return path.join(configDir, VALIDATORS_FILE);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function defaultKeyFile(configDir = globalConfigDir()) {
|
|
58
|
+
return path.join(configDir, "secrets", "typesafe.key");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const expandHome = (p) => (p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p);
|
|
62
|
+
|
|
63
|
+
/** The validators config, or {} when there's none. Throws on an invalid file. */
|
|
64
|
+
export function loadValidators(configDir = globalConfigDir()) {
|
|
65
|
+
let text;
|
|
66
|
+
try { text = fs.readFileSync(validatorsPath(configDir), "utf8"); } catch { return {}; }
|
|
67
|
+
const parsed = validatorsSchema.safeParse(YAML.parse(text) ?? {});
|
|
68
|
+
if (!parsed.success) throw new Error(`${validatorsPath(configDir)} is not valid: ${parsed.error.issues.map((i) => `${i.path.join(".") || "config"}: ${i.message}`).join("; ")}`);
|
|
69
|
+
return parsed.data;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Jev's settings and key when it's enabled and its key is readable; else null. */
|
|
73
|
+
export function jevSettings({ configDir = globalConfigDir(), env = process.env } = {}) {
|
|
74
|
+
let config;
|
|
75
|
+
try { config = loadValidators(configDir).jev; } catch { return null; }
|
|
76
|
+
if (!config?.enabled) return null;
|
|
77
|
+
const key = config.key_env ? String(env[config.key_env] ?? "").trim() : readKeyFile(expandHome(config.key_file));
|
|
78
|
+
return key ? { ...config, key } : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readKeyFile(file) {
|
|
82
|
+
try { return fs.readFileSync(file, "utf8").trim() || null; } catch { return null; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Save the key where only this user can read it (0600, in a 0700 directory)
|
|
87
|
+
* and enable Jev. The key never goes into validators.yml.
|
|
88
|
+
*/
|
|
89
|
+
export function saveJevKey(key, { configDir = globalConfigDir(), checks = [...JEV_CHECKS] } = {}) {
|
|
90
|
+
const clean = String(key ?? "").trim();
|
|
91
|
+
if (!clean || /\s/.test(clean)) throw new Error("that doesn't look like an API key (empty, or contains spaces)");
|
|
92
|
+
const keyFile = defaultKeyFile(configDir);
|
|
93
|
+
fs.mkdirSync(path.dirname(keyFile), { recursive: true, mode: 0o700 });
|
|
94
|
+
fs.chmodSync(path.dirname(keyFile), 0o700);
|
|
95
|
+
fs.writeFileSync(keyFile, `${clean}\n`, { mode: 0o600 });
|
|
96
|
+
fs.chmodSync(keyFile, 0o600);
|
|
97
|
+
const current = (() => { try { return loadValidators(configDir); } catch { return {}; } })();
|
|
98
|
+
const next = { ...current, jev: { enabled: true, key_file: keyFile, model: current.jev?.model ?? "jev-latest", checks } };
|
|
99
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
100
|
+
fs.writeFileSync(validatorsPath(configDir), YAML.stringify(next));
|
|
101
|
+
return { keyFile, configPath: validatorsPath(configDir) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The judge's settings with its agent resolved, or { problem } when it can't
|
|
106
|
+
* run as configured (unknown agent, host tools without consent), or null
|
|
107
|
+
* when there's no enabled judge.
|
|
108
|
+
*/
|
|
109
|
+
export function judgeSettings({ configDir = globalConfigDir(), agents = {}, providerOf, runsOnHost }) {
|
|
110
|
+
let config;
|
|
111
|
+
try { config = loadValidators(configDir).judge; } catch (error) { return { problem: error.message }; }
|
|
112
|
+
if (!config?.enabled) return null;
|
|
113
|
+
const agent = agents[config.agent];
|
|
114
|
+
if (!agent) return { problem: `the judge's agent "${config.agent}" isn't in agents.yml` };
|
|
115
|
+
if (agent.kind === "local") return { problem: "the judge needs an api or subscription agent; the local model's context is too small for a whole diff" };
|
|
116
|
+
if (runsOnHost(agent) && !config.host_tools) return { problem: `agent "${config.agent}" runs its tools on this machine, and a judge reads worker-written text; set host_tools: true on the judge in validators.yml to accept that, or use a sandboxed agent` };
|
|
117
|
+
return { ...config, provider: providerOf(agent) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function saveJudge({ agent, model, hostTools = false, checks = [...JUDGE_CHECK_NAMES] }, { configDir = globalConfigDir() } = {}) {
|
|
121
|
+
const current = (() => { try { return loadValidators(configDir); } catch { return {}; } })();
|
|
122
|
+
const next = { ...current, judge: { enabled: true, agent, model, checks, host_tools: Boolean(hostTools) } };
|
|
123
|
+
const parsed = validatorsSchema.safeParse(next);
|
|
124
|
+
if (!parsed.success) throw new Error(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
|
|
125
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
126
|
+
fs.writeFileSync(validatorsPath(configDir), YAML.stringify(next));
|
|
127
|
+
return { configPath: validatorsPath(configDir) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Build the guided judge list. Local agents cannot judge a whole diff. */
|
|
131
|
+
export function judgeAgentChoices({ agents, roles = {}, providerOf, runsOnHost }) {
|
|
132
|
+
const builderVendors = new Map();
|
|
133
|
+
for (const role of Object.values(roles)) {
|
|
134
|
+
if (role.phase !== "build" || !agents[role.agent] || agents[role.agent].kind === "local") continue;
|
|
135
|
+
const vendor = providerOf(agents[role.agent]);
|
|
136
|
+
builderVendors.set(vendor, (builderVendors.get(vendor) ?? 0) + 1);
|
|
137
|
+
}
|
|
138
|
+
const choices = Object.entries(agents)
|
|
139
|
+
.filter(([name, agent]) => name !== "local" && agent.kind !== "local")
|
|
140
|
+
.map(([name, agent]) => {
|
|
141
|
+
const vendor = providerOf(agent);
|
|
142
|
+
return { name, agent, vendor, hostTools: runsOnHost(agent), sharesBuilderVendor: builderVendors.has(vendor) };
|
|
143
|
+
});
|
|
144
|
+
choices.sort((a, b) => Number(a.sharesBuilderVendor) - Number(b.sharesBuilderVendor) || a.name.localeCompare(b.name));
|
|
145
|
+
const dominantBuilderVendor = [...builderVendors.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? null;
|
|
146
|
+
return { choices, dominantBuilderVendor };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Print numbered choices and keep asking until one valid number is entered. */
|
|
150
|
+
export async function chooseJudgeAgent({ choices, ask, write }) {
|
|
151
|
+
write("Configured judge agents:");
|
|
152
|
+
choices.forEach((choice, index) => {
|
|
153
|
+
const independence = choice.sharesBuilderVendor ? "same vendor as builders; verdicts are not independent" : "independent of your builders";
|
|
154
|
+
write(` ${index + 1}. ${choice.name}${choice.hostTools ? " (runs tools on this machine)" : ""} (${independence})`);
|
|
155
|
+
});
|
|
156
|
+
for (;;) {
|
|
157
|
+
const answer = String(await ask("Choose a judge agent: ")).trim();
|
|
158
|
+
if (/^[1-9][0-9]*$/.test(answer) && Number(answer) <= choices.length) return choices[Number(answer) - 1];
|
|
159
|
+
write(`Choose a number from 1 to ${choices.length}.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Explain the host boundary and accept only an explicit y or yes. */
|
|
164
|
+
export async function confirmJudgeHostTools({ agent, ask, write }) {
|
|
165
|
+
write("The judge's model can run commands on this machine as you, outside the sandbox, while reading text a worker wrote.");
|
|
166
|
+
write("Alternatively, use a sandboxed agent such as an api key, Codex or Muse.");
|
|
167
|
+
const answer = String(await ask(`Allow ${agent} to run as a judge with tools on this machine? [y/N] `)).trim().toLowerCase();
|
|
168
|
+
return answer === "y" || answer === "yes";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function removeJudge({ configDir = globalConfigDir() } = {}) {
|
|
172
|
+
const current = (() => { try { return loadValidators(configDir); } catch { return {}; } })();
|
|
173
|
+
delete current.judge;
|
|
174
|
+
if (fs.existsSync(validatorsPath(configDir))) fs.writeFileSync(validatorsPath(configDir), YAML.stringify(current));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Turn Jev off and delete the key file nomArmy saved. */
|
|
178
|
+
export function removeJev({ configDir = globalConfigDir() } = {}) {
|
|
179
|
+
const current = (() => { try { return loadValidators(configDir); } catch { return {}; } })();
|
|
180
|
+
const keyFile = current.jev?.key_file ? expandHome(current.jev.key_file) : null;
|
|
181
|
+
if (keyFile && path.resolve(keyFile) === path.resolve(defaultKeyFile(configDir))) fs.rmSync(keyFile, { force: true });
|
|
182
|
+
delete current.jev;
|
|
183
|
+
if (fs.existsSync(validatorsPath(configDir))) fs.writeFileSync(validatorsPath(configDir), YAML.stringify(current));
|
|
184
|
+
return { removedKey: Boolean(keyFile) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// A Jev outage must never slow nomArmy down, let alone stop it: after a
|
|
188
|
+
// failure (unreachable, timeout, 5xx, a rejected key), every job skips Jev
|
|
189
|
+
// for BREAKER_MS, and a job's own checks stop at their first failure.
|
|
190
|
+
export const BREAKER_MS = 10 * 60 * 1000;
|
|
191
|
+
let openUntil = 0, lastFailure = null;
|
|
192
|
+
export function jevBreaker(now = Date.now()) {
|
|
193
|
+
return now < openUntil ? { open: true, reason: lastFailure, retryAt: new Date(openUntil).toISOString() } : { open: false };
|
|
194
|
+
}
|
|
195
|
+
export function tripJevBreaker(reason, now = Date.now()) { openUntil = now + BREAKER_MS; lastFailure = reason; }
|
|
196
|
+
export function resetJevBreaker() { openUntil = 0; lastFailure = null; }
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* One System One request. Retries once on 429/529. Never logs the key.
|
|
200
|
+
* @returns {Promise<{ answers: object, usage: object|null }>}
|
|
201
|
+
*/
|
|
202
|
+
export async function askJev({ key, model = "jev-latest", state, questions, fetchFn = fetch, timeoutMs = 15000 }) {
|
|
203
|
+
const body = JSON.stringify({ model, state, questions });
|
|
204
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
207
|
+
let response;
|
|
208
|
+
try {
|
|
209
|
+
response = await fetchFn(JEV_ENDPOINT, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, body, signal: controller.signal });
|
|
210
|
+
} finally { clearTimeout(timer); }
|
|
211
|
+
if ((response.status === 429 || response.status === 529) && attempt === 0) { await new Promise((r) => setTimeout(r, 1500)); continue; }
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
const why = response.status === 401 ? "the API key was rejected (401)" : `TypeSafe answered ${response.status}`;
|
|
214
|
+
throw new Error(why);
|
|
215
|
+
}
|
|
216
|
+
const data = await response.json();
|
|
217
|
+
return { answers: data.answers ?? {}, usage: data.usage ?? null, model: data.model ?? model };
|
|
218
|
+
}
|
|
219
|
+
throw new Error("TypeSafe stayed rate-limited or overloaded");
|
|
220
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { credentialForms } from "./verification-network.mjs";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadHarnesses, matchHarnesses } from "./harnesses.mjs";
|
|
5
|
+
import { matchesGlob } from "./repo-query.mjs";
|
|
6
|
+
|
|
7
|
+
/** Copy regular evidence files only; never follow repository-controlled symlinks. */
|
|
8
|
+
export function collectVerificationArtifacts(cwd, jobDir, { secrets = [], maxFiles = 200, maxBytes = 50 * 1024 * 1024 } = {}) {
|
|
9
|
+
const { harnesses } = loadHarnesses();
|
|
10
|
+
const globs = matchHarnesses(cwd, harnesses).flatMap((name) => harnesses[name].artifacts);
|
|
11
|
+
const artifacts = [];
|
|
12
|
+
let bytes = 0, capped = false, dropped = 0;
|
|
13
|
+
const forms = credentialForms(secrets).map(s => Buffer.from(s));
|
|
14
|
+
const destination = path.join(jobDir, "artifacts");
|
|
15
|
+
// Do not reuse a directory the runner could have populated with symlinks.
|
|
16
|
+
fs.rmSync(destination, { recursive: true, force: true });
|
|
17
|
+
function walk(dir = "") {
|
|
18
|
+
for (const entry of fs.readdirSync(path.join(cwd, dir), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
19
|
+
const rel = path.posix.join(dir, entry.name);
|
|
20
|
+
if (entry.isSymbolicLink()) continue;
|
|
21
|
+
if (entry.isDirectory()) {
|
|
22
|
+
if ([".git", "node_modules"].includes(entry.name) || path.resolve(cwd, rel) === path.resolve(destination)) continue;
|
|
23
|
+
walk(rel);
|
|
24
|
+
} else if (entry.isFile() && globs.some((glob) => matchesGlob(rel, glob))) {
|
|
25
|
+
const source = path.join(cwd, rel);
|
|
26
|
+
const fd = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
27
|
+
try {
|
|
28
|
+
const stat = fs.fstatSync(fd);
|
|
29
|
+
if (!stat.isFile()) continue;
|
|
30
|
+
if (artifacts.length >= maxFiles || bytes + stat.size > maxBytes) { capped = true; continue; }
|
|
31
|
+
// Bound reads even if the file grows while verification is finishing.
|
|
32
|
+
const content = Buffer.alloc(stat.size);
|
|
33
|
+
const size = fs.readSync(fd, content, 0, content.length, 0);
|
|
34
|
+
if (forms.some(form => content.subarray(0, size).includes(form) || Buffer.from(rel).includes(form))) { dropped++; continue; }
|
|
35
|
+
const target = path.join(destination, rel);
|
|
36
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
37
|
+
fs.writeFileSync(target, content.subarray(0, size));
|
|
38
|
+
bytes += size;
|
|
39
|
+
artifacts.push(path.posix.join("artifacts", rel));
|
|
40
|
+
} finally { fs.closeSync(fd); }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (globs.length) walk();
|
|
45
|
+
return { artifacts, artifactsCapped: capped, ...(dropped ? { artifactsNote: `dropped ${dropped} artifact(s) containing credentials` } : {}) };
|
|
46
|
+
}
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
|
+
import { collectVerificationArtifacts } from "./verification-artifacts.mjs";
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Production-file revert/restore helpers. These operate on plain
|
|
@@ -164,7 +165,7 @@ export function createVerificationFlow(deps) {
|
|
|
164
165
|
// worktree may not be provably back to the worker's real edit, which the
|
|
165
166
|
// caller must treat as a hard, unconditional block, never as just another
|
|
166
167
|
// failed check (see the call site in executeImplement).
|
|
167
|
-
async function runRegressionCheck({ cwd, jobId, productionFiles, nameStatus, profile, baseSha, branch, mode }) {
|
|
168
|
+
async function runRegressionCheck({ cwd, jobId, productionFiles, nameStatus, profile, baseSha, branch, mode, verificationResult }) {
|
|
168
169
|
if (!productionFiles || productionFiles.length === 0) {
|
|
169
170
|
return { status: "not_run", rawRerunStatus: null, basis: "not-applicable", reason: "no production files changed", detail: null };
|
|
170
171
|
}
|
|
@@ -196,17 +197,25 @@ export function createVerificationFlow(deps) {
|
|
|
196
197
|
const restoreErrors = [];
|
|
197
198
|
for (const item of plan) { try { restoreWorkerVersion(item); } catch (error) { restoreErrors.push({ path: item.path, error: error.message }); } }
|
|
198
199
|
|
|
200
|
+
if (rerun.network && verificationResult?.network) {
|
|
201
|
+
verificationResult.network.reached = [...new Set([...(verificationResult.network.reached ?? []), ...(rerun.network.reached ?? [])])];
|
|
202
|
+
if (rerun.log && verificationResult.log) {
|
|
203
|
+
try { fs.appendFileSync(verificationResult.log, "\n\n[regression check]\n" + fs.readFileSync(rerun.log, "utf8")); }
|
|
204
|
+
catch { rerun = { ...rerun, status: "not_run", basis: "egress-log-failed", reason: "could not merge regression network log" }; }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const rerunEvidence = rerun.network ? { network: rerun.network, log: rerun.log, issues: rerun.issues } : {};
|
|
199
208
|
const mismatches = [...expectedAfterRestore].filter(([full, hash]) => currentBlobHash(full) !== hash).map(([full]) => full);
|
|
200
209
|
if (restoreErrors.length > 0 || mismatches.length > 0) {
|
|
201
|
-
return { status: "restore_failed", rawRerunStatus: rerun.status, basis: "restore-error",
|
|
210
|
+
return { ...rerunEvidence, status: "restore_failed", rawRerunStatus: rerun.status, basis: "restore-error",
|
|
202
211
|
reason: `production files may not be fully restored after regression check: ${[...restoreErrors.map(e => e.path), ...mismatches].join(", ")}`, detail: null };
|
|
203
212
|
}
|
|
204
213
|
if (revertErrors.length > 0) {
|
|
205
214
|
return { status: "not_run", rawRerunStatus: null, basis: "revert-error", reason: `failed to revert ${revertErrors.length} file(s): ${revertErrors.map(e => e.path).join(", ")}`, detail: null };
|
|
206
215
|
}
|
|
207
|
-
if (rerun.status === "fail") return { status: "pass", rawRerunStatus: "fail", basis: rerun.basis, reason: "reverting the production change made the same verification profile fail, as expected -- a test catches this regression", detail: rerun.detail };
|
|
208
|
-
if (rerun.status === "pass") return { status: "fail", rawRerunStatus: "pass", basis: rerun.basis, reason: "verification still passed with the production change reverted -- no test demonstrably catches this regression", detail: rerun.detail };
|
|
209
|
-
return { status: "not_run", rawRerunStatus: "not_run", basis: rerun.basis, reason: `regression rerun was inconclusive: ${rerun.reason}`, detail: rerun.detail };
|
|
216
|
+
if (rerun.status === "fail") return { ...rerunEvidence, status: "pass", rawRerunStatus: "fail", basis: rerun.basis, reason: "reverting the production change made the same verification profile fail, as expected -- a test catches this regression", detail: rerun.detail };
|
|
217
|
+
if (rerun.status === "pass") return { ...rerunEvidence, status: "fail", rawRerunStatus: "pass", basis: rerun.basis, reason: "verification still passed with the production change reverted -- no test demonstrably catches this regression", detail: rerun.detail };
|
|
218
|
+
return { ...rerunEvidence, status: "not_run", rawRerunStatus: "not_run", basis: rerun.basis, reason: `regression rerun was inconclusive: ${rerun.reason}`, detail: rerun.detail };
|
|
210
219
|
}
|
|
211
220
|
|
|
212
221
|
// ---------------------------------------------------------------------------
|
|
@@ -222,7 +231,8 @@ export function createVerificationFlow(deps) {
|
|
|
222
231
|
return {
|
|
223
232
|
status, profile: value?.profile ?? profile ?? null,
|
|
224
233
|
basis: value?.basis ?? (verificationRunner ? "registered-runner" : "none"),
|
|
225
|
-
reason: value?.reason ?? null, detail: value?.detail ?? null
|
|
234
|
+
reason: value?.reason ?? null, detail: value?.detail ?? null,
|
|
235
|
+
...(value?.network ? { network: value.network, issues: value.issues ?? [] } : {})
|
|
226
236
|
};
|
|
227
237
|
}
|
|
228
238
|
async function runIndependentVerification(context) {
|
|
@@ -230,7 +240,42 @@ export function createVerificationFlow(deps) {
|
|
|
230
240
|
return normalizeVerification({ status: "not_run", basis: "none",
|
|
231
241
|
reason: "no verification runner registered; profile execution is owned by the verification component" }, context.profile);
|
|
232
242
|
}
|
|
233
|
-
try {
|
|
243
|
+
try {
|
|
244
|
+
const artifactJobDir = context.jobDir ?? (context.logFile ? path.dirname(context.logFile) :
|
|
245
|
+
context.jobId && ensureJobsRoot ? path.join(ensureJobsRoot(), context.jobId) : null);
|
|
246
|
+
const value = await verificationRunner({ ...context, jobDir: artifactJobDir });
|
|
247
|
+
const normalized = normalizeVerification(value, context.profile);
|
|
248
|
+
// Networked runs must retain the proxy verdicts for every job mode.
|
|
249
|
+
if (!context.logFile && value?.network && context.jobId && ensureJobsRoot) {
|
|
250
|
+
context = { ...context, logFile: path.join(ensureJobsRoot(), context.jobId, "verification.log") };
|
|
251
|
+
}
|
|
252
|
+
// Persist only the runner-sanitized output (credentialed runs withhold it).
|
|
253
|
+
if (context.logFile && typeof value?.output === "string") {
|
|
254
|
+
try {
|
|
255
|
+
if (value?.network) fs.mkdirSync(path.dirname(context.logFile), { recursive: true });
|
|
256
|
+
fs.writeFileSync(context.logFile, value.output);
|
|
257
|
+
normalized.log = context.logFile;
|
|
258
|
+
} catch {
|
|
259
|
+
if (value?.network) return { ...normalized, status: "not_run", basis: "egress-log-failed", reason: "could not retain verification network log", detail: null };
|
|
260
|
+
// Non-networked logs remain best-effort for compatibility.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const jobDir = context.jobDir ?? (context.logFile ? path.dirname(context.logFile) :
|
|
264
|
+
context.jobId && ensureJobsRoot ? path.join(ensureJobsRoot(), context.jobId) : null);
|
|
265
|
+
if (jobDir && context.cwd) {
|
|
266
|
+
try {
|
|
267
|
+
Object.assign(normalized, value?.artifacts ? {
|
|
268
|
+
artifacts: value.artifacts, artifactsCapped: value.artifactsCapped,
|
|
269
|
+
...(value.artifactsNote ? { artifactsNote: value.artifactsNote } : {})
|
|
270
|
+
} : value?.network ? { artifacts: [], artifactsCapped: false } : collectVerificationArtifacts(context.cwd, jobDir));
|
|
271
|
+
if (normalized.artifactsCapped) normalized.detail = [normalized.detail, "artifact collection capped at 200 files / 50 MB"].filter(Boolean).join("; ");
|
|
272
|
+
} catch (error) {
|
|
273
|
+
normalized.artifacts = [];
|
|
274
|
+
normalized.artifactsError = error.message;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return normalized;
|
|
278
|
+
}
|
|
234
279
|
catch (error) {
|
|
235
280
|
// A crashed runner produced no evidence. `not_run` is the truthful state:
|
|
236
281
|
// it can never promote a recovery to success, and it never fabricates a
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { parseYaml } from './config.mjs';
|
|
5
|
+
import { target } from './egress-proxy.mjs';
|
|
6
|
+
|
|
7
|
+
export function validateVerificationNetwork(value) {
|
|
8
|
+
const mapping = v => v && typeof v === 'object' && !Array.isArray(v);
|
|
9
|
+
if (!mapping(value) || Object.keys(value).some(k => !['allow', 'env', 'keep_output'].includes(k)) ||
|
|
10
|
+
!Array.isArray(value.allow) || !value.allow.length || !mapping(value.env ?? {})) throw new Error('invalid verification_network: expected allow list and env name mapping');
|
|
11
|
+
if (value.keep_output !== undefined && typeof value.keep_output !== 'boolean') throw new Error('verification_network.keep_output must be boolean');
|
|
12
|
+
let allow;
|
|
13
|
+
try { allow = [...new Set(value.allow.map(v => target(v).authority))]; }
|
|
14
|
+
catch { throw new Error('verification_network.allow requires exact public DNS hosts with optional ports (no IPs or private names)'); }
|
|
15
|
+
const env = {};
|
|
16
|
+
for (const [name, source] of Object.entries(value.env ?? {})) {
|
|
17
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof source !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(source) ||
|
|
18
|
+
/^(?:https?_proxy|no_proxy|all_proxy)$/i.test(name) || name === '__proto__') throw new Error('verification_network.env must map test variable names to host variable names; proxy variables are reserved');
|
|
19
|
+
Object.defineProperty(env, name, { value: source, enumerable: true });
|
|
20
|
+
}
|
|
21
|
+
return { allow, env, ...(value.keep_output !== undefined ? { keep_output: value.keep_output } : {}) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Never call this on a job worktree. Only the coordinator supplies projectDir.
|
|
25
|
+
// Fail closed if Git cannot establish both untracked and ignored provenance.
|
|
26
|
+
export function loadVerificationNetwork(projectDir, { check = (args) => spawnSync('git', args, { cwd: projectDir, encoding: 'utf8', timeout: 5000 }) } = {}) {
|
|
27
|
+
if (!projectDir) return null;
|
|
28
|
+
const filename = '.nomarmy.local.yml', file = path.join(projectDir, filename);
|
|
29
|
+
let stat;
|
|
30
|
+
try { stat = fs.lstatSync(file); } catch (e) { if (e.code === 'ENOENT') return null; throw new Error('cannot read operator-local verification policy'); }
|
|
31
|
+
// Do not include YAML parser errors: the document might contain a mistaken secret.
|
|
32
|
+
let parsed;
|
|
33
|
+
try { parsed = stat.isFile() && !stat.isSymbolicLink() ? parseYaml(fs.readFileSync(file, 'utf8')) : null; }
|
|
34
|
+
catch { throw new Error('invalid operator-local YAML'); }
|
|
35
|
+
// The file also holds other operator-local settings (registries, the army);
|
|
36
|
+
// provenance only matters when it grants network.
|
|
37
|
+
if (parsed?.verification_network === undefined && stat.isFile() && !stat.isSymbolicLink()) return null;
|
|
38
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('verification_network requires a regular operator-local file');
|
|
39
|
+
const tracked = check(['ls-files', '--error-unmatch', '--', filename]);
|
|
40
|
+
const ignored = check(['check-ignore', '-q', '--', filename]);
|
|
41
|
+
if (tracked.status !== 1 || ignored.status !== 0) throw new Error('verification_network requires an untracked, gitignored .nomarmy.local.yml');
|
|
42
|
+
return validateVerificationNetwork(parsed.verification_network);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function credentialForms(secrets) {
|
|
46
|
+
return [...new Set(secrets.filter(Boolean).flatMap(secret => {
|
|
47
|
+
const bytes = Buffer.from(secret);
|
|
48
|
+
const percent = [...bytes].map(b => '%' + b.toString(16).padStart(2, '0')).join('');
|
|
49
|
+
return [secret, bytes.toString('base64'), bytes.toString('base64url'),
|
|
50
|
+
bytes.toString('hex'), bytes.toString('hex').toUpperCase(),
|
|
51
|
+
encodeURIComponent(secret), encodeURIComponent(secret).replace(/%[0-9A-F]{2}/g, s => s.toLowerCase()),
|
|
52
|
+
percent, percent.toUpperCase()];
|
|
53
|
+
}))].sort((a, b) => b.length - a.length);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function redactCredentials(value, secrets, { truncated = true } = {}) {
|
|
57
|
+
let text = String(value ?? '');
|
|
58
|
+
for (const secret of credentialForms(secrets)) {
|
|
59
|
+
text = text.split(secret).join('***');
|
|
60
|
+
// Capturing may have cut the final chunk in the middle of a credential.
|
|
61
|
+
for (let n = truncated ? Math.min(secret.length - 1, text.length) : 0; n > 0; n--) {
|
|
62
|
+
if (text.endsWith(secret.slice(0, n))) { text = text.slice(0, -n) + '***'; break; }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return text;
|
|
66
|
+
}
|