@b4run/sandbox 0.8.28
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/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/docker/docker-cli.d.ts +23 -0
- package/dist/docker/docker-cli.d.ts.map +1 -0
- package/dist/docker/docker-cli.js +29 -0
- package/dist/docker/docker-exec.d.ts +12 -0
- package/dist/docker/docker-exec.d.ts.map +1 -0
- package/dist/docker/docker-exec.js +74 -0
- package/dist/docker/docker-filesystem.d.ts +11 -0
- package/dist/docker/docker-filesystem.d.ts.map +1 -0
- package/dist/docker/docker-filesystem.js +87 -0
- package/dist/docker/docker-pid-exhaustion.d.ts +8 -0
- package/dist/docker/docker-pid-exhaustion.d.ts.map +1 -0
- package/dist/docker/docker-pid-exhaustion.js +39 -0
- package/dist/docker/docker-sandbox.d.ts +20 -0
- package/dist/docker/docker-sandbox.d.ts.map +1 -0
- package/dist/docker/docker-sandbox.js +267 -0
- package/dist/docker/thread-lifecycle.d.ts +15 -0
- package/dist/docker/thread-lifecycle.d.ts.map +1 -0
- package/dist/docker/thread-lifecycle.js +85 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +10 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/kubernetes/default-kube-client.d.ts +6 -0
- package/dist/kubernetes/default-kube-client.d.ts.map +1 -0
- package/dist/kubernetes/default-kube-client.js +331 -0
- package/dist/kubernetes/kube-client.d.ts +138 -0
- package/dist/kubernetes/kube-client.d.ts.map +1 -0
- package/dist/kubernetes/kube-client.js +26 -0
- package/dist/kubernetes/kube-exec.d.ts +7 -0
- package/dist/kubernetes/kube-exec.d.ts.map +1 -0
- package/dist/kubernetes/kube-exec.js +38 -0
- package/dist/kubernetes/kube-filesystem.d.ts +5 -0
- package/dist/kubernetes/kube-filesystem.d.ts.map +1 -0
- package/dist/kubernetes/kube-filesystem.js +57 -0
- package/dist/kubernetes/kube-sandbox.d.ts +25 -0
- package/dist/kubernetes/kube-sandbox.d.ts.map +1 -0
- package/dist/kubernetes/kube-sandbox.js +278 -0
- package/dist/testing/conformance.d.ts +13 -0
- package/dist/testing/conformance.d.ts.map +1 -0
- package/dist/testing/conformance.js +75 -0
- package/dist/testing/fake-sandbox.d.ts +16 -0
- package/dist/testing/fake-sandbox.d.ts.map +1 -0
- package/dist/testing/fake-sandbox.js +61 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { sandboxUnavailable } from "../errors.js";
|
|
3
|
+
import { createDefaultKubeClient } from "./default-kube-client.js";
|
|
4
|
+
import { KubeAuthorizationReviewError, REQUIRED_KUBE_PERMISSIONS, } from "./kube-client.js";
|
|
5
|
+
import { kubeExec } from "./kube-exec.js";
|
|
6
|
+
import { kubeFilesystem } from "./kube-filesystem.js";
|
|
7
|
+
const ROOT = "/workspace";
|
|
8
|
+
// Linear leading/trailing '-' trim. Avoids anchored `-+`/`^-+` regexes, which are a
|
|
9
|
+
// polynomial-ReDoS pattern (O(n^2) backtracking on adversarial dash runs) when run on
|
|
10
|
+
// an uncontrolled thread id.
|
|
11
|
+
const trimDashes = (s) => {
|
|
12
|
+
let start = 0;
|
|
13
|
+
let end = s.length;
|
|
14
|
+
while (start < end && s[start] === "-")
|
|
15
|
+
start++;
|
|
16
|
+
while (end > start && s[end - 1] === "-")
|
|
17
|
+
end--;
|
|
18
|
+
return s.slice(start, end);
|
|
19
|
+
};
|
|
20
|
+
// DNS-1123 label: lowercase alphanumeric + '-', <=63 chars. Bare truncation to 40
|
|
21
|
+
// chars would collide two thread IDs sharing a 40-char prefix onto one sandbox, so
|
|
22
|
+
// append a stable content hash when (and only when) the cleaned id exceeds the limit
|
|
23
|
+
// — short ids are returned verbatim, keeping existing names churn-free.
|
|
24
|
+
const sanitize = (s) => {
|
|
25
|
+
const clean = trimDashes(s.toLowerCase().replaceAll(/[^a-z0-9-]/g, "-")) || "x";
|
|
26
|
+
if (clean.length <= 40)
|
|
27
|
+
return clean;
|
|
28
|
+
const hash = createHash("sha256").update(s).digest("hex").slice(0, 8);
|
|
29
|
+
return `${trimDashes(clean.slice(0, 31))}-${hash}`;
|
|
30
|
+
};
|
|
31
|
+
const podName = (t) => `b4-sbx-${sanitize(t)}`;
|
|
32
|
+
const pvcName = (t) => `b4-sbx-vol-${sanitize(t)}`;
|
|
33
|
+
const netpolName = (t) => `b4-sbx-net-${sanitize(t)}`;
|
|
34
|
+
const permissionLabel = (permission) => `${permission.verb} ${permission.apiGroup || "core"}/${permission.resource}${permission.subresource === undefined ? "" : `/${permission.subresource}`}`;
|
|
35
|
+
export function resolveSecurity(policy) {
|
|
36
|
+
const sec = policy.security ?? {};
|
|
37
|
+
const dropCaps = sec.dropAllCapabilities ?? true;
|
|
38
|
+
const noNewPriv = sec.noNewPrivileges ?? true;
|
|
39
|
+
const readOnly = sec.readOnlyRootFilesystem ?? true;
|
|
40
|
+
const user = sec.runAsNonRoot === false
|
|
41
|
+
? undefined
|
|
42
|
+
: // `typeof null === "object"`, so guard against it explicitly — a raw-parsed
|
|
43
|
+
// config could carry null (the TS type excludes it); fail SAFE to the
|
|
44
|
+
// hardened non-root default rather than silently running as the image's root.
|
|
45
|
+
typeof sec.runAsNonRoot === "object" && sec.runAsNonRoot !== null
|
|
46
|
+
? sec.runAsNonRoot
|
|
47
|
+
: { uid: 1000, gid: 1000 };
|
|
48
|
+
const podSecurityContext = {
|
|
49
|
+
seccompProfile: { type: "RuntimeDefault" },
|
|
50
|
+
...(user
|
|
51
|
+
? {
|
|
52
|
+
runAsNonRoot: true,
|
|
53
|
+
runAsUser: user.uid,
|
|
54
|
+
runAsGroup: user.gid,
|
|
55
|
+
fsGroup: user.gid,
|
|
56
|
+
fsGroupChangePolicy: "OnRootMismatch",
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
};
|
|
60
|
+
const containerSecurityContext = {
|
|
61
|
+
...(noNewPriv ? { allowPrivilegeEscalation: false } : {}),
|
|
62
|
+
...(readOnly ? { readOnlyRootFilesystem: true } : {}),
|
|
63
|
+
...(dropCaps ? { capabilities: { drop: ["ALL"] } } : {}),
|
|
64
|
+
};
|
|
65
|
+
return { podSecurityContext, containerSecurityContext, readOnly, user };
|
|
66
|
+
}
|
|
67
|
+
/** Kubernetes SandboxProvider. Per thread: a keeper Pod `b4-sbx-<t>` (sleep
|
|
68
|
+
* infinity) + a PVC `b4-sbx-vol-<t>` at /workspace. acquire = create-or-reattach;
|
|
69
|
+
* release deletes the Pod (keeps the PVC); destroy deletes both. Hardening maps to
|
|
70
|
+
* SecurityContext; fsGroup chowns the PVC (no chown-init); the pod mounts no SA token. */
|
|
71
|
+
export function kubernetesSandbox(opts) {
|
|
72
|
+
const ns = opts.namespace ?? "b4-sandboxes";
|
|
73
|
+
const startupTimeoutMs = opts.startupTimeoutMs ?? 60_000;
|
|
74
|
+
const client = opts.client ?? createDefaultKubeClient();
|
|
75
|
+
const ensurePod = async (threadId, policy, signal) => {
|
|
76
|
+
const name = podName(threadId);
|
|
77
|
+
const labels = { "app.kubernetes.io/managed-by": "b4", "b4.run/thread": sanitize(threadId) };
|
|
78
|
+
await client.createNamespacedPvcIfAbsent(ns, {
|
|
79
|
+
name: pvcName(threadId),
|
|
80
|
+
labels,
|
|
81
|
+
storageGi: policy.resources?.diskGb ?? 1,
|
|
82
|
+
...(opts.storageClass ? { storageClass: opts.storageClass } : {}),
|
|
83
|
+
});
|
|
84
|
+
const phase = await client.readNamespacedPodPhase(ns, name);
|
|
85
|
+
if (phase === "Running") {
|
|
86
|
+
// Already running: fall through to the netpol block below, no recreate.
|
|
87
|
+
}
|
|
88
|
+
else if (phase === "Pending") {
|
|
89
|
+
// The keeper pod already exists but hasn't scheduled yet (slow scheduling,
|
|
90
|
+
// image pull, PVC binding, or a reattach mid-startup). Recreating it 409s on
|
|
91
|
+
// a real cluster, so wait it out rather than issue a duplicate create.
|
|
92
|
+
await waitForRunning(client, ns, name, startupTimeoutMs, signal);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
if (phase === "Failed" || phase === "Succeeded" || phase === "Unknown") {
|
|
96
|
+
// A crashed/completed keeper: delete and wait for the name to free up. Real
|
|
97
|
+
// K8s deletion is async (the pod lingers Terminating holding its name), so
|
|
98
|
+
// recreating the same name immediately would 409.
|
|
99
|
+
await client.deleteNamespacedPod(ns, name);
|
|
100
|
+
await waitForGone(client, ns, name, startupTimeoutMs, signal);
|
|
101
|
+
}
|
|
102
|
+
const { podSecurityContext, containerSecurityContext, readOnly, user } = resolveSecurity(policy);
|
|
103
|
+
const res = policy.resources;
|
|
104
|
+
const limits = {
|
|
105
|
+
...(res?.memoryMb ? { memory: `${res.memoryMb}Mi` } : {}),
|
|
106
|
+
...(res?.cpus ? { cpu: String(res.cpus) } : {}),
|
|
107
|
+
};
|
|
108
|
+
const env = [
|
|
109
|
+
...Object.entries(policy.env ?? {}).map(([name, value]) => ({ name, value })),
|
|
110
|
+
...(user ? [{ name: "HOME", value: ROOT }] : []),
|
|
111
|
+
];
|
|
112
|
+
const spec = {
|
|
113
|
+
name,
|
|
114
|
+
image: opts.image,
|
|
115
|
+
labels,
|
|
116
|
+
pvcName: pvcName(threadId),
|
|
117
|
+
env,
|
|
118
|
+
limits,
|
|
119
|
+
podSecurityContext,
|
|
120
|
+
containerSecurityContext,
|
|
121
|
+
readOnlyRootFilesystem: readOnly,
|
|
122
|
+
automountServiceAccountToken: false,
|
|
123
|
+
};
|
|
124
|
+
await client.createNamespacedPod(ns, spec);
|
|
125
|
+
await waitForRunning(client, ns, name, startupTimeoutMs, signal);
|
|
126
|
+
}
|
|
127
|
+
// Egress policy (best-effort — depends on a policy-capable CNI; preflight warns).
|
|
128
|
+
// SandboxPolicy["network"]: mode "deny" is default-closed (an optional `allowlist`
|
|
129
|
+
// carves out exceptions); mode "allow" is default-open (a `denylist` would carve
|
|
130
|
+
// out exceptions, but KubeNetworkPolicySpec doesn't model that yet — out of scope,
|
|
131
|
+
// matching Docker's bare-allow-is-open baseline).
|
|
132
|
+
const wantsPolicy = policy.network.mode === "deny";
|
|
133
|
+
if (wantsPolicy) {
|
|
134
|
+
await client.upsertNamespacedNetworkPolicy(ns, {
|
|
135
|
+
name: netpolName(threadId),
|
|
136
|
+
labels,
|
|
137
|
+
threadLabelValue: sanitize(threadId),
|
|
138
|
+
mode: policy.network.mode,
|
|
139
|
+
...(policy.network.allowlist ? { allowlist: policy.network.allowlist } : {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return name;
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
name: "kubernetes",
|
|
146
|
+
async acquire({ threadId, policy, signal }) {
|
|
147
|
+
const pod = await ensurePod(threadId, policy, signal);
|
|
148
|
+
return {
|
|
149
|
+
threadId,
|
|
150
|
+
filesystem: kubeFilesystem(client, ns, pod),
|
|
151
|
+
exec: kubeExec(client, ns, pod, policy.resources?.timeoutMs !== undefined
|
|
152
|
+
? { timeoutMs: policy.resources.timeoutMs }
|
|
153
|
+
: {}),
|
|
154
|
+
workspaceRoot: ROOT,
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
async release(threadId) {
|
|
158
|
+
await client.deleteNamespacedNetworkPolicy(ns, netpolName(threadId)).catch(() => { });
|
|
159
|
+
await client
|
|
160
|
+
.deleteNamespacedPod(ns, podName(threadId), { gracePeriodSeconds: 0 })
|
|
161
|
+
.catch(() => { });
|
|
162
|
+
},
|
|
163
|
+
async destroy(threadId) {
|
|
164
|
+
await client.deleteNamespacedNetworkPolicy(ns, netpolName(threadId)).catch(() => { });
|
|
165
|
+
await client
|
|
166
|
+
.deleteNamespacedPod(ns, podName(threadId), { gracePeriodSeconds: 0 })
|
|
167
|
+
.catch(() => { });
|
|
168
|
+
await client.deleteNamespacedPvc(ns, pvcName(threadId)).catch(() => { });
|
|
169
|
+
await waitForPvcGone(client, ns, pvcName(threadId), 30_000);
|
|
170
|
+
},
|
|
171
|
+
async preflight() {
|
|
172
|
+
const warnings = [];
|
|
173
|
+
const denied = [];
|
|
174
|
+
const apiFailures = [];
|
|
175
|
+
const transportFailures = [];
|
|
176
|
+
const labels = REQUIRED_KUBE_PERMISSIONS.map(permissionLabel);
|
|
177
|
+
const reviews = await Promise.allSettled(REQUIRED_KUBE_PERMISSIONS.map((permission) => client.canI(ns, permission)));
|
|
178
|
+
for (const [index, review] of reviews.entries()) {
|
|
179
|
+
const label = labels[index];
|
|
180
|
+
if (label === undefined)
|
|
181
|
+
continue;
|
|
182
|
+
if (review.status === "fulfilled") {
|
|
183
|
+
if (!review.value)
|
|
184
|
+
denied.push(label);
|
|
185
|
+
}
|
|
186
|
+
else if (review.reason instanceof KubeAuthorizationReviewError &&
|
|
187
|
+
review.reason.kind === "api") {
|
|
188
|
+
apiFailures.push(label);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
transportFailures.push(label);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
denied.sort();
|
|
195
|
+
apiFailures.sort();
|
|
196
|
+
transportFailures.sort();
|
|
197
|
+
const failureDetails = [
|
|
198
|
+
...(transportFailures.length > 0
|
|
199
|
+
? [
|
|
200
|
+
`Kubernetes API not reachable while reviewing permissions in namespace "${ns}": ${transportFailures.join(", ")}.`,
|
|
201
|
+
]
|
|
202
|
+
: []),
|
|
203
|
+
...(apiFailures.length > 0
|
|
204
|
+
? [
|
|
205
|
+
`Kubernetes authorization review failed in namespace "${ns}": ${apiFailures.join(", ")}.`,
|
|
206
|
+
]
|
|
207
|
+
: []),
|
|
208
|
+
...(denied.length > 0
|
|
209
|
+
? [`Missing Kubernetes permissions in namespace "${ns}": ${denied.join(", ")}.`]
|
|
210
|
+
: []),
|
|
211
|
+
];
|
|
212
|
+
if (failureDetails.length > 0) {
|
|
213
|
+
return { ok: false, detail: failureDetails.join(" ") };
|
|
214
|
+
}
|
|
215
|
+
const enforced = await client.networkPolicyEnforced(ns).catch(() => "unknown");
|
|
216
|
+
if (enforced !== true) {
|
|
217
|
+
warnings.push(`NetworkPolicy enforcement could not be confirmed in namespace "${ns}" (no policy-capable CNI detected). network:deny/allow egress control is best-effort until a CNI like Calico/Cilium is installed.`);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
detail: `Kubernetes reachable; required permissions granted in "${ns}".`,
|
|
222
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async function waitForRunning(client, ns, name, timeoutMs, signal) {
|
|
228
|
+
const deadline = Date.now() + timeoutMs;
|
|
229
|
+
for (;;) {
|
|
230
|
+
if (signal.aborted)
|
|
231
|
+
throw new Error(`Sandbox acquire aborted for pod "${name}".`);
|
|
232
|
+
const phase = await client.readNamespacedPodPhase(ns, name);
|
|
233
|
+
if (phase === "Running")
|
|
234
|
+
return;
|
|
235
|
+
if (phase === null) {
|
|
236
|
+
throw sandboxUnavailable(`Sandbox unavailable: pod "${name}" disappeared while starting. Run \`b4 check\`.`);
|
|
237
|
+
}
|
|
238
|
+
if (phase === "Failed" || phase === "Succeeded") {
|
|
239
|
+
// A SIGTERM'd `sleep infinity` exits 0 → Succeeded; treat it as a dead keeper
|
|
240
|
+
// rather than polling out the full timeout waiting for a Running it'll never reach.
|
|
241
|
+
throw sandboxUnavailable(`Sandbox unavailable: pod "${name}" entered ${phase}. Run \`b4 check\`.`);
|
|
242
|
+
}
|
|
243
|
+
if (Date.now() > deadline) {
|
|
244
|
+
throw sandboxUnavailable(`Sandbox unavailable: pod "${name}" not Running within ${timeoutMs}ms. Run \`b4 check\`.`);
|
|
245
|
+
}
|
|
246
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function waitForGone(client, ns, name, timeoutMs, signal) {
|
|
250
|
+
const deadline = Date.now() + timeoutMs;
|
|
251
|
+
for (;;) {
|
|
252
|
+
if (signal.aborted) {
|
|
253
|
+
throw new Error(`Sandbox acquire aborted while awaiting pod "${name}" deletion.`);
|
|
254
|
+
}
|
|
255
|
+
if ((await client.readNamespacedPodPhase(ns, name)) === null)
|
|
256
|
+
return;
|
|
257
|
+
if (Date.now() > deadline) {
|
|
258
|
+
throw sandboxUnavailable(`Sandbox unavailable: pod "${name}" still terminating after ${timeoutMs}ms. Run \`b4 check\`.`);
|
|
259
|
+
}
|
|
260
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** PVC deletion on a real cluster is async (pvc-protection finalizer + storage
|
|
264
|
+
* backend teardown), so destroy() polls until the PVC is actually gone before
|
|
265
|
+
* returning — otherwise an immediate re-acquire's createNamespacedPvcIfAbsent
|
|
266
|
+
* sees the still-existing (Terminating) PVC and rebinds the old data. Best-effort:
|
|
267
|
+
* destroy has no AbortSignal, so this is bounded by a plain time budget rather than
|
|
268
|
+
* a cancellation signal; giving up rather than throwing keeps cleanup non-fatal. */
|
|
269
|
+
async function waitForPvcGone(client, ns, name, timeoutMs) {
|
|
270
|
+
const deadline = Date.now() + timeoutMs;
|
|
271
|
+
for (;;) {
|
|
272
|
+
if (!(await client.pvcExists(ns, name).catch(() => false)))
|
|
273
|
+
return;
|
|
274
|
+
if (Date.now() > deadline)
|
|
275
|
+
return; // best-effort cleanup: give up rather than throw
|
|
276
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SandboxProvider } from "@b4run/workspace";
|
|
2
|
+
export declare function runProviderConformanceCase<T>(provider: Pick<SandboxProvider, "destroy">, threadIds: readonly string[], body: () => T | Promise<T>): Promise<T>;
|
|
3
|
+
/**
|
|
4
|
+
* The contract every SandboxProvider must satisfy. Reused by fakeSandbox (CI)
|
|
5
|
+
* and dockerSandbox (gated Docker lane) so the fake cannot drift from reality.
|
|
6
|
+
* Pass vitest's `describe` so the kit can group under any runner.
|
|
7
|
+
*/
|
|
8
|
+
export declare function runProviderConformance(opts: {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly makeProvider: () => SandboxProvider;
|
|
11
|
+
readonly describe: (name: string, fn: () => void) => void;
|
|
12
|
+
}): void;
|
|
13
|
+
//# sourceMappingURL=conformance.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conformance.d.ts","sourceRoot":"","sources":["../../src/testing/conformance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAMvD,wBAAsB,0BAA0B,CAAC,CAAC,EAChD,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAC,EAC1C,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,IAAI,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GACzB,OAAO,CAAC,CAAC,CAAC,CA+BZ;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,YAAY,EAAE,MAAM,eAAe,CAAA;IAC5C,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,IAAI,KAAK,IAAI,CAAA;CAC1D,GAAG,IAAI,CAmDP"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
const ctx = (workspaceRoot) => ({ signal: new AbortController().signal, workspaceRoot });
|
|
3
|
+
const policy = { network: { mode: "allow" } };
|
|
4
|
+
export async function runProviderConformanceCase(provider, threadIds, body) {
|
|
5
|
+
let bodyResult;
|
|
6
|
+
let bodyFailure;
|
|
7
|
+
let bodyPassed = false;
|
|
8
|
+
try {
|
|
9
|
+
bodyResult = await body();
|
|
10
|
+
bodyPassed = true;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
bodyFailure = error;
|
|
14
|
+
}
|
|
15
|
+
const cleanupResults = await Promise.allSettled(threadIds.map((threadId) => Promise.resolve().then(() => provider.destroy(threadId))));
|
|
16
|
+
const cleanupFailures = cleanupResults.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
17
|
+
if (!bodyPassed) {
|
|
18
|
+
if (cleanupFailures.length > 0) {
|
|
19
|
+
throw new AggregateError([bodyFailure, ...cleanupFailures], "SandboxProvider conformance body and cleanup failed");
|
|
20
|
+
}
|
|
21
|
+
throw bodyFailure;
|
|
22
|
+
}
|
|
23
|
+
if (cleanupFailures.length > 0) {
|
|
24
|
+
throw new AggregateError(cleanupFailures, "SandboxProvider conformance cleanup failed");
|
|
25
|
+
}
|
|
26
|
+
return bodyResult;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The contract every SandboxProvider must satisfy. Reused by fakeSandbox (CI)
|
|
30
|
+
* and dockerSandbox (gated Docker lane) so the fake cannot drift from reality.
|
|
31
|
+
* Pass vitest's `describe` so the kit can group under any runner.
|
|
32
|
+
*/
|
|
33
|
+
export function runProviderConformance(opts) {
|
|
34
|
+
opts.describe(`SandboxProvider conformance: ${opts.name}`, () => {
|
|
35
|
+
test("acquire is idempotent per thread and reattaches the workspace", async () => {
|
|
36
|
+
const p = opts.makeProvider();
|
|
37
|
+
await runProviderConformanceCase(p, ["t1"], async () => {
|
|
38
|
+
const a = await p.acquire({ threadId: "t1", policy, signal: ctx("/").signal });
|
|
39
|
+
await a.filesystem.writeFile(`${a.workspaceRoot}/x`, "1", ctx(a.workspaceRoot));
|
|
40
|
+
const b = await p.acquire({ threadId: "t1", policy, signal: ctx("/").signal });
|
|
41
|
+
expect(await b.filesystem.readFile(`${b.workspaceRoot}/x`, ctx(b.workspaceRoot))).toBe("1");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
test("threads are isolated", async () => {
|
|
45
|
+
const p = opts.makeProvider();
|
|
46
|
+
await runProviderConformanceCase(p, ["a", "b"], async () => {
|
|
47
|
+
const a = await p.acquire({ threadId: "a", policy, signal: ctx("/").signal });
|
|
48
|
+
await a.filesystem.writeFile(`${a.workspaceRoot}/secret`, "s", ctx(a.workspaceRoot));
|
|
49
|
+
const b = await p.acquire({ threadId: "b", policy, signal: ctx("/").signal });
|
|
50
|
+
expect(await b.filesystem.listDir(b.workspaceRoot, ctx(b.workspaceRoot))).not.toContain("secret");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
test("release keeps the volume, destroy clears it", async () => {
|
|
54
|
+
const p = opts.makeProvider();
|
|
55
|
+
await runProviderConformanceCase(p, ["t"], async () => {
|
|
56
|
+
const a = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal });
|
|
57
|
+
await a.filesystem.writeFile(`${a.workspaceRoot}/keep`, "1", ctx(a.workspaceRoot));
|
|
58
|
+
await p.release("t");
|
|
59
|
+
const r = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal });
|
|
60
|
+
expect(await r.filesystem.readFile(`${r.workspaceRoot}/keep`, ctx(r.workspaceRoot))).toBe("1");
|
|
61
|
+
await p.destroy("t");
|
|
62
|
+
const d = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal });
|
|
63
|
+
expect(await d.filesystem.listDir(d.workspaceRoot, ctx(d.workspaceRoot))).not.toContain("keep");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
test("exec returns a numeric exit code", async () => {
|
|
67
|
+
const p = opts.makeProvider();
|
|
68
|
+
await runProviderConformanceCase(p, ["t"], async () => {
|
|
69
|
+
const a = await p.acquire({ threadId: "t", policy, signal: ctx("/").signal });
|
|
70
|
+
const r = await a.exec.runCommand({ command: "true" }, ctx(a.workspaceRoot));
|
|
71
|
+
expect(typeof r.exitCode).toBe("number");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BackendContext, SandboxProvider } from "@b4run/workspace";
|
|
2
|
+
type ExecFn = (args: {
|
|
3
|
+
readonly command: string;
|
|
4
|
+
readonly cwd?: string;
|
|
5
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
6
|
+
}, ctx: BackendContext) => Promise<{
|
|
7
|
+
readonly stdout: string;
|
|
8
|
+
readonly stderr: string;
|
|
9
|
+
readonly exitCode: number;
|
|
10
|
+
}>;
|
|
11
|
+
/** In-memory SandboxProvider for unit + wiring tests. No Docker. */
|
|
12
|
+
export declare function fakeSandbox(opts?: {
|
|
13
|
+
readonly exec?: ExecFn;
|
|
14
|
+
}): SandboxProvider;
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=fake-sandbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fake-sandbox.d.ts","sourceRoot":"","sources":["../../src/testing/fake-sandbox.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EAId,eAAe,EAChB,MAAM,kBAAkB,CAAA;AAEzB,KAAK,MAAM,GAAG,CACZ,IAAI,EAAE;IACJ,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAChD,EACD,GAAG,EAAE,cAAc,KAChB,OAAO,CAAC;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA;AAI7F,oEAAoE;AACpE,wBAAgB,WAAW,CAAC,IAAI,GAAE;IAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,eAAe,CA4DlF"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const ROOT = "/workspace";
|
|
2
|
+
/** In-memory SandboxProvider for unit + wiring tests. No Docker. */
|
|
3
|
+
export function fakeSandbox(opts = {}) {
|
|
4
|
+
const volumes = new Map();
|
|
5
|
+
const liveThreads = new Set();
|
|
6
|
+
const volumeFor = (threadId) => {
|
|
7
|
+
let v = volumes.get(threadId);
|
|
8
|
+
if (!v) {
|
|
9
|
+
v = new Map();
|
|
10
|
+
volumes.set(threadId, v);
|
|
11
|
+
}
|
|
12
|
+
return v;
|
|
13
|
+
};
|
|
14
|
+
const makeFilesystem = (vol) => ({
|
|
15
|
+
async readFile(path) {
|
|
16
|
+
const v = vol.get(path);
|
|
17
|
+
if (v === undefined)
|
|
18
|
+
throw new Error(`ENOENT: ${path}`);
|
|
19
|
+
return v;
|
|
20
|
+
},
|
|
21
|
+
async writeFile(path, content) {
|
|
22
|
+
vol.set(path, content);
|
|
23
|
+
return { bytesWritten: Buffer.byteLength(content) };
|
|
24
|
+
},
|
|
25
|
+
async listDir(path) {
|
|
26
|
+
const prefix = path.endsWith("/") ? path : `${path}/`;
|
|
27
|
+
const names = new Set();
|
|
28
|
+
for (const key of vol.keys()) {
|
|
29
|
+
if (key.startsWith(prefix)) {
|
|
30
|
+
const part = key.slice(prefix.length).split("/")[0];
|
|
31
|
+
if (part !== undefined)
|
|
32
|
+
names.add(part);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return [...names].sort();
|
|
36
|
+
},
|
|
37
|
+
async realPath(path) {
|
|
38
|
+
return path;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const defaultExec = async () => ({ stdout: "", stderr: "", exitCode: 0 });
|
|
42
|
+
return {
|
|
43
|
+
name: "fake",
|
|
44
|
+
async acquire({ threadId }) {
|
|
45
|
+
liveThreads.add(threadId);
|
|
46
|
+
const vol = volumeFor(threadId);
|
|
47
|
+
const exec = { runCommand: (args, ctx) => (opts.exec ?? defaultExec)(args, ctx) };
|
|
48
|
+
return { threadId, filesystem: makeFilesystem(vol), exec, workspaceRoot: ROOT };
|
|
49
|
+
},
|
|
50
|
+
async release(threadId) {
|
|
51
|
+
liveThreads.delete(threadId);
|
|
52
|
+
},
|
|
53
|
+
async destroy(threadId) {
|
|
54
|
+
liveThreads.delete(threadId);
|
|
55
|
+
volumes.delete(threadId);
|
|
56
|
+
},
|
|
57
|
+
async preflight() {
|
|
58
|
+
return { ok: true };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAA;AACzD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA"}
|