@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,59 @@
1
+ /**
2
+ * OpenSandbox backend adapter (SPF #15, design doc §4.1).
3
+ *
4
+ * Maps flue's 9-verb `SandboxDriver` onto the spike-verified
5
+ * `@alibaba-group/opensandbox` v0.1.11 surface (see the scratchpad's
6
+ * `sandbox-spike.json`), per the design's driver-method table:
7
+ *
8
+ * exec -> sandbox.commands.run(cmd, opts, handlers) (streamed; no clamp — §3.1)
9
+ * readFile -> sandbox.files.readFile(path) (UTF-8 only, by contract)
10
+ * readFileBuffer -> exec("base64 -w0 < <path>") + decode (SDK has no binary read)
11
+ * writeFile -> sandbox.files.writeFiles([{path,data}]) (never pre-creates parents)
12
+ * mkdir -> files.createDirectories | exec("mkdir -p") (recursive via the shell)
13
+ * rm -> exec("rm [-r][-f] -- <path>") (fs.rm semantics, exactly)
14
+ * exists -> exec("test -e <path>") (NO "--" — test/[ has no such convention)
15
+ * readdir -> exec("test -d ... && ls -1A -- <path>")
16
+ * stat -> exec("stat -c '%F|%s|%Y' -L -- <path>; test -L -- <path>")
17
+ *
18
+ * `openSandboxDriver`/`openSandboxFactory` are the module's public surface —
19
+ * see their own doc comments. `find-or-create` lives in `openSandboxFactory`
20
+ * against `sandbox.ts`'s process-local lease registry (`getLease`/
21
+ * `registerLease`/`renewLease`); the underlying native OpenSandbox instance
22
+ * and the `SandboxDriver` built over it are cached in this module's own
23
+ * `NATIVE_BY_LEASE` map, keyed the same way, because `SandboxLease.native`'s
24
+ * shape (§4.3) only carries the three narrow methods flue's `sandboxFromDriver`
25
+ * must never see (`endpointUrl`/`egress`/`renew`) — not the raw handle.
26
+ *
27
+ * The `loadOpenSandboxSdk` indirection below is real and load-bearing: the
28
+ * specifier MUST NOT be a literal at the `import()` call site, or a plain
29
+ * `tsc -p tsconfig.json --noEmit` fails with TS2307 on every machine (the
30
+ * SDK is a documented user install, never a `package.json` dependency — see
31
+ * the design doc's §4.1). Keep that indirection; the obvious "simplification"
32
+ * back to a literal is a build break.
33
+ */
34
+ import type { SandboxDriver, SandboxFactory } from "@flue/runtime";
35
+ import type { SandboxSpec } from "./data_types.ts";
36
+ import type { CredentialBroker } from "./sandbox.ts";
37
+ import type { OpenSandboxCtor, OpenSandboxManagerCtor } from "./sandbox_sdk_types.ts";
38
+ /** The hand-written narrow SDK surface this driver needs — see `sandbox_sdk_types.ts`. */
39
+ export interface OpenSandboxModule {
40
+ Sandbox: OpenSandboxCtor;
41
+ SandboxManager: OpenSandboxManagerCtor;
42
+ }
43
+ export type SdkLoader = () => Promise<OpenSandboxModule>;
44
+ export declare const loadOpenSandboxSdk: SdkLoader;
45
+ /**
46
+ * `broker` defaults to `staticBroker` — the only broker this build registers
47
+ * — so every existing caller (§8.3a's tests included) sees a create `env`
48
+ * byte-identical to `spec.env`, exactly as PR A shipped it.
49
+ */
50
+ export declare function openSandboxDriver(spec: SandboxSpec, load?: SdkLoader, broker?: CredentialBroker): SandboxDriver;
51
+ /**
52
+ * `broker` defaults to `staticBroker` — the only broker this build
53
+ * registers; production callers (`factoryFor`, sandbox.ts) never pass one
54
+ * explicitly, so this stays a pure default. Tests inject a fake broker to
55
+ * prove the ordering guarantees in design §11's PR B test list (issue()
56
+ * happens inside the MISS branch, exactly once per lease; the HIT branch
57
+ * never re-issues; kill() throwing still runs credentials:revoke).
58
+ */
59
+ export declare function openSandboxFactory(spec: SandboxSpec, load?: SdkLoader, broker?: CredentialBroker): SandboxFactory;
@@ -0,0 +1,484 @@
1
+ /**
2
+ * OpenSandbox backend adapter (SPF #15, design doc §4.1).
3
+ *
4
+ * Maps flue's 9-verb `SandboxDriver` onto the spike-verified
5
+ * `@alibaba-group/opensandbox` v0.1.11 surface (see the scratchpad's
6
+ * `sandbox-spike.json`), per the design's driver-method table:
7
+ *
8
+ * exec -> sandbox.commands.run(cmd, opts, handlers) (streamed; no clamp — §3.1)
9
+ * readFile -> sandbox.files.readFile(path) (UTF-8 only, by contract)
10
+ * readFileBuffer -> exec("base64 -w0 < <path>") + decode (SDK has no binary read)
11
+ * writeFile -> sandbox.files.writeFiles([{path,data}]) (never pre-creates parents)
12
+ * mkdir -> files.createDirectories | exec("mkdir -p") (recursive via the shell)
13
+ * rm -> exec("rm [-r][-f] -- <path>") (fs.rm semantics, exactly)
14
+ * exists -> exec("test -e <path>") (NO "--" — test/[ has no such convention)
15
+ * readdir -> exec("test -d ... && ls -1A -- <path>")
16
+ * stat -> exec("stat -c '%F|%s|%Y' -L -- <path>; test -L -- <path>")
17
+ *
18
+ * `openSandboxDriver`/`openSandboxFactory` are the module's public surface —
19
+ * see their own doc comments. `find-or-create` lives in `openSandboxFactory`
20
+ * against `sandbox.ts`'s process-local lease registry (`getLease`/
21
+ * `registerLease`/`renewLease`); the underlying native OpenSandbox instance
22
+ * and the `SandboxDriver` built over it are cached in this module's own
23
+ * `NATIVE_BY_LEASE` map, keyed the same way, because `SandboxLease.native`'s
24
+ * shape (§4.3) only carries the three narrow methods flue's `sandboxFromDriver`
25
+ * must never see (`endpointUrl`/`egress`/`renew`) — not the raw handle.
26
+ *
27
+ * The `loadOpenSandboxSdk` indirection below is real and load-bearing: the
28
+ * specifier MUST NOT be a literal at the `import()` call site, or a plain
29
+ * `tsc -p tsconfig.json --noEmit` fails with TS2307 on every machine (the
30
+ * SDK is a documented user install, never a `package.json` dependency — see
31
+ * the design doc's §4.1). Keep that indirection; the obvious "simplification"
32
+ * back to a literal is a build break.
33
+ */
34
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
35
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
36
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
37
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
38
+ });
39
+ }
40
+ return path;
41
+ };
42
+ import { SandboxDiedError, sandboxFromDriver } from "@flue/runtime";
43
+ import { getLease, preflight, registerLease, renewLease, seedViaTransport, staticBroker } from "./sandbox.js";
44
+ // Indirected on purpose — see this module's header comment. Do not inline
45
+ // this string into the `import()` call below.
46
+ const OPENSANDBOX_SPECIFIER = "@alibaba-group/opensandbox";
47
+ export const loadOpenSandboxSdk = async () => {
48
+ try {
49
+ return (await import(__rewriteRelativeImportExtension(OPENSANDBOX_SPECIFIER)));
50
+ }
51
+ catch (error) {
52
+ throw new Error(`sandbox: the "opensandbox" backend needs the OpenSandbox SDK — install it with ` +
53
+ `\`npm i -g @alibaba-group/opensandbox@0.1.11\` alongside SPF (or \`npm i -D @alibaba-group/opensandbox@0.1.11\` for local test lanes). ` +
54
+ `(${error instanceof Error ? error.message : String(error)})`);
55
+ }
56
+ };
57
+ // ── small shell helpers ──────────────────────────────────────────────────
58
+ /** Single-quote a path for the container shell; embedded quotes become '\''. Mirrors flue's own (`dist/cloudflare/index.mjs:24-26`) and `sandbox.ts`'s copy. */
59
+ function shellQuote(value) {
60
+ return `'${value.replace(/'/g, "'\\''")}'`;
61
+ }
62
+ // ── liveness (design §4.1 point 4; `sandbox-api.md`'s liveness contract) ───
63
+ const LIVENESS_POLL_MS = 5_000;
64
+ /**
65
+ * States in which a pending call may still complete. NOT a spike-verified
66
+ * exhaustive enumeration — the spike observed a live `getInfo()` SHAPE
67
+ * (`status:{state}`) but never enumerated every string the control plane
68
+ * can report. Deliberately conservative: an unrecognized state is treated
69
+ * as dead rather than alive, so an unmeasured value fails a hung call
70
+ * loudly instead of silently hanging forever.
71
+ *
72
+ * Values are lower-cased, and MUST be compared case-insensitively below —
73
+ * the SDK's own `SandboxState` union is capitalized
74
+ * (`"Creating" | "Running" | "Pausing" | "Paused" | "Resuming" | "Deleting" |
75
+ * "Deleted" | "Error" | string`), so a case-sensitive `Set.has` against a
76
+ * live `"Running"` sandbox would misclassify it as dead. `paused` /
77
+ * `deleting` / `deleted` / `error` are treated as dead.
78
+ */
79
+ const OPENSANDBOX_LIVE_STATES = new Set(["running", "creating", "pausing", "resuming"]);
80
+ /** `setTimeout` + `.unref()` — never let a poll/watchdog timer hold the process open by itself (Node-only; harmless if `unref` is absent on a given timer handle). */
81
+ function unrefTimeout(fn, ms) {
82
+ const timer = setTimeout(fn, ms);
83
+ timer.unref?.();
84
+ return timer;
85
+ }
86
+ function watchLiveness(native, operation) {
87
+ let stopped = false;
88
+ let timer;
89
+ const promise = new Promise((_resolve, reject) => {
90
+ const poll = () => {
91
+ if (stopped)
92
+ return;
93
+ native
94
+ .getInfo()
95
+ .then((info) => {
96
+ if (stopped)
97
+ return;
98
+ if (!OPENSANDBOX_LIVE_STATES.has(info.status.state.toLowerCase())) {
99
+ stopped = true;
100
+ reject(new SandboxDiedError({ operation, reason: "stopped" }));
101
+ return;
102
+ }
103
+ timer = unrefTimeout(poll, LIVENESS_POLL_MS);
104
+ })
105
+ .catch(() => {
106
+ // A getInfo() transport failure is NOT treated as death — only an
107
+ // explicit non-live state is. A control-plane blip must not fail
108
+ // an in-flight call; keep polling.
109
+ if (!stopped)
110
+ timer = unrefTimeout(poll, LIVENESS_POLL_MS);
111
+ });
112
+ };
113
+ timer = unrefTimeout(poll, LIVENESS_POLL_MS);
114
+ });
115
+ return {
116
+ promise,
117
+ stop: () => {
118
+ stopped = true;
119
+ if (timer)
120
+ clearTimeout(timer);
121
+ },
122
+ };
123
+ }
124
+ /** Races `work()` against the sandbox's liveness signal ONLY — never `signal` (that race belongs to `sandboxFromDriver`, one layer up). */
125
+ async function callWithLiveness(native, operation, work) {
126
+ const watcher = watchLiveness(native, operation);
127
+ try {
128
+ return await Promise.race([work(), watcher.promise]);
129
+ }
130
+ finally {
131
+ watcher.stop();
132
+ }
133
+ }
134
+ // ── exec, with the timeout convention (design §3.1/§4.1 point 3) ──────────
135
+ /**
136
+ * `timeoutMs` is NEVER clamped (§3.1's named divergence from an earlier
137
+ * draft) — a caller-supplied value above `exec_timeout_seconds` reaches the
138
+ * SDK unmodified, forwarded best-effort as whole seconds, rounded UP (O-1 is
139
+ * open: the spike never confirmed `commands.run` honors a deadline option,
140
+ * so this adapter ALSO enforces the deadline host-side regardless). An
141
+ * expired command resolves `exitCode: 124` with the timeout on stderr — the
142
+ * `timeout(1)` convention — and never rejects; the still-running remote
143
+ * command is left to settle on its own (fire-and-forget, never surfaced as
144
+ * an unhandled rejection).
145
+ */
146
+ async function runExecWithTimeout(native, command, options, timeoutMs) {
147
+ const stdoutChunks = [];
148
+ const stderrChunks = [];
149
+ const handlers = {
150
+ onStdout: (chunk) => stdoutChunks.push(chunk.text),
151
+ onStderr: (chunk) => stderrChunks.push(chunk.text),
152
+ };
153
+ const runPromise = native.commands.run(command, options, handlers);
154
+ const mappedRun = runPromise.then((r) => ({
155
+ stdout: r.logs.stdout.map((c) => c.text).join(""),
156
+ stderr: r.logs.stderr.map((c) => c.text).join(""),
157
+ exitCode: r.exitCode,
158
+ }));
159
+ mappedRun.catch(() => { }); // the timeout branch may win the race; the loser must never surface as an unhandled rejection
160
+ let timer;
161
+ const timeoutPromise = new Promise((resolve) => {
162
+ timer = unrefTimeout(() => {
163
+ resolve({
164
+ stdout: stdoutChunks.join(""),
165
+ stderr: `${stderrChunks.join("")}\nsandbox: command timed out after ${timeoutMs}ms`,
166
+ exitCode: 124,
167
+ });
168
+ }, timeoutMs);
169
+ });
170
+ try {
171
+ return await Promise.race([mappedRun, timeoutPromise]);
172
+ }
173
+ finally {
174
+ if (timer)
175
+ clearTimeout(timer);
176
+ }
177
+ }
178
+ // ── the SandboxDriver (9 verbs, design §4.1's table) ────────────────────────
179
+ /**
180
+ * Builds the flue `SandboxDriver` over a lazily-resolved native instance.
181
+ * `getNative` is either a memoized create (standalone `openSandboxDriver`)
182
+ * or an already-created instance (the factory's `createSandbox`) — both
183
+ * shapes are `() => Promise<OpenSandboxInstance>`, so the 9 verbs below
184
+ * don't need to know which.
185
+ */
186
+ function buildDriver(getNative, spec) {
187
+ const driver = {
188
+ exec: async (command, options) => {
189
+ const native = await getNative();
190
+ const cwd = options?.cwd ?? spec.workspace_dir;
191
+ const timeoutMs = options?.timeoutMs ?? spec.exec_timeout_seconds * 1000;
192
+ // The SDK's own RunCommandOpts fields are `workingDirectory`/`envs`,
193
+ // not `cwd`/`env` — see OpenSandboxCommandOptions's doc comment.
194
+ return callWithLiveness(native, "exec", () => runExecWithTimeout(native, command, { workingDirectory: cwd, envs: options?.env, timeoutSeconds: Math.ceil(timeoutMs / 1000) }, timeoutMs));
195
+ },
196
+ readFile: async (path) => {
197
+ const native = await getNative();
198
+ return callWithLiveness(native, "readFile", () => native.files.readFile(path));
199
+ },
200
+ // No SDK binary read (design §4.1's table) — decode a `base64 -w0` exec,
201
+ // the redirect spelling (not a positional arg) for BusyBox/BSD `base64` parity.
202
+ readFileBuffer: async (path) => {
203
+ const r = await driver.exec(`base64 -w0 < ${shellQuote(path)}`, { cwd: spec.workspace_dir });
204
+ if (r.exitCode !== 0)
205
+ throw new Error(`sandbox: readFileBuffer failed for ${JSON.stringify(path)}: ${r.stderr || r.stdout}`);
206
+ return new Uint8Array(Buffer.from(r.stdout, "base64"));
207
+ },
208
+ // Never pre-creates parents — the `sandboxFromDriver` wrapper's mkdir-retry owns that (`sandbox-api.md:113`).
209
+ writeFile: async (path, content) => {
210
+ const native = await getNative();
211
+ return callWithLiveness(native, "writeFile", async () => {
212
+ await native.files.writeFiles([{ path, data: content }]);
213
+ });
214
+ },
215
+ mkdir: async (path, options) => {
216
+ if (options?.recursive) {
217
+ const r = await driver.exec(`mkdir -p -- ${shellQuote(path)}`, { cwd: spec.workspace_dir });
218
+ if (r.exitCode !== 0)
219
+ throw new Error(`sandbox: mkdir -p ${JSON.stringify(path)} failed: ${r.stderr || r.stdout}`);
220
+ return;
221
+ }
222
+ const native = await getNative();
223
+ return callWithLiveness(native, "mkdir", async () => {
224
+ await native.files.createDirectories([{ path }]);
225
+ });
226
+ },
227
+ // Already runs verbs through the shell, so the flags are implemented with
228
+ // `rm` directly (`sandbox-api.md:221`) — exact `fs.rm` semantics, no
229
+ // `SandboxOperationUnsupportedError` needed because the shell honors both.
230
+ rm: async (path, options) => {
231
+ const parts = ["rm"];
232
+ if (options?.recursive)
233
+ parts.push("-r");
234
+ if (options?.force)
235
+ parts.push("-f");
236
+ parts.push("--", shellQuote(path));
237
+ const r = await driver.exec(parts.join(" "), { cwd: spec.workspace_dir });
238
+ if (r.exitCode !== 0)
239
+ throw new Error(`sandbox: rm failed for ${JSON.stringify(path)}: ${r.stderr || r.stdout}`);
240
+ },
241
+ // MUST NOT use "--": `test`/`[` has no end-of-options convention — see
242
+ // the design doc's boxed rule (§4.1). Quoting (not "--") is what guards
243
+ // against whitespace/metacharacters here.
244
+ exists: async (path) => {
245
+ const r = await driver.exec(`test -e ${shellQuote(path)}`, { cwd: spec.workspace_dir });
246
+ return r.exitCode === 0;
247
+ },
248
+ readdir: async (path) => {
249
+ const r = await driver.exec(`test -d ${shellQuote(path)} && ls -1A -- ${shellQuote(path)}`, { cwd: spec.workspace_dir });
250
+ if (r.exitCode !== 0)
251
+ throw new Error(`sandbox: readdir failed — ${JSON.stringify(path)} is not a directory or does not exist`);
252
+ return r.stdout.split("\n").filter((line) => line.length > 0);
253
+ },
254
+ // `-L` dereferences (so isFile/isDirectory/size/mtime describe the
255
+ // TARGET); the companion `test -L` (non-following) answers isSymbolicLink.
256
+ // Never fabricates: an unparseable size/mtime is OMITTED, not zeroed.
257
+ stat: async (path) => {
258
+ const [infoResult, symlinkResult] = await Promise.all([
259
+ driver.exec(`stat -c '%F|%s|%Y' -L -- ${shellQuote(path)}`, { cwd: spec.workspace_dir }),
260
+ driver.exec(`test -L -- ${shellQuote(path)}`, { cwd: spec.workspace_dir }),
261
+ ]);
262
+ if (infoResult.exitCode !== 0)
263
+ throw new Error(`sandbox: stat failed for ${JSON.stringify(path)}: ${infoResult.stderr || infoResult.stdout}`);
264
+ const [typeStr, sizeStr, mtimeStr] = infoResult.stdout.trim().split("|");
265
+ const result = {
266
+ isFile: typeStr === "regular file",
267
+ isDirectory: typeStr === "directory",
268
+ isSymbolicLink: symlinkResult.exitCode === 0,
269
+ };
270
+ const size = Number.parseInt(sizeStr ?? "", 10);
271
+ if (Number.isFinite(size))
272
+ result.size = size;
273
+ const mtimeEpochSeconds = Number.parseInt(mtimeStr ?? "", 10);
274
+ if (Number.isFinite(mtimeEpochSeconds))
275
+ result.mtime = new Date(mtimeEpochSeconds * 1000);
276
+ return result;
277
+ },
278
+ };
279
+ return driver;
280
+ }
281
+ /** Adapts a `SandboxDriver` into `sandbox.ts`'s narrower `SandboxTransport` (five verbs, `size` not `stat`). */
282
+ function transportFromDriver(driver, spec) {
283
+ return {
284
+ exec: (cmd, opts) => driver.exec(cmd, { cwd: opts.cwd, timeoutMs: opts.timeoutMs }),
285
+ readFile: (path) => driver.readFile(path),
286
+ readFileBuffer: (path) => driver.readFileBuffer(path),
287
+ writeFile: (path, data) => driver.writeFile(path, data),
288
+ size: async (path) => {
289
+ const r = await driver.exec(`wc -c < ${shellQuote(path)}`, { cwd: spec.workspace_dir });
290
+ if (r.exitCode !== 0)
291
+ return null;
292
+ const n = Number.parseInt(r.stdout.trim(), 10);
293
+ return Number.isFinite(n) ? n : null;
294
+ },
295
+ };
296
+ }
297
+ /**
298
+ * `broker` defaults to `staticBroker` — the only broker this build registers
299
+ * — so every existing caller (§8.3a's tests included) sees a create `env`
300
+ * byte-identical to `spec.env`, exactly as PR A shipped it.
301
+ */
302
+ export function openSandboxDriver(spec, load = loadOpenSandboxSdk, broker = staticBroker) {
303
+ let nativePromise = null;
304
+ const ensureNative = () => {
305
+ if (!nativePromise)
306
+ nativePromise = createNativeSandbox(spec, load, broker).then(({ native }) => native);
307
+ return nativePromise;
308
+ };
309
+ return buildDriver(ensureNative, spec);
310
+ }
311
+ // ── create-time knobs (design §4.1's create-knobs note) ────────────────────
312
+ const DEFAULT_READY_TIMEOUT_SECONDS = 120;
313
+ /**
314
+ * Raised past the SDK's own 120s default when the resolved egress policy
315
+ * puts a sidecar in the sandbox's create path — the spike measured a 30-90s
316
+ * cold sidecar start. `skipHealthCheck` is deliberately never set to `true`
317
+ * (§4.1: skipping it would move the failure into the mandatory preflight,
318
+ * where the error would misleadingly name `git` instead of the sidecar).
319
+ */
320
+ function readyTimeoutSecondsFor(spec) {
321
+ if (spec.egress.default !== "deny")
322
+ return DEFAULT_READY_TIMEOUT_SECONDS;
323
+ return Math.max(DEFAULT_READY_TIMEOUT_SECONDS, Math.ceil(spec.lifetime_seconds / 10));
324
+ }
325
+ function connectionConfigFor(spec) {
326
+ const parsed = new URL(spec.opensandbox.base_url);
327
+ return {
328
+ domain: parsed.host,
329
+ protocol: parsed.protocol === "https:" ? "https" : "http",
330
+ apiKey: process.env[spec.opensandbox.api_key_env],
331
+ requestTimeoutSeconds: spec.request_timeout_seconds,
332
+ useServerProxy: spec.opensandbox.use_server_proxy,
333
+ };
334
+ }
335
+ /**
336
+ * PR B (design §6.1): the broker's `issue()` is the only thing standing
337
+ * between `sandboxSpecFor`'s already-resolved `spec.env` (`sandbox.env_allowlist
338
+ * ∩ (agent.env_allowlist ?? everything)`, for exactly the agent this lease is
339
+ * keyed to) and the provider's create call. `static` — the only broker this
340
+ * build registers — returns `spec.env` VERBATIM, so this is observably a
341
+ * no-op on the env PLANE (§11's "Deliberately NOT in A" is still true of the
342
+ * VALUES; what's new is the seam, the grant, and its ordered revoke step).
343
+ * Awaited HERE, inside `createNativeSandbox` — itself only ever called from
344
+ * an already-async boundary (`createSandbox`'s MISS branch, or
345
+ * `openSandboxDriver`'s lazily-memoized `nativePromise`) — never inside
346
+ * `factoryFor`, which stays sync (§9).
347
+ */
348
+ async function createEnv(spec, broker) {
349
+ const grant = await broker.issue(spec);
350
+ return { env: { ...grant.env }, grant };
351
+ }
352
+ /**
353
+ * OpenSandbox validates every metadata value against Kubernetes label rules
354
+ * AT CREATE (`_is_valid_label_value`: `[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?`,
355
+ * <=63 chars) — `lease_key` under the default `scope: agent` is
356
+ * `<adw_id>/<agent>`, and the `/` alone fails create unconditionally. Same
357
+ * sanitization the Cloudflare adapter already applies to this exact field
358
+ * (`sandbox_cloudflare.ts`'s `bridgeStub`).
359
+ */
360
+ function sanitizeMetadataLabelValue(value) {
361
+ const replaced = value.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 63);
362
+ return replaced.replace(/^[^A-Za-z0-9]+/, "").replace(/[^A-Za-z0-9]+$/, "") || "_";
363
+ }
364
+ async function createNativeSandbox(spec, load, broker) {
365
+ const sdk = await load();
366
+ const { env, grant } = await createEnv(spec, broker);
367
+ const metadataPrefix = spec.opensandbox.metadata_prefix;
368
+ try {
369
+ const native = await sdk.Sandbox.create({
370
+ connectionConfig: connectionConfigFor(spec),
371
+ image: spec.image,
372
+ env,
373
+ timeoutSeconds: spec.lifetime_seconds,
374
+ metadata: {
375
+ [`${metadataPrefix}.adw_id`]: sanitizeMetadataLabelValue(spec.adw_id),
376
+ [`${metadataPrefix}.agent`]: sanitizeMetadataLabelValue(spec.agent),
377
+ [`${metadataPrefix}.lease_key`]: sanitizeMetadataLabelValue(spec.lease_key),
378
+ },
379
+ networkPolicy: {
380
+ defaultAction: spec.egress.default,
381
+ egress: spec.egress.allow.map((target) => ({ action: "allow", target })),
382
+ },
383
+ readyTimeoutSeconds: readyTimeoutSecondsFor(spec),
384
+ });
385
+ return { native, grant };
386
+ }
387
+ catch (error) {
388
+ // The grant was issued above; a throwing create() must not strand it —
389
+ // there is no lease/teardown chain to fall back on here (design §6.1/§6.3).
390
+ await grant.revoke().catch(() => { });
391
+ throw error;
392
+ }
393
+ }
394
+ // ── find-or-create (design §4.1's lifecycle pseudocode) ────────────────────
395
+ /**
396
+ * This module's own cache of the native instance + driver behind each live
397
+ * lease, keyed identically to `sandbox.ts`'s `LEASES` map (`spec.lease_key`).
398
+ * `SandboxLease.native` (§4.3) deliberately exposes only `endpointUrl` /
399
+ * `egress` / `renew` — never the raw handle — so the `hit` branch below
400
+ * resolves its driver here, not through the lease.
401
+ */
402
+ const NATIVE_BY_LEASE = new Map();
403
+ /**
404
+ * `broker` defaults to `staticBroker` — the only broker this build
405
+ * registers; production callers (`factoryFor`, sandbox.ts) never pass one
406
+ * explicitly, so this stays a pure default. Tests inject a fake broker to
407
+ * prove the ordering guarantees in design §11's PR B test list (issue()
408
+ * happens inside the MISS branch, exactly once per lease; the HIT branch
409
+ * never re-issues; kill() throwing still runs credentials:revoke).
410
+ */
411
+ export function openSandboxFactory(spec, load = loadOpenSandboxSdk, broker = staticBroker) {
412
+ return {
413
+ async createSandbox({ id }) {
414
+ const key = spec.lease_key;
415
+ const existing = getLease(key);
416
+ if (existing) {
417
+ // HIT — no reconcile here (send()'s pre-dispatch call owns it, §5.4);
418
+ // the clock is the only thing touched.
419
+ await renewLease(existing);
420
+ if (!existing.flue_conversation_ids.includes(id))
421
+ existing.flue_conversation_ids.push(id);
422
+ const cached = NATIVE_BY_LEASE.get(key);
423
+ if (!cached) {
424
+ throw new Error(`sandbox: internal error — a lease exists for ${JSON.stringify(key)} but this process has no cached opensandbox driver for it`);
425
+ }
426
+ return sandboxFromDriver(cached.driver, spec.workspace_dir, {
427
+ onOrphanSettled: (settlement) => existing.log({
428
+ level: "warn",
429
+ msg: "sandbox: a command orphaned by an abort settled after the caller was released",
430
+ data: { command: settlement.command },
431
+ }),
432
+ });
433
+ }
434
+ // MISS — create, preflight, seed, register. Never leaves a lease or a
435
+ // journal entry behind on failure; never leaks the native sandbox either.
436
+ // `broker.issue(spec)` is awaited exactly once here, HERE only — never
437
+ // re-issued on a later HIT (design §6.1: the cached grant and its
438
+ // revoke step stay as they are).
439
+ const { native, grant } = await createNativeSandbox(spec, load, broker);
440
+ const driver = buildDriver(() => Promise.resolve(native), spec);
441
+ const transport = transportFromDriver(driver, spec);
442
+ try {
443
+ await preflight(spec, transport);
444
+ const seeded = await seedViaTransport(spec, transport);
445
+ const lease = registerLease(key, spec, transport);
446
+ lease.provider_id = native.id;
447
+ lease.seeded = seeded;
448
+ lease.native = {
449
+ endpointUrl: (port) => native.getEndpointUrl(port),
450
+ egress: () => native.getEgressPolicy(),
451
+ renew: (seconds) => native.renew(seconds),
452
+ };
453
+ // Position 1, 2 (PR B's credentials:revoke — the slot PR A left
454
+ // free), 3, 4: a step's own failure never skips the next one
455
+ // (`teardownLease` in `sandbox.ts`) — kill() throwing still runs
456
+ // revoke(). `provider:uncache` rides last: otherwise this module's
457
+ // own NATIVE_BY_LEASE cache — and the native SDK handle (and its
458
+ // keep-alive undici pool) it holds — is retained for the process
459
+ // lifetime; a long-lived `spf watch` accumulates one entry per agent
460
+ // per issue forever.
461
+ lease.teardown.push({ name: "provider:kill", run: () => native.kill() }, { name: "credentials:revoke", run: () => grant.revoke() }, { name: "provider:close", run: () => native.close() }, { name: "provider:uncache", run: async () => { NATIVE_BY_LEASE.delete(key); } });
462
+ lease.flue_conversation_ids.push(id);
463
+ NATIVE_BY_LEASE.set(key, { native, driver });
464
+ return sandboxFromDriver(driver, spec.workspace_dir, {
465
+ onOrphanSettled: (settlement) => lease.log({
466
+ level: "warn",
467
+ msg: "sandbox: a command orphaned by an abort settled after the caller was released",
468
+ data: { command: settlement.command },
469
+ }),
470
+ });
471
+ }
472
+ catch (error) {
473
+ // preflight/seed/register failed after the grant was already issued
474
+ // (design §6.1) — never leave a live credential behind a dead
475
+ // sandbox. Ordered the same as the real teardown chain: kill, then
476
+ // revoke, then close.
477
+ await native.kill().catch(() => { });
478
+ await grant.revoke().catch(() => { });
479
+ await native.close().catch(() => { });
480
+ throw error;
481
+ }
482
+ },
483
+ };
484
+ }