@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,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
|
+
}
|