@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,267 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { sandboxUnavailable } from "../errors.js";
|
|
3
|
+
import { createDocker } from "./docker-cli.js";
|
|
4
|
+
import { dockerExec } from "./docker-exec.js";
|
|
5
|
+
import { dockerFilesystem } from "./docker-filesystem.js";
|
|
6
|
+
import { createThreadLifecycleCoordinator } from "./thread-lifecycle.js";
|
|
7
|
+
const ROOT = "/workspace";
|
|
8
|
+
const sanitize = (s) => s.replaceAll(/[^a-zA-Z0-9_.-]/g, "_");
|
|
9
|
+
const containerName = (threadId) => `b4-sbx-${sanitize(threadId)}`;
|
|
10
|
+
const volumeName = (threadId) => `b4-sbx-vol-${sanitize(threadId)}`;
|
|
11
|
+
const recoveryAttempt = Symbol("dockerRecoveryAttempt");
|
|
12
|
+
function resolveLaunchConfig(policy) {
|
|
13
|
+
const sec = policy.security ?? {};
|
|
14
|
+
const user = sec.runAsNonRoot === false
|
|
15
|
+
? null
|
|
16
|
+
: typeof sec.runAsNonRoot === "object" && sec.runAsNonRoot !== null
|
|
17
|
+
? Object.freeze({ uid: sec.runAsNonRoot.uid, gid: sec.runAsNonRoot.gid })
|
|
18
|
+
: Object.freeze({ uid: 1000, gid: 1000 });
|
|
19
|
+
const effectiveEnv = new Map(Object.entries(policy.env ?? {}));
|
|
20
|
+
if (user !== null)
|
|
21
|
+
effectiveEnv.set("HOME", ROOT);
|
|
22
|
+
const env = Object.freeze([...effectiveEnv.entries()]
|
|
23
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
24
|
+
.map(([key, value]) => Object.freeze([key, value])));
|
|
25
|
+
// timeoutMs is intentionally handle-local: it changes exec cancellation,
|
|
26
|
+
// not the keeper container that is shared by every handle for a thread.
|
|
27
|
+
return Object.freeze({
|
|
28
|
+
networkMode: policy.network.mode === "deny" ? "none" : "bridge",
|
|
29
|
+
env,
|
|
30
|
+
memoryMb: policy.resources?.memoryMb ? policy.resources.memoryMb : null,
|
|
31
|
+
cpus: policy.resources?.cpus ? policy.resources.cpus : null,
|
|
32
|
+
dropAllCapabilities: sec.dropAllCapabilities ?? true,
|
|
33
|
+
noNewPrivileges: sec.noNewPrivileges ?? true,
|
|
34
|
+
readOnlyRootFilesystem: sec.readOnlyRootFilesystem ?? true,
|
|
35
|
+
pidsLimit: sec.pidsLimit ?? 512,
|
|
36
|
+
user,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const launchConfigKey = (config) => JSON.stringify(config);
|
|
40
|
+
const keeperIdentity = (image, config) => createHash("sha256")
|
|
41
|
+
.update(JSON.stringify({ image, launchConfig: config }))
|
|
42
|
+
.digest("hex");
|
|
43
|
+
const isB4CodedError = (error) => error instanceof Error &&
|
|
44
|
+
typeof error.code === "string" &&
|
|
45
|
+
/^B4_E\d{4}$/.test(error.code);
|
|
46
|
+
/**
|
|
47
|
+
* Docker reference SandboxProvider. Per thread: a persistent container
|
|
48
|
+
* `b4-sbx-<threadId>` (sleep infinity) with a named volume mounted at
|
|
49
|
+
* /workspace. acquire() reuses only a keeper owned by this provider lifecycle
|
|
50
|
+
* with a matching persisted identity; otherwise it replaces the keeper while
|
|
51
|
+
* preserving the volume. release() removes the container but KEEPS the volume;
|
|
52
|
+
* destroy() removes both. Network: deny → --network none (exact); allow →
|
|
53
|
+
* bridge (denylist is best-effort and NOT enforced here — see the spec's
|
|
54
|
+
* honest-scope note). Host env is never inherited; only policy.env is passed.
|
|
55
|
+
*/
|
|
56
|
+
export function dockerSandbox(opts) {
|
|
57
|
+
const docker = opts.docker ?? createDocker();
|
|
58
|
+
const lifecycleStates = new Map();
|
|
59
|
+
const lifecycle = createThreadLifecycleCoordinator();
|
|
60
|
+
const createLifecycleState = (launchConfig) => ({
|
|
61
|
+
generation: 0,
|
|
62
|
+
recoverySignal: new AbortController().signal,
|
|
63
|
+
launchConfig,
|
|
64
|
+
launchConfigKey: launchConfigKey(launchConfig),
|
|
65
|
+
keeperIdentity: keeperIdentity(opts.image, launchConfig),
|
|
66
|
+
});
|
|
67
|
+
const isRecoveryAttempt = (token) => typeof token === "object" && token !== null && recoveryAttempt in token;
|
|
68
|
+
const recoveryError = (threadId, phase, error) => {
|
|
69
|
+
if (isB4CodedError(error))
|
|
70
|
+
return error;
|
|
71
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
72
|
+
const wrapped = sandboxUnavailable(`Sandbox unavailable: Docker PID recovery ${phase} failed for thread "${threadId}": ${detail || "unknown error"}. Run \`b4 check\`.`);
|
|
73
|
+
Object.defineProperty(wrapped, "cause", { value: error, configurable: true });
|
|
74
|
+
return wrapped;
|
|
75
|
+
};
|
|
76
|
+
const ensureContainer = async (threadId, launchConfig, expectedIdentity, signal, reuseExisting) => {
|
|
77
|
+
const name = containerName(threadId);
|
|
78
|
+
const running = await docker.run(["ps", "-q", "--filter", `name=^${name}$`], { signal });
|
|
79
|
+
const runningId = running.stdout.trim();
|
|
80
|
+
const existing = runningId
|
|
81
|
+
? running
|
|
82
|
+
: await docker.run(["ps", "-aq", "--filter", `name=^${name}$`], { signal });
|
|
83
|
+
if (existing.stdout.trim()) {
|
|
84
|
+
if (reuseExisting) {
|
|
85
|
+
const inspected = await docker.run(["inspect", "--format", '{{ index .Config.Labels "b4.sandbox.identity" }}', name], { signal });
|
|
86
|
+
if (inspected.exitCode === 0 && inspected.stdout.trim() === expectedIdentity) {
|
|
87
|
+
if (runningId)
|
|
88
|
+
return name;
|
|
89
|
+
const started = await docker.run(["start", name], { signal });
|
|
90
|
+
if (started.exitCode !== 0) {
|
|
91
|
+
throw sandboxUnavailable(`Sandbox unavailable: could not start keeper for thread "${threadId}": ${started.stderr.trim() || "unknown error"}. Run \`b4 check\`.`);
|
|
92
|
+
}
|
|
93
|
+
return name;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const removed = await docker.run(["rm", "-f", name], { signal });
|
|
97
|
+
if (removed.exitCode !== 0) {
|
|
98
|
+
throw sandboxUnavailable(`Sandbox unavailable: could not replace stale keeper for thread "${threadId}": ${removed.stderr.trim() || "unknown error"}. Run \`b4 check\`.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const net = ["--network", launchConfig.networkMode];
|
|
102
|
+
const envArgs = launchConfig.env.flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
103
|
+
const limits = [
|
|
104
|
+
...(launchConfig.memoryMb !== null ? ["--memory", `${launchConfig.memoryMb}m`] : []),
|
|
105
|
+
...(launchConfig.cpus !== null ? ["--cpus", String(launchConfig.cpus)] : []),
|
|
106
|
+
];
|
|
107
|
+
const user = launchConfig.user;
|
|
108
|
+
const hardening = [
|
|
109
|
+
...(launchConfig.dropAllCapabilities ? ["--cap-drop", "ALL"] : []),
|
|
110
|
+
...(launchConfig.noNewPrivileges ? ["--security-opt", "no-new-privileges"] : []),
|
|
111
|
+
"--pids-limit",
|
|
112
|
+
String(launchConfig.pidsLimit),
|
|
113
|
+
...(launchConfig.readOnlyRootFilesystem
|
|
114
|
+
? ["--read-only", "--tmpfs", "/tmp", "--tmpfs", "/run"]
|
|
115
|
+
: []),
|
|
116
|
+
...(user !== null ? ["--user", `${user.uid}:${user.gid}`] : []),
|
|
117
|
+
];
|
|
118
|
+
// Architecture B (no steady-state root): a fresh named volume mounts
|
|
119
|
+
// root:root, so a non-root keeper cannot write /workspace. Fix it with a
|
|
120
|
+
// CREATE-ONLY, VOLUME-ABSENCE-CHECKED, ephemeral (`--rm`) root chown — the
|
|
121
|
+
// only root that ever runs, and it takes no agent input. On reattach (the
|
|
122
|
+
// volume already exists) this is skipped so a populated volume is never
|
|
123
|
+
// re-chowned. Skipped entirely when runAsNonRoot:false (`user` is null).
|
|
124
|
+
// The inspect→chown is not atomic, but chown is idempotent, so two racing
|
|
125
|
+
// acquires for the same fresh thread both converge on the same ownership.
|
|
126
|
+
if (user !== null) {
|
|
127
|
+
const volExists = await docker.run(["volume", "inspect", volumeName(threadId)], { signal });
|
|
128
|
+
if (volExists.exitCode !== 0) {
|
|
129
|
+
const init = await docker.run([
|
|
130
|
+
"run",
|
|
131
|
+
"--rm",
|
|
132
|
+
"--user",
|
|
133
|
+
"0:0",
|
|
134
|
+
"-v",
|
|
135
|
+
`${volumeName(threadId)}:${ROOT}`,
|
|
136
|
+
opts.image,
|
|
137
|
+
"sh",
|
|
138
|
+
"-c",
|
|
139
|
+
`mkdir -p ${ROOT} && chown ${user.uid}:${user.gid} ${ROOT}`,
|
|
140
|
+
], { signal });
|
|
141
|
+
if (init.exitCode !== 0) {
|
|
142
|
+
throw sandboxUnavailable(`Sandbox unavailable: could not initialize workspace ownership for thread "${threadId}": ${init.stderr.trim() || "unknown error"}. Run \`b4 check\`.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const created = await docker.run([
|
|
147
|
+
"run",
|
|
148
|
+
"-d",
|
|
149
|
+
"--name",
|
|
150
|
+
name,
|
|
151
|
+
"--label",
|
|
152
|
+
`b4.sandbox=${sanitize(threadId)}`,
|
|
153
|
+
"--label",
|
|
154
|
+
`b4.sandbox.identity=${expectedIdentity}`,
|
|
155
|
+
"-v",
|
|
156
|
+
`${volumeName(threadId)}:${ROOT}`,
|
|
157
|
+
...net,
|
|
158
|
+
...envArgs,
|
|
159
|
+
...limits,
|
|
160
|
+
...hardening,
|
|
161
|
+
opts.image,
|
|
162
|
+
"sleep",
|
|
163
|
+
"infinity",
|
|
164
|
+
], { signal });
|
|
165
|
+
if (created.exitCode !== 0) {
|
|
166
|
+
throw sandboxUnavailable(`Sandbox unavailable: docker run failed for thread "${threadId}": ${created.stderr.trim() || "unknown error"}. Run \`b4 check\`.`);
|
|
167
|
+
}
|
|
168
|
+
return name;
|
|
169
|
+
};
|
|
170
|
+
const recoverAndRetry = async (threadId, token, retry) => lifecycle.runExclusive(threadId, async () => {
|
|
171
|
+
if (!isRecoveryAttempt(token))
|
|
172
|
+
return undefined;
|
|
173
|
+
const { state, generation } = token;
|
|
174
|
+
if (lifecycleStates.get(threadId) !== state || generation > state.generation) {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
if (generation === state.generation) {
|
|
178
|
+
// Removal and recreation are provider lifecycle work. They use a
|
|
179
|
+
// provider-owned non-aborted signal so cancellation of one caller
|
|
180
|
+
// cannot strand the shared thread without a keeper.
|
|
181
|
+
const signal = state.recoverySignal;
|
|
182
|
+
try {
|
|
183
|
+
const removed = await docker
|
|
184
|
+
.run(["rm", "-f", containerName(threadId)], { signal })
|
|
185
|
+
.catch((error) => {
|
|
186
|
+
throw recoveryError(threadId, "removal", error);
|
|
187
|
+
});
|
|
188
|
+
if (removed.exitCode !== 0) {
|
|
189
|
+
throw sandboxUnavailable(`Sandbox unavailable: could not remove PID-exhausted container for thread "${threadId}": ${removed.stderr.trim() || "unknown error"}. Run \`b4 check\`.`);
|
|
190
|
+
}
|
|
191
|
+
await ensureContainer(threadId, state.launchConfig, state.keeperIdentity, signal, false).catch((error) => {
|
|
192
|
+
throw recoveryError(threadId, "recreation", error);
|
|
193
|
+
});
|
|
194
|
+
state.generation = generation + 1;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (lifecycleStates.get(threadId) === state)
|
|
198
|
+
lifecycleStates.delete(threadId);
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return retry();
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
name: "docker",
|
|
206
|
+
acquire({ threadId, policy, signal }) {
|
|
207
|
+
return lifecycle.runExclusive(threadId, async () => {
|
|
208
|
+
const requestedLaunchConfig = resolveLaunchConfig(policy);
|
|
209
|
+
const requestedLaunchConfigKey = launchConfigKey(requestedLaunchConfig);
|
|
210
|
+
const existingState = lifecycleStates.get(threadId);
|
|
211
|
+
if (existingState !== undefined &&
|
|
212
|
+
existingState.launchConfigKey !== requestedLaunchConfigKey) {
|
|
213
|
+
throw sandboxUnavailable(`Sandbox unavailable: thread "${threadId}" already has a different keeper configuration. Release the thread sandbox first, then acquire it with a different policy.`);
|
|
214
|
+
}
|
|
215
|
+
const launchConfig = existingState?.launchConfig ?? requestedLaunchConfig;
|
|
216
|
+
const state = existingState ?? createLifecycleState(launchConfig);
|
|
217
|
+
const container = await ensureContainer(threadId, launchConfig, state.keeperIdentity, signal, existingState !== undefined);
|
|
218
|
+
lifecycleStates.set(threadId, state);
|
|
219
|
+
const pidExhaustionRecovery = {
|
|
220
|
+
captureToken: () => ({
|
|
221
|
+
[recoveryAttempt]: true,
|
|
222
|
+
state,
|
|
223
|
+
generation: state.generation,
|
|
224
|
+
}),
|
|
225
|
+
recoverAndRetry: (token, retry) => recoverAndRetry(threadId, token, retry),
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
threadId,
|
|
229
|
+
filesystem: dockerFilesystem(docker, container, {
|
|
230
|
+
runWithExecLease: (operation) => lifecycle.runShared(threadId, operation),
|
|
231
|
+
pidExhaustionRecovery,
|
|
232
|
+
}),
|
|
233
|
+
exec: dockerExec(docker, container, {
|
|
234
|
+
runWithExecLease: (operation) => lifecycle.runShared(threadId, operation),
|
|
235
|
+
...(policy.resources?.timeoutMs !== undefined
|
|
236
|
+
? { timeoutMs: policy.resources.timeoutMs }
|
|
237
|
+
: {}),
|
|
238
|
+
pidExhaustionRecovery,
|
|
239
|
+
}),
|
|
240
|
+
workspaceRoot: ROOT,
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
release(threadId) {
|
|
245
|
+
return lifecycle.runExclusive(threadId, async () => {
|
|
246
|
+
lifecycleStates.delete(threadId);
|
|
247
|
+
await docker.run(["rm", "-f", containerName(threadId)]).catch(() => { });
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
destroy(threadId) {
|
|
251
|
+
return lifecycle.runExclusive(threadId, async () => {
|
|
252
|
+
lifecycleStates.delete(threadId);
|
|
253
|
+
await docker.run(["rm", "-f", containerName(threadId)]).catch(() => { });
|
|
254
|
+
await docker.run(["volume", "rm", volumeName(threadId)]).catch(() => { });
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
async preflight() {
|
|
258
|
+
const v = await docker
|
|
259
|
+
.run(["version", "--format", "{{.Server.Version}}"])
|
|
260
|
+
.catch(() => undefined);
|
|
261
|
+
if (!v || v.exitCode !== 0) {
|
|
262
|
+
return { ok: false, detail: "Docker daemon not reachable (`docker version` failed)." };
|
|
263
|
+
}
|
|
264
|
+
return { ok: true, detail: `Docker ${v.stdout.trim()}` };
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ThreadLifecycleCoordinator {
|
|
2
|
+
readonly pendingThreadCount: number;
|
|
3
|
+
runShared<T>(threadId: string, operation: () => Promise<T>): Promise<T>;
|
|
4
|
+
runExclusive<T>(threadId: string, operation: () => Promise<T>): Promise<T>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Keyed fair shared/exclusive gate for Docker container access.
|
|
8
|
+
*
|
|
9
|
+
* Exec and filesystem operations share the keeper concurrently. Lifecycle
|
|
10
|
+
* mutations are exclusive: they wait for admitted container operations to
|
|
11
|
+
* drain, and their queue position prevents later operations from starting.
|
|
12
|
+
* Idle thread state is discarded.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createThreadLifecycleCoordinator(): ThreadLifecycleCoordinator;
|
|
15
|
+
//# sourceMappingURL=thread-lifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thread-lifecycle.d.ts","sourceRoot":"","sources":["../../src/docker/thread-lifecycle.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAA;IACnC,SAAS,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;IACvE,YAAY,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;CAC3E;AAaD;;;;;;;GAOG;AACH,wBAAgB,gCAAgC,IAAI,0BAA0B,CAmF7E"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed fair shared/exclusive gate for Docker container access.
|
|
3
|
+
*
|
|
4
|
+
* Exec and filesystem operations share the keeper concurrently. Lifecycle
|
|
5
|
+
* mutations are exclusive: they wait for admitted container operations to
|
|
6
|
+
* drain, and their queue position prevents later operations from starting.
|
|
7
|
+
* Idle thread state is discarded.
|
|
8
|
+
*/
|
|
9
|
+
export function createThreadLifecycleCoordinator() {
|
|
10
|
+
const states = new Map();
|
|
11
|
+
const drain = (threadId, state) => {
|
|
12
|
+
if (state.exclusiveActive || state.activeShared > 0)
|
|
13
|
+
return;
|
|
14
|
+
const first = state.queue[0];
|
|
15
|
+
if (first === undefined) {
|
|
16
|
+
if (states.get(threadId) === state)
|
|
17
|
+
states.delete(threadId);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (first.kind === "exclusive") {
|
|
21
|
+
state.queue.shift();
|
|
22
|
+
state.exclusiveActive = true;
|
|
23
|
+
first.start();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
while (state.queue[0]?.kind === "shared") {
|
|
27
|
+
const entry = state.queue.shift();
|
|
28
|
+
if (entry === undefined)
|
|
29
|
+
break;
|
|
30
|
+
state.activeShared += 1;
|
|
31
|
+
entry.start();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const enqueue = (threadId, kind, operation) => {
|
|
35
|
+
let state = states.get(threadId);
|
|
36
|
+
if (state === undefined) {
|
|
37
|
+
state = { activeShared: 0, exclusiveActive: false, queue: [] };
|
|
38
|
+
states.set(threadId, state);
|
|
39
|
+
}
|
|
40
|
+
const threadState = state;
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const entry = {
|
|
43
|
+
kind,
|
|
44
|
+
start: () => {
|
|
45
|
+
void Promise.resolve()
|
|
46
|
+
.then(operation)
|
|
47
|
+
.then((value) => {
|
|
48
|
+
if (kind === "shared")
|
|
49
|
+
threadState.activeShared -= 1;
|
|
50
|
+
else
|
|
51
|
+
threadState.exclusiveActive = false;
|
|
52
|
+
drain(threadId, threadState);
|
|
53
|
+
resolve(value);
|
|
54
|
+
}, (error) => {
|
|
55
|
+
if (kind === "shared")
|
|
56
|
+
threadState.activeShared -= 1;
|
|
57
|
+
else
|
|
58
|
+
threadState.exclusiveActive = false;
|
|
59
|
+
drain(threadId, threadState);
|
|
60
|
+
reject(error);
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
if (kind === "shared" && !threadState.exclusiveActive && threadState.queue.length === 0) {
|
|
65
|
+
threadState.activeShared += 1;
|
|
66
|
+
entry.start();
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
threadState.queue.push(entry);
|
|
70
|
+
drain(threadId, threadState);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
get pendingThreadCount() {
|
|
76
|
+
return states.size;
|
|
77
|
+
},
|
|
78
|
+
runShared(threadId, operation) {
|
|
79
|
+
return enqueue(threadId, "shared", operation);
|
|
80
|
+
},
|
|
81
|
+
runExclusive(threadId, operation) {
|
|
82
|
+
return enqueue(threadId, "exclusive", operation);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { B4ErrorCode } from "@b4run/sdk";
|
|
2
|
+
/** An `Error` tagged with a stable B4.run registry code so surfaces can link docs. */
|
|
3
|
+
export interface B4CodedError extends Error {
|
|
4
|
+
readonly code: B4ErrorCode;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Construct a "sandbox unavailable" error carrying the `B4_E2001` code. The
|
|
8
|
+
* code rides on the error object so an HTTP/SSE error body (or any caught-error
|
|
9
|
+
* surface) can attach the docs link without re-deriving it from the message.
|
|
10
|
+
*/
|
|
11
|
+
export declare function sandboxUnavailable(message: string): B4CodedError;
|
|
12
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C,sFAAsF;AACtF,MAAM,WAAW,YAAa,SAAQ,KAAK;IACzC,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;CAC3B;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,CAIhE"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Construct a "sandbox unavailable" error carrying the `B4_E2001` code. The
|
|
3
|
+
* code rides on the error object so an HTTP/SSE error body (or any caught-error
|
|
4
|
+
* surface) can attach the docs link without re-deriving it from the message.
|
|
5
|
+
*/
|
|
6
|
+
export function sandboxUnavailable(message) {
|
|
7
|
+
const error = new Error(message);
|
|
8
|
+
error.code = "B4_E2001";
|
|
9
|
+
return error;
|
|
10
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { SandboxConfig, SandboxHandle, SandboxPolicy, SandboxProvider, } from "@b4run/workspace";
|
|
2
|
+
export { type DockerSandboxOptions, dockerSandbox } from "./docker/docker-sandbox.js";
|
|
3
|
+
export { KubeAuthorizationReviewError, type KubeClient, type KubePermission, } from "./kubernetes/kube-client.js";
|
|
4
|
+
export { type KubernetesSandboxOptions, kubernetesSandbox } from "./kubernetes/kube-sandbox.js";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,aAAa,EACb,aAAa,EACb,aAAa,EACb,eAAe,GAChB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,KAAK,oBAAoB,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAA;AACrF,OAAO,EACL,4BAA4B,EAC5B,KAAK,UAAU,EACf,KAAK,cAAc,GACpB,MAAM,6BAA6B,CAAA;AACpC,OAAO,EAAE,KAAK,wBAAwB,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Default KubeClient backed by the real @kubernetes/client-node (v1.x) API.
|
|
2
|
+
* KubeConfig.loadFromDefault() auto-detects in-cluster ServiceAccount token vs
|
|
3
|
+
* ~/.kube/config. Unit tests never construct this — they inject a fake KubeClient. */
|
|
4
|
+
import { type KubeClient } from "./kube-client.js";
|
|
5
|
+
export declare function createDefaultKubeClient(): KubeClient;
|
|
6
|
+
//# sourceMappingURL=default-kube-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"default-kube-client.d.ts","sourceRoot":"","sources":["../../src/kubernetes/default-kube-client.ts"],"names":[],"mappings":"AAAA;;sFAEsF;AAiBtF,OAAO,EAEL,KAAK,UAAU,EAKhB,MAAM,kBAAkB,CAAA;AA4IzB,wBAAgB,uBAAuB,IAAI,UAAU,CAoNpD"}
|