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.
Files changed (102) hide show
  1. package/README.md +86 -480
  2. package/bin/nomarmy.mjs +1081 -185
  3. package/docker/Dockerfile +2 -2
  4. package/docker/Dockerfile.go +6 -4
  5. package/docker/Dockerfile.rust +17 -2
  6. package/harnesses/_template/README.md +27 -0
  7. package/harnesses/_template/harness.yml +26 -0
  8. package/harnesses/browser-playwright/README.md +35 -0
  9. package/harnesses/browser-playwright/fixture/package.json +1 -0
  10. package/harnesses/browser-playwright/fixture/page.html +1 -0
  11. package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
  12. package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
  13. package/harnesses/browser-playwright/harness.yml +18 -0
  14. package/harnesses/go/README.md +45 -0
  15. package/harnesses/go/harness.yml +14 -0
  16. package/harnesses/mock-oidc/README.md +31 -0
  17. package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
  18. package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
  19. package/harnesses/mock-oidc/harness.yml +19 -0
  20. package/harnesses/node/README.md +53 -0
  21. package/harnesses/node/harness.yml +18 -0
  22. package/harnesses/python/README.md +46 -0
  23. package/harnesses/python/harness.yml +16 -0
  24. package/harnesses/rust/README.md +45 -0
  25. package/harnesses/rust/harness.yml +13 -0
  26. package/install.sh +29 -9
  27. package/lib/admission.mjs +178 -30
  28. package/lib/agents.mjs +8 -6
  29. package/lib/army.mjs +25 -10
  30. package/lib/codex-link.mjs +37 -0
  31. package/lib/config.mjs +15 -0
  32. package/lib/connect.mjs +232 -19
  33. package/lib/continue-from.mjs +103 -0
  34. package/lib/coordinator-instructions.mjs +5 -1
  35. package/lib/diff-checks.mjs +114 -0
  36. package/lib/dispatch-schema.mjs +14 -12
  37. package/lib/doctor.mjs +98 -9
  38. package/lib/egress-proxy.mjs +116 -0
  39. package/lib/execute.mjs +241 -33
  40. package/lib/git-record.mjs +27 -3
  41. package/lib/harness-schema.mjs +61 -0
  42. package/lib/harnesses.mjs +99 -0
  43. package/lib/health.mjs +162 -18
  44. package/lib/install-freshness.mjs +114 -0
  45. package/lib/jev-checks.mjs +110 -0
  46. package/lib/job-format.mjs +54 -0
  47. package/lib/judge.mjs +130 -0
  48. package/lib/limits.mjs +77 -0
  49. package/lib/model-probe.mjs +61 -0
  50. package/lib/mutation.mjs +159 -0
  51. package/lib/notify.mjs +30 -3
  52. package/lib/openclaw-install.mjs +122 -0
  53. package/lib/openclaw-path.mjs +28 -0
  54. package/lib/openclaw-run.mjs +74 -12
  55. package/lib/openclaw-runtime-health.mjs +56 -0
  56. package/lib/outcome.mjs +21 -2
  57. package/lib/outcomes.mjs +6 -0
  58. package/lib/path-utils.mjs +4 -0
  59. package/lib/podman-health.mjs +41 -0
  60. package/lib/process.mjs +4 -1
  61. package/lib/propose.mjs +10 -11
  62. package/lib/refusal-retry.mjs +16 -0
  63. package/lib/registry-python.mjs +98 -0
  64. package/lib/registry-secrets.mjs +140 -0
  65. package/lib/repo-query.mjs +13 -7
  66. package/lib/runs.mjs +7 -1
  67. package/lib/same-path.mjs +14 -0
  68. package/lib/sandbox-images.mjs +499 -83
  69. package/lib/sandbox-vm.mjs +32 -0
  70. package/lib/scan.mjs +5 -1
  71. package/lib/schema.mjs +20 -11
  72. package/lib/scout.mjs +21 -3
  73. package/lib/server-context.mjs +21 -1
  74. package/lib/setup-steps.mjs +55 -0
  75. package/lib/share.mjs +82 -0
  76. package/lib/stale-sessions.mjs +60 -0
  77. package/lib/stats.mjs +315 -0
  78. package/lib/statusline.mjs +32 -6
  79. package/lib/subscription-setup.mjs +13 -0
  80. package/lib/suggestions.mjs +153 -0
  81. package/lib/thinking.mjs +23 -0
  82. package/lib/transcript.mjs +30 -5
  83. package/lib/usage-limits.mjs +329 -0
  84. package/lib/user-config.mjs +106 -0
  85. package/lib/validators.mjs +220 -0
  86. package/lib/verification-artifacts.mjs +46 -0
  87. package/lib/verification-flow.mjs +52 -7
  88. package/lib/verification-network.mjs +66 -0
  89. package/lib/verify.mjs +338 -85
  90. package/lib/worker-prompt.mjs +5 -2
  91. package/lib/wsl-cli.mjs +152 -0
  92. package/lib/wsl.mjs +230 -0
  93. package/lib/zod-issues.mjs +15 -0
  94. package/mcp/server.mjs +165 -34
  95. package/package.json +7 -5
  96. package/playbooks/feature.md +8 -5
  97. package/scripts/configure-openclaw.sh +4 -2
  98. package/scripts/generate-harness-docs.mjs +42 -0
  99. package/scripts/install-openclaw.mjs +23 -0
  100. package/scripts/lib.sh +9 -2
  101. package/scripts/select-model.mjs +12 -5
  102. package/scripts/start-inference.sh +2 -2
@@ -1,37 +1,42 @@
1
- // nomArmy per-language sandbox images (v1.3).
1
+ // nomArmy's sandbox images, composed from the harnesses a repo matches.
2
2
  //
3
- // The base sandbox (docker/Dockerfile) has python3/pip, but not any
4
- // third-party PACKAGE a real repo needs (boto3, pandas, ...) -- and it can't,
5
- // since those are repo-specific. A worker or verification run against a Go
6
- // or Rust repository needs `go`/`cargo` inside the sandbox for the same
7
- // underlying reason (the base image can't carry every toolchain), though
8
- // there the fix is simpler: the toolchain itself is repo-independent, so one
9
- // static image covers every Go repo and every Rust repo. None of this is
10
- // worth baking into the one image every job uses regardless of language --
11
- // a native compiler or a repo's own dependency set is real, non-trivial
12
- // memory pressure competing with the same tight local-inference budget
13
- // lib/sizing.mjs already fights to protect, and most installs will never
14
- // touch Go, Rust, or a Python repo with real third-party dependencies.
3
+ // The base sandbox has python3/pip and Node, but not a repo's own packages
4
+ // (boto3, pandas, a lockfile's dependencies) or other toolchains (Go, Rust),
5
+ // and it can't: those are repo-specific. So each repo gets one image built
6
+ // from a shared base plus a layer per matching harness (harnesses/, loaded
7
+ // by lib/harnesses.mjs): toolchains first, so Podman's layer cache shares
8
+ // them across repos, then Python, then Node, then declarative harnesses,
9
+ // respecting each harness's `after`. A Go backend with a Node frontend gets
10
+ // both.
15
11
  //
