@namewta/speculo 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/src/ops-resources.js +1 -1
  2. package/dist/src/ops-resources.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
  5. package/template/workflows/ops/H-host-manage/H-host-manage.md +5 -1
  6. package/template/workflows/ops/I-initialize/I-initialize.md +2 -2
  7. package/template/workflows/ops/README.md +8 -5
  8. package/template/workflows/ops/common/CAPABILITIES.md +2 -2
  9. package/template/workflows/ops/common/USAGE.md +25 -25
  10. package/template/workflows/ops/common/examples/README.md +1 -1
  11. package/template/workflows/ops/common/examples/register.example.json +1 -1
  12. package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
  13. package/template/workflows/ops/common/schemas/host.schema.json +65 -2
  14. package/template/workflows/ops/common/schemas/plan.schema.json +315 -3
  15. package/template/workflows/ops/common/schemas/spec.schema.json +137 -1
  16. package/template/workflows/ops/common/schemas/status.schema.json +143 -1
  17. package/template/workflows/ops/common/service-profiles/elasticsearch.md +13 -0
  18. package/template/workflows/ops/common/service-profiles/redis.md +4 -0
  19. package/template/workflows/ops/common/templates/CONTROLLER-RECORD.md +16 -2
  20. package/template/workflows/ops/common/templates/HOST-README.md +12 -2
  21. package/template/workflows/ops/common/tests/test_ops.mjs +982 -0
  22. package/template/workflows/ops/common/tools/bootstrap.ps1 +2 -2
  23. package/template/workflows/ops/common/tools/bootstrap.sh +2 -2
  24. package/template/workflows/ops/common/tools/demo-local.mjs +101 -0
  25. package/template/workflows/ops/common/tools/ops.mjs +4 -0
  26. package/template/workflows/ops/common/tools/opslib/agent.mjs +904 -0
  27. package/template/workflows/ops/common/tools/opslib/cli.mjs +311 -0
  28. package/template/workflows/ops/common/tools/opslib/control_files.mjs +57 -0
  29. package/template/workflows/ops/common/tools/opslib/core.mjs +393 -0
  30. package/template/workflows/ops/common/tools/opslib/docs.mjs +436 -0
  31. package/template/workflows/ops/common/tools/opslib/execution.mjs +396 -0
  32. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +109 -0
  33. package/template/workflows/ops/common/tools/opslib/model.mjs +272 -0
  34. package/template/workflows/ops/common/tools/opslib/{native_windows.py → native_windows.mjs} +16 -12
  35. package/template/workflows/ops/common/tools/opslib/planner.mjs +781 -0
  36. package/template/workflows/ops/common/tools/opslib/services.mjs +76 -0
  37. package/template/workflows/ops/common/tools/opslib/sources.mjs +56 -0
  38. package/template/workflows/ops/common/tools/opslib/transport.mjs +127 -0
  39. package/template/workflows/ops/common/tools/validate-ops.mjs +43 -30
  40. package/template/workflows/ops/common/tests/test_ops.py +0 -392
  41. package/template/workflows/ops/common/tools/demo-local.py +0 -64
  42. package/template/workflows/ops/common/tools/ops.py +0 -7
  43. package/template/workflows/ops/common/tools/opslib/__init__.py +0 -2
  44. package/template/workflows/ops/common/tools/opslib/__pycache__/__init__.cpython-312.pyc +0 -0
  45. package/template/workflows/ops/common/tools/opslib/__pycache__/core.cpython-312.pyc +0 -0
  46. package/template/workflows/ops/common/tools/opslib/__pycache__/model.cpython-312.pyc +0 -0
  47. package/template/workflows/ops/common/tools/opslib/agent.py +0 -510
  48. package/template/workflows/ops/common/tools/opslib/cli.py +0 -172
  49. package/template/workflows/ops/common/tools/opslib/core.py +0 -199
  50. package/template/workflows/ops/common/tools/opslib/docs.py +0 -199
  51. package/template/workflows/ops/common/tools/opslib/execution.py +0 -248
  52. package/template/workflows/ops/common/tools/opslib/host_recipes.py +0 -67
  53. package/template/workflows/ops/common/tools/opslib/model.py +0 -199
  54. package/template/workflows/ops/common/tools/opslib/planner.py +0 -497
  55. package/template/workflows/ops/common/tools/opslib/services.py +0 -47
  56. package/template/workflows/ops/common/tools/opslib/sources.py +0 -27
  57. package/template/workflows/ops/common/tools/opslib/transport.py +0 -55
