nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -480
- package/bin/nomarmy.mjs +1081 -185
- package/docker/Dockerfile +2 -2
- package/docker/Dockerfile.go +6 -4
- package/docker/Dockerfile.rust +17 -2
- package/harnesses/_template/README.md +27 -0
- package/harnesses/_template/harness.yml +26 -0
- package/harnesses/browser-playwright/README.md +35 -0
- package/harnesses/browser-playwright/fixture/package.json +1 -0
- package/harnesses/browser-playwright/fixture/page.html +1 -0
- package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
- package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
- package/harnesses/browser-playwright/harness.yml +18 -0
- package/harnesses/go/README.md +45 -0
- package/harnesses/go/harness.yml +14 -0
- package/harnesses/mock-oidc/README.md +31 -0
- package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
- package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
- package/harnesses/mock-oidc/harness.yml +19 -0
- package/harnesses/node/README.md +53 -0
- package/harnesses/node/harness.yml +18 -0
- package/harnesses/python/README.md +46 -0
- package/harnesses/python/harness.yml +16 -0
- package/harnesses/rust/README.md +45 -0
- package/harnesses/rust/harness.yml +13 -0
- package/install.sh +29 -9
- package/lib/admission.mjs +178 -30
- package/lib/agents.mjs +8 -6
- package/lib/army.mjs +25 -10
- package/lib/codex-link.mjs +37 -0
- package/lib/config.mjs +15 -0
- package/lib/connect.mjs +232 -19
- package/lib/continue-from.mjs +103 -0
- package/lib/coordinator-instructions.mjs +5 -1
- package/lib/diff-checks.mjs +114 -0
- package/lib/dispatch-schema.mjs +14 -12
- package/lib/doctor.mjs +98 -9
- package/lib/egress-proxy.mjs +116 -0
- package/lib/execute.mjs +241 -33
- package/lib/git-record.mjs +27 -3
- package/lib/harness-schema.mjs +61 -0
- package/lib/harnesses.mjs +99 -0
- package/lib/health.mjs +162 -18
- package/lib/install-freshness.mjs +114 -0
- package/lib/jev-checks.mjs +110 -0
- package/lib/job-format.mjs +54 -0
- package/lib/judge.mjs +130 -0
- package/lib/limits.mjs +77 -0
- package/lib/model-probe.mjs +61 -0
- package/lib/mutation.mjs +159 -0
- package/lib/notify.mjs +30 -3
- package/lib/openclaw-install.mjs +122 -0
- package/lib/openclaw-path.mjs +28 -0
- package/lib/openclaw-run.mjs +74 -12
- package/lib/openclaw-runtime-health.mjs +56 -0
- package/lib/outcome.mjs +21 -2
- package/lib/outcomes.mjs +6 -0
- package/lib/path-utils.mjs +4 -0
- package/lib/podman-health.mjs +41 -0
- package/lib/process.mjs +4 -1
- package/lib/propose.mjs +10 -11
- package/lib/refusal-retry.mjs +16 -0
- package/lib/registry-python.mjs +98 -0
- package/lib/registry-secrets.mjs +140 -0
- package/lib/repo-query.mjs +13 -7
- package/lib/runs.mjs +7 -1
- package/lib/same-path.mjs +14 -0
- package/lib/sandbox-images.mjs +499 -83
- package/lib/sandbox-vm.mjs +32 -0
- package/lib/scan.mjs +5 -1
- package/lib/schema.mjs +20 -11
- package/lib/scout.mjs +21 -3
- package/lib/server-context.mjs +21 -1
- package/lib/setup-steps.mjs +55 -0
- package/lib/share.mjs +82 -0
- package/lib/stale-sessions.mjs +60 -0
- package/lib/stats.mjs +315 -0
- package/lib/statusline.mjs +32 -6
- package/lib/subscription-setup.mjs +13 -0
- package/lib/suggestions.mjs +153 -0
- package/lib/thinking.mjs +23 -0
- package/lib/transcript.mjs +30 -5
- package/lib/usage-limits.mjs +329 -0
- package/lib/user-config.mjs +106 -0
- package/lib/validators.mjs +220 -0
- package/lib/verification-artifacts.mjs +46 -0
- package/lib/verification-flow.mjs +52 -7
- package/lib/verification-network.mjs +66 -0
- package/lib/verify.mjs +338 -85
- package/lib/worker-prompt.mjs +5 -2
- package/lib/wsl-cli.mjs +152 -0
- package/lib/wsl.mjs +230 -0
- package/lib/zod-issues.mjs +15 -0
- package/mcp/server.mjs +165 -34
- package/package.json +7 -5
- package/playbooks/feature.md +8 -5
- package/scripts/configure-openclaw.sh +4 -2
- package/scripts/generate-harness-docs.mjs +42 -0
- package/scripts/install-openclaw.mjs +23 -0
- package/scripts/lib.sh +9 -2
- package/scripts/select-model.mjs +12 -5
- package/scripts/start-inference.sh +2 -2
package/lib/sandbox-images.mjs
CHANGED
|
@@ -1,37 +1,42 @@
|
|
|
1
|
-
// nomArmy
|
|
1
|
+
// nomArmy's sandbox images, composed from the harnesses a repo matches.
|
|
2
2
|
//
|
|
3
|
-
// The base sandbox
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// packages
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
61
|
-
*
|
|
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
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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)
|
|
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
|
-
/**
|
|
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 :
|
|
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 =
|
|
107
|
-
if (!lock) {
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return { files:
|
|
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
|
|
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) =>
|
|
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", "--",
|
|
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.
|
|
154
|
-
*
|
|
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) {
|
|
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
|
|
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).
|
|
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
|
|
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
|
|
337
|
-
const
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|