16
- // So these images are built lazily, on first use, and cached by Podman from
17
- // then on (an ordinary tagged image, indistinguishable from one built by
18
- // hand). The build itself needs network access to fetch packages/rustup/pip
19
- // packages -- that happens once, on the HOST, via `podman build`; the
20
- // resulting container a job actually runs in still gets --network none like
21
- // every other sandbox (this is the actual fix for "pip install has no
22
- // network in the sandbox": install at BUILD time, which has network, never
23
- // at run time, which never does). This only ever affects which image
24
- // nomArmy's OWN independent verification runs against (lib/verify.mjs) -- it
25
- // does not change what sandbox the worker's own tool calls execute in, which
26
- // is still whatever OpenClaw is configured with (see docs/experiments for
27
- // why: `openclaw agent exec` has no per-call sandbox override today).
28
-
12
+ // Images are built lazily, on first use, and tagged by a hash of the
13
+ // Dockerfile and every file copied into it, so a lockfile change rebuilds
14
+ // once and is cached from then on. The build needs network to fetch
15
+ // packages; that happens once, on the HOST, via `podman build`. The
16
+ // containers jobs and verification run in still get --network none: install
17
+ // at build time, which has network, never at run time, which doesn't.
29
18
  import crypto from "node:crypto";
19
+ import { requirementsInputs, validatePythonProject } from "./registry-python.mjs";
20
+ import { readRegistrySecrets, hasRegistryDeclarations, assertNoRegistryCopies, registryInstallRun } from "./registry-secrets.mjs";
21
+ import { parse as parseYaml } from "yaml";
30
22
  import fs from "node:fs";
31
23
  import os from "node:os";
32
24
  import path from "node:path";
33
25
  import { fileURLToPath } from "node:url";
26
+ import { loadHarnesses, matchHarnesses } from "./harnesses.mjs";
34
27
  import { execFileSync } from "node:child_process";
28
+ import { toPosix } from "./path-utils.mjs";
29
+
30
+ // The Node base every sandbox image starts from, read from docker/Dockerfile
31
+ // so there's one place to change it (and Dependabot's bumps reach the
32
+ // generated Python and Node dependency images too, which it can't see).
33
+ const FALLBACK_BASE = "node:26-bookworm-slim";
34
+ export function baseImage(dockerfile = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "docker", "Dockerfile")) {
35
+ try {
36
+ const m = /^FROM\s+(\S+)/m.exec(fs.readFileSync(dockerfile, "utf8"));
37
+ return m ? m[1] : FALLBACK_BASE;
38
+ } catch { return FALLBACK_BASE; }
39
+ }
35
40
 
36
41
  const HERE = path.dirname(fileURLToPath(import.meta.url));
37
42
  // lib/ and docker/ are always siblings, whether running from the dev
@@ -40,9 +45,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
40
45
  // location works in either case with no extra configuration.
41
46
  const DOCKER_DIR = path.join(HERE, "..", "docker");
42
47
 