@@ -0,0 +1,904 @@
1
+ /** Ephemeral local/SSH target agent. No third-party modules or target installation.
2
+ Receives trusted code and a JSON request; never executes repository text implicitly.
3
+ */
4
+ import { spawnSync } from "node:child_process";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import {
7
+ chmodSync, chownSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync,
8
+ openSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, statfsSync, statSync,
9
+ unlinkSync, writeSync, accessSync, constants as fsConstants,
10
+ } from "node:fs";
11
+ import { tmpdir, hostname, userInfo, machine as osMachine, type as osType } from "node:os";
12
+ import { dirname, join, delimiter, isAbsolute, relative, resolve, sep } from "node:path";
13
+ import { createConnection } from "node:net";
14
+
15
+ export class Failure extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "Failure";
19
+ }
20
+ }
21
+
22
+ function stamp() {
23
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
24
+ }
25
+ function packed(v) {
26
+ return Buffer.from(JSON.stringify(sortKeys(v)), "utf8");
27
+ }
28
+ function sortKeys(value) {
29
+ if (Array.isArray(value)) return value.map(sortKeys);
30
+ if (value && typeof value === "object") {
31
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys(value[k])]));
32
+ }
33
+ return value;
34
+ }
35
+ function sha(v) {
36
+ return createHash("sha256").update(Buffer.isBuffer(v) ? v : packed(v)).digest("hex");
37
+ }
38
+ function checkId(v) {
39
+ if (typeof v !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v)) throw new Failure("invalid resource id");
40
+ }
41
+ function platformSystem() {
42
+ if (process.platform === "win32") return "Windows";
43
+ if (process.platform === "darwin") return "Darwin";
44
+ if (process.platform === "linux") return "Linux";
45
+ return osType();
46
+ }
47
+ function which(cmd) {
48
+ if (!cmd) return null;
49
+ const tryPath = (p) => { try { accessSync(p, fsConstants.F_OK); return p; } catch { return null; } };
50
+ if (isAbsolute(cmd) || cmd.includes("/") || cmd.includes("\\")) return tryPath(cmd);
51
+ const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";") : [""];
52
+ const names = process.platform === "win32" && !exts.some((e) => cmd.toLowerCase().endsWith(e.toLowerCase()))
53
+ ? [cmd, ...exts.map((e) => cmd + e)]
54
+ : [cmd];
55
+ for (const dir of (process.env.PATH || "").split(delimiter)) {
56
+ for (const name of names) {
57
+ const hit = tryPath(join(dir, name));
58
+ if (hit) return hit;
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+ function currentSid() {
64
+ const who = spawnSync("whoami", ["/user", "/fo", "csv", "/nh"], { encoding: "utf8" });
65
+ if (who.status !== 0) throw new Error(who.stderr);
66
+ const line = who.stdout.trim();
67
+ const parts = [];
68
+ let cur = "", inQ = false;
69
+ for (const ch of line) {
70
+ if (ch === '"') { inQ = !inQ; continue; }
71
+ if (ch === "," && !inQ) { parts.push(cur); cur = ""; continue; }
72
+ cur += ch;
73
+ }
74
+ parts.push(cur);
75
+ return parts[1];
76
+ }
77
+ function noLinks(path) {
78
+ path = resolve(path);
79
+ const chain = [];
80
+ let cur = path;
81
+ while (true) {
82
+ chain.unshift(cur);
83
+ const parent = dirname(cur);
84
+ if (parent === cur) break;
85
+ cur = parent;
86
+ }
87
+ for (const q of chain) {
88
+ let st;
89
+ try { st = lstatSync(q); } catch (e) { if (e.code === "ENOENT") continue; throw e; }
90
+ if (st.isSymbolicLink()) throw new Failure("symlink/reparse point: " + q);
91
+ }
92
+ }
93
+ function secureFile(path) {
94
+ if (process.platform !== "win32") { chmodSync(path, 0o600); return; }
95
+ const sid = currentSid();
96
+ const ic = spawnSync("icacls", [path, "/inheritance:r", "/grant:r", `*${sid}:F`, "*S-1-5-18:F"], { encoding: "utf8" });
97
+ if (ic.status !== 0) throw new Error(ic.stderr);
98
+ }
99
+ function mkdir(path, mode = 0o750) {
100
+ noLinks(path);
101
+ const missing = [];
102
+ let p = path;
103
+ while (!existsSync(p)) { missing.push(p); const n = dirname(p); if (n === p) break; p = n; }
104
+ for (const d of missing.reverse()) mkdirSync(d, { mode });
105
+ }
106
+ function atomic(path, data, mode = 0o600) {
107
+ noLinks(path);
108
+ mkdir(dirname(path));
109
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
110
+ const tmp = join(dirname(path), ".ops-" + randomBytes(6).toString("hex"));
111
+ const fd = openSync(tmp, "w");
112
+ try { writeSync(fd, buf); fsyncSync(fd); } finally { closeSync(fd); }
113
+ try {
114
+ if (process.platform === "win32") secureFile(tmp);
115
+ else chmodSync(tmp, mode);
116
+ try { renameSync(tmp, path); }
117
+ catch (error) {
118
+ if (process.platform === "win32" && (error.code === "EEXIST" || error.code === "EPERM" || error.code === "EACCES")) {
119
+ unlinkSync(path); renameSync(tmp, path);
120
+ } else throw error;
121
+ }
122
+ if (process.platform !== "win32") chmodSync(path, mode);
123
+ } catch (error) {
124
+ try { unlinkSync(tmp); } catch {}
125
+ throw error;
126
+ }
127
+ }
128
+ function wj(p, v) { atomic(p, JSON.stringify(v, null, 2) + "\n"); }
129
+ function rj(p) { noLinks(p); return JSON.parse(readFileSync(p, "utf8")); }
130
+
131
+ export function fingerprint() {
132
+ let stable = "";
133
+ for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
134
+ try { stable = readFileSync(p, "utf8").trim(); break; } catch {}
135
+ }
136
+ if (process.platform === "win32") {
137
+ try {
138
+ const r = spawnSync("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], { encoding: "utf8" });
139
+ const m = r.stdout.match(/MachineGuid\s+REG_SZ\s+(\S+)/i);
140
+ if (m) stable = m[1];
141
+ } catch {}
142
+ }
143
+ if (platformSystem() === "Darwin" && !stable) {
144
+ const p = spawnSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8", timeout: 5000 });
145
+ const m = p.stdout.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
146
+ if (m) stable = m[1];
147
+ }
148
+ if (!stable) throw new Failure("stable machine identity unavailable; explicit identity adapter required");
149
+ return sha({ machine: stable, platform: platformSystem() });
150
+ }
151
+
152
+ export function fileState(path, limit = 16 * 1024 * 1024) {
153
+ path = resolve(path);
154
+ noLinks(path);
155
+ if (!existsSync(path)) return { kind: "absent" };
156
+ const s = statSync(path);
157
+ const st = lstatSync(path);
158
+ if (st.isFile()) {
159
+ if (s.size > limit) throw new Failure("snapshot exceeds bounded file size: " + path);
160
+ const result = { kind: "file", sha256: sha(readFileSync(path)), size: s.size, mode: s.mode & 0o777 };
161
+ const name = path.split(/[\\/]/).pop();
162
+ if (name === ".ops-project.json" || name === ".ops-host.json") result.owner = rj(path);
163
+ return result;
164
+ }
165
+ if (st.isDirectory()) {
166
+ const entries = readdirSync(path).sort();
167
+ if (entries.length > 10000) throw new Failure("directory snapshot exceeds 10000 entries");
168
+ return { kind: "directory", entries_digest: sha(entries), entry_count: entries.length };
169
+ }
170
+ throw new Failure("unsupported file type: " + path);
171
+ }
172
+
173
+ function walkTree(root) {
174
+ const out = [root];
175
+ const st = lstatSync(root);
176
+ if (!st.isDirectory()) return out;
177
+ const stack = [root];
178
+ while (stack.length) {
179
+ const dir = stack.pop();
180
+ let names;
181
+ try { names = readdirSync(dir); } catch { continue; }
182
+ for (const name of names) {
183
+ const p = join(dir, name);
184
+ out.push(p);
185
+ try { if (lstatSync(p).isDirectory()) stack.push(p); } catch {}
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ export function treeState(path) {
192
+ const root = resolve(path);
193
+ noLinks(root);
194
+ if (!existsSync(root)) throw new Failure("quarantine item no longer exists");
195
+ const rows = [];
196
+ let total = 0;
197
+ for (const p of walkTree(root)) {
198
+ noLinks(p);
199
+ if (rows.length >= 10000) throw new Failure("purge manifest exceeds 10000 entries; split a reviewed cleanup");
200
+ const info = fileState(p);
201
+ if (info.kind === "file") total += info.size;
202
+ if (total > 256 * 1024 * 1024) throw new Failure("purge manifest exceeds 256 MiB; split or use a separately reviewed cleanup adapter");
203
+ const rel = relative(root, p).replaceAll("\\", "/");
204
+ rows.push({ path: rel || ".", state: info });
205
+ }
206
+ return { kind: "tree", manifest_sha256: sha([...rows].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)), logical_bytes: total, entries: rows.length };
207
+ }
208
+
209
+ function diskUsage(p) {
210
+ const s = statfsSync(p);
211
+ const bsize = Number(s.bsize);
212
+ return { total: Number(s.blocks) * bsize, free: Number(s.bfree) * bsize };
213
+ }
214
+
215
+ export function inventory(req) {
216
+ const tools = {};
217
+ for (const [name, args] of [["python3", ["--version"]], ["python", ["--version"]], ["uv", ["--version"]],
218
+ ["java", ["-version"]], ["node", ["--version"]], ["npm", ["--version"]],
219
+ ["volta", ["--version"]], ["docker", ["--version"]], ["git", ["--version"]], ["ssh", ["-V"]]]) {
220
+ const path = which(name);
221
+ const item = { path, status: "missing" };
222
+ if (path) {
223
+ try {
224
+ const p = spawnSync(path, args, { encoding: "utf8", timeout: 8000 });
225
+ item.status = p.status === 0 ? "observed" : "failed";
226
+ item.version = ((p.stdout || "") + (p.stderr || "")).trim().slice(0, 2000);
227
+ } catch { item.status = "unavailable"; }
228
+ }
229
+ tools[name] = item;
230
+ }
231
+ let docker_control = null;
232
+ if (req.include_docker) {
233
+ try {
234
+ const context = spawnSync("docker", ["context", "show"], { encoding: "utf8", timeout: 20000 });
235
+ if (context.status !== 0) throw new Error("docker context");
236
+ const ctx = context.stdout.trim();
237
+ const endpoint = JSON.parse(spawnSync("docker", ["context", "inspect", ctx, "--format", "{{json .Endpoints.docker.Host}}"], { encoding: "utf8", timeout: 20000 }).stdout.trim());
238
+ const info = JSON.parse(spawnSync("docker", ["--context", ctx, "info", "--format", "{{json .}}"], { encoding: "utf8", timeout: 30000 }).stdout.trim());
239
+ const compose = spawnSync("docker", ["--context", ctx, "compose", "version", "--short"], { encoding: "utf8", timeout: 20000 }).stdout.trim();
240
+ docker_control = { status: "observed", context: ctx, endpoint, id: info.ID, data_root: info.DockerRootDir, os_type: info.OSType, compose_version: compose };
241
+ } catch { docker_control = { status: "unavailable" }; }
242
+ }
243
+ const diagnostics = { issues: [], memory: {}, disks: {} };
244
+ try {
245
+ const mem = {};
246
+ for (const line of readFileSync("/proc/meminfo", "utf8").split("\n")) {
247
+ if (!line.includes(":")) continue;
248
+ const [k, v] = line.split(":", 2);
249
+ mem[k] = parseInt(v.trim().split(/\s+/)[0], 10) * 1024;
250
+ }
251
+ diagnostics.memory = Object.fromEntries(["MemTotal", "MemAvailable", "Cached", "SwapTotal", "SwapFree"].map((k) => [k, mem[k]]));
252
+ if (mem.MemTotal && mem.MemAvailable / mem.MemTotal < 0.05) diagnostics.issues.push("low-memory-available: diagnose pressure before any cleanup");
253
+ } catch {}
254
+ for (const candidate of req.disk_roots ?? [userInfo().homedir]) {
255
+ let p = candidate;
256
+ while (!existsSync(p) && dirname(p) !== p) p = dirname(p);
257
+ const usage = diskUsage(p);
258
+ diagnostics.disks[candidate] = { observed_path: p, total: usage.total, free: usage.free };
259
+ if (usage.free / Math.max(usage.total, 1) < 0.1) diagnostics.issues.push("low-disk-free:" + candidate);
260
+ }
261
+ const snapshots = {};
262
+ for (const p of req.paths ?? []) snapshots[p] = (req.deep_paths ?? []).includes(p) ? treeState(p) : fileState(p);
263
+ let uid = null;
264
+ try { uid = process.getuid?.() ?? null; } catch { uid = null; }
265
+ return {
266
+ identity: fingerprint(), observed_at: stamp(), diagnostics, docker_control,
267
+ platform: platformSystem().toLowerCase(),
268
+ architecture: process.platform === "win32" ? (process.env.PROCESSOR_ARCHITECTURE || osMachine()) : (osMachine() || process.arch),
269
+ hostname: hostname(),
270
+ account: process.env.USERNAME || process.env.USER || "unknown",
271
+ uid,
272
+ node: process.execPath,
273
+ tools,
274
+ defaults: { JAVA_HOME: process.env.JAVA_HOME, PATH: process.env.PATH, SDKMAN_DIR: process.env.SDKMAN_DIR, VOLTA_HOME: process.env.VOLTA_HOME },
275
+ snapshots,
276
+ };
277
+ }
278
+
279
+ function under(path, root, allowRoot = false) {
280
+ const p = resolve(path), r = resolve(root);
281
+ if (!isAbsolute(p) || p.split(/[\\/]/).includes("..")) throw new Failure("nonabsolute/traversing path");
282
+ const rel = relative(r, p);
283
+ if (rel.startsWith("..") || isAbsolute(rel)) throw new Failure("path outside approved root: " + p);
284
+ if (p === r && !allowRoot) throw new Failure("operation may not target entire host root");
285
+ noLinks(p);
286
+ return p;
287
+ }
288
+
289
+ function checkedPath(path, req, allowRoot = false) {
290
+ if ((req.external_files ?? []).includes(path)) {
291
+ noLinks(path);
292
+ return resolve(path);
293
+ }
294
+ return under(path, req.root, allowRoot);
295
+ }
296
+
297
+ export function stripSecrets(text, values) {
298
+ for (const v of [...new Set(values)].sort((a, b) => b.length - a.length)) {
299
+ if (v) text = text.split(v).join("[REDACTED]");
300
+ }
301
+ return text.replace(/(password|passwd|token|secret|access_key)(\s*[=:]\s*)[^\s,;]+/gi, "$1$2[REDACTED]");
302
+ }
303
+
304
+ export const agentHooks = { spawnSync };
305
+
306
+ function stdinBuffer(stdin) {
307
+ if (stdin == null) return undefined;
308
+ return Buffer.isBuffer(stdin) ? stdin : Buffer.from(String(stdin), "utf8");
309
+ }
310
+
311
+ export function command(argv, { cwd, env = null, stdin = null, timeout = 300, secrets = null, success_codes = null } = {}) {
312
+ if (!Array.isArray(argv) || !argv.length || !argv.every((x) => typeof x === "string" && !x.includes("\0"))) {
313
+ throw new Failure("argv must be a nonempty string array");
314
+ }
315
+ const values = secrets || [];
316
+ const runTmp = join(cwd, ".ops-command-tmp");
317
+ mkdir(runTmp, 0o700);
318
+ const outPath = join(runTmp, "out.bin"), errPath = join(runTmp, "err.bin");
319
+ try {
320
+ // Node 24: encoding "buffer" with a string input throws ERR_UNKNOWN_ENCODING.
321
+ const p = agentHooks.spawnSync(argv[0], argv.slice(1), {
322
+ cwd,
323
+ env: { ...process.env, ...(env || {}), TMPDIR: runTmp, TMP: runTmp, TEMP: runTmp },
324
+ input: stdinBuffer(stdin),
325
+ timeout: timeout * 1000,
326
+ maxBuffer: 32 * 1024 * 1024,
327
+ encoding: "buffer",
328
+ windowsHide: true,
329
+ });
330
+ if (p.error && p.error.code === "ETIMEDOUT") throw new Failure("command timed out; side effects may have occurred, inspect before replanning");
331
+ if (p.error) throw new Failure(String(p.error.message || p.error));
332
+ const rawOut = (p.stdout || Buffer.alloc(0)).subarray(0, 2 * 1024 * 1024);
333
+ const rawErr = (p.stderr || Buffer.alloc(0)).subarray(0, 2 * 1024 * 1024);
334
+ const stdout = rawOut.toString("utf8");
335
+ const stderr = rawErr.toString("utf8");
336
+ const result = {
337
+ exit_code: p.status,
338
+ stdout,
339
+ stderr,
340
+ log_stdout: stripSecrets(stdout, values),
341
+ log_stderr: stripSecrets(stderr, values),
342
+ output_sha256: sha(Buffer.concat([rawOut, rawErr])),
343
+ };
344
+ if (!(success_codes || [0]).includes(p.status)) {
345
+ throw new Failure("command failed with exit=" + p.status + "; output_sha256=" + result.output_sha256 + "; " + result.log_stderr.slice(-1500));
346
+ }
347
+ return result;
348
+ } finally {
349
+ try { unlinkSync(outPath); } catch {}
350
+ try { unlinkSync(errPath); } catch {}
351
+ try { rmSync(runTmp, { recursive: true, force: true }); } catch {}
352
+ }
353
+ }
354
+
355
+ function parseDockerJson(text, label) {
356
+ try {
357
+ return JSON.parse(String(text).trim());
358
+ } catch (e) {
359
+ throw new Failure(label + " is not valid JSON: " + (e.message || e));
360
+ }
361
+ }
362
+
363
+ export { parseDockerJson };
364
+
365
+ export function inspectContainerGate(item) {
366
+ const st = item.State || {};
367
+ const name = item.Name || item.Id || "unknown";
368
+ if (st.Status !== "running" || st.OOMKilled || st.Restarting) {
369
+ throw new Failure("container not running after compose-up: " + name + " status=" + (st.Status || "unknown"));
370
+ }
371
+ if (st.Health && st.Health.Status && st.Health.Status !== "healthy") {
372
+ throw new Failure("container health is " + st.Health.Status + " after compose --wait; TCP/proxy listen is not sufficient");
373
+ }
374
+ }
375
+
376
+ function dockerBase(op) {
377
+ const cmd = [op.docker || "docker"];
378
+ if (op.context) cmd.push("--context", op.context);
379
+ return cmd;
380
+ }
381
+ function composeBase(op) {
382
+ return dockerBase(op).concat(["compose", "--project-name", op.compose_name, "--project-directory", op.project_root, "--file", join(op.project_root, "compose", "compose.yaml")]);
383
+ }
384
+
385
+ function composeUp(op, req) {
386
+ const root = checkedPath(op.project_root, req);
387
+ const base = composeBase(op);
388
+ const docker = dockerBase(op);
389
+ const info = command(docker.concat(["info", "--format", "{{json .}}"]), { cwd: root, timeout: 30, secrets: req.secrets || [] });
390
+ const daemon = parseDockerJson(info.stdout, "docker info");
391
+ if (daemon.ID !== op.expected_docker_id) throw new Failure("Docker daemon identity changed since approval");
392
+ const actual = daemon.DockerRootDir;
393
+ if (req.strict_docker_root !== false) {
394
+ under(actual, req.root);
395
+ if (resolve(actual) !== join(req.root, "_runtime", "docker")) {
396
+ throw new Failure("Docker data-root must be registered host_root/_runtime/docker; existing engine migration needs a separate approved host plan");
397
+ }
398
+ }
399
+ command(base.concat(["config", "--quiet"]), { cwd: root, timeout: 30, secrets: req.secrets || [] });
400
+ const model = JSON.parse(readFileSync(join(root, "compose", "compose.yaml"), "utf8"));
401
+ if (Object.values(model.services).some((s) => "build" in s)) {
402
+ command(base.concat(["build", "--pull=false"]), { cwd: root, timeout: op.timeout || 1200, secrets: req.secrets || [] });
403
+ }
404
+ command(base.concat(["pull", "--ignore-buildable"]), { cwd: root, timeout: op.timeout || 1200, secrets: req.secrets || [] });
405
+ for (const [name, service] of Object.entries(model.services)) {
406
+ const image = service.image || op.compose_name + "-" + name;
407
+ const conf = command(docker.concat(["image", "inspect", image, "--format", "{{json .Config.Volumes}}"]), { cwd: root, timeout: 30, secrets: req.secrets || [] });
408
+ const declared = parseDockerJson(conf.stdout, "image volumes for " + image) || {};
409
+ const mapped = new Set([...(service.volumes || []).map((v) => v.target), ...(service.tmpfs || [])]);
410
+ const missing = Object.keys(declared).filter((k) => !mapped.has(k));
411
+ if (missing.length) throw new Failure("image declares unmapped VOLUME(s), refusing anonymous persistence: " + JSON.stringify(missing.sort()));
412
+ }
413
+ command(base.concat(["up", "--detach", "--remove-orphans", "--wait", "--wait-timeout", String(op.wait_timeout || 120)]), { cwd: root, timeout: op.timeout || 1200, secrets: req.secrets || [] });
414
+ const ids = command(base.concat(["ps", "--all", "--quiet"]), { cwd: root, timeout: 30 }).stdout.split(/\s+/).filter(Boolean);
415
+ if (!ids.length) throw new Failure("Compose returned no containers");
416
+ for (const cid of ids) {
417
+ const item = parseDockerJson(command(docker.concat(["inspect", cid]), { cwd: root, timeout: 30, secrets: req.secrets || [] }).stdout, "docker inspect")[0];
418
+ inspectContainerGate(item);
419
+ for (const mount of item.Mounts || []) {
420
+ if (mount.Type === "volume") throw new Failure("anonymous/named persistence detected after start; stop and reconcile");
421
+ if (mount.Type === "bind") under(mount.Source, root);
422
+ }
423
+ }
424
+ return { containers: ids, docker_data_root: actual, compose_name: op.compose_name, verified_at: stamp() };
425
+ }
426
+
427
+ function pyQuote(s) {
428
+ return encodeURIComponent(s).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
429
+ }
430
+
431
+ function allocation(op, req) {
432
+ const provider = checkedPath(op.provider_root, req);
433
+ const aid = op.allocation_id;
434
+ checkId(aid);
435
+ const marker = join(provider, "allocations", aid + ".json");
436
+ if (op.compose_name) {
437
+ const info = parseDockerJson(command(dockerBase(op).concat(["info", "--format", "{{json .}}"]), { cwd: provider, timeout: 30 }).stdout, "provider docker info");
438
+ if (info.ID !== op.expected_docker_id) throw new Failure("provider Docker daemon identity drift");
439
+ }
440
+ const ownership = {
441
+ allocation_id: aid, resource: op.resource, app_username: op.app_username,
442
+ owner_project_id: op.owner_project_id, environment: op.environment, credential_ref: op.credential_ref,
443
+ };
444
+ if (existsSync(marker)) {
445
+ if (JSON.stringify(rj(marker)) !== JSON.stringify(ownership)) throw new Failure("allocation ownership conflict");
446
+ return { allocation_id: aid, status: "already-owned-no-password-reset" };
447
+ }
448
+ if (op.kind === "mysql-allocation") {
449
+ const base = dockerBase(op).concat(["compose", "--project-name", op.compose_name, "--project-directory", provider, "--file", join(provider, "compose", "compose.yaml"),
450
+ "exec", "-T", "-e", "MYSQL_PWD", op.compose_service, "mysql", "--batch", "--skip-column-names", "--user", op.admin_username]);
451
+ const env = { MYSQL_PWD: op.admin_password };
452
+ const sql = (q) => command(base, { cwd: provider, env, stdin: q, timeout: 120, secrets: req.secrets || [] }).stdout.trim();
453
+ const db = op.resource, user = op.app_username;
454
+ const exists = sql("SELECT (SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='" + db + "')+(SELECT COUNT(*) FROM mysql.user WHERE user='" + user + "');\n");
455
+ if (exists !== "0") throw new Failure("database/user already exists without allocation marker; verified adoption required");
456
+ const hx = Buffer.from(op.app_password).toString("hex");
457
+ const q = "CREATE DATABASE `" + db + "`;\nSET @p=CONVERT(0x" + hx + " USING utf8mb4);\nSET @s=CONCAT('CREATE USER ''" + user + "''@''%'' IDENTIFIED BY ',QUOTE(@p));\nPREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;\nGRANT " + op.privileges.join(",") + " ON `" + db + "`.* TO '" + user + "'@'%';\n";
458
+ sql(q);
459
+ const app = [...base.slice(0, -1), user, "--database", db];
460
+ command(app, { cwd: provider, env: { MYSQL_PWD: op.app_password }, stdin: "SELECT DATABASE();\n", timeout: 30, secrets: req.secrets || [] });
461
+ } else if (op.kind === "redis-allocation") {
462
+ const base = dockerBase(op).concat(["compose", "--project-name", op.compose_name, "--project-directory", provider, "--file", join(provider, "compose", "compose.yaml"),
463
+ "exec", "-T", "-e", "REDISCLI_AUTH", op.compose_service, "redis-cli", "--user", op.admin_username, "--raw"]);
464
+ const env = { REDISCLI_AUTH: op.admin_password };
465
+ const old = command(base.concat(["ACL", "GETUSER", op.app_username]), { cwd: provider, env, secrets: req.secrets || [] }).stdout.trim();
466
+ if (old) throw new Failure("Redis user exists without allocation marker; verified adoption required");
467
+ const args = ["ACL", "SETUSER", op.app_username, "reset", "on", ">" + op.app_password, "~" + op.prefix + ":*", "resetchannels", "-@all", "+@read", "+@write", "-@dangerous", "+ping"];
468
+ const resp = "*" + args.length + "\r\n" + args.map((x) => "$" + Buffer.byteLength(x) + "\r\n" + x + "\r\n").join("");
469
+ const reply = command(base.concat(["--pipe"]), { cwd: provider, env, stdin: resp, secrets: req.secrets || [] }).stdout;
470
+ if (!reply.includes("errors: 0")) throw new Failure("Redis ACL pipe did not confirm zero errors");
471
+ const saved = command(base.concat(["ACL", "SAVE"]), { cwd: provider, env, secrets: req.secrets || [] }).stdout.trim();
472
+ if (saved !== "OK") throw new Failure("Redis ACL persistence was not confirmed");
473
+ const app = [...base];
474
+ app[app.indexOf("--user") + 1] = op.app_username;
475
+ const pong = command(app.concat(["PING"]), { cwd: provider, env: { REDISCLI_AUTH: op.app_password }, secrets: req.secrets || [] }).stdout.trim();
476
+ if (pong !== "PONG") throw new Failure("new Redis ACL could not authenticate");
477
+ } else if (op.kind === "minio-allocation") {
478
+ let u;
479
+ try { u = new URL(op.endpoint); } catch { throw new Failure("invalid MinIO endpoint"); }
480
+ if (!["http:", "https:"].includes(u.protocol) || !u.hostname || u.username || u.password) throw new Failure("invalid MinIO endpoint");
481
+ const auth = pyQuote(op.admin_username) + ":" + pyQuote(op.admin_password) + "@";
482
+ const url = u.protocol + "//" + auth + u.host + u.pathname + u.search + u.hash;
483
+ const env = { MC_HOST_ops: url };
484
+ const mc = [op.client_path, "--config-dir", join(provider, "run", "mc"), "--json"];
485
+ mkdir(join(provider, "run", "mc"), 0o700);
486
+ const buckets = command(mc.concat(["ls", "ops"]), { cwd: provider, env, secrets: req.secrets || [] }).stdout;
487
+ if (buckets.split("\n").filter((l) => l.trim()).some((line) => { try { return JSON.parse(line).key?.replace(/\/$/, "") === op.resource; } catch { return false; } })) {
488
+ throw new Failure("MinIO bucket exists without allocation marker");
489
+ }
490
+ const users = command(mc.concat(["admin", "user", "list", "ops"]), { cwd: provider, env, secrets: req.secrets || [] }).stdout;
491
+ if (users.includes(op.app_username)) throw new Failure("MinIO user exists without allocation marker");
492
+ command(mc.concat(["mb", "ops/" + op.resource]), { cwd: provider, env, secrets: req.secrets || [] });
493
+ if (!op.secret_argv_acknowledged) throw new Failure("MinIO secret argv exposure not approved");
494
+ command(mc.concat(["admin", "user", "add", "ops", op.app_username, op.app_password]), { cwd: provider, env, secrets: req.secrets || [] });
495
+ const policy = { Version: "2012-10-17", Statement: [
496
+ { Effect: "Allow", Action: ["s3:GetBucketLocation", "s3:ListBucket"], Resource: ["arn:aws:s3:::" + op.resource] },
497
+ { Effect: "Allow", Action: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"], Resource: ["arn:aws:s3:::" + op.resource + "/*"] },
498
+ ] };
499
+ const policyPath = join(provider, "config", "minio", "policies", aid + ".json");
500
+ wj(policyPath, policy);
501
+ command(mc.concat(["admin", "policy", "create", "ops", aid, policyPath]), { cwd: provider, env, secrets: req.secrets || [] });
502
+ command(mc.concat(["admin", "policy", "attach", "ops", aid, "--user", op.app_username]), { cwd: provider, env, secrets: req.secrets || [] });
503
+ const appauth = pyQuote(op.app_username) + ":" + pyQuote(op.app_password) + "@";
504
+ const appurl = u.protocol + "//" + appauth + u.host + u.pathname + u.search + u.hash;
505
+ command(mc.concat(["ls", "ops/" + op.resource]), { cwd: provider, env: { MC_HOST_ops: appurl }, secrets: req.secrets || [] });
506
+ } else throw new Failure("unknown allocation adapter");
507
+ wj(marker, ownership);
508
+ return { allocation_id: aid, status: "provisioned-and-authenticated", ownership_path: marker };
509
+ }
510
+
511
+ function getpwnam(name) {
512
+ const text = readFileSync("/etc/passwd", "utf8");
513
+ for (const line of text.split("\n")) {
514
+ const [user, , uid, gid] = line.split(":");
515
+ if (user === name) return { pw_uid: Number(uid), pw_gid: Number(gid) };
516
+ }
517
+ throw new Failure("unknown account: " + name);
518
+ }
519
+
520
+ function b64decode(s) {
521
+ if (typeof s !== "string" || s.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(s)) throw new Failure("invalid base64");
522
+ return Buffer.from(s, "base64");
523
+ }
524
+
525
+ function httpGetSync(url, { timeoutMs = 4000, limit = 1048576, headers = {}, redirectOrigin = null } = {}) {
526
+ const payload = JSON.stringify({ url, timeoutMs, limit, headers, redirectOrigin });
527
+ const code = `
528
+ const http = require("http"); const https = require("https");
529
+ const {url, timeoutMs, limit, headers, redirectOrigin} = JSON.parse(process.argv[1]);
530
+ function go(u, hops) {
531
+ if (hops > 10) { console.error("too many redirects"); process.exit(2); }
532
+ const lib = u.startsWith("https:") ? https : http;
533
+ const req = lib.get(u, { headers, timeout: timeoutMs }, res => {
534
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
535
+ const next = new URL(res.headers.location, u).href;
536
+ if (redirectOrigin) {
537
+ const o = new URL(u), n = new URL(next);
538
+ if (o.protocol !== n.protocol || o.host !== n.host) { console.error("cross-origin mirror redirect rejected"); process.exit(3); }
539
+ }
540
+ res.resume();
541
+ return go(next, hops + 1);
542
+ }
543
+ const chunks = []; let n = 0;
544
+ res.on("data", c => { n += c.length; if (n <= limit) chunks.push(c); });
545
+ res.on("end", () => process.stdout.write(JSON.stringify({ status: res.statusCode, body: Buffer.concat(chunks).toString("utf8"), truncated: n > limit })));
546
+ });
547
+ req.on("timeout", () => { req.destroy(new Error("timeout")); });
548
+ req.on("error", e => { process.stderr.write(e.message); process.exit(2); });
549
+ }
550
+ go(url, 0);
551
+ `;
552
+ const p = spawnSync(process.execPath, ["-e", code, payload], { encoding: "utf8", timeout: timeoutMs + 2000 });
553
+ if (p.status === 3) throw new Failure("cross-origin mirror redirect rejected");
554
+ if (p.status !== 0) throw new Failure(p.stderr || "http request failed");
555
+ return JSON.parse(p.stdout);
556
+ }
557
+
558
+ function tcpConnectSync(host, port, timeoutMs) {
559
+ const p = spawnSync(process.execPath, ["-e", `
560
+ const net = require("net");
561
+ const s = net.connect({host: process.argv[1], port: +process.argv[2]}, () => { s.end(); process.exit(0); });
562
+ s.setTimeout(+process.argv[3], () => { s.destroy(); process.exit(2); });
563
+ s.on("error", () => process.exit(2));
564
+ `, host, String(port), String(timeoutMs)], { timeout: timeoutMs + 1000 });
565
+ return p.status === 0;
566
+ }
567
+
568
+ function execute(op, req) {
569
+ const kind = op.kind;
570
+ const secrets = req.secrets || [];
571
+ if (kind.endsWith("-allocation")) return allocation(op, req);
572
+ if (kind === "grant-runtime") {
573
+ if (process.platform === "win32") throw new Failure("POSIX runtime grant cannot be used on Windows");
574
+ const account = getpwnam(op.account);
575
+ const root = checkedPath(op.project_root, req);
576
+ const hostroot = resolve(req.root);
577
+ chmodSync(hostroot, 0o755);
578
+ const grantDirs = [root];
579
+ let x = dirname(root);
580
+ while (x !== hostroot && x.startsWith(hostroot) && x !== dirname(x)) {
581
+ grantDirs.push(x);
582
+ x = dirname(x);
583
+ }
584
+ for (const p of grantDirs) {
585
+ noLinks(p); chownSync(p, -1, account.pw_gid); chmodSync(p, 0o750);
586
+ }
587
+ for (const rel of op.directories) {
588
+ const base = under(join(root, rel), root);
589
+ mkdir(base);
590
+ let count = 0;
591
+ for (const p of walkTree(base)) {
592
+ count += 1;
593
+ if (count > 10000) throw new Failure("runtime permission grant exceeds scan bound");
594
+ noLinks(p);
595
+ chownSync(p, -1, account.pw_gid);
596
+ if (["data", "logs", "run"].includes(rel.split("/")[0])) chownSync(p, account.pw_uid, account.pw_gid);
597
+ const st = statSync(p);
598
+ chmodSync(p, (st.isDirectory() || (st.mode & 0o111)) ? 0o750 : 0o640);
599
+ }
600
+ }
601
+ return { account: op.account, project_root: root, directories: op.directories };
602
+ }
603
+ if (kind === "mkdir") {
604
+ const p = checkedPath(op.path, req);
605
+ mkdir(p, Number(op.mode ?? 488));
606
+ if (process.platform !== "win32" && "mode" in op) chmodSync(p, op.mode);
607
+ if (process.platform !== "win32" && ("uid" in op || "gid" in op)) chownSync(p, op.uid ?? -1, op.gid ?? -1);
608
+ return { path: p, state: fileState(p) };
609
+ }
610
+ if (kind === "write") {
611
+ const p = checkedPath(op.path, req);
612
+ const data = b64decode(op.content_b64);
613
+ const current = fileState(p);
614
+ const expected = op.expected;
615
+ const name = p.split(/[\\/]/).pop();
616
+ if (name === ".ops-project.json" && current.kind === "file" && JSON.stringify(rj(p)) !== JSON.stringify(JSON.parse(data.toString("utf8")))) {
617
+ throw new Failure("cannot overwrite another deployment ownership marker");
618
+ }
619
+ if (current.kind === "file" && current.sha256 === sha(data)) {
620
+ if (process.platform === "win32") secureFile(p);
621
+ else chmodSync(p, op.mode ?? 0o600);
622
+ return { path: p, sha256: sha(data), unchanged: true, state: fileState(p) };
623
+ }
624
+ if (sha(current) !== sha(expected ?? { kind: "absent" })) throw new Failure("file drift since plan: " + p);
625
+ if (current.kind === "file") {
626
+ const backup = join(req.root, "_host", "runs", req.run_id, "before", sha(Buffer.from(String(p))) + ".bin");
627
+ if (!existsSync(backup)) atomic(backup, readFileSync(p));
628
+ }
629
+ atomic(p, data, op.mode ?? 384);
630
+ return { path: p, sha256: sha(data), state: fileState(p) };
631
+ }
632
+ if (kind === "command" || kind === "verify-command") {
633
+ const cwd = checkedPath(op.cwd, req, true);
634
+ const r = command(op.argv, { cwd, env: op.env, stdin: op.stdin, timeout: op.timeout ?? 300, secrets });
635
+ if ("expect_stdout" in op && r.stdout.trim() !== op.expect_stdout.trim()) throw new Failure("verification stdout mismatch");
636
+ if ("stdout_pattern" in op && !new RegExp(op.stdout_pattern).test(r.stdout)) throw new Failure("verification pattern mismatch");
637
+ return r;
638
+ }
639
+ if (kind === "compose-up") return composeUp(op, req);
640
+ if (kind === "compose-stop") {
641
+ const root = checkedPath(op.project_root, req);
642
+ return command(composeBase(op).concat(["stop"]), { cwd: root, timeout: 120, secrets });
643
+ }
644
+ if (kind === "health") {
645
+ const deadline = Date.now() + (op.timeout ?? 60) * 1000;
646
+ let last = "";
647
+ while (Date.now() < deadline) {
648
+ try {
649
+ if (op.type === "tcp") {
650
+ if (!tcpConnectSync(op.hostname, op.port, 3000)) throw new Failure("tcp connect failed");
651
+ } else if (op.type === "http") {
652
+ let u;
653
+ try { u = new URL(op.url); } catch { throw new Failure("invalid health URL"); }
654
+ if (!["http:", "https:"].includes(u.protocol) || u.username || u.password) throw new Failure("invalid health URL");
655
+ const r = httpGetSync(op.url, { timeoutMs: 4000, limit: 1048576 });
656
+ if (r.status !== (op.status ?? 200)) throw new Failure("unexpected HTTP status");
657
+ if (op.contains && !r.body.includes(op.contains)) throw new Failure("health body mismatch");
658
+ } else throw new Failure("unsupported health type");
659
+ return { healthy: true, at: stamp() };
660
+ } catch (e) {
661
+ last = e.message || String(e);
662
+ spawnSync(process.execPath, ["-e", "setTimeout(()=>{},1000)"], { timeout: 2000 });
663
+ }
664
+ }
665
+ throw new Failure("health timeout: " + last);
666
+ }
667
+ if (kind === "assert-file") {
668
+ const p = checkedPath(op.path, req);
669
+ const s = fileState(p);
670
+ if (s.kind !== "file") throw new Failure("expected persistent file missing");
671
+ if (op.sha256 && s.sha256 !== op.sha256) throw new Failure("persistent file hash mismatch");
672
+ return s;
673
+ }
674
+ if (kind === "purge-quarantine") {
675
+ const src = checkedPath(op.path, req);
676
+ const qroot = join(req.root, "_host", "quarantine");
677
+ const rel = relative(qroot, src);
678
+ if (rel.startsWith("..") || isAbsolute(rel)) throw new Failure("purge outside quarantine");
679
+ const parts = rel.split(/[\\/]/).filter(Boolean);
680
+ if (parts.length !== 2) throw new Failure("purge must name one run/item, not a quarantine root");
681
+ const originalReceipts = join(req.root, "_host", "runs", parts[0], "receipts");
682
+ let owned = false;
683
+ try {
684
+ for (const name of readdirSync(originalReceipts)) {
685
+ if (!name.endsWith(".json") || name.endsWith(".started.json")) continue;
686
+ const rec = rj(join(originalReceipts, name));
687
+ if (rec.result?.quarantine_path === src && rec.status === "succeeded") owned = true;
688
+ }
689
+ } catch {}
690
+ if (!owned) throw new Failure("no successful isolation receipt owns this quarantine item");
691
+ const observed = treeState(src);
692
+ if (sha(observed) !== sha(op.expected ?? {})) throw new Failure("quarantine content changed after planning");
693
+ const before = diskUsage(src).free;
694
+ const st = lstatSync(src);
695
+ if (st.isDirectory()) rmSync(src, { recursive: true, force: true });
696
+ else unlinkSync(src);
697
+ const after = diskUsage(qroot).free;
698
+ return { deleted_quarantine: src, logical_bytes: observed.logical_bytes, observed_free_space_delta: after - before, irreversible: true };
699
+ }
700
+ if (kind === "quarantine") {
701
+ const src = checkedPath(op.path, req);
702
+ const rel = relative(req.root, src);
703
+ const parts = rel.split(/[\\/]/);
704
+ const posixRel = rel.replaceAll("\\", "/");
705
+ if (!(posixRel.startsWith("_host/cache/") || parts.includes("logs"))) {
706
+ throw new Failure("cleanup only supports registered cache/log targets; data/env/backups/releases are protected");
707
+ }
708
+ if (parts.some((p) => ["data", "env", "backups", "releases"].includes(p))) throw new Failure("protected path");
709
+ if (sha(fileState(src)) !== sha(op.expected ?? {})) throw new Failure("cleanup candidate drift");
710
+ const dst = join(req.root, "_host", "quarantine", req.run_id, op.item_id);
711
+ mkdir(dirname(dst));
712
+ if (existsSync(dst)) throw new Failure("quarantine destination exists");
713
+ renameSync(src, dst);
714
+ return { quarantine_path: dst, released_bytes: 0, note: "same-volume isolation is not disk reclamation" };
715
+ }
716
+ if (kind === "defaults") {
717
+ const current = inventory({});
718
+ for (const [name, expected] of Object.entries(op.expected)) {
719
+ const actual = current.tools[name];
720
+ if (!actual || actual.version !== expected.version) throw new Failure("default runtime not restored: " + name);
721
+ }
722
+ return { defaults_verified: Object.keys(op.expected), at: stamp() };
723
+ }
724
+ throw new Failure("unsupported operation kind: " + kind);
725
+ }
726
+
727
+ function median(times) {
728
+ const a = [...times].sort((x, y) => x - y);
729
+ const m = Math.floor(a.length / 2);
730
+ return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
731
+ }
732
+
733
+ export function benchmarkMirrors(req) {
734
+ const candidates = req.candidates || [];
735
+ if (!(candidates.length >= 1 && candidates.length <= 6)) throw new Failure("mirror test requires 1..6 explicitly approved candidates");
736
+ const rounds = req.rounds ?? 3;
737
+ if (![1, 2, 3].includes(rounds)) throw new Failure("mirror rounds must be 1..3");
738
+ const limit = 262144;
739
+ const results = [];
740
+ for (const item of candidates) {
741
+ if (JSON.stringify(Object.keys(item).sort()) !== JSON.stringify(["approved", "ecosystem", "expected_sha256", "id", "trust", "url"])) {
742
+ throw new Failure("mirror candidate contract mismatch");
743
+ }
744
+ checkId(item.id);
745
+ let url;
746
+ try { url = new URL(item.url); } catch { throw new Failure("mirror samples require credential-free HTTPS with certificate verification"); }
747
+ if (url.protocol !== "https:" || !url.hostname || url.username || url.password) {
748
+ throw new Failure("mirror samples require credential-free HTTPS with certificate verification");
749
+ }
750
+ if (!item.approved || !["official", "intranet", "approved-third-party"].includes(item.trust)) throw new Failure("unapproved mirror candidate");
751
+ if (!/^[a-f0-9]{64}$/.test(item.expected_sha256)) throw new Failure("mirror sample needs a preverified SHA-256");
752
+ const times = [];
753
+ let error = null;
754
+ for (let i = 0; i < rounds; i++) {
755
+ try {
756
+ const started = process.hrtime.bigint();
757
+ const r = httpGetSync(item.url, { timeoutMs: 5000, limit: limit + 1, headers: { "User-Agent": "Speculo-OPS/2.2 mirror-probe" }, redirectOrigin: true });
758
+ const elapsed = Number(process.hrtime.bigint() - started) / 1e9;
759
+ const data = Buffer.from(r.body);
760
+ if (data.length > limit) throw new Failure("sample exceeds 256 KiB bound");
761
+ if (sha(data) !== item.expected_sha256) throw new Failure("sample integrity mismatch");
762
+ times.push(elapsed);
763
+ } catch (e) { error = e.message || String(e); break; }
764
+ }
765
+ results.push({ id: item.id, ecosystem: item.ecosystem, url: item.url, verified: times.length === rounds, median_seconds: times.length ? median(times) : null, error });
766
+ }
767
+ const best = {};
768
+ for (const row of results) {
769
+ if (row.verified && (!(row.ecosystem in best) || row.median_seconds < best[row.ecosystem].median_seconds)) best[row.ecosystem] = row;
770
+ }
771
+ return {
772
+ identity: fingerprint(), observed_at: stamp(), candidates: results, best_by_ecosystem: best,
773
+ configuration_changed: false,
774
+ note: "Candidate trust and sample digest are supplied by the administrator. Measured latency is not a guarantee of future download speed. A separate approved plan changes configuration.",
775
+ };
776
+ }
777
+
778
+ export function main(req) {
779
+ const identity = fingerprint();
780
+ if (req.identity && identity !== req.identity) throw new Failure("target identity drift");
781
+ const action = req.action;
782
+ if (action === "probe") return inventory(req);
783
+ if (action === "mirror-probe") return benchmarkMirrors(req);
784
+ if (action === "snapshot") return { identity, paths: Object.fromEntries((req.paths || []).map((p) => [p, fileState(p)])), at: stamp() };
785
+ const root = resolve(req.root);
786
+ const parts = root.split(/[\\/]/).filter(Boolean);
787
+ if (!isAbsolute(root) || parts.length < 2 || root.split(/[\\/]/).includes("..")) {
788
+ throw new Failure("unsafe host root");
789
+ }
790
+ noLinks(root);
791
+ for (const key of ["host_id", "run_id", "controller_id"]) checkId(req[key]);
792
+ const owner = { host_id: req.host_id, run_id: req.run_id, controller_id: req.controller_id, plan_digest: req.plan_digest };
793
+ const marker = join(root, ".ops-host.json");
794
+ const lockdir = join(root, "_host", "execution.lock");
795
+ if (action === "lock") {
796
+ if (existsSync(root) && !existsSync(marker)) {
797
+ let nonempty = false;
798
+ try { nonempty = readdirSync(root).length > 0; } catch {}
799
+ if (nonempty && !req.adopt_root) throw new Failure("nonempty unowned host root; explicit adoption plan required");
800
+ }
801
+ mkdir(root, 0o755);
802
+ if (process.platform !== "win32" && (statSync(root).mode & 0o022)) {
803
+ throw new Failure("host root is group/world writable; secure it in an explicit host preparation step before deployment");
804
+ }
805
+ if (process.platform === "win32") {
806
+ const sid = currentSid();
807
+ const ic = spawnSync("icacls", [root, "/inheritance:r", "/grant:r", `*${sid}:(OI)(CI)F`, "*S-1-5-18:(OI)(CI)F"], { encoding: "utf8" });
808
+ if (ic.status !== 0) throw new Error(ic.stderr);
809
+ }
810
+ if (existsSync(marker)) {
811
+ const got = rj(marker);
812
+ if (got.host_id !== req.host_id || got.identity !== identity) throw new Failure("host root ownership conflict");
813
+ } else wj(marker, { host_id: req.host_id, identity });
814
+ mkdir(dirname(lockdir));
815
+ try {
816
+ mkdirSync(lockdir, { mode: 0o700 });
817
+ wj(join(lockdir, "owner.json"), owner);
818
+ } catch (e) {
819
+ if (e.code === "EEXIST") {
820
+ if (!existsSync(join(lockdir, "owner.json")) || JSON.stringify(rj(join(lockdir, "owner.json"))) !== JSON.stringify(owner)) {
821
+ throw new Failure("target lock held by another operation");
822
+ }
823
+ } else if (e instanceof Failure) throw e;
824
+ else throw e;
825
+ }
826
+ return { locked: true, owner };
827
+ }
828
+ if (!existsSync(marker) || JSON.stringify(rj(marker)) !== JSON.stringify({ host_id: req.host_id, identity })) {
829
+ throw new Failure("target root marker missing/conflicting");
830
+ }
831
+ if (action === "receipts") {
832
+ const folder = join(root, "_host", "runs", req.run_id, "receipts");
833
+ const receipts = {};
834
+ if (existsSync(folder)) {
835
+ for (const name of readdirSync(folder).sort()) {
836
+ if (!name.endsWith(".json")) continue;
837
+ receipts[name.replace(/\.json$/, "")] = rj(join(folder, name));
838
+ }
839
+ }
840
+ return { receipts };
841
+ }
842
+ if (!existsSync(lockdir) || JSON.stringify(rj(join(lockdir, "owner.json"))) !== JSON.stringify(owner)) {
843
+ throw new Failure("target lock not owned");
844
+ }
845
+ if (action === "unlock") {
846
+ try { unlinkSync(join(lockdir, "owner.json")); } catch {}
847
+ try { rmdirSync(lockdir); }
848
+ catch { rmSync(lockdir, { recursive: true, force: true }); }
849
+ return { unlocked: true };
850
+ }
851
+ if (action !== "step") throw new Failure("unknown agent action");
852
+ const op = req.operation;
853
+ checkId(op.step_id);
854
+ const receipts = join(root, "_host", "runs", req.run_id, "receipts");
855
+ mkdir(receipts, 0o700);
856
+ const receipt = join(receipts, op.step_id + ".json");
857
+ const started = join(receipts, op.step_id + ".started.json");
858
+ const opDigest = req.operation_digest;
859
+ if (existsSync(receipt)) {
860
+ const r = rj(receipt);
861
+ if (r.operation_digest !== opDigest) throw new Failure("operation digest mismatch");
862
+ return r;
863
+ }
864
+ if (existsSync(started)) return { status: "unknown", step_id: op.step_id, reason: "started without terminal receipt; do not replay blindly" };
865
+ const callLock = join(lockdir, "active-call");
866
+ try { mkdirSync(callLock, { mode: 0o700 }); }
867
+ catch (e) {
868
+ if (e.code === "EEXIST") throw new Failure("another target call is executing or crashed; inspect before manual recovery");
869
+ throw e;
870
+ }
871
+ try {
872
+ wj(started, { operation_digest: opDigest, at: stamp(), pid: process.pid });
873
+ let record;
874
+ try {
875
+ let result = execute(op, req);
876
+ if (result && typeof result === "object") {
877
+ result = Object.fromEntries(Object.entries(result).filter(([k]) => k !== "stdout" && k !== "stderr"));
878
+ }
879
+ record = { status: "succeeded", step_id: op.step_id, operation_digest: opDigest, at: stamp(), result };
880
+ } catch (e) {
881
+ record = {
882
+ status: "failed", step_id: op.step_id, operation_digest: opDigest, at: stamp(),
883
+ error: stripSecrets(e.message || String(e), req.secrets || []),
884
+ side_effects_possible: !["health", "assert-file", "defaults", "verify-command"].includes(op.kind),
885
+ };
886
+ }
887
+ wj(receipt, record);
888
+ return record;
889
+ } finally {
890
+ try { rmSync(callLock, { recursive: true, force: true }); } catch {}
891
+ }
892
+ }
893
+
894
+ function emit(response) {
895
+ process.stdout.write(JSON.stringify(response) + "\n");
896
+ }
897
+
898
+ if (globalThis.OPS_REQUEST !== undefined) {
899
+ try {
900
+ emit({ ok: true, result: main(globalThis.OPS_REQUEST) });
901
+ } catch (e) {
902
+ emit({ ok: false, error: stripSecrets(e.message || String(e), (globalThis.OPS_REQUEST || {}).secrets || []) });
903
+ }
904
+ }