@gr8ful/spf 0.9.2 → 0.10.0

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 (37) hide show
  1. package/README.md +56 -0
  2. package/assets/defaults/spf.config.yaml +75 -0
  3. package/assets/skill/references/config.md +98 -4
  4. package/dist/chains/index.d.ts +2 -0
  5. package/dist/chains/index.js +4 -0
  6. package/dist/cli/commands/doctor.js +339 -2
  7. package/dist/cli/commands/fanout.d.ts +7 -14
  8. package/dist/cli/commands/fanout.js +45 -39
  9. package/dist/cli/commands/loop.d.ts +2 -0
  10. package/dist/cli/commands/loop.js +198 -0
  11. package/dist/cli/commands/run.js +14 -4
  12. package/dist/cli/commands/watch.d.ts +29 -1
  13. package/dist/cli/commands/watch.js +219 -64
  14. package/dist/cli/index.js +14 -0
  15. package/dist/core/agent_cc.d.ts +11 -0
  16. package/dist/core/agent_cc.js +25 -2
  17. package/dist/core/agent_flue.js +14 -5
  18. package/dist/core/agents.d.ts +61 -1
  19. package/dist/core/agents.js +363 -6
  20. package/dist/core/data_types.d.ts +316 -0
  21. package/dist/core/data_types.js +143 -0
  22. package/dist/core/loop.d.ts +230 -0
  23. package/dist/core/loop.js +290 -0
  24. package/dist/core/quality.d.ts +1 -2
  25. package/dist/core/sandbox.d.ts +236 -0
  26. package/dist/core/sandbox.js +655 -0
  27. package/dist/core/sandbox_cloudflare.d.ts +137 -0
  28. package/dist/core/sandbox_cloudflare.js +505 -0
  29. package/dist/core/sandbox_opensandbox.d.ts +59 -0
  30. package/dist/core/sandbox_opensandbox.js +484 -0
  31. package/dist/core/sandbox_sdk_types.d.ts +171 -0
  32. package/dist/core/sandbox_sdk_types.js +20 -0
  33. package/dist/core/watch.d.ts +56 -0
  34. package/dist/core/watch.js +354 -51
  35. package/dist/core/worktree_data.d.ts +1 -0
  36. package/dist/core/worktree_data.js +37 -0
  37. package/package.json +1 -1
