nomarmy 0.1.0-alpha.5 → 0.1.0-alpha.7

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.
Files changed (57) hide show
  1. package/README.md +7 -5
  2. package/bin/nomarmy.mjs +193 -14
  3. package/docker/Dockerfile.go +5 -3
  4. package/docker/Dockerfile.rust +16 -1
  5. package/harnesses/_template/README.md +27 -0
  6. package/harnesses/_template/harness.yml +26 -0
  7. package/harnesses/browser-playwright/README.md +35 -0
  8. package/harnesses/browser-playwright/fixture/package.json +1 -0
  9. package/harnesses/browser-playwright/fixture/page.html +1 -0
  10. package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
  11. package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
  12. package/harnesses/browser-playwright/harness.yml +18 -0
  13. package/harnesses/go/README.md +45 -0
  14. package/harnesses/go/harness.yml +14 -0
  15. package/harnesses/mock-oidc/README.md +31 -0
  16. package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
  17. package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
  18. package/harnesses/mock-oidc/harness.yml +19 -0
  19. package/harnesses/node/README.md +53 -0
  20. package/harnesses/node/harness.yml +18 -0
  21. package/harnesses/python/README.md +46 -0
  22. package/harnesses/python/harness.yml +16 -0
  23. package/harnesses/rust/README.md +45 -0
  24. package/harnesses/rust/harness.yml +13 -0
  25. package/install.sh +16 -2
  26. package/lib/admission.mjs +3 -0
  27. package/lib/agents.mjs +6 -5
  28. package/lib/army.mjs +9 -6
  29. package/lib/config.mjs +15 -0
  30. package/lib/connect.mjs +2 -0
  31. package/lib/coordinator-instructions.mjs +1 -1
  32. package/lib/dispatch-schema.mjs +12 -11
  33. package/lib/doctor.mjs +62 -1
  34. package/lib/egress-proxy.mjs +116 -0
  35. package/lib/execute.mjs +8 -3
  36. package/lib/harness-schema.mjs +61 -0
  37. package/lib/harnesses.mjs +99 -0
  38. package/lib/health.mjs +10 -3
  39. package/lib/install-freshness.mjs +83 -0
  40. package/lib/openclaw-run.mjs +5 -6
  41. package/lib/podman-health.mjs +41 -0
  42. package/lib/propose.mjs +10 -11
  43. package/lib/registry-python.mjs +96 -0
  44. package/lib/registry-secrets.mjs +128 -0
  45. package/lib/repo-query.mjs +1 -1
  46. package/lib/sandbox-images.mjs +484 -81
  47. package/lib/sandbox-vm.mjs +32 -0
  48. package/lib/schema.mjs +10 -11
  49. package/lib/verification-artifacts.mjs +46 -0
  50. package/lib/verification-flow.mjs +46 -9
  51. package/lib/verification-network.mjs +66 -0
  52. package/lib/verify.mjs +305 -85
  53. package/lib/zod-issues.mjs +15 -0
  54. package/mcp/server.mjs +29 -3
  55. package/package.json +6 -4
  56. package/playbooks/feature.md +1 -1
  57. package/scripts/generate-harness-docs.mjs +42 -0
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  ## TL;DR
14
14
 
