@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.
- package/README.md +56 -0
- package/assets/defaults/spf.config.yaml +75 -0
- package/assets/skill/references/config.md +98 -4
- package/dist/chains/index.d.ts +2 -0
- package/dist/chains/index.js +4 -0
- package/dist/cli/commands/doctor.js +339 -2
- package/dist/cli/commands/fanout.d.ts +7 -14
- package/dist/cli/commands/fanout.js +45 -39
- package/dist/cli/commands/loop.d.ts +2 -0
- package/dist/cli/commands/loop.js +198 -0
- package/dist/cli/commands/run.js +14 -4
- package/dist/cli/commands/watch.d.ts +29 -1
- package/dist/cli/commands/watch.js +219 -64
- package/dist/cli/index.js +14 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +25 -2
- package/dist/core/agent_flue.js +14 -5
- package/dist/core/agents.d.ts +61 -1
- package/dist/core/agents.js +363 -6
- package/dist/core/data_types.d.ts +316 -0
- package/dist/core/data_types.js +143 -0
- package/dist/core/loop.d.ts +230 -0
- package/dist/core/loop.js +290 -0
- package/dist/core/quality.d.ts +1 -2
- package/dist/core/sandbox.d.ts +236 -0
- package/dist/core/sandbox.js +655 -0
- package/dist/core/sandbox_cloudflare.d.ts +137 -0
- package/dist/core/sandbox_cloudflare.js +505 -0
- package/dist/core/sandbox_opensandbox.d.ts +59 -0
- package/dist/core/sandbox_opensandbox.js +484 -0
- package/dist/core/sandbox_sdk_types.d.ts +171 -0
- package/dist/core/sandbox_sdk_types.js +20 -0
- package/dist/core/watch.d.ts +56 -0
- package/dist/core/watch.js +354 -51
- package/dist/core/worktree_data.d.ts +1 -0
- package/dist/core/worktree_data.js +37 -0
- 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
|
+
}
|