43
- /** Marker files, checked at the worktree root only (cheap, no recursive
44
- * walk) -- this decides which SANDBOX IMAGE a job needs, a narrower and
45
- * hotter-path question than lib/scan.mjs's full evidence-gathering pass. */
48
+ /** Legacy standalone toolchain builders; resolution uses composed harnesses. */
46
49
  export const LANGUAGE_IMAGES = Object.freeze({
47
50
  go: { image: "openclaw-nomarmy-coder-go:bookworm", dockerfile: "Dockerfile.go", markers: ["go.mod"] },
48
51
  rust: { image: "openclaw-nomarmy-coder-rust:bookworm", dockerfile: "Dockerfile.rust", markers: ["Cargo.toml"] },
@@ -57,9 +60,8 @@ export const LANGUAGE_IMAGES = Object.freeze({
57
60
  * explicitly. OpenClaw's exec tool does not inherit a sandbox image's own
58
61
  * ENV PATH on its own; this is the config knob that closes that gap. Paths
59
62
  * match each Dockerfile's own ENV lines (Dockerfile.go's GOPATH=/home/node/go,
60
- * Dockerfile.rust's CARGO_HOME=/home/node/.cargo). Python needs nothing here:
61
- * `pip3 install --break-system-packages` (no --user) puts entry-point
62
- * scripts on the image's already-default PATH.
63
+ * Dockerfile.rust's CARGO_HOME=/home/node/.cargo). Python project managers use /deps/python/.venv/bin; requirements-only
64
+ * repos retain their system pip entry points.
63
65
  */
64
66
  // Where a Node repo's installed packages live in its dependency image
65
67
  // (nodeDependencyFiles below). /node_modules links here: Node's resolver,
@@ -80,6 +82,7 @@ export const SANDBOX_NPM_ENV = Object.freeze({
80
82
  export const EXEC_PATH_PREPEND = Object.freeze({
81
83
  go: ["/usr/local/go/bin", "/home/node/go/bin"],
82
84
  rust: ["/home/node/.cargo/bin"],
85
+ python: ["/deps/python/.venv/bin"],
83
86
  node: [NODE_DEPS_BIN],
84
87
  "python+node": [NODE_DEPS_BIN],
85
88
  });
@@ -89,39 +92,131 @@ export const EXEC_PATH_PREPEND = Object.freeze({
89
92
  * `.nomarmy.yml`'s `environment.python.requirements` if declared (a repo
90
93
  * with several requirements files -- app, dev, a sub-package's own -- has no
91
94
  * single conventional name this could guess), otherwise bare
92
- * `requirements.txt` if that file actually exists. Never fabricated: a repo
93
- * with neither declares nothing to install and gets the base image, same as
94
- * before this existed.
95
+ * `requirements.txt` if no higher-priority root project manager applies.
96
+ * Project manifests are handled separately by the composed Python layer;
97
+ * they must never be passed to the legacy pip -r builders.
95
98
  */
96
99
  export function pythonRequirementsFor(cwd, config) {
97
100
  const declared = config?.environment?.python?.requirements;
98
- if (Array.isArray(declared) && declared.length > 0) return declared;
101
+ if (Array.isArray(declared) && declared.length > 0) {
102
+ declared.forEach(source => assertRepositorySource(cwd, source, true));
103
+ return declared;
104
+ }
105
+ if (pythonProjectManager(cwd)) return [];
99
106
  return cwd && fs.existsSync(path.join(cwd, "requirements.txt")) ? ["requirements.txt"] : [];
100
107
  }
101
108
 
102
- /** One package directory's own install: { files, reason } (dir is "." or a relative path). */
109
+ /** Root manager precedence; explicit requirements are resolved by the caller. */
110
+ function pythonProjectManager(cwd) {
111
+ if (!cwd || typeof cwd !== "string" || !fs.existsSync(cwd)) return null;
112
+ if (repositoryFile(cwd, "uv.lock")) return "uv";
113
+ const manifest = repositoryFile(cwd, "pyproject.toml")
114
+ ? fs.readFileSync(path.join(cwd, "pyproject.toml"), "utf8") : "";
115
+ if (repositoryFile(cwd, "poetry.lock") || /^\s*\[tool\.poetry\]\s*(?:#.*)?$/m.test(manifest)) return "poetry";
116
+ return /^\s*\[project\]\s*(?:#.*)?$/m.test(manifest) ? "pyproject" : null;
117
+ }
118
+
119
+ function pythonProjectLayer(cwd, manager, files, secrets = []) {
120
+ const credentialed = secrets.some(secret => secret.ecosystem === "pip");
121
+ if (credentialed && manager === "poetry") {
122
+ throw new Error("registries: poetry installs can't be made build-free; use uv, or wheels via pip");
123
+ }
124
+ if (credentialed) validatePythonProject(cwd, manager);
125
+ for (const source of ["pyproject.toml", ...(manager === "uv" ? ["uv.lock"] : manager === "poetry" ? ["poetry.lock"] : [])]) {
126
+ if (repositoryFile(cwd, source)) files.push({ source: toPosix(source), destination: toPosix(`py/${source}`) });
127
+ }
128
+ // Parse TOML with Python 3.11's standard library, not shell interpolation or
129
+ // an approximation of dependency strings. Never install the project itself.
130
+ const readProject = 'import tomllib; d=tomllib.load(open("pyproject.toml", "rb")); ';
131
+ let install;
132
+ if (manager === "uv") {
133
+ install = credentialed ? "uv sync --frozen --no-install-project --all-groups --no-build" : "pip3 install --no-cache-dir --break-system-packages uv && uv sync --frozen --no-install-project --all-groups";
134
+ } else if (manager === "poetry") {
135
+ const dev = readProject + 'p=d.get("tool", {}).get("poetry", {}); print("--with dev" if "dev" in p.get("group", {}) else "")';
136
+ install = `pip3 install --no-cache-dir --break-system-packages poetry && poetry install --no-root --no-interaction $(python3 -c ${shellQuote(dev)})`;
137
+ } else {
138
+ const deps = readProject + 'import subprocess; p=d.get("project", {}); extras=p.get("optional-dependencies", {}); deps=p.get("dependencies", []) + [dep for name in ("test", "tests", "dev") for dep in extras.get(name, [])]; subprocess.check_call(["/deps/python/.venv/bin/python", "-m", "pip", "install", "--no-cache-dir", ' + (credentialed ? '"--only-binary=:all:", ' : '') + '"--", *deps]) if deps else None';
139
+ install = `python3 -c ${shellQuote(deps)}`;
140
+ }
141
+ return `COPY py/ /deps/python/
142
+ ENV UV_PROJECT_ENVIRONMENT=/deps/python/.venv
143
+ ENV POETRY_VIRTUALENVS_CREATE=false
144
+ ${credentialed ? "RUN python3 -m venv /deps/python/.venv\n" + (manager === "uv" ? "RUN pip3 install --no-cache-dir --break-system-packages --only-binary=:all: --index-url https://pypi.org/simple uv==0.8.22\n" : "") : ""}${registryInstallRun(`cd /deps/python && (${credentialed ? "" : "python3 -m venv /deps/python/.venv && "}${manager === "poetry" ? "export VIRTUAL_ENV=/deps/python/.venv && " : ""}${install} || touch .nomarmy-${manager}-install-failed)`, "pip", secrets)}
145
+ RUN chown -R node:node /deps/python`;
146
+ }
147
+
148
+ const NODE_LOCKS = ["pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb", "package-lock.json", "npm-shrinkwrap.json"];
149
+
150
+ /** Resolve workspace globs against real directories, never following symlinks. */
151
+ function workspaceMembers(root, patterns) {
152
+ if (!patterns.length) return [];
153
+ const regex = (glob) => {
154
+ let out = "";
155
+ for (let i = 0; i < glob.length; i++) {
156
+ const c = glob[i];
157
+ if (c === "*" && glob[i + 1] === "*") {
158
+ i++;
159
+ if (glob[i + 1] === "/") { i++; out += "(?:.*/)?"; }
160
+ else out += ".*";
161
+ } else if (c === "*") out += "[^/]*";
162
+ else if (c === "?") out += "[^/]";
163
+ else if (c === "{") out += "(?:";
164
+ else if (c === "}") out += ")";
165
+ else if (c === ",") out += "|";
166
+ else out += c.replace(/[.*+?^$()|[\]\\]/g, "\\$&");
167
+ }
168
+ return new RegExp("^" + out + "$");
169
+ };
170
+ const globs = patterns.filter((p) => typeof p === "string").map((p) => ({
171
+ exclude: p.startsWith("!"), re: regex(p.replace(/^!/, "").replace(/^\.\//, "").replace(/\/$/, "")),
172
+ }));
173
+ const members = [];
174
+ const walk = (dir) => {
175
+ for (const e of fs.readdirSync(path.join(root, dir), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
176
+ if (!e.isDirectory() || e.name === "node_modules" || e.name.startsWith(".")) continue;
177
+ const rel = dir ? dir + "/" + e.name : e.name;
178
+ if (globs.some((g) => !g.exclude && g.re.test(rel)) &&
179
+ !globs.some((g) => g.exclude && g.re.test(rel)) &&
180
+ fs.existsSync(path.join(root, rel, "package.json"))) members.push(rel);
181
+ walk(rel);
182
+ }
183
+ };
184
+ walk("");
185
+ return members;
186
+ }
187
+
103
188
  function nodePackageAt(cwd, dir) {
104
- const at = (f) => path.join(cwd, dir, f), rel = (f) => (dir === "." ? f : `${dir}/${f}`);
189
+ const at = (f) => path.join(cwd, dir, f), rel = (f) => (dir === "." ? f : dir + "/" + f);
105
190
  if (!fs.existsSync(at("package.json"))) return { files: [], reason: "no package.json" };
106
- const lock = ["package-lock.json", "npm-shrinkwrap.json"].find((f) => fs.existsSync(at(f)));
107
- if (!lock) {
108
- const other = ["pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock"].find((f) => fs.existsSync(at(f)));
109
- return { files: [], reason: other ? `${other} isn't supported yet (npm lockfiles only)` : "no package-lock.json" };
191
+ const lock = NODE_LOCKS.find((f) => fs.existsSync(at(f)));
192
+ if (!lock) return { files: [], reason: "no package-lock.json" };
193
+ let pkg;
194
+ try { pkg = JSON.parse(fs.readFileSync(at("package.json"), "utf8")); }
195
+ catch { return { files: [], reason: "package.json doesn't parse" }; }
196
+ const manager = lock === "pnpm-lock.yaml" ? "pnpm" : lock === "yarn.lock"
197
+ ? (fs.existsSync(at(".yarnrc.yml")) || /^__metadata:/m.test(fs.readFileSync(at(lock), "utf8")) ? "yarn-berry" : "yarn")
198
+ : lock.startsWith("bun.") ? "bun" : "npm";
199
+ const files = [rel("package.json"), rel(lock)];
200
+ let patterns = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages;
201
+ const workspace = Boolean(pkg.workspaces) || fs.existsSync(at("pnpm-workspace.yaml"));
202
+ if (fs.existsSync(at("pnpm-workspace.yaml"))) {
203
+ files.push(rel("pnpm-workspace.yaml"));
204
+ patterns = parseYaml(fs.readFileSync(at("pnpm-workspace.yaml"), "utf8"))?.packages ?? [];
110
205
  }
111
- try {
112
- if (JSON.parse(fs.readFileSync(at("package.json"), "utf8")).workspaces) return { files: [], reason: "npm workspaces aren't supported yet" };
113
- } catch { return { files: [], reason: "package.json doesn't parse" }; }
114
- return { files: [rel("package.json"), rel(lock)], reason: null };
206
+ if (fs.existsSync(at(".yarnrc.yml"))) files.push(rel(".yarnrc.yml"));
207
+ const members = workspaceMembers(at("."), Array.isArray(patterns) ? patterns : []).map(rel);
208
+ files.push(...members.map((m) => m + "/package.json"));
209
+ return { files, manager, workspace, members, yarnFallback: !pkg.packageManager && manager.startsWith("yarn") ? (manager === "yarn" ? "1" : "stable") : null, reason: null };
115
210
  }
116
211
 
117
- // Every directory holding an npm lockfile: git's tracked files when this is
212
+ // Every directory holding a supported lockfile: git's tracked files when this is
118
213
  // a checkout (which skips node_modules and anything ignored), else a
119
214
  // shallow walk.
120
215
  function lockfileDirs(cwd) {
121
- const isLock = (f) => /(^|\/)(package-lock|npm-shrinkwrap)\.json$/.test(f) && !/(^|\/)node_modules\//.test(f);
216
+ const isLock = (f) => NODE_LOCKS.includes(path.posix.basename(f)) && !/(^|\/)node_modules\//.test(f);
122
217
  let files = null;
123
218
  try {
124
- files = execFileSync("git", ["ls-files", "-z", "--", "*package-lock.json", "*npm-shrinkwrap.json"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\0").filter(Boolean);
219
+ files = execFileSync("git", ["ls-files", "-z", "--", ...NODE_LOCKS.map((name) => "*" + name)], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\0").filter(Boolean);
125
220
  } catch { /* not a checkout */ }
126
221
  if (files === null) {
127
222
  files = [];
@@ -150,8 +245,9 @@ function lockfileDirs(cwd) {
150
245
  *
151
246
  * `packages` are the directories ("." for the root) that will install;
152
247
  * `files` their package.json and lockfile paths; `skipped` those that
153
- * won't, each with its reason. npm workspaces, yarn, pnpm and bun aren't
154
- * installed yet. `environment.node.install: false` turns it off.
248
+ * won't, each with its reason. Workspace members share their root install.
249
+ * `installs` selects each root's manager; `links` includes workspace members.
250
+ * `environment.node.install: false` turns it off.
155
251
  */
156
252
  export function nodeDependencyFiles(cwd, config) {
157
253
  const none = (reason) => ({ files: [], packages: [], skipped: [], reason });
@@ -159,18 +255,25 @@ export function nodeDependencyFiles(cwd, config) {
159
255
  if (config?.environment?.node?.install === false) return none("environment.node.install is false");
160
256
  const dirs = lockfileDirs(cwd);
161
257
  if (!dirs.includes(".")) dirs.unshift(".");
162
- const packages = [], skipped = [], files = [];
258
+ const packages = [], skipped = [], files = [], installs = [], links = [];
259
+ const covered = new Set();
163
260
  for (const dir of dirs) {
261
+ if (covered.has(dir)) continue;
164
262
  const found = nodePackageAt(cwd, dir);
165
- if (found.files.length) { packages.push(dir); files.push(...found.files); }
263
+ if (found.files.length) {
264
+ packages.push(dir); files.push(...found.files);
265
+ installs.push({ dir, manager: found.manager, yarnFallback: found.yarnFallback });
266
+ links.push(...(dir !== "." || found.workspace ? [dir] : []), ...found.members);
267
+ for (const member of found.members) covered.add(member);
268
+ }
166
269
  else if (dir !== "." || fs.existsSync(path.join(cwd, "package.json"))) skipped.push({ dir, reason: found.reason });
167
270
  }
168
271
  const reason = packages.length ? null : (skipped[0]?.reason ?? "no package.json");
169
- return { files, packages, skipped, reason };
272
+ return { files: [...new Set(files)], packages, skipped, reason, installs, links: [...new Set(links)] };
170
273
  }
171
274
 
172
275
  /**
173
- * Give each non-root package in `worktree` a node_modules that resolves to
276
+ * Give each non-root package and workspace root/member a node_modules that resolves to
174
277
  * its install in the dependency image: a symlink to /deps/<dir>/node_modules,
175
278
  * dangling on the host, valid in the sandbox (both mount the worktree at
176
279
  * /workspace). Node and TypeScript look for node_modules beside the package
@@ -181,11 +284,10 @@ export function nodeDependencyFiles(cwd, config) {
181
284
  export function linkNodePackages(worktree, config, { env = process.env } = {}) {
182
285
  if (env.NOMARMY_AGENT_IMAGE) return []; // someone else's image: /deps may not exist
183
286
  const made = [];
184
- for (const dir of nodeDependencyFiles(worktree, config).packages) {
185
- if (dir === ".") continue;
287
+ for (const dir of nodeDependencyFiles(worktree, config).links ?? []) {
186
288
  const link = path.join(worktree, dir, "node_modules");
187
289
  try { fs.lstatSync(link); continue; } catch { /* nothing there yet */ }
188
- try { fs.symlinkSync(`${NODE_DEPS_ROOT}/${dir}/node_modules`, link, "dir"); made.push(`${dir}/node_modules`); } catch { /* best-effort */ }
290
+ try { fs.symlinkSync(dir === "." ? NODE_DEPS_DIR : `${NODE_DEPS_ROOT}/${dir}/node_modules`, link, "dir"); made.push(dir === "." ? "node_modules" : `${dir}/node_modules`); } catch { /* best-effort */ }
189
291
  }
190
292
  return made;
191
293
  }
@@ -209,7 +311,7 @@ export function detectPrimaryLanguage(cwd, config = null) {
209
311
  }
210
312
 
211
313
  function defaultRun(cmd, args) {
212
- return execFileSync(cmd, args, { encoding: "utf8" });
314
+ return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
213
315
  }
214
316
 
215
317
  function imageExists(image, run) {
@@ -278,7 +380,7 @@ function pythonDockerfile(requirements) {
278
380
  // packaged pip refuses to install into the system environment at all
279
381
  // (PEP 668) without --break-system-packages; this container IS the
280
382
  // dedicated environment, there is no venv to prefer it over.
281
- return `FROM node:24-bookworm-slim
383
+ return `FROM ${baseImage()}
282
384
  ENV DEBIAN_FRONTEND=noninteractive
283
385
  RUN apt-get update && apt-get install -y --no-install-recommends \\
284
386
  bash ca-certificates curl git jq python3 python3-pip python3-venv ripgrep build-essential \\
@@ -332,25 +434,51 @@ export function dependencyImageTag(cwd, files) {
332
434
  return `openclaw-nomarmy-coder-deps-${hash.digest("hex").slice(0, 8)}:bookworm`;
333
435
  }
334
436
 
335
- export function dependencyDockerfile({ requirements = [], nodeFiles = [], nodePackages = null }) {
336
- const reqCopies = requirements.map((_, i) => `COPY py/req-${i}.txt /tmp/reqs/req-${i}.txt`).join("\n");
337
- const pip = requirements.length ? `RUN pip3 install --no-cache-dir --break-system-packages ${requirements.map((_, i) => `-r /tmp/reqs/req-${i}.txt`).join(" ")}` : "";
437
+ export function dependencyDockerfile({ requirements = [], nodeFiles = [], nodePackages = null, nodeInstalls = null, secrets = [], requirementCount = requirements.length, requirementIndexes = requirements.map((_, i) => i) }) {
438
+ const reqRoot = secrets.some((s) => s.ecosystem === "pip") ? "/deps/requirements" : "/tmp/reqs";
439
+ const reqCopies = Array.from({ length: requirementCount }, (_, i) => `COPY py/req-${i}.txt ${reqRoot}/req-${i}.txt`).join("\n");
440
+ const pip = requirements.length ? registryInstallRun(`pip3 install --no-cache-dir --break-system-packages ${secrets.some(s => s.ecosystem === "pip") ? "--only-binary=:all: " : ""}${requirementIndexes.map(i => `-r ${reqRoot}/req-${i}.txt`).join(" ")}${secrets.some(s => s.ecosystem === "pip") ? " || touch /deps/requirements/.nomarmy-pip-install-failed" : ""}`, "pip", secrets) : "";
338
441
  const packages = nodePackages ?? (nodeFiles.length ? ["."] : []);
339
442
  // Owned by the sandbox's "node" user from the start and installed as it,
340
443
  // so a tool that writes a cache under node_modules (vite, babel) can, and
341
444
  // no chown -R layer doubles the image.
342
- const nodeCopies = packages.length ? [`RUN mkdir -p ${NODE_DEPS_ROOT} && chown node:node ${NODE_DEPS_ROOT}`, ...nodeFiles.map((f) => `COPY --chown=node:node node/${f} ${NODE_DEPS_ROOT}/${f}`), "USER node"].join("\n") : "";
445
+ const managers = nodeInstalls ?? packages.map((dir) => ({ dir, manager: "npm" }));
446
+ const setup = [
447
+ ...(managers.some(({ manager }) => manager === "pnpm" || manager.startsWith("yarn")) ? ["RUN npm install -g corepack && corepack enable"] : []),
448
+ ...(managers.some(({ manager }) => manager === "bun") ? ["RUN npm install -g bun"] : []),
449
+ ];
450
+ const nodeCopies = packages.length ? [...setup, `RUN mkdir -p ${NODE_DEPS_ROOT} && chown node:node ${NODE_DEPS_ROOT}`, ...nodeFiles.map((f) => `COPY --chown=node:node node/${f} ${NODE_DEPS_ROOT}/${f}`), "USER node"].join("\n") : "";
343
451
  // One layer per package, each laid out at its repo path under /deps. A
344
452
  // package that can't install (a private registry, say) leaves a marker
345
453
  // instead of failing the image, so the others still work.
346
- const installs = packages.map((dir) => {
454
+ const installs = managers.map(({ dir, manager, yarnFallback, packageManager }) => {
455
+ if (manager === "yarn-berry" && secrets.some((secret) => secret.ecosystem === "npm")) {
456
+ throw new Error("registries: Yarn Berry does not read .npmrc; private registry builds currently require Yarn Classic");
457
+ }
347
458
  const at = dir === "." ? NODE_DEPS_ROOT : `${NODE_DEPS_ROOT}/${dir}`;
348
- return `RUN cd ${at} && (npm ci --no-audit --no-fund || touch .nomarmy-npm-ci-failed) && npm cache clean --force`;
459
+ const command = { pnpm: "pnpm install --frozen-lockfile", yarn: "yarn install --frozen-lockfile",
460
+ "yarn-berry": "YARN_NODE_LINKER=node-modules yarn install --immutable", bun: "bun install --frozen-lockfile" }[manager];
461
+ if (secrets.some(secret => secret.ecosystem === "npm")) {
462
+ const marker = manager === "npm" ? ".nomarmy-npm-ci-failed" : `.nomarmy-${manager}-install-failed`;
463
+ const install = command ?? "npm ci --no-audit --no-fund";
464
+ // Bun creates an npm-compatible node_modules tree. npm rebuild actually
465
+ // runs its dependency lifecycle scripts; bun pm untrusted only lists them.
466
+ const rebuild = manager === "pnpm" ? "pnpm rebuild" : "npm rebuild";
467
+ return [
468
+ ...((manager === "pnpm" || manager.startsWith("yarn")) ? [`RUN cd ${shellQuote(at)} && ${packageManager ? "corepack install" : `${yarnFallback ? `corepack prepare yarn@${yarnFallback} --activate && ` : ""}${manager} --version`}`] : []),
469
+ registryInstallRun(`cd ${shellQuote(at)} && (${install} --ignore-scripts || touch ${marker})`, "npm", secrets),
470
+ // The mount must be gone before any dependency code executes. No
471
+ // scripts can copy its contents during the preceding install.
472
+ `RUN cd ${shellQuote(at)} && ((test ! -e /home/node/.npmrc && test ! -L /home/node/.npmrc) || { touch ${marker}; exit 1; }) && (${rebuild} || touch ${marker})`,
473
+ ].join("\n");
474
+ }
475
+ if (command) return registryInstallRun(`cd ${shellQuote(at)} && (${yarnFallback ? `corepack prepare yarn@${yarnFallback} --activate && ` : ""}${command} || touch .nomarmy-${manager}-install-failed)`, "npm", secrets);
476
+ return registryInstallRun(`cd ${at} && (npm ci --no-audit --no-fund || touch .nomarmy-npm-ci-failed) && npm cache clean --force`, "npm", secrets);
349
477
  }).join("\n");
350
478
  // The root's packages are reachable from anywhere under /workspace via
351
479
  // /node_modules; each other package gets a worktree link (linkNodePackages).
352
480
  const root = packages.length ? ["USER root", ...(packages.includes(".") ? [`RUN ln -s ${NODE_DEPS_DIR} /node_modules`, `ENV PATH=${NODE_DEPS_BIN}:$PATH`] : [])].join("\n") : "";
353
- return `FROM node:24-bookworm-slim
481
+ return `FROM ${baseImage()}
354
482
  ENV DEBIAN_FRONTEND=noninteractive
355
483
  RUN apt-get update && apt-get install -y --no-install-recommends \\
356
484
  bash ca-certificates curl git jq python3 python3-pip python3-venv ripgrep build-essential \\
@@ -378,7 +506,7 @@ export function ensureDependencyImageBuilt(cwd, config, { run = defaultRun } = {
378
506
  fs.mkdirSync(path.dirname(path.join(context, "node", f)), { recursive: true });
379
507
  fs.copyFileSync(path.join(cwd, f), path.join(context, "node", f));
380
508
  }
381
- fs.writeFileSync(path.join(context, "Dockerfile"), dependencyDockerfile({ requirements, nodeFiles, nodePackages: node.packages }));
509
+ fs.writeFileSync(path.join(context, "Dockerfile"), dependencyDockerfile({ requirements, nodeFiles, nodePackages: node.packages, nodeInstalls: node.installs }));
382
510
  run("podman", ["build", "-t", image, "-f", path.join(context, "Dockerfile"), context]);
383
511
  } catch (error) {
384
512
  throw new Error(`failed to build the dependency sandbox image (${image}) from ${all.join(", ")}: ${error.message}`);
@@ -388,28 +516,316 @@ export function ensureDependencyImageBuilt(cwd, config, { run = defaultRun } = {
388
516
  return image;
389
517
  }
390
518
 
391
- /**
392
- * The image a verification run against `cwd` should use: an explicit
393
- * override always wins (matches every other explicit-config-beats-
394
- * auto-detect choice in this codebase); otherwise a detected Go/Rust repo
395
- * gets its language image, a Python repo with real dependencies gets its
396
- * own dependency-hashed image (both built lazily), and everything else gets
397
- * `defaultImage` unchanged -- zero behavior change for Node repos, or
398
- * Python repos that declare nothing to install.
519
+ /** Matched registry layers plus the existing config-only Python and shrinkwrap installs. */
520
+ export function sandboxHarnesses(cwd, config = null, harnesses = loadHarnesses().harnesses) {
521
+ if (!cwd || typeof cwd !== "string") return { harnesses, matched: [] };
522
+ const matched = matchHarnesses(cwd, harnesses, config?.harnesses ?? []);
523
+ for (const [name, needed] of [["python", pythonRequirementsFor(cwd, config).length], ["node", nodeDependencyFiles(cwd, config).files.length]]) {
524
+ if (needed && harnesses[name] && !matched.includes(name)) matched.push(name);
525
+ }
526
+ return { harnesses, matched };
527
+ }
528
+
529
+ // A priority topological sort: explicit after edges take precedence over
530
+ // cache-friendly defaults. Unmatched predecessors do not force an install.
531
+ function imageHarnessOrder(harnesses, matched) {
532
+ const rank = (name) => ({ go: 0, rust: 1, python: 2, node: 3 }[harnesses[name].image.builtin] ?? 4);
533
+ const pending = new Set(matched), order = [];
534
+ while (pending.size) {
535
+ const ready = [...pending].filter((name) => !(harnesses[name].after ?? []).some((parent) => pending.has(parent)));
536
+ ready.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
537
+ if (!ready.length) throw new Error(`sandbox harness after cycle: ${[...pending].join(", ")}`);
538
+ order.push(ready[0]); pending.delete(ready[0]);
539
+ }
540
+ return order;
541
+ }
542
+
543
+ export function sandboxPathEntries(cwd, config = null, selection = sandboxHarnesses(cwd, config)) {
544
+ const entries = [...new Set(imageHarnessOrder(selection.harnesses, selection.matched)
545
+ .flatMap((name) => {
546
+ const builtin = selection.harnesses[name].image.builtin;
547
+ if (builtin === "python" && (pythonRequirementsFor(cwd, config).length || !pythonProjectManager(cwd))) return [];
548
+ return EXEC_PATH_PREPEND[builtin] ?? [];
549
+ }))];
550
+ const pythonBin = EXEC_PATH_PREPEND.python[0];
551
+ return entries.includes(pythonBin) ? [pythonBin, ...entries.filter((entry) => entry !== pythonBin)] : entries;
552
+ }
553
+
554
+ const shellQuote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
555
+
556
+ // Only regular, repository-contained inputs enter the build context. In
557
+ // particular, workspace paths must not pull files from outside the checkout.
558
+ function assertRepositorySource(cwd, source, allowMissing = false) {
559
+ const fail = () => { throw new Error("sandbox dependency source must be a repository-contained regular file (no absolute or escaping paths)"); };
560
+ if (typeof source !== "string" || !source || path.isAbsolute(source) ||
561
+ source.split(/[\\/]/).includes("..")) fail();
562
+ if (repositoryFile(cwd, source)) return;
563
+ if (allowMissing) {
564
+ // Language detection also accepts not-yet-created requirements. Check the
565
+ // nearest existing ancestor, including symlinks, before allowing absence.
566
+ const root = fs.realpathSync(cwd);
567
+ let target = path.resolve(cwd, source);
568
+ while (target.startsWith(path.resolve(cwd) + path.sep)) {
569
+ try {
570
+ fs.lstatSync(target);
571
+ } catch (error) {
572
+ if (error.code !== "ENOENT") fail();
573
+ target = path.dirname(target);
574
+ continue;
575
+ }
576
+ let real;
577
+ try { real = fs.realpathSync(target); } catch { fail(); }
578
+ if (target !== path.resolve(cwd, source) &&
579
+ real.startsWith(root + path.sep) && fs.statSync(target).isDirectory()) return;
580
+ fail();
581
+ }
582
+ if (target === path.resolve(cwd) && target !== path.resolve(cwd, source)) return;
583
+ }
584
+ fail();
585
+ }
586
+
587
+ function repositoryFile(cwd, rel) {
588
+ const root = fs.realpathSync(cwd);
589
+ const target = path.resolve(cwd, rel);
590
+ if (path.isAbsolute(rel) || !target.startsWith(path.resolve(cwd) + path.sep)) return false;
591
+ try {
592
+ return fs.realpathSync(target).startsWith(root + path.sep) && fs.statSync(target).isFile();
593
+ } catch { return false; }
594
+ }
595
+
596
+ function goDependencyLayer(cwd, files, secrets = []) {
597
+ const offline = "ENV GOPROXY=off\nENV GOSUMDB=off";
598
+ if (fs.existsSync(path.join(cwd, "vendor"))) return offline;
599
+ const inputs = new Set(["go.mod", "go.sum", "go.work", "go.work.sum"]);
600
+ if (repositoryFile(cwd, "go.work")) {
601
+ // Go work files allow use ./dir, use ( ... ), and quoted paths.
602
+ const tokens = fs.readFileSync(path.join(cwd, "go.work"), "utf8")
603
+ .match(/"(?:\\.|[^"\\])*"|`[^`]*`|\/\/[^\n]*|[^\s()]+|[()]/g) ?? [];
604
+ let use = false, block = false;
605
+ for (const token of tokens) {
606
+ if (token.startsWith("//")) continue;
607
+ if (token === "use") { use = true; continue; }
608
+ if (!use) continue;
609
+ if (token === "(") { block = true; continue; }
610
+ if (token === ")") { use = block = false; continue; }
611
+ let dir = token;
612
+ if (token.startsWith('"')) { try { dir = JSON.parse(token); } catch { continue; } }
613
+ else if (token.startsWith("`")) dir = token.slice(1, -1);
614
+ for (const name of ["go.mod", "go.sum"]) inputs.add(path.posix.join(dir, name));
615
+ if (!block) use = false;
616
+ }
617
+ }
618
+ const copied = [...inputs].filter((rel) => repositoryFile(cwd, rel));
619
+ if (!copied.some((rel) => rel === "go.mod" || rel === "go.work")) return offline;
620
+ // Dockerfile COPY sources and Linux-container destinations use POSIX separators.
621
+ for (const source of copied) files.push({ source: toPosix(source), destination: toPosix(`go/${source}`) });
622
+ return `COPY --chown=node:node go/ /deps/go/
623
+ USER node
624
+ ${registryInstallRun("cd /deps/go && (go mod download || touch .nomarmy-go-mod-download-failed)", "go", secrets)}
625
+ ${offline}`;
626
+ }
627
+
628
+ function rustDependencyLayer(cwd, files, secrets = []) {
629
+ const manifests = [];
630
+ const walk = (dir, depth) => {
631
+ for (const entry of fs.readdirSync(path.join(cwd, dir), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
632
+ const rel = path.posix.join(dir, entry.name);
633
+ if (entry.isDirectory() && depth < 4 && entry.name !== "target" && !entry.name.startsWith(".")) walk(rel, depth + 1);
634
+ else if (entry.isFile() && entry.name === "Cargo.toml") manifests.push(rel);
635
+ }
636
+ };
637
+ walk("", 0);
638
+ for (const source of manifests) files.push({ source: toPosix(source), destination: toPosix(`rust/${source}`) });
639
+ // Private registry names/indexes are project metadata, not credentials.
640
+ for (const source of [".cargo/config", ".cargo/config.toml"]) {
641
+ if (repositoryFile(cwd, source)) files.push({ source: toPosix(source), destination: toPosix(`rust/${source}`) });
642
+ }
643
+ const locked = repositoryFile(cwd, "Cargo.lock");
644
+ if (locked) files.push({ source: "Cargo.lock", destination: "rust/Cargo.lock" });
645
+ const stubs = new Map();
646
+ for (const manifest of manifests) {
647
+ const text = fs.readFileSync(path.join(cwd, manifest), "utf8");
648
+ if (!/^\s*\[package\]/m.test(text)) continue; // virtual workspace
649
+ const dir = path.posix.dirname(manifest);
650
+ const add = (target, content) => {
651
+ const rel = path.posix.normalize(path.posix.join(dir, target));
652
+ if (path.posix.isAbsolute(target) || rel === ".." || rel.startsWith("../")) return;
653
+ stubs.set(`rust/${rel}`, content);
654
+ };
655
+ add("src/lib.rs", "// Dependency-fetch placeholder.\n");
656
+ add("src/main.rs", "fn main() {}\n");
657
+ // Explicit lib/bin/example/test/bench targets may live outside src/.
658
+ let section = "";
659
+ for (const line of text.split("\n")) {
660
+ const header = line.match(/^\s*\[\[?([^\]]+)\]\]?/);
661
+ if (header) section = header[1].trim();
662
+ if (!["lib", "bin", "example", "test", "bench"].includes(section)) continue;
663
+ const target = line.match(/^\s*path\s*=\s*(?:"([^"\n]+)"|'([^'\n]+)')/);
664
+ if (target) add(target[1] ?? target[2], section === "lib" ? "// Dependency-fetch placeholder.\n" : "fn main() {}\n");
665
+ }
666
+ }
667
+ for (const [destination, content] of stubs) {
668
+ if (!files.some((file) => file.destination === destination)) files.push({ destination, content });
669
+ }
670
+ return `${manifests.length ? `COPY --chown=node:node rust/ /deps/rust/
671
+ USER node
672
+ ${registryInstallRun(`cd /deps/rust && (cargo fetch${locked ? " --locked" : ""} || touch .nomarmy-cargo-fetch-failed)`, "cargo", secrets)}
673
+ ` : ""}ENV CARGO_NET_OFFLINE=true`;
674
+ }
675
+
676
+ /** Pure recipe composition except for reading its input files. No image builds.
677
+ * files maps repository paths to their small, isolated build-context paths.
399
678
  */
400
- export function resolveSandboxImage({ cwd, explicitImage, defaultImage, config = null, run = defaultRun }) {
679
+ export function composeSandboxImage(cwd, config = null, selection = sandboxHarnesses(cwd, config), options = null) {
680
+ // No options means a recipe preview (never a build). Build callers must
681
+ // supply options, even when trustedDir is unavailable: that fails closed.
682
+ if (options === null) return composeWithRegistrySecrets(cwd, config, selection, []);
683
+ // A build-shaped call reads declarations the way ensureComposedImageBuilt does.
684
+ return trustedComposition(cwd, config, selection, options.trustedDir ? readRegistrySecrets(options.trustedDir) : [], options.trustedDir).composed;
685
+ }
686
+
687
+ function trustedComposition(cwd, config, selection, secrets, trustedDir) {
688
+ const plain = composeWithRegistrySecrets(cwd, config, selection, []);
689
+ if (!trustedDir) {
690
+ const note = "private-registry credentials were not used: this job changed dependency inputs (trusted checkout unavailable)";
691
+ return { composed: plain && hasRegistryDeclarations(cwd) ? { ...plain, note } : plain, secrets: [] };
692
+ }
693
+ if (!plain || !secrets.length) return { composed: plain, secrets: [] };
694
+ // Even an uncredentialed build must never COPY a declared credential.
695
+ assertNoRegistryCopies(cwd, plain.files, secrets);
696
+ let changed;
697
+ if (!trustedDir) changed = ["trusted checkout unavailable"];
698
+ else {
699
+ const trusted = composeWithRegistrySecrets(trustedDir, config, sandboxHarnesses(trustedDir, config), []);
700
+ const inputs = (recipe) => new Set((recipe?.files ?? []).filter(f => f.source).map(f => f.source));
701
+ const actual = inputs(plain), expected = inputs(trusted);
702
+ changed = [...new Set([...actual, ...expected])].sort().filter(source => {
703
+ if (!actual.has(source) || !expected.has(source)) return true;
704
+ try {
705
+ return !fs.readFileSync(path.join(cwd, source)).equals(fs.readFileSync(path.join(trustedDir, source)));
706
+ } catch { return true; }
707
+ });
708
+ }
709
+ if (!changed.length) return { composed: composeWithRegistrySecrets(trustedDir, config, sandboxHarnesses(trustedDir, config), secrets), secrets };
710
+ const note = `private-registry credentials were not used: this job changed dependency inputs (${changed.slice(0, 5).join(", ")}${changed.length > 5 ? ", ..." : ""})`;
711
+ return { composed: { ...plain, note }, secrets: [] };
712
+ }
713
+
714
+ function composeWithRegistrySecrets(cwd, config, selection, secrets) {
715
+ const order = imageHarnessOrder(selection.harnesses, selection.matched);
716
+ if (!order.length) return null;
717
+ const footer = 'USER node\nWORKDIR /workspace\nCMD ["sleep", "infinity"]\n';
718
+ const base = dependencyDockerfile({}).slice(0, -footer.length).trimEnd() + "\n";
719
+ const dependencyLayer = (options) => dependencyDockerfile({ ...options, secrets }).slice(base.length, -footer.length).trim();
720
+ const layers = [], files = [];
721
+ let pythonVenv = false;
722
+ for (const name of order) {
723
+ const { image } = selection.harnesses[name];
724
+ let layer = "";
725
+ if (image.builtin === "go" || image.builtin === "rust") {
726
+ const source = fs.readFileSync(path.join(DOCKER_DIR, `Dockerfile.${image.builtin}`), "utf8");
727
+ const begin = image.builtin === "go" ? "ENV GO_VERSION=" : "USER node";
728
+ layer = source.slice(source.indexOf(begin), source.indexOf("WORKDIR /workspace")).trim();
729
+ layer += "\n" + (image.builtin === "go" ? goDependencyLayer(cwd, files, secrets) : rustDependencyLayer(cwd, files, secrets));
730
+ } else if (image.builtin === "python") {
731
+ const requirements = pythonRequirementsFor(cwd, config);
732
+ const manager = requirements.length ? null : pythonProjectManager(cwd);
733
+ if (manager) {
734
+ layer = pythonProjectLayer(cwd, manager, files, secrets);
735
+ pythonVenv = true;
736
+ } else {
737
+ const inputs = requirementsInputs(cwd, requirements, secrets.some(s => s.ecosystem === "pip"));
738
+ files.push(...inputs.files);
739
+ layer = dependencyLayer({ requirements, requirementCount: inputs.files.length, requirementIndexes: inputs.indexes });
740
+ }
741
+ } else if (image.builtin === "node") {
742
+ const node = nodeDependencyFiles(cwd, config);
743
+ node.files.forEach((source) => files.push({ source: toPosix(source), destination: toPosix(`node/${source}`) }));
744
+ const installs = node.installs?.map(install => ({ ...install,
745
+ packageManager: Boolean(JSON.parse(fs.readFileSync(path.join(cwd, install.dir, "package.json"), "utf8")).packageManager),
746
+ }));
747
+ layer = dependencyLayer({ nodeFiles: node.files, nodePackages: node.packages, nodeInstalls: installs });
748
+ } else if (image.builtin === "playwright") {
749
+ const node = nodeDependencyFiles(cwd, config);
750
+ const dirs = [...new Set([...node.packages, ...(node.links ?? [])])].filter((dir) => {
751
+ try {
752
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, dir, "package.json"), "utf8"));
753
+ return ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"].some((field) => pkg[field]?.["@playwright/test"]);
754
+ } catch { return false; }
755
+ });
756
+ const marker = "/ms-playwright/.nomarmy-playwright-install-failed";
757
+ layer = "ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright\nRUN mkdir -p /ms-playwright\n";
758
+ layer += dirs.length ? dirs.map((dir) =>
759
+ `RUN (cd ${shellQuote(path.posix.join("/deps", dir))} && npx --no-install playwright install --with-deps chromium) || touch ${marker}`
760
+ ).join("\n") : `RUN touch ${marker}`;
761
+ layer += "\nRUN chmod -R a+rX /ms-playwright";
762
+ } else if (image.builtin) {
763
+ throw new Error(`unknown sandbox builtin for harness ${name}: ${image.builtin}`);
764
+ } else {
765
+ // Run a child shell so even an explicit exit produces a named failure.
766
+ const commands = [
767
+ ...(image.apt.length ? [`apt-get update && apt-get install -y --no-install-recommends ${image.apt.map(shellQuote).join(" ")} && rm -rf /var/lib/apt/lists/*`] : []),
768
+ ...image.run,
769
+ ];
770
+ layer = commands.map((command) => `RUN ${JSON.stringify(["/bin/sh", "-ec", `/bin/sh -ec ${shellQuote(command)} || { echo ${shellQuote(`sandbox harness ${name} failed`)} >&2; exit 1; }`])}`).join("\n");
771
+ }
772
+ if (layer) layers.push(`USER root\n# harness: ${name}\n${layer}`);
773
+ }
774
+ if (!layers.length) return null; // Matched metadata may have no supported install.
775
+ const runtime = pythonVenv ? 'ENV VIRTUAL_ENV=/deps/python/.venv\nENV PATH="/deps/python/.venv/bin:${PATH}"\n' : "";
776
+ for (const { source } of files) if (source !== undefined) assertRepositorySource(cwd, source);
777
+ assertNoRegistryCopies(cwd, files, secrets);
778
+ const dockerfile = (secrets.length ? "# syntax=docker/dockerfile:1\n" : "") + base + layers.join("\n") + "\n" + runtime + footer;
779
+ const hash = crypto.createHash("sha256").update(dockerfile).update("\0");
780
+ for (const { ecosystem, source, format, privatePatterns, digest } of secrets) {
781
+ hash.update(JSON.stringify({ ecosystem, source, format, privatePatterns, digest })).update("\0");
782
+ }
783
+ for (const { source, destination, content } of files) {
784
+ hash.update(source ?? "").update("\0").update(destination).update("\0")
785
+ .update(content ?? fs.readFileSync(path.join(cwd, source))).update("\0");
786
+ }
787
+ return { dockerfile, files, image: `openclaw-nomarmy-coder-deps-${hash.digest("hex").slice(0, 8)}:bookworm`, pathEntries: sandboxPathEntries(cwd, config, selection) };
788
+ }
789
+
790
+ export function ensureComposedImageBuilt(cwd, config, { run = defaultRun, trustedDir, onNote = () => {} } = {}) {
791
+ // With a trusted checkout, declarations come only from it: a job's worktree
792
+ // is worker-writable, and a .nomarmy.local.yml there must never choose
793
+ // which host file gets mounted into a build. Without one, a declaration is
794
+ // detected by its YAML key only; credential paths are never inspected.
795
+ const declared = trustedDir ? readRegistrySecrets(trustedDir) : [];
796
+ const { composed, secrets } = trustedComposition(cwd, config, sandboxHarnesses(cwd, config), declared, trustedDir);
797
+ if (!composed) return null;
798
+ if (composed.note) onNote(composed.note);
799
+ if (imageExists(composed.image, run)) return composed.image;
800
+ const context = fs.mkdtempSync(path.join(os.tmpdir(), "nomarmy-harnesses-"));
801
+ try {
802
+ for (const { source, destination, content } of composed.files) {
803
+ const target = path.join(context, destination);
804
+ fs.mkdirSync(path.dirname(target), { recursive: true });
805
+ if (content !== undefined) fs.writeFileSync(target, content);
806
+ else fs.copyFileSync(path.join(secrets.length ? trustedDir : cwd, source), target);
807
+ }
808
+ fs.writeFileSync(path.join(context, "Dockerfile"), composed.dockerfile);
809
+ run("podman", ["build", ...secrets.flatMap(({ id, source }) => ["--secret", `id=${id},src=${source}`]), "-t", composed.image, "-f", path.join(context, "Dockerfile"), context]);
810
+ } catch (error) {
811
+ throw new Error(`failed to build sandbox harness image (${composed.image}): ${declared.length ? "credentialed build failed (output withheld)" : error.message}${composed.note ? "; " + composed.note : ""}`);
812
+ } finally {
813
+ fs.rmSync(context, { recursive: true, force: true });
814
+ }
815
+ return composed.image;
816
+ }
817
+
818
+ /** Worker and verification share this resolver; explicit overrides always win. */
819
+ export function resolveSandboxImage({ cwd, explicitImage, defaultImage, config = null, run = defaultRun, trustedDir, onNote }) {
401
820
  if (explicitImage) return explicitImage;
402
- const lang = detectPrimaryLanguage(cwd, config);
403
- if (!lang) return defaultImage;
404
- if (lang === "python") return ensurePythonImageBuilt(cwd, config, { run }) ?? defaultImage;
405
- if (lang === "node" || lang === "python+node") return ensureDependencyImageBuilt(cwd, config, { run }) ?? defaultImage;
406
- return ensureLanguageImageBuilt(lang, { run });
821
+ return ensureComposedImageBuilt(cwd, config, { run, trustedDir, onNote }) ?? defaultImage;
407
822
  }
408
823
 
409
824
  /** What each npm package's node_modules is right now: "link", "dir" or "none", by package dir ("." is the root). */
410
825
  export function nodeModulesState(worktree, config) {
411
826
  const out = {};
412
- for (const dir of nodeDependencyFiles(worktree, config).packages) {
827
+ const node = nodeDependencyFiles(worktree, config);
828
+ for (const dir of new Set([...node.packages, ...(node.links ?? [])])) {
413
829
  try { out[dir] = fs.lstatSync(path.join(worktree, dir, "node_modules")).isSymbolicLink() ? "link" : "dir"; } catch { out[dir] = "none"; }
414
830
  }
415
831
  return out;