15
- 1. **Have** Git, Node 20+ and [Podman](https://podman.io) (on macOS: `brew install podman && podman machine init && podman machine start`).
15
+ 1. **Have** Git, Node 24.16+ (or 26.1+; OpenClaw, which nomArmy installs, needs it) and [Podman](https://podman.io) (on macOS: `brew install podman && podman machine init --memory 8192 && podman machine start`; Podman's 2 GiB default is too small for nomArmy's sandboxes).
16
16
  2. **Install and set up:**
17
17
  ```bash
18
18
  npm install -g nomarmy@alpha
@@ -67,6 +67,7 @@ Around that core: **agents** say where a job can run, the **army** says which ro
67
67
  | [Agents and the army](https://github.com/rayson-tech/nomarmy/blob/main/docs/agents-and-army.md) | Where a job can run, who does what, usage limits, picking an agent |
68
68
  | [`/feature` runs](https://github.com/rayson-tech/nomarmy/blob/main/docs/feature-runs.md) | A feature end to end, and watching what nomArmy is doing |
69
69
  | [Your repository](https://github.com/rayson-tech/nomarmy/blob/main/docs/your-repo.md) | `.nomarmy.yml`, verification, languages and dependencies, what nomArmy checks |
70
+ | [Harnesses](https://github.com/rayson-tech/nomarmy/blob/main/docs/harnesses.md) | Ecosystem registry, detection, network levels, and requirements |
70
71
  | [Configuration](https://github.com/rayson-tech/nomarmy/blob/main/docs/configuration.md) | Settings, swapping the local model, sizing, admission |
71
72
  | [Reference](https://github.com/rayson-tech/nomarmy/blob/main/docs/reference.md) | Every CLI command and MCP tool |
72
73
  | [Security posture](https://github.com/rayson-tech/nomarmy/blob/main/docs/security.md) | What the sandbox holds back, and the one exception |
@@ -88,9 +89,10 @@ A worker gets a writable git worktree inside a Podman sandbox and nothing else:
88
89
  | Agents: api keys | Live-verified with xAI; other providers built to OpenClaw's documented interface |
89
90
  | Agents: subscriptions | ChatGPT (Codex) and Muse Code sandboxed and live-verified; Claude live-verified, but its tools run on the host (scout and review by default) |
90
91
  | The army and `/feature` | Driven by a real Claude Code General across three runs, about 18 implement jobs |
91
- | Go, Rust, Python and Node repos | Dependency images live-verified for Python and Node (npm); Go and Rust toolchains live-verified |
92
- | Disposable per-job services (Postgres, mocks) | Not built |
93
- | Browser/E2E testing inside a nom | Not built |
92
+ | Go, Rust, Python and Node repos, and mixed ones | Harness images live-verified: Go modules and Rust crates prefetched; npm, pnpm, yarn, bun and workspaces; pip, pyproject, uv and poetry |
93
+ | Fake services beside the app (mock login server, mock APIs) | Live-verified on a private network with no route out (the `services` harness level) |
94
+ | Browser tests (Playwright + Chromium) | Live-verified offline, with screenshots and traces kept as job evidence |
95
+ | Private registries and a verification-only network allowlist | Live-verified; each passed an independent security review |
94
96
 
95
97
  What we've learned from real runs, including where delegating pays and where it doesn't, is in [docs/findings.md](https://github.com/rayson-tech/nomarmy/blob/main/docs/findings.md).
96
98
 
@@ -101,7 +103,7 @@ What we've learned from real runs, including where delegating pays and where it
101
103
  - **Claude subscription token counts** come from the Claude CLI's own session log, since OpenClaw sees only the final reply. Totals include cache reads and writes, which make up most of an agent's prompt; each part is also kept separately.
102
104
  - **Test-workaround detection is a flag, not a verdict**: a legitimate new skip still gets flagged.
103
105
  - **Deploy-time failures need your own check.** See [Add a check for what unit tests can't see](https://github.com/rayson-tech/nomarmy/blob/main/docs/your-repo.md#nomarmyyml).
104
- - **Node dependencies install only from npm lockfiles**, one per package (no workspaces, yarn, pnpm or bun yet), and only from the public registry: the image build has no credentials for a private one.
106
+ - **Node private registries are not supported yet**: npm, pnpm, yarn and bun lockfiles and workspaces are supported, but the image build has no credentials for private registries (dependency plan step 8).
105
107
  - **Verification needing services** (a database, a mock server) reports `not_run` instead of running without them. The compose parser doesn't resolve YAML anchors.
106
108
  - **Same-host sandboxes**: the MCP server, OpenClaw and every job's sandbox run on the machine with the coordinator. Only the model can be elsewhere (an agent, or [a shared model server](https://github.com/rayson-tech/nomarmy/blob/main/docs/install.md#a-shared-model-server)).
107
109
 
package/bin/nomarmy.mjs CHANGED
@@ -18,13 +18,17 @@ import { buildConfigProposal } from "../lib/propose.mjs";
18
18
  import { detectHardware } from "../lib/hardware.mjs";
19
19
  import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf.mjs";
20
20
  import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
21
- import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv } from "../lib/connect.mjs";
21
+ import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir } from "../lib/connect.mjs";
22
+ import { compareVersions, readPackageVersion, readInstallVersions } from "../lib/install-freshness.mjs";
22
23
  import { ID_RE, AUTH_ENV_NAME_RE, OPENCLAW_PROVIDER_ID_RE, openclawProviderId, isNativeProviderType } from "../lib/dispatch-schema.mjs";
23
24
  import { loadAgents, readAgentsFile, writeAgentsFile, agentsConfigPath, apiAgentAsPoolEntry, describeAgent as describeAgentLabel, agentRunsToolsOnHost, agentProviderId, AGENT_KINDS, API_PROVIDER_TYPES, RESERVED_AGENT_NAMES, BUILTIN_LOCAL_AGENT } from "../lib/agents.mjs";
24
25
  import { loadArmy, mergeArmy, describeArmy, readArmyFile, updateArmyInFile, assignRoleInFile, parseTargetSpec, armyLayerPath, globalConfigDir, DEFAULT_ARMY, ARMY_PHASES, LOCAL_CONFIG_FILENAME } from "../lib/army.mjs";
25
26
  import { parseLlamaUrl } from "../lib/execution.mjs";
26
27
  import { setupSteps, formatSetupSteps, runSetupPlaybook } from "../lib/setup-steps.mjs";
27
28
  import { readUsageSnapshots } from "../lib/usage-limits.mjs";
29
+ import { pickMachine, planResize } from "../lib/sandbox-vm.mjs";
30
+ import { MIN_PODMAN_VM_MB } from "../lib/doctor.mjs";
31
+ import { liveLeases } from "../lib/slots.mjs";
28
32
  import { ensureProviderConfig } from "../lib/openclaw-config.mjs";
29
33
  import { recordProbeSuccess } from "../lib/health.mjs";
30
34
  import { pruneJobRuntime } from "../lib/prune.mjs";
@@ -112,8 +116,9 @@ Usage: nomarmy <command> [options]
112
116
  registration (never done silently)
113
117
  --restart-inference with --json, also stop/start local
114
118
  inference (never done silently)
115
- update Pull the latest nomArmy code and re-sync the installed
116
- MCP copy (fast-forward only; refuses on local changes).
119
+ update Update nomArmy and reconnect your coordinators: installs
120
+ npm's latest alpha, or for a git checkout pulls (fast-forward
121
+ only; refuses on local changes). Then restart open sessions.
117
122
  agents <list|add|update|remove>
118
123
  Every account a job can run on, in one list:
119
124
  ~/.config/nomarmy/agents.yml (or NOMARMY_CONFIG_DIR).
@@ -190,7 +195,7 @@ Usage: nomarmy <command> [options]
190
195
  which agent the General is, in --global
191
196
  (default) or --local
192
197
  config paths where agents.yml and the three army layers live
193
- jobs [--watch|--events|--prune] [--interval N] [--older-than DAYS]
198
+ jobs [--watch|--events|--prune|--wait <jobId>] [--interval N] [--older-than DAYS]
194
199
  what's running across every session (agent, model, phase,
195
200
  last tool call, files changed, heartbeat) and what just
196
201
  finished; --watch redraws every N seconds (default 3);
@@ -198,7 +203,9 @@ Usage: nomarmy <command> [options]
198
203
  finish (for Claude Code's background monitor; --json for
199
204
  JSON lines); --prune removes the bulky runtime data
200
205
  from finished jobs older than DAYS (default 2), keeping
201
- their records, reports and any retained worktree
206
+ their records, reports and any retained worktree;
207
+ --wait <jobId> [--timeout <seconds>] blocks for one job
208
+ to finish (default timeout 1800; --json is supported)
202
209
  health check what's likely to break a run before it does:
203
210
  expiring logins, an outdated OpenClaw or plugin, roles
204
211
  that can't be dispatched, an unloadable agents.yml,
@@ -211,6 +218,12 @@ Usage: nomarmy <command> [options]
211
218
  (Re-)register the MCP server with one or more coordinators.
212
219
  With no target and not --json, prompts an interactive
213
220
  multi-select instead.
221
+ sandbox The Podman VM every sandbox shares (macOS, Windows): its
222
+ memory, disk and images. --memory <GiB> resizes it (stops,
223
+ sets, restarts; refused while jobs run); --prune removes
224
+ images no container uses (nomArmy rebuilds its own on
225
+ demand); --repair restores missing subordinate ID
226
+ ranges (refused while jobs run); --yes skips confirming
214
227
  start <profile> Start local inference (wraps scripts/start-inference.sh).
215
228
  stop <profile> Stop local inference (wraps scripts/stop-inference.sh).
216
229
  uninstall Remove the MCP registration and install directory.
@@ -1631,14 +1644,9 @@ function git(args) {
1631
1644
  */
1632
1645
  async function cmdUpdate() {
1633
1646
  const say = (s) => { if (!json) console.log(s); };
1634
- // Installed from npm: there's no checkout to pull. npm updates the
1635
- // package; connect resyncs the copy each coordinator runs.
1636
- if (!fs.existsSync(path.join(nomarmyRoot, ".git"))) {
1637
- const how = "npm install -g nomarmy@alpha && nomarmy connect";
1638
- if (json) return out({ error: "installed from npm, not a git checkout", fix: how });
1639
- console.log(`This nomArmy was installed from npm, so there's nothing to pull. Update with:\n ${how}`);
1640
- return;
1641
- }
1647
+ // Installed from npm: npm updates the package, then connect resyncs the
1648
+ // copy each coordinator runs.
1649
+ if (!fs.existsSync(path.join(nomarmyRoot, ".git"))) return updateFromNpm();
1642
1650
  const status = git(["status", "--porcelain"]);
1643
1651
  if (status) {
1644
1652
  if (json) { out({ error: "working tree is not clean; refusing to pull over local changes", status }); process.exit(1); }
@@ -1693,6 +1701,45 @@ async function cmdUpdate() {
1693
1701
  console.log(c.yellow("\nThe MCP server is a per-session child process: every open Claude Code / Codex / Cursor session needs a restart to pick this up, not just this one."));
1694
1702
  }
1695
1703
 
1704
+ // The coordinators nomArmy is registered with. Cursor has no CLI to probe,
1705
+ // so it counts when its own config already lists nomArmy.
1706
+ function connectedTargets() {
1707
+ return [commandExists("claude") && "claude", commandExists("codex") && "codex", cursorAlreadyConnected() && "cursor"].filter(Boolean);
1708
+ }
1709
+
1710
+ async function updateFromNpm() {
1711
+ const current = readPackageVersion(nomarmyRoot);
1712
+ let latest = null;
1713
+ try { latest = execFileSync("npm", ["view", "nomarmy", "dist-tags.alpha"], { encoding: "utf8", timeout: 20000 }).trim(); } catch { /* offline */ }
1714
+ if (!latest) {
1715
+ const fix = "npm install -g nomarmy@alpha && nomarmy connect claude";
1716
+ if (json) { out({ error: "could not read nomarmy's latest release from npm", fix }); process.exit(1); }
1717
+ console.log(c.red("Couldn't reach npm to find nomArmy's latest release.") + ` Update by hand:\n ${fix}`);
1718
+ process.exit(1);
1719
+ }
1720
+ const { copyVersion } = readInstallVersions(defaultInstallDir());
1721
+ const upgrade = compareVersions(current, latest) < 0;
1722
+ const staleCopy = !copyVersion || compareVersions(copyVersion, upgrade ? latest : current) < 0;
1723
+ if (!upgrade && !staleCopy) {
1724
+ if (json) return out({ updated: false, version: current, reason: "already up to date" });
1725
+ console.log(c.green(`✓ nomArmy ${current} is the latest, and your coordinators run it.`));
1726
+ return;
1727
+ }
1728
+ if (upgrade) {
1729
+ if (!json) console.log(c.bold(`🍪 Updating nomArmy ${current} → ${latest}\n`));
1730
+ execFileSync("npm", ["install", "-g", `nomarmy@${latest}`, "--no-audit", "--no-fund"], { stdio: json ? "ignore" : "inherit" });
1731
+ }
1732
+ // A child process, so the reconnect runs the code just installed rather
1733
+ // than the old code this process loaded.
1734
+ const targets = connectedTargets();
1735
+ if (targets.length) {
1736
+ if (!json) console.log(`\nReconnecting ${targets.join(", ")}...`);
1737
+ execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", ...targets, ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
1738
+ }
1739
+ if (json) return out({ updated: upgrade, from: current, version: upgrade ? latest : current, resynced: targets });
1740
+ console.log(c.yellow("\nRestart every open Claude Code, Codex and Cursor session: each keeps the code it started with until then."));
1741
+ }
1742
+
1696
1743
  function commandExists(cmd) {
1697
1744
  try { execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }); return true; }
1698
1745
  catch { return false; }
@@ -1783,6 +1830,77 @@ async function cmdConnect() {
1783
1830
  function runScript(name, args = []) {
1784
1831
  execFileSync("bash", [path.join(nomarmyRoot, "scripts", name), ...args], { cwd: nomarmyRoot, stdio: "inherit" });
1785
1832
  }
1833
+ // `nomarmy sandbox`: see lib/sandbox-vm.mjs.
1834
+ async function cmdSandbox() {
1835
+ const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
1836
+ const podman = (args, opts = {}) => spawnSync("podman", args, { encoding: "utf8", ...opts });
1837
+ const machine = process.platform === "linux" ? null : pickMachine(podman(["machine", "inspect"]).stdout);
1838
+ let images = null;
1839
+ try {
1840
+ const rows = JSON.parse(podman(["system", "df", "--format", "json"]).stdout || "[]");
1841
+ const row = rows.find((r) => /image/i.test(r.Type ?? ""));
1842
+ if (row) images = { count: row.Total ?? null, size: row.Size ?? null, reclaimable: row.Reclaimable ?? null };
1843
+ } catch { /* podman missing or old */ }
1844
+ const runningJobs = liveLeases(path.join(stateRoot, "leases")).length;
1845
+ const memoryGib = value("memory");
1846
+
1847
+ if (!memoryGib && !flag("prune") && !flag("repair")) {
1848
+ if (json) return out({ platform: process.platform, machine, images, runningJobs, minimumMb: MIN_PODMAN_VM_MB });
1849
+ console.log(c.bold("🍪 nomArmy sandbox"));
1850
+ if (process.platform === "linux") console.log("\nPodman runs natively on Linux: there's no VM to size.");
1851
+ else if (!machine) console.log(c.yellow("\nNo Podman machine found. Run: podman machine init && podman machine start"));
1852
+ else {
1853
+ const low = machine.memoryMb && machine.memoryMb < MIN_PODMAN_VM_MB;
1854
+ console.log(`\nVM ${machine.name} (${machine.state}): ${machine.cpus} CPUs, ${low ? c.red(`${machine.memoryMb / 1024} GiB memory`) : `${machine.memoryMb / 1024} GiB memory`}, ${machine.diskGb} GB disk`);
1855
+ if (low) console.log(c.yellow(` Too small: worker commands get cut off below ${MIN_PODMAN_VM_MB / 1024} GiB. Fix: nomarmy sandbox --memory 8`));
1856
+ }
1857
+ if (images) console.log(`Images: ${images.count}, ${images.size}${images.reclaimable ? `, ${images.reclaimable} reclaimable (nomarmy sandbox --prune)` : ""}`);
1858
+ console.log(c.dim(runningJobs ? `${runningJobs} nomArmy job(s) running.` : "No nomArmy jobs running."));
1859
+ return;
1860
+ }
1861
+
1862
+ const ask = async (question) => {
1863
+ if (flag("yes")) return true;
1864
+ if (!process.stdin.isTTY) throw new Error(`${question} Re-run with --yes to confirm without a terminal.`);
1865
+ const rl = createInterface({ input, output });
1866
+ try { return await confirm(rl, question, { defaultYes: false }); } finally { rl.close(); }
1867
+ };
1868
+ const runPodman = (args) => {
1869
+ console.log(c.dim(`$ podman ${args.join(" ")}`));
1870
+ const r = podman(args, { stdio: "inherit" });
1871
+ if (r.status !== 0) throw new Error(`podman ${args.join(" ")} failed (exit ${r.status ?? "none"}).`);
1872
+ };
1873
+
1874
+ if (flag("repair")) {
1875
+ // Restore the subordinate ID ranges a Podman machine ships with, only
1876
+ // where they're missing, then let Podman re-read them.
1877
+ if (process.platform === "linux") throw new Error("On Linux, add a range for your user to /etc/subuid and /etc/subgid (e.g. `sudo usermod --add-subuids 100000-1099999 --add-subgids 100000-1099999 $USER`), then run `podman system migrate`.");
1878
+ if (!machine) throw new Error("No Podman machine found. Run: podman machine init && podman machine start");
1879
+ // podman system migrate stops every container, a running job's sandbox included.
1880
+ if (runningJobs > 0) throw new Error(`${runningJobs} nomArmy job(s) are running; the repair restarts Podman's containers and would kill their sandboxes. Run it when they're done.`);
1881
+ const script = 'set -e; restored=0; for f in /etc/subuid /etc/subgid; do if ! grep -q "^$(id -un):" "$f" 2>/dev/null; then echo "$(id -un):100000:1000000" | sudo tee -a "$f" >/dev/null; echo "restored $f"; restored=1; else echo "$f already has a range"; fi; done; if [ "$restored" = 1 ]; then podman system migrate; else echo "nothing to repair"; fi';
1882
+ if (!(await ask(`Restore missing subordinate ID ranges in the ${machine.name} VM and run podman system migrate?`))) { console.log(c.dim("Nothing changed.")); return; }
1883
+ const r = spawnSync("podman", ["machine", "ssh", machine.name, script], { stdio: "inherit" });
1884
+ if (r.status !== 0) throw new Error(`the repair failed (exit ${r.status ?? "none"}).`);
1885
+ console.log(c.green("✓ Done. Check with: nomarmy doctor"));
1886
+ return;
1887
+ }
1888
+
1889
+ if (memoryGib) {
1890
+ const plan = planResize({ gib: memoryGib, machine, hostMemoryMb: os.totalmem() / 1024 / 1024, runningJobs });
1891
+ if (!plan.ok) throw new Error(plan.problems.join("; "));
1892
+ for (const w of plan.warnings) console.log(c.yellow(`⚠ ${w}`));
1893
+ if (!(await ask(`Restart the Podman VM with ${memoryGib} GiB (from ${machine.memoryMb / 1024})?`))) { console.log(c.dim("Nothing changed.")); return; }
1894
+ for (const args of plan.commands) runPodman(args);
1895
+ console.log(c.green(`✓ The Podman VM now has ${memoryGib} GiB.`));
1896
+ }
1897
+ if (flag("prune")) {
1898
+ if (runningJobs > 0) throw new Error(`${runningJobs} nomArmy job(s) are running; prune when they're done.`);
1899
+ if (!(await ask(`Remove every image no container uses${images?.reclaimable ? ` (about ${images.reclaimable})` : ""}? nomArmy rebuilds its own when a job needs them.`))) { console.log(c.dim("Nothing removed.")); return; }
1900
+ runPodman(["image", "prune", "--all", "--force"]);
1901
+ }
1902
+ }
1903
+
1786
1904
  async function cmdStart() { console.log(c.bold("🍪 Starting inference...\n")); runScript("start-inference.sh", argv.slice(1)); }
1787
1905
  async function cmdStop() { runScript("stop-inference.sh", argv.slice(1)); }
1788
1906
  function safeDu(dir) {
@@ -2369,6 +2487,66 @@ async function streamJobEvents() {
2369
2487
  }
2370
2488
  }
2371
2489
 
2490
+ /** Wait for one job in the shared, cross-session state directory. */
2491
+ async function waitForJobCli() {
2492
+ const requested = value("wait");
2493
+ const jobId = requested ? path.basename(requested) : null;
2494
+ const timeoutSeconds = Number(value("timeout", "1800"));
2495
+ if (!jobId || jobId !== requested) {
2496
+ if (json) out({ error: "--wait needs a job id" });
2497
+ else console.error("nomarmy jobs: --wait needs a job id");
2498
+ process.exitCode = 2;
2499
+ return;
2500
+ }
2501
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) {
2502
+ if (json) out({ error: "--timeout must be a non-negative number of seconds" });
2503
+ else console.error("nomarmy jobs: --timeout must be a non-negative number of seconds");
2504
+ process.exitCode = 2;
2505
+ return;
2506
+ }
2507
+ const jobDir = path.join(jobsRootDir(), jobId);
2508
+ const lease = path.join(agentStateRoot(), "leases", `${jobId}.json`);
2509
+ if (!fs.existsSync(jobDir) && !fs.existsSync(lease)) {
2510
+ if (json) out({ error: `unknown job id: ${jobId}` });
2511
+ else console.error(`nomarmy jobs: unknown job id: ${jobId}`);
2512
+ process.exitCode = 2;
2513
+ return;
2514
+ }
2515
+ const deadline = Date.now() + timeoutSeconds * 1000;
2516
+ for (;;) {
2517
+ const status = readJsonSafe(path.join(jobDir, "status.json")) ?? {};
2518
+ const meta = readJsonSafe(path.join(jobDir, "metadata.json")) ?? {};
2519
+ if (status.state === "finished" || meta.outcome) {
2520
+ const issues = Array.isArray(meta.issues) ? meta.issues : Array.isArray(status.issues) ? status.issues : [];
2521
+ const result = {
2522
+ jobId,
2523
+ outcome: meta.outcome ?? status.outcome ?? null,
2524
+ coordinatorStatus: meta.coordinatorStatus ?? status.coordinatorStatus ?? null,
2525
+ branch: meta.branch ?? status.branch ?? null,
2526
+ commit: meta.commit?.sha ?? meta.commit ?? status.commit?.sha ?? status.commit ?? null,
2527
+ issues,
2528
+ };
2529
+ if (json) out(result);
2530
+ else {
2531
+ const firstIssue = issues[0];
2532
+ const issueText = firstIssue == null ? null : typeof firstIssue === "string" ? firstIssue : firstIssue.message ?? JSON.stringify(firstIssue);
2533
+ console.log([result.jobId, result.outcome ?? "unknown", result.coordinatorStatus ?? "unknown",
2534
+ result.branch ? `branch=${result.branch}` : null, result.commit ? `commit=${result.commit}` : null,
2535
+ issueText ? `issue=${issueText}` : null].filter(Boolean).join(" "));
2536
+ }
2537
+ process.exitCode = result.coordinatorStatus === "complete" ? 0 : 1;
2538
+ return;
2539
+ }
2540
+ if (Date.now() >= deadline) {
2541
+ if (json) out({ error: `timed out waiting for job ${jobId}` });
2542
+ else console.error(`nomarmy jobs: timed out waiting for job ${jobId}`);
2543
+ process.exitCode = 2;
2544
+ return;
2545
+ }
2546
+ await new Promise((resolve) => setTimeout(resolve, Math.min(2000, Math.max(1, deadline - Date.now()))));
2547
+ }
2548
+ }
2549
+
2372
2550
  /**
2373
2551
  * `nomarmy jobs --prune [--older-than DAYS]`: remove runtime/ (per-job npm
2374
2552
  * cache, harness state such as Codex's, OpenClaw's transcript) from
@@ -2385,6 +2563,7 @@ function pruneJobRuntimeCli() {
2385
2563
  }
2386
2564
 
2387
2565
  async function cmdJobs() {
2566
+ if (flag("wait")) return waitForJobCli();
2388
2567
  if (flag("events")) return streamJobEvents();
2389
2568
  if (flag("prune")) return pruneJobRuntimeCli();
2390
2569
  if (json) return out(collectJobs());
@@ -2431,7 +2610,7 @@ async function cmdStatusline() {
2431
2610
  process.stdout.write(`${statusLineText({ session })}\n`);
2432
2611
  }
2433
2612
 
2434
- const commands = { scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, install: cmdInstall, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
2613
+ const commands = { scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, install: cmdInstall, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, sandbox: cmdSandbox, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
2435
2614
  // doctor command
2436
2615
  async function cmdDoctor() {
2437
2616
  // Import lazily to avoid circular dependencies
@@ -9,15 +9,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
9
9
  # (verified live: apt installed 1.19 here while go.dev's stable was 1.27) --
10
10
  # same reasoning as using rustup instead of apt for Rust in Dockerfile.rust.
11
11
  # Pinned, not "latest", for a reproducible build; bump GO_VERSION by hand
12
- # periodically against https://go.dev/dl/.
12
+ # periodically against https://go.dev/dl/, with both SHA256 values from
13
+ # https://go.dev/dl/?mode=json.
13
14
  ENV GO_VERSION=1.27.1
14
15
  RUN ARCH="$(dpkg --print-architecture)" \
15
16
  && case "$ARCH" in \
16
- amd64) GOARCH=amd64 ;; \
17
- arm64) GOARCH=arm64 ;; \
17
+ amd64) GOARCH=amd64; SHA256=63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445 ;; \
18
+ arm64) GOARCH=arm64; SHA256=3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec ;; \
18
19
  *) echo "unsupported architecture for Go install: $ARCH" >&2; exit 1 ;; \
19
20
  esac \
20
21
  && curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" -o /tmp/go.tgz \
22
+ && echo "${SHA256} /tmp/go.tgz" | sha256sum -c - \
21
23
  && tar -C /usr/local -xzf /tmp/go.tgz \
22
24
  && rm /tmp/go.tgz
23
25
  ENV PATH="/usr/local/go/bin:${PATH}"
@@ -14,6 +14,21 @@ USER node
14
14
  ENV RUSTUP_HOME=/home/node/.rustup
15
15
  ENV CARGO_HOME=/home/node/.cargo
16
16
  ENV PATH="${CARGO_HOME}/bin:${PATH}"
17
- RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
17
+ # rustup-init is pinned by version and checksum instead of piping
18
+ # sh.rustup.rs into sh; rustup then verifies the toolchain it downloads.
19
+ # Bump RUSTUP_VERSION and both hashes together, from
20
+ # https://static.rust-lang.org/rustup/archive/<version>/<target>/rustup-init.sha256.
21
+ ENV RUSTUP_VERSION=1.29.1
22
+ RUN ARCH="$(dpkg --print-architecture)" \
23
+ && case "$ARCH" in \
24
+ amd64) TARGET=x86_64-unknown-linux-gnu; SHA256=dda7234360b7f578ca8b0ddcb80145646fa61a67c1720a5abc7051b35c9fcb71 ;; \
25
+ arm64) TARGET=aarch64-unknown-linux-gnu; SHA256=15f6e4ce9f583b929c996c91562bad6d4454f3281de858b02cdfdef615fac433 ;; \
26
+ *) echo "unsupported architecture for Rust install: $ARCH" >&2; exit 1 ;; \
27
+ esac \
28
+ && curl --proto '=https' --tlsv1.2 -fsSL "https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/${TARGET}/rustup-init" -o /tmp/rustup-init \
29
+ && echo "${SHA256} /tmp/rustup-init" | sha256sum -c - \
30
+ && chmod +x /tmp/rustup-init \
31
+ && /tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain stable \
32
+ && rm /tmp/rustup-init
18
33
  WORKDIR /workspace
19
34
  CMD ["sleep", "infinity"]
@@ -0,0 +1,27 @@
1
+ # Harness name
2
+
3
+ Write a short summary of what this harness provides.
4
+
5
+ ## Detection
6
+
7
+ List the markers and packages that match. Explain any `after` layers.
8
+
9
+ ## Installed today
10
+
11
+ List tools and dependencies, versions, build commands, and known gaps.
12
+
13
+ ## Verification and network
14
+
15
+ Document each proposed profile. Declare the lowest network level needed.
16
+ For allowlist verification, document operator opt-in and dedicated test tenants
17
+ with throwaway credentials, never production. Workers always remain offline.
18
+ This registry does not enable services or allowlist networking yet.
19
+
20
+ ## Requirements and artifacts
21
+
22
+ Explain memory, shared memory, KVM, and artifact globs, if any.
23
+
24
+ ## Fixture
25
+
26
+ Add a tiny repository under `fixture/`. Describe how to verify it and its
27
+ expected results. Keep credentials and host-specific paths out of fixtures.
@@ -0,0 +1,26 @@
1
+ # Copy this folder to harnesses/<name>. Underscore folders are ignored.
2
+ # name: example-harness # Required kebab-case name, equal to the folder name.
3
+ # summary: Short description of what this installs.
4
+ # detect: # Any matching rule applies. Paths are relative to the repo.
5
+ # - file: example.config.json
6
+ # - package: example-package # A Node dependency in a discovered package.json.
7
+ # - lockfile: example.lock
8
+ # after: [node] # Existing harnesses that must precede this one.
9
+ # image: # Choose declarative apt/run OR builtin, never both.
10
+ # apt: [example-package] # OS packages installed at image build time.
11
+ # run: ["example-install --frozen"] # Build commands, not worker commands.
12
+ # # builtin: node # Install logic implemented in lib/.
13
+ # verification: # Proposed profiles mapped to commands or command lists.
14
+ # quick: example-test
15
+ # full: [example-lint, example-test]
16
+ # artifacts: [test-results/**] # Repository-relative artifact globs.
17
+ # requires: # Optional resource requirements.
18
+ # memoryMb: 1024
19
+ # shmMb: 512
20
+ # kvm: false
21
+ # network: none # Default; services and allowlist are future verification levels.
22
+ # services: [] # Service names; omit entirely for network none, even if empty.
23
+ # suggestedRole: # Optional role suggestion.
24
+ # name: example-qa
25
+ # description: Runs the example suite.
26
+ # docs: README.md # Default; path relative to this harness folder.
@@ -0,0 +1,35 @@
1
+ # Playwright browser harness
2
+
3
+ Installs Chromium and its OS dependencies using the repository's installed
4
+ @playwright/test version, after the Node dependency layer. Commit a supported
5
+ Node lockfile. Root and discovered nested packages are supported; browsers live
6
+ in /ms-playwright and are readable by the node user. Installation failure leaves
7
+ a .nomarmy-playwright-install-failed marker in /ms-playwright.
8
+
9
+ Select the browser verification profile (npx playwright test) in .nomarmy.yml:
10
+
11
+ ```yaml
12
+ verification:
13
+ browser:
14
+ commands: [npx playwright test]
15
+ environment: none
16
+ ```
17
+
18
+ Allow at least 1024 MB memory. nomArmy's verification container allocates 512 MB
19
+ /dev/shm (or the largest shared-memory requirement of the matched harnesses).
20
+ For OpenClaw-managed worker sandboxes, unless OpenClaw exposes a shm setting,
21
+ launch Chromium with --disable-dev-shm-usage via Playwright launchOptions, as
22
+ the fixture does.
23
+
24
+ The fixture opens its own static HTML with file://: the test needs no network.
25
+ Install its npm dependencies and Chromium before running offline.
26
+
27
+ Screenshots, traces and reports under test-results/** and playwright-report/**
28
+ become job evidence under artifacts/, listed in verification.artifacts.
29
+ Collection is limited to 200 files and 50 MB; a cap is reported in verification.
30
+ Most of the time the General checks the UI itself after merging. This harness
31
+ is for repositories with a real end-to-end suite as their gate.
32
+
33
+ ## Running the fixture
34
+
35
+ `fixture/` has no lockfile on purpose: with one, nomArmy's own repository would match this harness and every nomArmy job would build Chromium into its image. To try it, copy the folder somewhere, run `npm install --package-lock-only` there (with network), commit, and verify it with the `browser` profile. Checked this way: headless Chromium passes offline, and the screenshot and trace land in the job's `artifacts/`.
@@ -0,0 +1 @@
1
+ {"private":true,"type":"module","scripts":{"test":"playwright test"},"devDependencies":{"@playwright/test":"1.55.1"}}
@@ -0,0 +1 @@
1
+ <!doctype html><title>Offline fixture</title><h1>Browser ready</h1>
@@ -0,0 +1,5 @@
1
+ import { test, expect } from '@playwright/test';
2
+ test('opens local fixture', async ({ page }) => {
3
+ await page.goto(new URL('./page.html', import.meta.url).href);
4
+ await expect(page.getByRole('heading')).toHaveText('Browser ready');
5
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from '@playwright/test';
2
+ export default defineConfig({
3
+ testDir: '.',
4
+ testMatch: 'page.spec.js',
5
+ reporter: [['html', { open: 'never' }]],
6
+ use: { browserName: 'chromium', screenshot: 'on', trace: 'on',
7
+ launchOptions: { args: ['--disable-dev-shm-usage'] } },
8
+ });
@@ -0,0 +1,18 @@
1
+ name: browser-playwright
2
+ summary: Offline Chromium end-to-end tests with the repository's Playwright version
3
+ detect:
4
+ - package: '@playwright/test'
5
+ - file: playwright.config.ts
6
+ - file: playwright.config.js
7
+ - file: playwright.config.mjs
8
+ after: [node]
9
+ image:
10
+ builtin: playwright
11
+ verification:
12
+ browser: npx playwright test
13
+ artifacts: [test-results/**, playwright-report/**]
14
+ requires:
15
+ memoryMb: 1024
16
+ shmMb: 512
17
+ network: none
18
+ docs: README.md
@@ -0,0 +1,45 @@
1
+ # Go harness
2
+
3
+ Go toolchain with prefetched modules for offline verification.
4
+
5
+ ## Detection
6
+
7
+ Matches `go.mod` or `go.work` at the repository root.
8
+
9
+ ## Installed today
10
+
11
+ Installs Go 1.27.1 from the upstream tarball for amd64 or arm64, on the Node 24 Debian bookworm base with Python 3 and pip. Sets GOPATH for the node user.
12
+
13
+ At image build, copies root `go.mod` and `go.sum` (when present), plus `go.work`, `go.work.sum` and each repository-contained workspace module’s manifests. Runs `go mod download` as node into the default module cache, `/home/node/go/pkg/mod`. Repositories with `vendor/` skip this download. A failed download leaves `/deps/go/.nomarmy-go-mod-download-failed` without failing the image build. Runtime sets `GOPROXY=off` and `GOSUMDB=off`; no `GOFLAGS` override is set, preserving Go’s default module behavior. Copied metadata is hashed into the image tag, so dependency changes rebuild the cache. The layer composes with Node dependencies in mixed repositories.
14
+
15
+ ## Verification and network
16
+
17
+ Proposes `go test ./...` as the `quick` profile. Network level: `none`. Matched harnesses compose the sandbox image used by workers and verification. The verification profile is a proposal; the registry does not change job profiles or network access.
18
+
19
+ ## Remaining limitations
20
+
21
+ Private modules and registries use the build-only credentials described below; no credentials are copied into the image. A failure marker means the cache may be incomplete and offline verification can still fail. Workspace modules and local replacements outside the repository are not copied.
22
+
23
+ ## Requirements and artifacts
24
+
25
+ No additional resource requirements or artifact globs are declared.
26
+
27
+
28
+ ## Private registries
29
+
30
+ Use `registries:` in the gitignored `.nomarmy.local.yml` only; the committed
31
+ config rejects it. Values are host credential **file paths**, never tokens.
32
+ Use `go: { netrc: ~/.netrc, private: "github.com/acme/*" }`. Only `go mod download` receives the node-owned netrc plus RUN-local GOPRIVATE and GONOSUMDB; vendored builds need no download mount.
33
+
34
+ Credentials stay on the host and enter Podman only as build-secret mounts;
35
+ no credential files are copied into the context or passed to sandbox jobs.
36
+ Use a **read-only, least-privilege token** and reviewed dependencies. Secrets
37
+ are accessible to install code during that RUN, so malicious packages are not
38
+ made safe by a mount. Credential rotation invalidates both tag and install
39
+ cache. Credentialed install output/build-error details are withheld.
40
+
41
+ See [Private registries](../../docs/your-repo.md#private-registries) for the
42
+ configuration and required manual canary check: inspect `podman history
43
+ --no-trunc`, image configuration, the exported filesystem **and every saved
44
+ image layer**, plus failure logs/job records, for absent secret bytes. Unit
45
+ tests stub Podman; live isolation needs independent security review.
@@ -0,0 +1,14 @@
1
+ name: go
2
+ summary: Go toolchain with prefetched modules for offline verification
3
+ detect:
4
+ - file: go.mod
5
+ - file: go.work
6
+ after: []
7
+ image:
8
+ builtin: go
9
+ verification:
10
+ quick: go test ./...
11
+ artifacts: []
12
+ requires: {}
13
+ network: none
14
+ docs: README.md
@@ -0,0 +1,31 @@
1
+ # Mock OIDC
2
+
3
+ Enable explicitly in your repository's `.nomarmy.yml` (there are no detection rules):
4
+
5
+ ```yaml
6
+ harnesses: [mock-oidc]
7
+ verification:
8
+ auth:
9
+ commands: [npm test]
10
+ ```
11
+
12
+ Point your application's issuer/discovery configuration at `OIDC_ISSUER` or
13
+ `OAUTH2_ISSUER`, both exported as `http://mock-oidc:8080/default` during nomArmy
14
+ verification. Configure your test client ID, redirect URI and audience to match
15
+ what your application expects; allow HTTP only in this test configuration.
16
+
17
+ The pinned NAV mock OAuth2 server supplies discovery, signing keys, token and
18
+ mock authorization flows. It is not an Okta/Auth0 tenant: it does not prove real
19
+ identity, MFA, tenant policies, upstream federation or production compatibility.
20
+ Never use real credentials with this fake.
21
+
22
+ nomArmy pulls the pinned image on the host if missing, then starts it without
23
+ published ports on a per-run `--internal` Podman network. Discovery must return
24
+ 2xx within 30 seconds before commands run. Containers and the network are
25
+ removed even if verification fails. Workers remain `--network none`; only
26
+ nomArmy's own verification can reach the fake. Env maps in harness manifests
27
+ are non-secret literals, not secret references or host-environment imports.
28
+
29
+ `fixture/` is a tiny Node-built-ins-only repository. Run its `quick` profile
30
+ through nomArmy verification. It fetches discovery over the private network;
31
+ it requires no internet or npm packages. It is not a host-side unit test.
@@ -0,0 +1,4 @@
1
+ harnesses: [mock-oidc]
2
+ verification:
3
+ quick:
4
+ commands: [node --test discovery.test.mjs]
@@ -0,0 +1,16 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ test('private mock issuer discovery', async () => {
5
+ const issuer = process.env.OIDC_ISSUER;
6
+ assert.equal(issuer, 'http://mock-oidc:8080/default');
7
+ const response = await fetch(`${issuer}/.well-known/openid-configuration`, {
8
+ redirect: 'error', signal: AbortSignal.timeout(5000),
9
+ });
10
+ assert.equal(response.status, 200);
11
+ const document = await response.json();
12
+ assert.equal(document.issuer, issuer);
13
+ assert.equal(new URL(document.jwks_uri).origin, new URL(issuer).origin);
14
+ assert.equal(new URL(document.authorization_endpoint).origin, new URL(issuer).origin);
15
+ assert.equal(new URL(document.token_endpoint).origin, new URL(issuer).origin);
16
+ });