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.
- package/README.md +7 -5
- package/bin/nomarmy.mjs +193 -14
- package/docker/Dockerfile.go +5 -3
- package/docker/Dockerfile.rust +16 -1
- 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 +16 -2
- package/lib/admission.mjs +3 -0
- package/lib/agents.mjs +6 -5
- package/lib/army.mjs +9 -6
- package/lib/config.mjs +15 -0
- package/lib/connect.mjs +2 -0
- package/lib/coordinator-instructions.mjs +1 -1
- package/lib/dispatch-schema.mjs +12 -11
- package/lib/doctor.mjs +62 -1
- package/lib/egress-proxy.mjs +116 -0
- package/lib/execute.mjs +8 -3
- package/lib/harness-schema.mjs +61 -0
- package/lib/harnesses.mjs +99 -0
- package/lib/health.mjs +10 -3
- package/lib/install-freshness.mjs +83 -0
- package/lib/openclaw-run.mjs +5 -6
- package/lib/podman-health.mjs +41 -0
- package/lib/propose.mjs +10 -11
- package/lib/registry-python.mjs +96 -0
- package/lib/registry-secrets.mjs +128 -0
- package/lib/repo-query.mjs +1 -1
- package/lib/sandbox-images.mjs +484 -81
- package/lib/sandbox-vm.mjs +32 -0
- package/lib/schema.mjs +10 -11
- package/lib/verification-artifacts.mjs +46 -0
- package/lib/verification-flow.mjs +46 -9
- package/lib/verification-network.mjs +66 -0
- package/lib/verify.mjs +305 -85
- package/lib/zod-issues.mjs +15 -0
- package/mcp/server.mjs +29 -3
- package/package.json +6 -4
- package/playbooks/feature.md +1 -1
- 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
|
|
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 |
|
|
92
|
-
|
|
|
93
|
-
| Browser
|
|
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
|
|
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
|
|
116
|
-
|
|
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:
|
|
1635
|
-
//
|
|
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
|
package/docker/Dockerfile.go
CHANGED
|
@@ -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}"
|
package/docker/Dockerfile.rust
CHANGED
|
@@ -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
|
-
|
|
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,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,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
|
+
});
|