@@ -0,0 +1,655 @@
1
+ /**
2
+ * Sandbox lease registry, workspace transport, and teardown chain — SPF #15.
3
+ *
4
+ * `SandboxSpec`/`SandboxBackend`/`SandboxScope` live in `data_types.ts`
5
+ * beside the valibot schemas they are derived from (that leaf module imports
6
+ * only valibot; this one is runtime machinery). Everything ELSE the design
7
+ * needs lives here: `SandboxTransport`/`SandboxLease`/`TeardownStep`/
8
+ * `SandboxLog`, the process-local lease registry keyed on `spec.lease_key`,
9
+ * `registerRunLog`'s logger channel, the teardown chain, and the workspace
10
+ * transport (seed / reconcile / extract) written ONCE against the abstract
11
+ * `SandboxTransport` — both backends (OpenSandbox's `SandboxDriver`,
12
+ * Cloudflare's `CloudflareSandboxStub`) satisfy it, so there is exactly one
13
+ * copy of the git sequences below, not one per adapter.
14
+ *
15
+ * The ONE invariant everything in the transport section exists to hold:
16
+ * at every sync point, the sandbox's git HEAD tree is byte-identical to the
17
+ * host tree the judges (permissions/gates/quality/changes) will inspect —
18
+ * tree equality, not history equality. See the design doc's §5.2 for the
19
+ * full proof and the measured transcripts this code reproduces.
20
+ *
21
+ * Backend adapters (`sandbox_opensandbox.ts`, `sandbox_cloudflare.ts`) are a
22
+ * separate slice of this feature — see their own module comments. This file
23
+ * dispatches to them (`factoryFor`) and calls into their transport objects
24
+ * (`seedViaTransport`, `preflight`, `reconcileWorkspace`, `extractWorkspace`)
25
+ * but owns no SDK-specific code itself.
26
+ */
27
+ import { spawnSync } from "node:child_process";
28
+ import { createHash } from "node:crypto";
29
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import path from "node:path";
32
+ import { cloudflareFactory } from "./sandbox_cloudflare.js";
33
+ import { openSandboxFactory } from "./sandbox_opensandbox.js";
34
+ /**
35
+ * Factored out of `staticBroker.issue()` so `spf doctor`'s per-agent line
36
+ * (§7 check #9) can print the IDENTICAL sentence a real grant's `describe()`
37
+ * would, from key NAMES it already resolved from config alone — doctor has
38
+ * no live `SandboxSpec` and, deliberately, never reads operator-env VALUES
39
+ * just to build this string.
40
+ */
41
+ export function describeStaticCredentials(keyNames) {
42
+ const sorted = [...new Set(keyNames)].sort();
43
+ if (sorted.length === 0) {
44
+ return "static broker: no credentials issued (sandbox.env_allowlist resolves to none for this agent)";
45
+ }
46
+ return (`static broker: ${sorted.length} key(s) copied from the operator environment at create time (${sorted.join(", ")}) — ` +
47
+ `revoke() clears SPF's own in-memory copy only; the key itself is never rotated or invalidated ` +
48
+ `(§6.2 — true revocation needs a provider provisioning API, which this build does not have)`);
49
+ }
50
+ /**
51
+ * The ONLY broker registered in this build (§10 non-goal 2; §11's "Deliberately
52
+ * NOT in A"). `issue()` is the identity on `spec.env` — B is observably a
53
+ * no-op on the env PLANE (§6.1); what it adds is this seam, the ordered
54
+ * revoke step, and the describe() line. `revoke()` clears the grant's own
55
+ * `env` object IN PLACE — it does not, and cannot, un-inject an
56
+ * already-created container's process environment; §6.2 is explicit that
57
+ * "revocable" means nothing for a static key, and this is the honest
58
+ * implementation of that, not a `revoke()` that pretends otherwise.
59
+ */
60
+ export const staticBroker = {
61
+ id: "static",
62
+ async issue(spec) {
63
+ const env = { ...spec.env };
64
+ const keyNames = Object.keys(env).sort();
65
+ return {
66
+ env,
67
+ describe: () => describeStaticCredentials(keyNames),
68
+ async revoke() {
69
+ for (const key of Object.keys(env))
70
+ delete env[key];
71
+ },
72
+ };
73
+ },
74
+ };
75
+ /** The registry `validateSandboxConfig` checks `sandbox.credentials.broker` against (agents.ts). */
76
+ export const KNOWN_CREDENTIAL_BROKER_IDS = [staticBroker.id];
77
+ // ── the lease registry ──────────────────────────────────────────────────────
78
+ const NOOP_LOG = () => { };
79
+ /**
80
+ * THE LOGGER CHANNEL. Module-level, keyed on adw_id, called from where a
81
+ * tracer is genuinely in hand (`agents.ts`'s `execute()`, immediately after
82
+ * the spec is built). Leases resolve `lease.log` through this map with a
83
+ * no-op fallback, so a lease created by a caller that never registered one
84
+ * still works and simply logs nowhere. `teardownRun(adwId)` DELETES the
85
+ * entry — otherwise `spf watch` would retain a closure over every Run it
86
+ * ever processed.
87
+ */
88
+ export function registerRunLog(adwId, log) {
89
+ RUN_LOGS.set(adwId, log);
90
+ }
91
+ /**
92
+ * Resolves the CURRENT registration for `adwId`, falling back to the no-op —
93
+ * the same lookup `SandboxLease.log` performs, exposed for callers (the
94
+ * seed) that run BEFORE any lease exists and therefore have no `lease.log`
95
+ * to reach through.
96
+ */
97
+ function resolveRunLog(adwId) {
98
+ return RUN_LOGS.get(adwId) ?? NOOP_LOG;
99
+ }
100
+ const RUN_LOGS = new Map();
101
+ const LEASES = new Map();
102
+ /**
103
+ * Registers a created lease. THREE arguments — the logger is not one of
104
+ * them (see `SandboxLease.log`'s doc comment). Returns the mutable lease so
105
+ * the caller (an adapter) can set `provider_id`/`native` and push its own
106
+ * `teardown` steps once the underlying provider sandbox actually exists.
107
+ */
108
+ export function registerLease(key, spec, transport) {
109
+ const lease = {
110
+ spec,
111
+ provider_id: "",
112
+ transport,
113
+ log: (event) => (RUN_LOGS.get(spec.adw_id) ?? NOOP_LOG)(event),
114
+ created_at: new Date().toISOString(),
115
+ seeded: null,
116
+ turn: 0,
117
+ flue_conversation_ids: [],
118
+ native: {},
119
+ teardown: [],
120
+ };
121
+ LEASES.set(key, lease);
122
+ return lease;
123
+ }
124
+ /** Find-or-create lookup for adapters — not exported in the design doc's own code block, but required by it: `createSandbox`'s `hit`/`miss` branch reads exactly this. */
125
+ export function getLease(key) {
126
+ return LEASES.get(key);
127
+ }
128
+ export function leases() {
129
+ return [...LEASES.values()];
130
+ }
131
+ /**
132
+ * Bounded renewal for the adapter's `hit` branch: `min(lifetime_seconds,
133
+ * remaining budget to max_total_lifetime_seconds)`. Throws — never silently
134
+ * truncates to zero — once the ceiling from creation has passed, so a
135
+ * long-lived `spf watch` process cannot renew a lease forever.
136
+ */
137
+ export async function renewLease(lease) {
138
+ if (!lease.native.renew)
139
+ return;
140
+ const createdAtMs = new Date(lease.created_at).getTime();
141
+ const maxEndMs = createdAtMs + lease.spec.max_total_lifetime_seconds * 1000;
142
+ const remainingSeconds = Math.floor((maxEndMs - Date.now()) / 1000);
143
+ if (remainingSeconds <= 0) {
144
+ throw new Error(`sandbox: lease ${JSON.stringify(lease.spec.lease_key)} has exceeded sandbox.max_total_lifetime_seconds ` +
145
+ `(${lease.spec.max_total_lifetime_seconds}s since ${lease.created_at}) — renewal refused`);
146
+ }
147
+ const seconds = Math.min(lease.spec.lifetime_seconds, remainingSeconds);
148
+ await lease.native.renew(seconds);
149
+ }
150
+ // ── teardown chain ───────────────────────────────────────────────────────────
151
+ export async function teardownLease(lease) {
152
+ for (const step of lease.teardown) {
153
+ try {
154
+ await step.run();
155
+ }
156
+ catch (error) {
157
+ lease.log({
158
+ level: "error",
159
+ msg: `sandbox teardown step ${JSON.stringify(step.name)} failed`,
160
+ data: { error: error instanceof Error ? error.message : String(error) },
161
+ });
162
+ }
163
+ }
164
+ }
165
+ /** Tears down every lease whose `spec.adw_id === adwId` — a SET, not a single lease (see design §4.3). */
166
+ export async function teardownRun(adwId) {
167
+ const mine = leases().filter((l) => l.spec.adw_id === adwId);
168
+ for (const lease of mine) {
169
+ await teardownLease(lease);
170
+ for (const [key, value] of LEASES) {
171
+ if (value === lease) {
172
+ LEASES.delete(key);
173
+ break;
174
+ }
175
+ }
176
+ }
177
+ RUN_LOGS.delete(adwId);
178
+ }
179
+ export async function teardownAll() {
180
+ for (const lease of leases())
181
+ await teardownLease(lease);
182
+ LEASES.clear();
183
+ }
184
+ /**
185
+ * Per-RUN scope, keyed EXPLICITLY on the run's adw_id — never on ambient
186
+ * async context. Flue's claim loop is detached from the wrap site's own
187
+ * async context (see the design doc's §4.3 "Why not AsyncLocalStorage"), so
188
+ * `createSandbox` runs outside any `AsyncLocalStorage` scope a wrapper here
189
+ * could establish. Attribution instead rides data already in hand:
190
+ * `lease.spec.adw_id`, written at create time.
191
+ */
192
+ export async function withRunScope(adwId, fn) {
193
+ try {
194
+ return await fn();
195
+ }
196
+ finally {
197
+ await teardownRun(adwId);
198
+ }
199
+ }
200
+ // ── backend dispatch ─────────────────────────────────────────────────────────
201
+ /**
202
+ * SYNC. Returns a closure; performs NO I/O. Every provider call — the
203
+ * preflight, the seed, the `setup` commands — lives inside the returned
204
+ * `createSandbox`, which is the async boundary flue awaits itself.
205
+ */
206
+ export function factoryFor(spec) {
207
+ if (spec.backend === "opensandbox")
208
+ return openSandboxFactory(spec);
209
+ if (spec.backend === "cloudflare")
210
+ return cloudflareFactory(spec);
211
+ throw new Error(`sandbox.factoryFor: backend ${JSON.stringify(spec.backend)} has no remote factory — "local" is handled by agent_flue.ts's sandboxFor() directly and never reaches here`);
212
+ }
213
+ // ── small path/shell helpers shared by the transport below ─────────────────
214
+ /** Single-quote a path for the container shell; embedded quotes become '\''. Mirrors flue's own `cloudflare/index.mjs` helper. */
215
+ function shellQuote(value) {
216
+ return `'${value.replace(/'/g, "'\\''")}'`;
217
+ }
218
+ /** Repo-relative POSIX form of `child` under `root`, or null when `child` is not under `root` (both already-normalized POSIX paths). */
219
+ function relativeIfUnder(root, child) {
220
+ const normRoot = path.posix.normalize(root).replace(/\/+$/, "") || "/";
221
+ const normChild = path.posix.normalize(child);
222
+ if (normChild === normRoot)
223
+ return "";
224
+ if (normChild.startsWith(`${normRoot}/`))
225
+ return normChild.slice(normRoot.length + 1);
226
+ return null;
227
+ }
228
+ // ── host-side git plumbing (byte-safe: Buffers, never a UTF-8 round-trip) ──
229
+ function runGit(args, cwd, env) {
230
+ const result = spawnSync("git", args, { cwd, env: env ? { ...process.env, ...env } : process.env });
231
+ return { stdout: result.stdout ?? Buffer.alloc(0), stderr: result.stderr ?? Buffer.alloc(0), status: result.status ?? 1 };
232
+ }
233
+ /** For ASCII/short output only (rev-parse, write-tree, …) — never for diff bytes. */
234
+ function gitText(args, cwd, env) {
235
+ const r = runGit(args, cwd, env);
236
+ if (r.status !== 0)
237
+ throw new Error(`git ${args.join(" ")} (in ${cwd}) failed: ${r.stderr.toString("utf-8")}`);
238
+ return r.stdout.toString("utf-8");
239
+ }
240
+ /** Raw bytes — the only safe way to capture `git diff --binary` output. */
241
+ function gitBuffer(args, cwd, env) {
242
+ const r = runGit(args, cwd, env);
243
+ if (r.status !== 0)
244
+ throw new Error(`git ${args.join(" ")} (in ${cwd}) failed: ${r.stderr.toString("utf-8")}`);
245
+ return r.stdout;
246
+ }
247
+ function requireHostCommit(hostRoot) {
248
+ const r = runGit(["rev-parse", "--verify", "HEAD"], hostRoot);
249
+ if (r.status !== 0) {
250
+ throw new Error(`sandbox: ${hostRoot} has no commits yet — run 'git commit' at least once before running a sandboxed chain here`);
251
+ }
252
+ }
253
+ /**
254
+ * Submodules are out of scope (§5.2): a gitlink carries no content in ANY
255
+ * export mechanism, so `checkout-index` writes the gitlink directory entry
256
+ * only — the seeded tree is the superproject alone. Detected once, at the
257
+ * seed, and reported as a single `warn` naming the submodule paths — never
258
+ * a `validate()` failure, because a submodule-bearing repo must still be
259
+ * able to run. Parses `.gitmodules`'s `path = ` lines directly rather than
260
+ * shelling out to `git submodule status`, which requires the submodules to
261
+ * already be registered/initialized to enumerate cleanly.
262
+ */
263
+ function listSubmodulePaths(hostRoot) {
264
+ const gitmodulesPath = path.join(hostRoot, ".gitmodules");
265
+ if (!existsSync(gitmodulesPath))
266
+ return [];
267
+ const text = readFileSync(gitmodulesPath, "utf-8");
268
+ const paths = [];
269
+ for (const line of text.split("\n")) {
270
+ const match = /^\s*path\s*=\s*(.+?)\s*$/.exec(line);
271
+ if (match)
272
+ paths.push(match[1]);
273
+ }
274
+ return paths;
275
+ }
276
+ /**
277
+ * Attributes-proof HEAD tree, host-side: a TEMPORARY index (the operator's
278
+ * real index is never touched) plus `checkout-index`, never `git archive`.
279
+ * `git archive` honors in-tree `.gitattributes` — `export-ignore` drops
280
+ * files, `export-subst` rewrites `$Format:…$` placeholders — so it does not
281
+ * produce the host tree. `checkout-index` writes the index's blobs verbatim.
282
+ */
283
+ function buildHeadTreeTar(hostRoot) {
284
+ const workDir = mkdtempSync(path.join(tmpdir(), "spf-sbx-seed-"));
285
+ try {
286
+ const tmpIndex = path.join(workDir, "index");
287
+ const checkoutDir = path.join(workDir, "tree");
288
+ mkdirSync(checkoutDir, { recursive: true });
289
+ const env = { GIT_INDEX_FILE: tmpIndex };
290
+ gitText(["read-tree", "HEAD"], hostRoot, env);
291
+ gitText(["checkout-index", "-a", "-f", `--prefix=${checkoutDir}${path.sep}`], hostRoot, env);
292
+ const tarPath = path.join(workDir, "seed.tar");
293
+ const tarResult = spawnSync("tar", ["-cf", tarPath, "-C", checkoutDir, "."]);
294
+ if (tarResult.status !== 0) {
295
+ throw new Error(`sandbox: tar -cf failed building the seed archive: ${(tarResult.stderr ?? Buffer.alloc(0)).toString("utf-8")}`);
296
+ }
297
+ return readFileSync(tarPath);
298
+ }
299
+ finally {
300
+ rmSync(workDir, { recursive: true, force: true });
301
+ }
302
+ }
303
+ /**
304
+ * The host's uncommitted delta (tracked edits + untracked adds + deletions +
305
+ * mode changes), as ONE patch, computed against a TEMPORARY index so the
306
+ * operator's real index is never touched. Empty (zero-length) on a clean
307
+ * host tree — callers must treat that as the normal case, not an error.
308
+ */
309
+ function computeUncommittedPatch(hostRoot) {
310
+ const workDir = mkdtempSync(path.join(tmpdir(), "spf-sbx-patch-"));
311
+ try {
312
+ const tmpIndex = path.join(workDir, "index");
313
+ const env = { GIT_INDEX_FILE: tmpIndex };
314
+ gitText(["read-tree", "HEAD"], hostRoot, env);
315
+ gitText(["add", "-A"], hostRoot, env);
316
+ return gitBuffer(["-c", "core.autocrlf=false", "diff", "--cached", "--binary", "HEAD"], hostRoot, env);
317
+ }
318
+ finally {
319
+ rmSync(workDir, { recursive: true, force: true });
320
+ }
321
+ }
322
+ /** {head_sha, patch_sha256} — the fingerprint reconcile compares against `lease.seeded`. */
323
+ function hostFingerprint(hostRoot) {
324
+ const head_sha = gitText(["rev-parse", "HEAD"], hostRoot).trim();
325
+ const patch = computeUncommittedPatch(hostRoot);
326
+ const patch_sha256 = createHash("sha256").update(patch).digest("hex");
327
+ return { head_sha, patch_sha256 };
328
+ }
329
+ // ── in-sandbox git plumbing, over SandboxTransport only ─────────────────────
330
+ /**
331
+ * Materialize + apply/commit ONE patch inside the sandbox. Both directions
332
+ * move a patch as base64 — never as a raw string through `readFile`/
333
+ * `writeFile(string)` — because a non-UTF-8 byte in a TEXT diff hunk (any
334
+ * source file without a NUL in its first 8000 bytes, e.g. Latin-1/Shift-JIS)
335
+ * would be replaced by a UTF-8 round-trip, changing the patch's length and
336
+ * making `git apply` fail on context mismatch. `--index` is what gives the
337
+ * following commit something to commit; the empty-patch branch is the
338
+ * MANDATORY else (a zero-byte patch makes `git apply` exit 128 — "No valid
339
+ * patches in input" — and, without `--index`, a non-empty apply leaves the
340
+ * commit with nothing staged, "no changes added to commit").
341
+ */
342
+ async function applyPatchInSandbox(transport, spec, name, patch) {
343
+ const b64Path = path.posix.join(spec.scratch_dir, `${name}.b64`);
344
+ const rawPath = path.posix.join(spec.scratch_dir, name);
345
+ await transport.writeFile(b64Path, patch.toString("base64"));
346
+ // Stdin redirection (`< file`), not a positional file argument: GNU
347
+ // coreutils' `base64` accepts both spellings, but a BusyBox/BSD `base64`
348
+ // may only support the redirect form — this keeps the transport working
349
+ // on either without changing behavior on the GNU image the design targets.
350
+ const decodeResult = await transport.exec(`base64 -d < ${shellQuote(b64Path)} > ${shellQuote(rawPath)}`, { cwd: spec.workspace_dir });
351
+ if (decodeResult.exitCode !== 0) {
352
+ throw new Error(`sandbox: failed to decode ${name} inside the sandbox: ${decodeResult.stderr}`);
353
+ }
354
+ if (patch.length === 0) {
355
+ const r = await transport.exec("git tag -f spf-local spf-base", { cwd: spec.workspace_dir });
356
+ if (r.exitCode !== 0)
357
+ throw new Error(`sandbox: failed to tag spf-local at spf-base: ${r.stderr}`);
358
+ return;
359
+ }
360
+ const applyCmd = `git -c core.autocrlf=false apply --index --binary --whitespace=nowarn ${shellQuote(rawPath)} && ` +
361
+ `git -c user.email=spf@local -c user.name=spf commit -q -m spf-local && git tag -f spf-local`;
362
+ const r = await transport.exec(applyCmd, { cwd: spec.workspace_dir });
363
+ if (r.exitCode !== 0)
364
+ throw new Error(`sandbox: failed to apply/commit the uncommitted host patch (${name}): ${r.stderr}`);
365
+ }
366
+ /** `.spf/` (belt) plus the handoff dir's repo-relative form when it happens to be nested despite validation — see the design doc's §5.2 step 5. */
367
+ async function excludeSpfFromSandboxGit(transport, spec) {
368
+ const lines = [".spf/"];
369
+ const handoffRel = relativeIfUnder(spec.workspace_dir, spec.handoff_sandbox);
370
+ if (handoffRel !== null && handoffRel !== "")
371
+ lines.push(`${handoffRel}/`);
372
+ const excludePath = path.posix.join(spec.workspace_dir, ".git", "info", "exclude");
373
+ const script = lines.map((line) => `printf '%s\\n' ${shellQuote(line)} >> ${shellQuote(excludePath)}`).join(" && ");
374
+ const r = await transport.exec(script, { cwd: spec.workspace_dir });
375
+ if (r.exitCode !== 0)
376
+ throw new Error(`sandbox: failed to append ${excludePath}: ${r.stderr}`);
377
+ }
378
+ /** `exec("wc -c < <path>")`, trimmed and parsed — trim is load-bearing: BSD/busybox `wc` pads the number. */
379
+ async function sizeOf(transport, filePath, cwd) {
380
+ const r = await transport.exec(`wc -c < ${shellQuote(filePath)}`, { cwd });
381
+ if (r.exitCode !== 0)
382
+ return null;
383
+ const n = Number.parseInt(r.stdout.trim(), 10);
384
+ return Number.isFinite(n) ? n : null;
385
+ }
386
+ // ── the handoff mirror (both directions; gitignored, so git cannot carry it) ─
387
+ function listHostFilesRecursive(dir) {
388
+ if (!existsSync(dir))
389
+ return [];
390
+ const out = [];
391
+ const walk = (d) => {
392
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
393
+ const full = path.join(d, entry.name);
394
+ if (entry.isDirectory())
395
+ walk(full);
396
+ else if (entry.isFile())
397
+ out.push(full);
398
+ }
399
+ };
400
+ walk(dir);
401
+ return out;
402
+ }
403
+ async function pushHandoffMirror(spec, transport) {
404
+ await transport.exec(`mkdir -p ${shellQuote(spec.handoff_sandbox)}`, { cwd: spec.workspace_dir });
405
+ for (const hostPath of listHostFilesRecursive(spec.handoff_host)) {
406
+ const rel = path.relative(spec.handoff_host, hostPath).split(path.sep).join("/");
407
+ const sandboxPath = path.posix.join(spec.handoff_sandbox, rel);
408
+ const bytes = readFileSync(hostPath);
409
+ if (bytes.length > spec.transport.max_mirror_bytes) {
410
+ throw new Error(`sandbox: handoff mirror file ${hostPath} is ${bytes.length} bytes, over transport.max_mirror_bytes (${spec.transport.max_mirror_bytes})`);
411
+ }
412
+ const dir = path.posix.dirname(sandboxPath);
413
+ if (dir !== spec.handoff_sandbox)
414
+ await transport.exec(`mkdir -p ${shellQuote(dir)}`, { cwd: spec.workspace_dir });
415
+ await transport.writeFile(sandboxPath, bytes);
416
+ }
417
+ }
418
+ async function mirrorFileBackToHost(transport, spec, sandboxPath, hostPath) {
419
+ const size = await sizeOf(transport, sandboxPath, spec.workspace_dir);
420
+ if (size !== null && size > spec.transport.max_mirror_bytes) {
421
+ throw new Error(`sandbox: handoff mirror file ${sandboxPath} is ${size} bytes, over transport.max_mirror_bytes (${spec.transport.max_mirror_bytes})`);
422
+ }
423
+ const bytes = await transport.readFileBuffer(sandboxPath);
424
+ if (bytes.length > spec.transport.max_mirror_bytes) {
425
+ throw new Error(`sandbox: handoff mirror file ${sandboxPath} is ${bytes.length} bytes, over transport.max_mirror_bytes (${spec.transport.max_mirror_bytes})`);
426
+ }
427
+ mkdirSync(path.dirname(hostPath), { recursive: true });
428
+ writeFileSync(hostPath, bytes);
429
+ }
430
+ async function pullHandoffMirror(spec, transport) {
431
+ const listing = await transport.exec(`[ -d ${shellQuote(spec.handoff_sandbox)} ] && find ${shellQuote(spec.handoff_sandbox)} -type f || true`, { cwd: spec.workspace_dir });
432
+ const sandboxPaths = listing.stdout
433
+ .split("\n")
434
+ .map((line) => line.trim())
435
+ .filter(Boolean);
436
+ for (const sandboxPath of sandboxPaths) {
437
+ const rel = path.posix.relative(spec.handoff_sandbox, sandboxPath);
438
+ const hostPath = path.join(spec.handoff_host, ...rel.split("/"));
439
+ await mirrorFileBackToHost(transport, spec, sandboxPath, hostPath);
440
+ }
441
+ // Extra repo-relative paths git cannot carry (sandbox.transport.mirror).
442
+ for (const relPath of spec.transport.mirror) {
443
+ const sandboxPath = path.posix.join(spec.workspace_dir, relPath);
444
+ const exists = await transport.exec(`test -e ${shellQuote(sandboxPath)}`, { cwd: spec.workspace_dir });
445
+ if (exists.exitCode !== 0)
446
+ continue;
447
+ const hostPath = path.join(spec.host_root, ...relPath.split("/"));
448
+ await mirrorFileBackToHost(transport, spec, sandboxPath, hostPath);
449
+ }
450
+ }
451
+ // ── preflight ────────────────────────────────────────────────────────────────
452
+ /**
453
+ * Mandatory create-time check, not a "leaning": the transport below shells
454
+ * out to `git`, `tar` and `base64` inside the sandbox, so all three are hard
455
+ * requirements of ANY image, not a convenience. Run by both adapters before
456
+ * their seed.
457
+ */
458
+ export async function preflight(spec, transport) {
459
+ // Binaries first, against cwd "/" (always present) rather than
460
+ // `workspace_dir` (not guaranteed to exist yet): a missing-binary image
461
+ // must fail with the named "missing git, tar, or base64" error, not a
462
+ // workspace_dir-creation error that happens to fire first because the
463
+ // same broken exec path also can't run `mkdir`.
464
+ const r = await transport.exec("git --version && tar --version && base64 --version", { cwd: "/" });
465
+ if (r.exitCode !== 0) {
466
+ throw new Error(`sandbox: image ${JSON.stringify(spec.image)} is missing git, tar, or base64 — the workspace transport requires all three. ` +
467
+ `Use a debian-family image with git installed, or add the missing binaries via sandbox.setup. (${r.stderr || r.stdout})`);
468
+ }
469
+ // Nothing creates `workspace_dir` before this point, and every exec after
470
+ // preflight chdirs into it via `cwd` — create it now that binaries are confirmed.
471
+ const mkdirResult = await transport.exec(`mkdir -p ${shellQuote(spec.workspace_dir)}`, { cwd: "/" });
472
+ if (mkdirResult.exitCode !== 0) {
473
+ throw new Error(`sandbox: failed to create workspace_dir ${JSON.stringify(spec.workspace_dir)}: ${mkdirResult.stderr}`);
474
+ }
475
+ }
476
+ // ── seed (host -> sandbox), once per lease ──────────────────────────────────
477
+ /**
478
+ * Seeds a freshly created sandbox from the host tree and returns the
479
+ * fingerprint to store as `lease.seeded`. Steps mirror the design doc's
480
+ * §5.2 exactly (refuse-no-commits, mkdir scratch+handoff FIRST, the
481
+ * attributes-proof tar, extract, `git init`+`spf-base` tag, the uncommitted
482
+ * delta as `spf-local`, the handoff mirror's mandatory first push, then
483
+ * `setup`). Called by an adapter's `createSandbox` on the `miss` branch,
484
+ * against the raw driver/stub transport before any lease exists.
485
+ */
486
+ export async function seedViaTransport(spec, transport) {
487
+ requireHostCommit(spec.host_root);
488
+ const submodulePaths = listSubmodulePaths(spec.host_root);
489
+ if (submodulePaths.length > 0) {
490
+ resolveRunLog(spec.adw_id)({
491
+ level: "warn",
492
+ msg: "sandbox: this repo has submodules — their content is not seeded, synced, or extracted (the superproject only is)",
493
+ data: { submodules: submodulePaths },
494
+ });
495
+ }
496
+ const mkdirResult = await transport.exec(`mkdir -p ${shellQuote(spec.scratch_dir)} ${shellQuote(spec.handoff_sandbox)}`, {
497
+ cwd: spec.workspace_dir,
498
+ });
499
+ if (mkdirResult.exitCode !== 0)
500
+ throw new Error(`sandbox: failed to create scratch_dir/handoff_dir: ${mkdirResult.stderr}`);
501
+ // step 3 — attributes-proof HEAD tree, uploaded raw (no base64: this is a write of real bytes, not a read-back).
502
+ const tar = buildHeadTreeTar(spec.host_root);
503
+ if (tar.length > spec.transport.max_seed_bytes) {
504
+ throw new Error(`sandbox: seed archive is ${tar.length} bytes, over transport.max_seed_bytes (${spec.transport.max_seed_bytes}) — fails loudly, never truncates`);
505
+ }
506
+ const seedTarPath = path.posix.join(spec.scratch_dir, "seed.tar");
507
+ await transport.writeFile(seedTarPath, tar);
508
+ // step 4
509
+ const extractResult = await transport.exec(`tar -xf ${shellQuote(seedTarPath)} -C ${shellQuote(spec.workspace_dir)} && rm -f ${shellQuote(seedTarPath)}`, { cwd: spec.workspace_dir });
510
+ if (extractResult.exitCode !== 0)
511
+ throw new Error(`sandbox: failed to extract the seed archive: ${extractResult.stderr}`);
512
+ // step 5 — the -c user.email/-c user.name flags MUST attach to `commit`,
513
+ // not to `add` (a no-op there): an image with no preconfigured git
514
+ // identity otherwise fails this founding commit outright.
515
+ const initResult = await transport.exec("git init -q && git -c core.autocrlf=false add -A && " +
516
+ "git -c user.email=spf@local -c user.name=spf commit -q -m spf-base && git tag -f spf-base", { cwd: spec.workspace_dir });
517
+ if (initResult.exitCode !== 0)
518
+ throw new Error(`sandbox: failed to initialize the sandbox git repo: ${initResult.stderr}`);
519
+ await excludeSpfFromSandboxGit(transport, spec);
520
+ // step 6 — the host's uncommitted delta, so HEAD == the live host tree.
521
+ const patch = computeUncommittedPatch(spec.host_root);
522
+ if (patch.length > spec.transport.max_patch_bytes) {
523
+ throw new Error(`sandbox: the host's uncommitted patch is ${patch.length} bytes, over transport.max_patch_bytes (${spec.transport.max_patch_bytes})`);
524
+ }
525
+ await applyPatchInSandbox(transport, spec, "seed.patch", patch);
526
+ // step 6.5 — MANDATORY here: the first send's reconcileWorkspace is a
527
+ // documented no-op (no lease exists yet), so this is the only thing that
528
+ // establishes the handoff plane before the dispatch whose rendered prompt
529
+ // names it.
530
+ await pushHandoffMirror(spec, transport);
531
+ // step 7 — setup, once per sandbox.
532
+ for (const cmd of spec.setup) {
533
+ const r = await transport.exec(cmd, { cwd: spec.workspace_dir, timeoutMs: spec.exec_timeout_seconds * 1000 });
534
+ if (r.exitCode !== 0)
535
+ throw new Error(`sandbox: setup command ${JSON.stringify(cmd)} failed (exit ${r.exitCode}): ${r.stderr}`);
536
+ }
537
+ return hostFingerprint(spec.host_root);
538
+ }
539
+ // ── reconcile: the host tree can change under the sandbox ──────────────────
540
+ /**
541
+ * Compares the live host fingerprint against `lease.seeded` and applies
542
+ * exactly one of two remedies on a mismatch — conflating them is a
543
+ * data-loss bug (see the design doc's §5.4). A no-op when there is no lease
544
+ * yet (the first send of a run — the seed that follows is the sync point)
545
+ * OR when the fingerprint already matches (nothing has moved on the host
546
+ * since the last sync point, so the sandbox's handoff content — already
547
+ * synced by the last extract — cannot be stale either; no provider call at
548
+ * all in that case).
549
+ */
550
+ export async function reconcileWorkspace(spec) {
551
+ const lease = LEASES.get(spec.lease_key);
552
+ if (!lease)
553
+ return;
554
+ const transport = lease.transport;
555
+ const live = hostFingerprint(spec.host_root);
556
+ if (lease.seeded && lease.seeded.head_sha === live.head_sha && lease.seeded.patch_sha256 === live.patch_sha256) {
557
+ // The WORKSPACE tree hasn't moved, but the handoff plane lives outside
558
+ // git (under the gitignored data_dir) and is never covered by this
559
+ // fingerprint — another agent can have written a handoff file (e.g. a
560
+ // reviewer's `writes: []` review.md) since this lease's last sync
561
+ // point. Push it every time, even on a fingerprint HIT, or a lease-hit
562
+ // dispatch is handed artifact paths that don't exist in its container.
563
+ await pushHandoffMirror(spec, transport);
564
+ return; // workspace tree unchanged since the last sync point
565
+ }
566
+ if (lease.seeded && lease.seeded.head_sha === live.head_sha) {
567
+ // ROW 2 — head_sha unchanged, patch differs: working-tree edits only.
568
+ const resetResult = await transport.exec("git reset --hard spf-base && git clean -fd", { cwd: spec.workspace_dir });
569
+ if (resetResult.exitCode !== 0)
570
+ throw new Error(`sandbox: reconcile (working-tree edits) reset failed: ${resetResult.stderr}`);
571
+ const patch = computeUncommittedPatch(spec.host_root);
572
+ if (patch.length > spec.transport.max_patch_bytes) {
573
+ throw new Error(`sandbox: reconcile patch is ${patch.length} bytes, over transport.max_patch_bytes (${spec.transport.max_patch_bytes})`);
574
+ }
575
+ await applyPatchInSandbox(transport, spec, "re.patch", patch);
576
+ }
577
+ else {
578
+ // ROW 3 — the host committed: spf-base is stale. FULL RE-SEED, beginning
579
+ // with the reset (tar -xf is additive; a stale tracked file the new
580
+ // HEAD lacks would otherwise survive extraction). `setup` is NOT re-run.
581
+ const tar = buildHeadTreeTar(spec.host_root);
582
+ if (tar.length > spec.transport.max_seed_bytes) {
583
+ throw new Error(`sandbox: re-seed archive is ${tar.length} bytes, over transport.max_seed_bytes (${spec.transport.max_seed_bytes})`);
584
+ }
585
+ const patch = computeUncommittedPatch(spec.host_root);
586
+ if (patch.length > spec.transport.max_patch_bytes) {
587
+ throw new Error(`sandbox: reconcile patch is ${patch.length} bytes, over transport.max_patch_bytes (${spec.transport.max_patch_bytes})`);
588
+ }
589
+ const resetResult = await transport.exec("git reset --hard spf-base && git clean -fd", { cwd: spec.workspace_dir });
590
+ if (resetResult.exitCode !== 0)
591
+ throw new Error(`sandbox: re-seed reset failed: ${resetResult.stderr}`);
592
+ const seedTarPath = path.posix.join(spec.scratch_dir, "seed.tar");
593
+ await transport.writeFile(seedTarPath, tar);
594
+ // --allow-empty: the new HEAD's tree can coincide with the stale
595
+ // spf-base's (e.g. a host-side no-op/--allow-empty commit) — the
596
+ // re-seed's OWN commit must not fail merely because nothing changed,
597
+ // for the same reason extractWorkspace's turn-commit doesn't either.
598
+ const reseedResult = await transport.exec(`tar -xf ${shellQuote(seedTarPath)} -C ${shellQuote(spec.workspace_dir)} && rm -f ${shellQuote(seedTarPath)} && ` +
599
+ "git -c core.autocrlf=false add -A && git -c user.email=spf@local -c user.name=spf commit -q --allow-empty -m spf-base && git tag -f spf-base", { cwd: spec.workspace_dir });
600
+ if (reseedResult.exitCode !== 0)
601
+ throw new Error(`sandbox: re-seed extract/commit failed: ${reseedResult.stderr}`);
602
+ await applyPatchInSandbox(transport, spec, "seed.patch", patch);
603
+ lease.turn = 0;
604
+ }
605
+ lease.seeded = hostFingerprint(spec.host_root);
606
+ await pushHandoffMirror(spec, transport);
607
+ }
608
+ // ── extract (sandbox -> host), after every dispatch resolves ───────────────
609
+ /**
610
+ * Three ordered acts: stage+diff in-sandbox, apply on the host (atomic —
611
+ * `--allow-empty` is mandatory here, the opposite fix from the in-sandbox
612
+ * applies, because no commit follows it), then advance the sandbox's HEAD
613
+ * with a marker commit so the NEXT extract emits an increment rather than
614
+ * the cumulative delta again. A no-op when there is no lease yet.
615
+ */
616
+ export async function extractWorkspace(spec) {
617
+ const lease = LEASES.get(spec.lease_key);
618
+ if (!lease)
619
+ return;
620
+ const transport = lease.transport;
621
+ const outPatchPath = path.posix.join(spec.scratch_dir, "out.patch");
622
+ const outPatchB64Path = path.posix.join(spec.scratch_dir, "out.patch.b64");
623
+ const stageResult = await transport.exec(`git -c core.autocrlf=false add -A && git -c core.autocrlf=false diff --cached --binary HEAD > ${shellQuote(outPatchPath)} && ` +
624
+ `base64 -w0 < ${shellQuote(outPatchPath)} > ${shellQuote(outPatchB64Path)}`, { cwd: spec.workspace_dir });
625
+ if (stageResult.exitCode !== 0)
626
+ throw new Error(`sandbox: extract failed to stage/diff: ${stageResult.stderr}`);
627
+ // size FIRST, read SECOND — fail loudly before transferring anything.
628
+ const size = await sizeOf(transport, outPatchPath, spec.workspace_dir);
629
+ if (size !== null && size > spec.transport.max_patch_bytes) {
630
+ throw new Error(`sandbox: extract patch is ${size} bytes, over transport.max_patch_bytes (${spec.transport.max_patch_bytes})`);
631
+ }
632
+ const b64 = await transport.readFile(outPatchB64Path);
633
+ const patch = Buffer.from(b64, "base64");
634
+ if (size === null && patch.length > spec.transport.max_patch_bytes) {
635
+ throw new Error(`sandbox: extract patch is ${patch.length} bytes, over transport.max_patch_bytes (${spec.transport.max_patch_bytes})`);
636
+ }
637
+ // 2. host: apply. Atomic; a failure changes nothing and fails the send.
638
+ // --allow-empty is mandatory: scout/refiner/reviewer ship writes: [] and
639
+ // write only into the handoff plane, so their workspace delta is empty by
640
+ // design, every time — without the flag every one of their sends dies.
641
+ const applyResult = spawnSync("git", ["apply", "--allow-empty", "--binary", "--whitespace=nowarn", "-p1", "-"], {
642
+ cwd: spec.host_root,
643
+ input: patch,
644
+ });
645
+ if (applyResult.status !== 0) {
646
+ throw new Error(`sandbox: host git apply failed: ${(applyResult.stderr ?? Buffer.alloc(0)).toString("utf-8")}`);
647
+ }
648
+ // 3. in-sandbox, ONLY after the host apply succeeded: advance HEAD.
649
+ lease.turn += 1;
650
+ const commitResult = await transport.exec(`git -c user.email=spf@local -c user.name=spf -c core.autocrlf=false commit -q --allow-empty -m spf-turn-${lease.turn} && git tag -f spf-local`, { cwd: spec.workspace_dir });
651
+ if (commitResult.exitCode !== 0)
652
+ throw new Error(`sandbox: extract's turn commit failed: ${commitResult.stderr}`);
653
+ lease.seeded = hostFingerprint(spec.host_root);
654
+ await pullHandoffMirror(spec, transport);
655
+ }