@gpzhang2001/sharpkit-sandbox 0.2.1

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/lib/index.js ADDED
@@ -0,0 +1,1145 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { Service } from "@deepseek-ai/cordis";
6
+ import z from "@deepseek-ai/schemastery";
7
+ //#region src/caido.ts
8
+ /** Exact login mutation body strix posts (caido_bootstrap.py `_LOGIN_AS_GUEST_BODY`). */
9
+ const LOGIN_AS_GUEST_QUERY = "mutation LoginAsGuest { loginAsGuest { token { accessToken } } }";
10
+ /** Minimal CreateProject mutation (error identified by typename only — the
11
+ * payload error union has no shared `code` field in Caido 0.56.0's schema). */
12
+ const CREATE_PROJECT_DOC = "mutation CreateProject($input: CreateProjectInput!) { createProject(input: $input) { error { __typename } project { id name temporary } } }";
13
+ /** Minimal SelectProject mutation (typename-only error, same schema reason). */
14
+ const SELECT_PROJECT_DOC = "mutation SelectProject($id: ID!) { selectProject(id: $id) { currentProject { project { id } } error { __typename } } }";
15
+ /** Project identity strix creates in every sandbox (protocol constant, not a tunable). */
16
+ const PROJECT_NAME = "sandbox";
17
+ /**
18
+ * Build the container-internal curl login command (strix caido_bootstrap.py:46-57).
19
+ * @param containerBaseUrl - the in-container Caido base URL (`http://127.0.0.1:48080`).
20
+ * @returns the shell command string for exec.
21
+ */
22
+ function loginCurlCommand(containerBaseUrl) {
23
+ const body = JSON.stringify({ query: LOGIN_AS_GUEST_QUERY });
24
+ return `curl -fsS -X POST -H "Content-Type: application/json" -d ${JSON.stringify(body)} ${containerBaseUrl}/graphql`;
25
+ }
26
+ /**
27
+ * Extract the guest token from a login response payload.
28
+ * @param stdout - raw curl stdout.
29
+ * @returns the access token.
30
+ * @throws when the payload is unparseable or carries no token (strix error wording).
31
+ */
32
+ function parseLoginToken(stdout) {
33
+ let payload;
34
+ try {
35
+ payload = JSON.parse(stdout);
36
+ } catch (error) {
37
+ throw new Error(`unparseable response: ${String(error)}: '${stdout}'`);
38
+ }
39
+ const token = pathOf(payload, [
40
+ "data",
41
+ "loginAsGuest",
42
+ "token",
43
+ "accessToken"
44
+ ]);
45
+ if (typeof token !== "string" || token === "") throw new Error(`loginAsGuest returned no token: ${JSON.stringify(payload)}`);
46
+ return token;
47
+ }
48
+ /** Best-effort nested property lookup on an unknown JSON value. */
49
+ function pathOf(value, path) {
50
+ let current = value;
51
+ for (const key of path) {
52
+ if (typeof current !== "object" || current === null) return void 0;
53
+ current = current[key];
54
+ }
55
+ return current;
56
+ }
57
+ /**
58
+ * Run the guest login with retries (strix `_login_as_guest`: attempts with
59
+ * capped linear backoff 2,4,6,8,8… seconds; per-attempt exec timeout).
60
+ * @param exec - container exec channel.
61
+ * @param containerBaseUrl - in-container Caido base URL.
62
+ * @param options - attempts/timeout/backoff knobs and injected sleep.
63
+ * @returns the access token.
64
+ * @throws when every attempt fails (strix error wording).
65
+ */
66
+ async function loginAsGuest(exec, containerBaseUrl, options) {
67
+ const command = loginCurlCommand(containerBaseUrl);
68
+ let lastError = "no attempt made";
69
+ for (let attempt = 1; attempt <= options.attempts; attempt++) {
70
+ try {
71
+ const result = await exec(command, options.timeoutMs);
72
+ if (result.ok) return parseLoginToken(result.stdout);
73
+ lastError = `curl exit ${result.exitCode === null ? "unknown" : result.exitCode}: ${result.stderr.slice(0, 200)}`;
74
+ } catch (error) {
75
+ lastError = String(error instanceof Error ? error.message : error);
76
+ }
77
+ if (attempt < options.attempts) await options.sleep(Math.min(2e3 * attempt, 8e3));
78
+ }
79
+ throw new Error(`loginAsGuest failed after ${options.attempts} attempts: ${lastError}`);
80
+ }
81
+ /**
82
+ * POST one GraphQL document with bearer auth and return the `data` object.
83
+ * @param fetchFn - fetch channel.
84
+ * @param baseUrl - host-side Caido base URL.
85
+ * @param token - bearer token.
86
+ * @param doc - the GraphQL document.
87
+ * @param variables - operation variables.
88
+ * @param signal - cancellation for teardown.
89
+ */
90
+ async function graphql(fetchFn, baseUrl, token, doc, variables, signal) {
91
+ const response = await fetchFn(`${baseUrl}/graphql`, {
92
+ method: "POST",
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ Authorization: `Bearer ${token}`
96
+ },
97
+ body: JSON.stringify({
98
+ query: doc,
99
+ variables
100
+ }),
101
+ signal
102
+ });
103
+ const text = await response.text();
104
+ if (response.status !== 200) throw new Error(`caido graphql HTTP ${response.status}: ${text.slice(0, 200)}`);
105
+ let payload;
106
+ try {
107
+ payload = JSON.parse(text);
108
+ } catch (error) {
109
+ throw new Error(`caido graphql unparseable response: ${String(error)}`);
110
+ }
111
+ const data = pathOf(payload, ["data"]);
112
+ if (typeof data !== "object" || data === null) {
113
+ const errors = pathOf(payload, ["errors"]);
114
+ const detail = Array.isArray(errors) ? JSON.stringify(errors).slice(0, 300) : text.slice(0, 200);
115
+ throw new Error(`caido graphql carried no data: ${detail}`);
116
+ }
117
+ return data;
118
+ }
119
+ /** Extract the sibling error of an operation result, for error messages. */
120
+ function errorOf(entry) {
121
+ if (typeof entry !== "object" || entry === null) return void 0;
122
+ const error = entry["error"];
123
+ if (typeof error !== "object" || error === null) return void 0;
124
+ return error;
125
+ }
126
+ /**
127
+ * Full bootstrap: guest login, then create the temporary sandbox project and
128
+ * select it (strix `bootstrap_caido`).
129
+ * @param exec - container exec channel (login curl runs in-container).
130
+ * @param fetchFn - host-side fetch channel (project calls).
131
+ * @param urls - container and host base URLs.
132
+ * @param options - retry/timeout knobs and injected sleep.
133
+ * @returns the ready endpoint.
134
+ */
135
+ async function bootstrapCaido(exec, fetchFn, urls, options) {
136
+ const token = await loginAsGuest(exec, urls.containerBaseUrl, options);
137
+ const createData = await graphql(fetchFn, urls.hostBaseUrl, token, CREATE_PROJECT_DOC, { input: {
138
+ name: PROJECT_NAME,
139
+ temporary: true
140
+ } }, options.signal);
141
+ const createError = errorOf(createData["createProject"]);
142
+ if (createError !== void 0) throw new Error(`createProject failed: ${createError.__typename}${createError.code === void 0 ? "" : ` (${createError.code})`}`);
143
+ const projectId = pathOf(createData["createProject"], ["project", "id"]);
144
+ if (typeof projectId !== "string" || projectId === "") throw new Error("createProject returned no project id");
145
+ const selectError = errorOf((await graphql(fetchFn, urls.hostBaseUrl, token, SELECT_PROJECT_DOC, { id: projectId }, options.signal))["selectProject"]);
146
+ if (selectError !== void 0) throw new Error(`selectProject failed: ${selectError.__typename}${selectError.code === void 0 ? "" : ` (${selectError.code})`}`);
147
+ return {
148
+ baseUrl: urls.hostBaseUrl,
149
+ token,
150
+ projectId
151
+ };
152
+ }
153
+ /**
154
+ * Shared, lazily-resolved bootstrap task (strix `CaidoBootstrapHandle`): the
155
+ * promise is created once and shared, so individual consumer cancellations
156
+ * cannot cancel the shared bootstrap; `close()` aborts it for teardown.
157
+ */
158
+ var CaidoBootstrap = class {
159
+ endpoint;
160
+ controller = new AbortController();
161
+ settled;
162
+ constructor(start) {
163
+ this.endpoint = start(this.controller.signal).then((endpoint) => {
164
+ this.settled = endpoint;
165
+ return endpoint;
166
+ }, (error) => {
167
+ throw error;
168
+ });
169
+ this.endpoint.catch(() => {});
170
+ }
171
+ /** Resolve the endpoint; rejects with the bootstrap failure, shared by all callers. */
172
+ get() {
173
+ return this.endpoint;
174
+ }
175
+ /** The resolved endpoint, or undefined while pending/failed (strix `peek`). */
176
+ peek() {
177
+ return this.settled;
178
+ }
179
+ /** Abort a pending bootstrap; failures are swallowed (teardown path). */
180
+ close() {
181
+ this.controller.abort();
182
+ }
183
+ };
184
+ //#endregion
185
+ //#region src/mounts.ts
186
+ /** Metadata names that get their own read-only overlay mount. */
187
+ const PROTECTED_METADATA_NAMES = [
188
+ ".git",
189
+ ".agents",
190
+ ".codex"
191
+ ];
192
+ /**
193
+ * Whether `child` is `parent` itself or underneath it, POSIX-style.
194
+ * @param parent - candidate ancestor path, already canonical.
195
+ * @param child - candidate descendant path, already canonical.
196
+ */
197
+ function isSubpath(parent, child) {
198
+ return child === parent || child.startsWith(`${parent}/`);
199
+ }
200
+ /**
201
+ * Parse a git worktree `.git` pointer file for its `gitdir:` line.
202
+ * @param content - the raw pointer file text.
203
+ * @param base - directory of the pointer file, for relative gitdir values.
204
+ * @param resolve - canonicalizer for the candidate path.
205
+ * @returns the resolved gitdir, or null when absent/malformed.
206
+ */
207
+ function parseGitdirPointer(content, base, resolve) {
208
+ for (const rawLine of content.split("\n")) {
209
+ const line = rawLine.trimEnd();
210
+ const separator = line.indexOf(":");
211
+ if (separator === -1) continue;
212
+ if (line.slice(0, separator).trim() !== "gitdir") continue;
213
+ const value = line.slice(separator + 1).trim();
214
+ if (value === "") continue;
215
+ return resolve(value.startsWith("/") ? value : `${base}/${value}`);
216
+ }
217
+ return null;
218
+ }
219
+ /**
220
+ * Build the read-only metadata overlay mounts for one source tree (strix
221
+ * `_metadata_mounts`): each protected name that exists in the tree is mounted
222
+ * read-only at `<target>/<name>`; a file-shaped `.git` (worktree pointer)
223
+ * additionally gets its resolved gitdir mounted read-only when the gitdir
224
+ * stays inside the tree.
225
+ * @param tree - canonical host path of the source tree.
226
+ * @param target - container mount target of the tree (e.g. `/workspace/app`).
227
+ * @param probe - filesystem facts.
228
+ * @returns the overlay mounts (possibly empty).
229
+ */
230
+ function metadataMounts(tree, target, probe) {
231
+ const mounts = [];
232
+ for (const name of PROTECTED_METADATA_NAMES) {
233
+ const path = `${tree}/${name}`;
234
+ if (!probe.exists(path)) continue;
235
+ const isDir = probe.isDirectory(path);
236
+ if (!isDir && !probe.isFile(path)) continue;
237
+ const resolved = probe.resolve(path);
238
+ if (!isSubpath(tree, resolved)) continue;
239
+ mounts.push({
240
+ source: resolved,
241
+ target: `${target}/${name}`,
242
+ readOnly: true
243
+ });
244
+ if (!isDir) {
245
+ const content = probe.readTextFile(path);
246
+ if (content === null) continue;
247
+ const gitdir = parseGitdirPointer(content, path.substring(0, path.lastIndexOf("/")), probe.resolve);
248
+ if (gitdir === null || !probe.exists(gitdir) || !isSubpath(tree, gitdir)) continue;
249
+ const relative = gitdir.slice(tree.length + 1);
250
+ mounts.push({
251
+ source: gitdir,
252
+ target: `${target}/${relative}`,
253
+ readOnly: true
254
+ });
255
+ }
256
+ }
257
+ return mounts;
258
+ }
259
+ /**
260
+ * Build every bind mount for the session's sources: workspace mounts plus
261
+ * metadata overlays, sorted shallowest-target-first so nested targets land on
262
+ * top (strix sort by `/` count).
263
+ * @param sources - the source specs; entries missing either path part are skipped.
264
+ * @param probe - filesystem facts.
265
+ * @param workspaceRoot - container workspace root (default `/workspace`).
266
+ * @returns the sorted mounts.
267
+ */
268
+ function buildBindMounts(sources, probe, workspaceRoot) {
269
+ const mounts = [];
270
+ for (const source of sources) {
271
+ if (source.workspaceSubdir === "" || source.sourcePath === "") continue;
272
+ const resolved = probe.resolve(source.sourcePath);
273
+ const target = `${workspaceRoot}/${source.workspaceSubdir}`;
274
+ mounts.push({
275
+ source: resolved,
276
+ target,
277
+ readOnly: false
278
+ });
279
+ if (source.protectMetadata === true) mounts.push(...metadataMounts(resolved, target, probe));
280
+ }
281
+ return mounts.sort((a, b) => targetDepth(a.target) - targetDepth(b.target) || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
282
+ }
283
+ /** Path depth = number of `/` separators (strix ordering metric). */
284
+ function targetDepth(target) {
285
+ let depth = 0;
286
+ for (const char of target) if (char === "/") depth++;
287
+ return depth;
288
+ }
289
+ /**
290
+ * Validate an extra file's container path (strix `_extra_file_rel_path`):
291
+ * must live under the workspace root, have no empty/`.`/`..` segments, and
292
+ * carry no control characters.
293
+ * @param containerPath - the requested absolute container path.
294
+ * @param workspaceRoot - container workspace root (default `/workspace`).
295
+ * @returns the workspace-relative path, or null when invalid.
296
+ */
297
+ function extraFileRelPath(containerPath, workspaceRoot) {
298
+ const prefix = `${workspaceRoot}/`;
299
+ if (!containerPath.startsWith(prefix)) return null;
300
+ const rel = containerPath.slice(prefix.length).replace(/^\/+/, "");
301
+ if (rel === "") return null;
302
+ const segments = rel.split("/");
303
+ for (const segment of segments) {
304
+ if (segment === "" || segment === "." || segment === "..") return null;
305
+ for (const char of segment) {
306
+ const code = char.codePointAt(0);
307
+ if (code === void 0 || code < 32 || code === 127) return null;
308
+ }
309
+ }
310
+ return rel;
311
+ }
312
+ /**
313
+ * Whether a candidate workspace-relative path collides with any source root
314
+ * or previously placed extra file (strix `_collides_with_source_root`,
315
+ * ancestor relationships included).
316
+ * @param rel - candidate workspace-relative path.
317
+ * @param roots - existing roots (subdirs and placed extra files), workspace-relative.
318
+ */
319
+ function collidesWithRoots(rel, roots) {
320
+ return roots.some((root) => rel === root || rel.startsWith(`${root}/`) || root.startsWith(`${rel}/`));
321
+ }
322
+ /**
323
+ * Sanitize a scan id for a staging directory name (strix keeps `[alnum]-_.`,
324
+ * everything else becomes `-`).
325
+ * @param scanId - the raw scan id.
326
+ * @returns the sanitized name fragment (empty collapses to a single `-`).
327
+ */
328
+ function stagingDirName(scanId) {
329
+ let safe = "";
330
+ for (const char of scanId) safe += /[A-Za-z0-9]/.test(char) || char === "-" || char === "_" || char === "." ? char : "-";
331
+ return safe === "" ? "-" : safe;
332
+ }
333
+ //#endregion
334
+ //#region src/spec.ts
335
+ /** Values that disable the json-file log rotation opts (strix `_apply_log_limits`). */
336
+ const LOG_DISABLED_SIZES = /* @__PURE__ */ new Set([
337
+ "0",
338
+ "off",
339
+ "none",
340
+ "unlimited"
341
+ ]);
342
+ /**
343
+ * Build the `docker create` argv for a sandbox spec. Env/hosts/labels are
344
+ * emitted in sorted-key order for deterministic tests; mounts keep their
345
+ * (pre-sorted) order.
346
+ * @param spec - the assembled create spec.
347
+ * @returns the full argv, `["docker","create",…flags,image,…command]`.
348
+ */
349
+ function buildCreateArgv(spec) {
350
+ const argv = ["docker", "create"];
351
+ for (const cap of spec.caps ?? []) argv.push("--cap-add", cap);
352
+ for (const key of Object.keys(spec.extraHosts ?? {}).sort()) argv.push("--add-host", `${key}=${spec.extraHosts?.[key]}`);
353
+ for (const key of Object.keys(spec.env).sort()) argv.push("-e", `${key}=${spec.env[key]}`);
354
+ for (const mount of spec.bindMounts) argv.push("-v", mount.readOnly ? `${mount.source}:${mount.target}:ro` : `${mount.source}:${mount.target}`);
355
+ if (spec.network !== void 0 && spec.network !== "") argv.push("--network", spec.network);
356
+ else argv.push("-p", `127.0.0.1::${spec.caidoPort}`);
357
+ const limits = spec.resourceLimits;
358
+ if (limits?.memLimit !== void 0 && limits.memLimit !== "") argv.push("--memory", limits.memLimit);
359
+ if (limits?.shmSize !== void 0 && limits.shmSize !== "") argv.push("--shm-size", limits.shmSize);
360
+ if (limits?.cpus !== void 0 && limits.cpus > 0) argv.push("--cpus", String(limits.cpus));
361
+ if (limits?.pidsLimit !== void 0 && Number.isInteger(limits.pidsLimit) && limits.pidsLimit > 0) argv.push("--pids-limit", String(limits.pidsLimit));
362
+ if (logRotationEnabled(spec.logMaxSize)) argv.push("--log-driver", "json-file", "--log-opt", `max-size=${spec.logMaxSize}`, "--log-opt", `max-file=${spec.logMaxFile ?? 3}`);
363
+ for (const key of Object.keys(spec.labels ?? {}).sort()) argv.push("--label", `${key}=${spec.labels?.[key]}`);
364
+ argv.push(spec.image, ...spec.command);
365
+ return argv;
366
+ }
367
+ /**
368
+ * Whether json-file rotation opts should be emitted for a max-size value.
369
+ * @param logMaxSize - the configured max-size; absent disables (docker default, unbounded).
370
+ * @returns true when rotation opts must be emitted.
371
+ */
372
+ function logRotationEnabled(logMaxSize) {
373
+ return logMaxSize !== void 0 && logMaxSize !== "" && !LOG_DISABLED_SIZES.has(logMaxSize.toLowerCase());
374
+ }
375
+ /**
376
+ * Build the container environment (strix session_manager.py:316-329 parity).
377
+ * Proxy vars point at the in-container Caido so all container HTTP traffic is
378
+ * interceptable; NO_PROXY keeps CDP/localhost traffic out of the proxy.
379
+ * @param options - ports/identity inputs; uid/gid only on Linux (ownership remap).
380
+ * @returns the env record in stable insertion order.
381
+ */
382
+ function buildContainerEnv(options) {
383
+ const proxy = `http://127.0.0.1:${options.caidoPort}`;
384
+ const env = {
385
+ PYTHONUNBUFFERED: "1",
386
+ HOST_GATEWAY: "host.docker.internal",
387
+ http_proxy: proxy,
388
+ https_proxy: proxy,
389
+ ALL_PROXY: proxy,
390
+ NO_PROXY: "localhost,127.0.0.1"
391
+ };
392
+ if (options.platform === "linux" && options.uid !== void 0 && options.uid > 0) {
393
+ env.SHARPKIT_HOST_UID = String(options.uid);
394
+ env.SHARPKIT_HOST_GID = String(options.gid ?? options.uid);
395
+ }
396
+ return env;
397
+ }
398
+ /**
399
+ * Build the non-interactive exec argv: a fresh login shell per call
400
+ * (`bash -lc`), matching the S1 spike and the image's login-shell PATH fixups.
401
+ */
402
+ function buildExecArgv(options) {
403
+ const argv = [
404
+ "docker",
405
+ "exec",
406
+ "-i"
407
+ ];
408
+ if (options.cwd !== void 0) argv.push("-w", options.cwd);
409
+ argv.push(options.containerId, "bash", "-lc", options.command);
410
+ return argv;
411
+ }
412
+ /**
413
+ * Build the PTY exec argv (`docker exec -it`); the host-side PTY is provided
414
+ * by `ctx.subprocess.spawnTerminal` and Ctrl-C is delivered as `\x03`
415
+ * (spike finding D1.1).
416
+ */
417
+ function buildExecTtyArgv(options) {
418
+ const argv = [
419
+ "docker",
420
+ "exec",
421
+ "-it"
422
+ ];
423
+ if (options.cwd !== void 0) argv.push("-w", options.cwd);
424
+ argv.push(options.containerId, "bash", "-lc", options.command);
425
+ return argv;
426
+ }
427
+ /**
428
+ * Build the argv resolving a published port's host endpoint (`docker port`).
429
+ */
430
+ function buildPortArgv(containerId, port) {
431
+ return [
432
+ "docker",
433
+ "port",
434
+ containerId,
435
+ String(port)
436
+ ];
437
+ }
438
+ /**
439
+ * Parse `docker port` output into endpoints, preferring IPv4 (strix publishes
440
+ * to 127.0.0.1). IPv6 literals arrive bracketed and are returned bracket-free
441
+ * with `bracketedIPv6` only when the raw host contains `:`.
442
+ * @param output - the raw `docker port` stdout (zero or more lines).
443
+ * @returns endpoints, IPv4 entries first; empty when nothing is published.
444
+ */
445
+ function parsePortOutput(output) {
446
+ const endpoints = [];
447
+ for (const rawLine of output.split("\n")) {
448
+ const line = rawLine.trim();
449
+ if (line === "") continue;
450
+ const lastColon = line.lastIndexOf(":");
451
+ if (lastColon === -1) continue;
452
+ const port = Number.parseInt(line.slice(lastColon + 1), 10);
453
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
454
+ let host = line.slice(0, lastColon);
455
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
456
+ if (host === "") continue;
457
+ endpoints.push({
458
+ host,
459
+ port
460
+ });
461
+ }
462
+ const preferred = endpoints.filter((endpoint) => !endpoint.host.includes(":"));
463
+ const ipv6 = endpoints.filter((endpoint) => endpoint.host.includes(":"));
464
+ return [...preferred, ...ipv6];
465
+ }
466
+ /**
467
+ * Build the argv resolving the container's IP on a sandbox network (strix
468
+ * `StrixDockerSandboxSession._resolve_exposed_port`, network mode).
469
+ */
470
+ function buildNetworkIpArgv(containerId, network) {
471
+ return [
472
+ "docker",
473
+ "inspect",
474
+ "--format",
475
+ `{{.NetworkSettings.Networks.${network}.IPAddress}}`,
476
+ containerId
477
+ ];
478
+ }
479
+ /** Container-side path reference for `docker cp` (`<id>:<path>`). */
480
+ function containerRef(containerId, containerPath) {
481
+ return `${containerId}:${containerPath}`;
482
+ }
483
+ /** Build the `docker cp` argv copying a host file into the container. */
484
+ function buildPutFileArgv(hostPath, containerId, containerPath) {
485
+ return [
486
+ "docker",
487
+ "cp",
488
+ hostPath,
489
+ containerRef(containerId, containerPath)
490
+ ];
491
+ }
492
+ /** Build the `docker cp` argv copying a container file out to a host path. */
493
+ function buildGetFileArgv(containerId, containerPath, hostPath) {
494
+ return [
495
+ "docker",
496
+ "cp",
497
+ containerRef(containerId, containerPath),
498
+ hostPath
499
+ ];
500
+ }
501
+ /** Build the graceful stop argv (`docker stop -t <seconds>`). */
502
+ function buildStopArgv(containerId, graceMs) {
503
+ const seconds = Math.max(0, Math.round(graceMs / 1e3));
504
+ return [
505
+ "docker",
506
+ "stop",
507
+ "-t",
508
+ String(seconds),
509
+ containerId
510
+ ];
511
+ }
512
+ /** Build the plain remove argv; the caller escalates to force-remove on failure. */
513
+ function buildRmArgv(containerId) {
514
+ return [
515
+ "docker",
516
+ "rm",
517
+ containerId
518
+ ];
519
+ }
520
+ /** Build the force-remove argv (fallback path and failure cleanup). */
521
+ function buildRmForceArgv(containerId) {
522
+ return [
523
+ "docker",
524
+ "rm",
525
+ "-f",
526
+ containerId
527
+ ];
528
+ }
529
+ /** Build the argv checking whether an image is present locally. */
530
+ function buildImageInspectArgv(image) {
531
+ return [
532
+ "docker",
533
+ "image",
534
+ "inspect",
535
+ image
536
+ ];
537
+ }
538
+ /** Build the pull argv (strix pulls only when the image is missing). */
539
+ function buildPullArgv(image) {
540
+ return [
541
+ "docker",
542
+ "pull",
543
+ image
544
+ ];
545
+ }
546
+ /** Build the start argv. */
547
+ function buildStartArgv(containerId) {
548
+ return [
549
+ "docker",
550
+ "start",
551
+ containerId
552
+ ];
553
+ }
554
+ //#endregion
555
+ //#region src/brand.ts
556
+ /** Brand a raw string as a {@link SandboxSessionId}. */
557
+ function SandboxSessionId(id) {
558
+ return id;
559
+ }
560
+ /** Brand a raw string as a {@link SandboxProcessId}. */
561
+ function SandboxProcessId(id) {
562
+ return id;
563
+ }
564
+ //#endregion
565
+ //#region src/session.ts
566
+ /**
567
+ * One docker-CLI sandbox session: exec/PTY command execution, file transfer
568
+ * via `docker cp`, lazy Caido endpoint, and strix-parity teardown. The
569
+ * subprocess seam arrives as a structural interface so the session is
570
+ * drivable from tests exactly like the S1 spike drove it. Teardown follows
571
+ * strix session_manager.cleanup order (staging → caido → container), each
572
+ * step best-effort with logging; host-side PTY trees are terminated first
573
+ * (spike finding D1.4: host terminate cannot reach daemon-owned container
574
+ * processes, so `docker rm -f` is the authoritative reaper).
575
+ * @module @gpzhang2001/sharpkit-sandbox/session
576
+ */
577
+ /** Collected output read helper: reader text or ''. */
578
+ function readerText(handle, stream) {
579
+ return handle.collected[stream]?.readFrom(0).text ?? "";
580
+ }
581
+ /**
582
+ * Run one argv to completion with collected output and a hard timeout that
583
+ * terminates (and joins) the tree — the primitive every docker CLI call in
584
+ * the session goes through. A timeout and a caller abort terminate the host
585
+ * tree identically but are reported separately (`timedOut` vs `aborted`);
586
+ * per spike finding D1.4, host-side termination cannot reach daemon-owned
587
+ * container processes — a timed-out `docker exec` may leave its command
588
+ * running inside the container until the session stops and reaps it.
589
+ * @param subprocess - the subprocess seam.
590
+ * @param argv - full argv, argv[0] a program (never shell-interpreted).
591
+ * @param options - timeout and terminate grace.
592
+ * @returns the collected outcome.
593
+ */
594
+ async function runCollectArgv(subprocess, argv, options) {
595
+ const handle = subprocess.spawn({
596
+ argv,
597
+ cwd: process.cwd(),
598
+ stdio: {
599
+ stdin: "ignore",
600
+ stdout: { maxBytes: options.collectMaxBytes },
601
+ stderr: { maxBytes: options.collectMaxBytes }
602
+ },
603
+ graceMs: options.graceMs
604
+ });
605
+ let timedOut = false;
606
+ let aborted = false;
607
+ let timer;
608
+ let onAbort;
609
+ const deadline = new Promise((resolve) => {
610
+ timer = setTimeout(() => {
611
+ timedOut = true;
612
+ handle.terminate();
613
+ resolve("deadline");
614
+ }, options.timeoutMs);
615
+ const signal = options.signal;
616
+ if (signal !== void 0) {
617
+ onAbort = () => {
618
+ aborted = true;
619
+ handle.terminate();
620
+ resolve("deadline");
621
+ };
622
+ signal.addEventListener("abort", onAbort, { once: true });
623
+ }
624
+ });
625
+ const winner = await Promise.race([handle.done.then((outcome) => ({ outcome })), deadline.then(() => "deadline")]);
626
+ if (timer !== void 0) clearTimeout(timer);
627
+ if (onAbort !== void 0) options.signal?.removeEventListener("abort", onAbort);
628
+ const outcome = winner === "deadline" ? await handle.done : winner.outcome;
629
+ return {
630
+ exitCode: outcome.exitCode,
631
+ signal: outcome.signal,
632
+ stdout: readerText(handle, "stdout"),
633
+ stderr: readerText(handle, "stderr"),
634
+ timedOut,
635
+ aborted
636
+ };
637
+ }
638
+ /**
639
+ * The docker-CLI-backed {@link PentestSandboxSession}. Constructed by the
640
+ * service after the container is created and started; never constructed
641
+ * directly by consumers.
642
+ */
643
+ var DockerCliSandboxSession = class {
644
+ sessionId;
645
+ scanId;
646
+ containerId;
647
+ deps;
648
+ ttyProcesses = /* @__PURE__ */ new Map();
649
+ stopped = false;
650
+ constructor(deps) {
651
+ this.deps = deps;
652
+ this.sessionId = crypto.randomUUID();
653
+ this.scanId = deps.scanId;
654
+ this.containerId = deps.containerId;
655
+ }
656
+ async ready() {
657
+ await this.deps.bootstrap.get();
658
+ }
659
+ async caidoEndpoint() {
660
+ return this.deps.bootstrap.get();
661
+ }
662
+ async exec(command, options) {
663
+ if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`);
664
+ const timeoutMs = options?.timeoutMs ?? this.deps.defaultExecTimeoutMs;
665
+ return runCollectArgv(this.deps.subprocess, buildExecArgv({
666
+ containerId: this.containerId,
667
+ command,
668
+ cwd: options?.cwd
669
+ }), {
670
+ timeoutMs,
671
+ graceMs: this.deps.graceMs,
672
+ collectMaxBytes: this.deps.collectMaxBytes,
673
+ signal: options?.signal
674
+ });
675
+ }
676
+ /**
677
+ * Start a cancellable long-running exec with no timeout (jobs own the
678
+ * lifetime): the caller polls {@link SandboxExecProcess.readOutput} deltas
679
+ * and terminates on cancel.
680
+ */
681
+ execJob(command, options) {
682
+ if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`);
683
+ const handle = this.deps.subprocess.spawn({
684
+ argv: buildExecArgv({
685
+ containerId: this.containerId,
686
+ command,
687
+ cwd: options?.cwd
688
+ }),
689
+ cwd: process.cwd(),
690
+ stdio: {
691
+ stdin: "ignore",
692
+ stdout: { maxBytes: this.deps.collectMaxBytes },
693
+ stderr: { maxBytes: this.deps.collectMaxBytes }
694
+ },
695
+ graceMs: this.deps.graceMs
696
+ });
697
+ let offset = 0;
698
+ return {
699
+ processId: SandboxProcessId(crypto.randomUUID()),
700
+ done: handle.done.then((outcome) => {
701
+ const stdout = handle.collected.stdout?.readFrom(0).text ?? "";
702
+ const stderr = handle.collected.stderr?.readFrom(0).text ?? "";
703
+ return {
704
+ exitCode: outcome.exitCode,
705
+ signal: outcome.signal,
706
+ stdout,
707
+ stderr,
708
+ timedOut: false,
709
+ aborted: false
710
+ };
711
+ }),
712
+ readOutput: () => {
713
+ const read = handle.collected.stdout?.readFrom(offset);
714
+ if (read === void 0) return "";
715
+ offset = read.nextOffset;
716
+ return read.text;
717
+ },
718
+ terminate: () => {
719
+ handle.terminate();
720
+ }
721
+ };
722
+ }
723
+ async execTty(command, options) {
724
+ if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`);
725
+ const handle = await this.deps.subprocess.spawnTerminal({
726
+ argv: buildExecTtyArgv({
727
+ containerId: this.containerId,
728
+ command,
729
+ cwd: options?.cwd
730
+ }),
731
+ cwd: process.cwd(),
732
+ rows: options?.rows ?? 24,
733
+ cols: options?.cols ?? 80,
734
+ graceMs: this.deps.graceMs
735
+ });
736
+ const id = SandboxProcessId(crypto.randomUUID());
737
+ const listeners = /* @__PURE__ */ new Set();
738
+ const decoder = new TextDecoder();
739
+ handle.output.on("data", (chunk) => {
740
+ const text = decoder.decode(chunk, { stream: true });
741
+ for (const listener of listeners) listener(text);
742
+ });
743
+ const done = handle.done.then((outcome) => ({
744
+ exitCode: outcome.exitCode,
745
+ signal: outcome.signal,
746
+ stdout: "",
747
+ stderr: "",
748
+ timedOut: false,
749
+ aborted: false
750
+ }));
751
+ const record = {
752
+ handle,
753
+ listeners,
754
+ process: {
755
+ id,
756
+ command,
757
+ write: (chars) => handle.write(chars),
758
+ subscribe: (listener) => {
759
+ listeners.add(listener);
760
+ return () => {
761
+ listeners.delete(listener);
762
+ };
763
+ },
764
+ done,
765
+ terminate: () => handle.terminate()
766
+ }
767
+ };
768
+ this.ttyProcesses.set(id, record);
769
+ done.then(() => {
770
+ this.ttyProcesses.delete(id);
771
+ }, () => {
772
+ this.ttyProcesses.delete(id);
773
+ });
774
+ return record.process;
775
+ }
776
+ async writeStdin(processId, chars) {
777
+ const record = this.ttyProcesses.get(processId);
778
+ if (record === void 0) throw new Error(`write_stdin: no live interactive process ${processId} in scan ${this.scanId}`);
779
+ await record.handle.write(chars);
780
+ }
781
+ async putFile(hostPath, containerPath) {
782
+ const result = await runCollectArgv(this.deps.subprocess, buildPutFileArgv(hostPath, this.containerId, containerPath), {
783
+ timeoutMs: this.deps.defaultExecTimeoutMs,
784
+ graceMs: this.deps.graceMs,
785
+ collectMaxBytes: this.deps.collectMaxBytes
786
+ });
787
+ if (result.exitCode !== 0) throw new Error(`putFile failed (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`);
788
+ }
789
+ async getFile(containerPath) {
790
+ const name = basename(containerPath);
791
+ if (name === "" || name === "/" || name === ".") throw new Error(`getFile: container path must name a file: ${containerPath}`);
792
+ const dir = await mkdtemp(join(tmpdir(), "sharpkit-getfile-"));
793
+ try {
794
+ const hostPath = join(dir, name);
795
+ const result = await runCollectArgv(this.deps.subprocess, buildGetFileArgv(this.containerId, containerPath, hostPath), {
796
+ timeoutMs: this.deps.defaultExecTimeoutMs,
797
+ graceMs: this.deps.graceMs,
798
+ collectMaxBytes: this.deps.collectMaxBytes
799
+ });
800
+ if (result.exitCode !== 0) throw new Error(`getFile failed (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`);
801
+ return new Uint8Array(await readFile(hostPath));
802
+ } finally {
803
+ await rm(dir, {
804
+ recursive: true,
805
+ force: true
806
+ }).catch(() => {});
807
+ }
808
+ }
809
+ async stop() {
810
+ if (this.stopped) return;
811
+ this.stopped = true;
812
+ const log = this.deps.logger;
813
+ for (const record of this.ttyProcesses.values()) try {
814
+ await record.handle.terminate();
815
+ } catch (error) {
816
+ log.debug(`stop(${this.scanId}): tty terminate raised: ${String(error)}`);
817
+ }
818
+ this.ttyProcesses.clear();
819
+ if (this.deps.stagingDir !== void 0) await rm(this.deps.stagingDir, {
820
+ recursive: true,
821
+ force: true
822
+ }).catch(() => {});
823
+ this.deps.bootstrap.close();
824
+ try {
825
+ const stopped = await runCollectArgv(this.deps.subprocess, buildStopArgv(this.containerId, this.deps.graceMs), {
826
+ timeoutMs: this.deps.defaultExecTimeoutMs,
827
+ graceMs: this.deps.graceMs,
828
+ collectMaxBytes: this.deps.collectMaxBytes
829
+ });
830
+ if (stopped.exitCode !== 0) log.warn(`stop(${this.scanId}): docker stop exit ${stopped.exitCode}: ${stopped.stderr.slice(0, 200)}`);
831
+ } catch (error) {
832
+ log.debug(`stop(${this.scanId}): docker stop raised: ${String(error)}`);
833
+ }
834
+ try {
835
+ if ((await runCollectArgv(this.deps.subprocess, buildRmArgv(this.containerId), {
836
+ timeoutMs: this.deps.defaultExecTimeoutMs,
837
+ graceMs: this.deps.graceMs,
838
+ collectMaxBytes: this.deps.collectMaxBytes
839
+ })).exitCode !== 0) await runCollectArgv(this.deps.subprocess, buildRmForceArgv(this.containerId), {
840
+ timeoutMs: this.deps.defaultExecTimeoutMs,
841
+ graceMs: this.deps.graceMs,
842
+ collectMaxBytes: this.deps.collectMaxBytes
843
+ });
844
+ } catch (error) {
845
+ log.error(`stop(${this.scanId}): container removal raised; container may need manual reaping: ${String(error)}`);
846
+ }
847
+ }
848
+ };
849
+ /**
850
+ * Stage extra files for bind mounting (strix `build_extra_file_bind_mounts`):
851
+ * one numbered subdir per file, content written, mounted read-only at its
852
+ * workspace path.
853
+ * @param stagingDir - the session staging directory (already created).
854
+ * @param items - validated extra files: rel path + bytes.
855
+ * @param workspaceRoot - container workspace root (default `/workspace`).
856
+ * @returns the mounts, in placement order.
857
+ */
858
+ async function stageExtraFiles(stagingDir, items, workspaceRoot) {
859
+ const mounts = [];
860
+ let index = 0;
861
+ for (const item of items) {
862
+ const staged = join(stagingDir, String(index), basename(item.rel));
863
+ await mkdir(dirname(staged), { recursive: true });
864
+ await writeFile(staged, item.content);
865
+ mounts.push({
866
+ source: staged,
867
+ target: `${workspaceRoot}/${item.rel}`,
868
+ readOnly: true
869
+ });
870
+ index++;
871
+ }
872
+ return mounts;
873
+ }
874
+ //#endregion
875
+ //#region src/index.ts
876
+ /**
877
+ * Docker sandbox capability for the sharpkit pentest suite: the
878
+ * `ctx.pentestSandbox` service (decision D1: `ctx.subprocess` + docker CLI,
879
+ * argv built by the pure spec module). Port of strix runtime/session_manager
880
+ * `create_or_reuse` + `cleanup` semantics: sessions cached by scan id,
881
+ * container lifecycle over the CLI, extra files staged under the temp dir
882
+ * (a remote docker daemon resolves bind sources on its own filesystem), and
883
+ * a lazy Caido bootstrap that runs concurrently with scan start. Teardown is
884
+ * registered once via ctx.effect and stops every live session best-effort.
885
+ * @module @gpzhang2001/sharpkit-sandbox
886
+ */
887
+ /** Container-side Caido port (protocol constant with the image, not a tunable). */
888
+ const CAIDO_PORT = 48080;
889
+ /** Keep-alive command (protocol with the image entrypoint, which execs it). */
890
+ const KEEPALIVE_COMMAND = [
891
+ "tail",
892
+ "-f",
893
+ "/dev/null"
894
+ ];
895
+ /**
896
+ * The pentest sandbox service: creates, caches, and reuses docker-CLI
897
+ * sandbox sessions. Load as a plugin after a subprocess provider; it
898
+ * registers as `ctx.pentestSandbox` (one per context).
899
+ */
900
+ var PentestSandboxService = class extends Service {
901
+ static inject = ["subprocess"];
902
+ static Config = z.object({
903
+ image: z.string().default("ghcr.io/gpzhang2001/sharpkit-sandbox:1.0.0-fork2"),
904
+ containerGraceMs: z.number().default(1e4),
905
+ workspaceRoot: z.string().default("/workspace"),
906
+ network: z.string(),
907
+ memLimit: z.string(),
908
+ shmSize: z.string(),
909
+ cpus: z.number(),
910
+ pidsLimit: z.number(),
911
+ logMaxSize: z.string().default("50m"),
912
+ logMaxFile: z.number().default(3),
913
+ runLabelId: z.string(),
914
+ runLabelType: z.string(),
915
+ caidoLoginAttempts: z.number().default(10),
916
+ caidoLoginTimeoutMs: z.number().default(15e3),
917
+ defaultExecTimeoutMs: z.number().default(12e4),
918
+ execCollectMaxBytes: z.number().default(1048576)
919
+ });
920
+ config;
921
+ sessions = /* @__PURE__ */ new Map();
922
+ /** node:fs-backed mount facts; a handful of sync calls on a few paths. */
923
+ probe = {
924
+ resolve: (path) => realpathSync(path.startsWith("~/") ? join(homedir(), path.slice(2)) : path),
925
+ exists: (path) => existsSync(path),
926
+ isDirectory: (path) => {
927
+ try {
928
+ return statSync(path).isDirectory();
929
+ } catch {
930
+ return false;
931
+ }
932
+ },
933
+ isFile: (path) => {
934
+ try {
935
+ return statSync(path).isFile();
936
+ } catch {
937
+ return false;
938
+ }
939
+ },
940
+ readTextFile: (path) => {
941
+ try {
942
+ return readFileSync(path, "utf8");
943
+ } catch {
944
+ return null;
945
+ }
946
+ }
947
+ };
948
+ constructor(ctx, config = {}) {
949
+ super(ctx, "pentestSandbox");
950
+ this.config = config;
951
+ ctx.effect(() => async () => {
952
+ for (const session of this.sessions.values()) await session.stop();
953
+ this.sessions.clear();
954
+ }, "pentest-sandbox session teardown");
955
+ }
956
+ /**
957
+ * Create (or reuse) the sandbox session for a scan id — strix
958
+ * `create_or_reuse` parity, including lazy Caido bootstrap.
959
+ * @param options - scan identity, sources, extra files.
960
+ * @returns the live session.
961
+ */
962
+ async createSession(options) {
963
+ const cached = this.sessions.get(options.scanId);
964
+ if (cached !== void 0) {
965
+ this.ctx.logger.debug(`pentest-sandbox: reusing session for scan ${options.scanId}`);
966
+ return cached;
967
+ }
968
+ const config = this.config;
969
+ const subprocess = this.ctx.subprocess;
970
+ const cli = {
971
+ timeoutMs: config.defaultExecTimeoutMs,
972
+ graceMs: config.containerGraceMs,
973
+ collectMaxBytes: config.execCollectMaxBytes
974
+ };
975
+ let stagingDir;
976
+ let containerId;
977
+ try {
978
+ const mounts = buildBindMounts(options.sources ?? [], this.probe, config.workspaceRoot);
979
+ const extraMounts = await this.stageExtraFiles(options, config.workspaceRoot);
980
+ if (extraMounts.stagingDir !== void 0) stagingDir = extraMounts.stagingDir;
981
+ const allMounts = [...mounts, ...extraMounts.mounts].sort((a, b) => a.target.split("/").length - b.target.split("/").length || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
982
+ const labels = {};
983
+ if (config.runLabelId !== void 0) labels["sharpkit-run-id"] = config.runLabelId;
984
+ if (config.runLabelType !== void 0) labels["sharpkit-run-type"] = config.runLabelType;
985
+ const spec = {
986
+ image: config.image,
987
+ command: KEEPALIVE_COMMAND,
988
+ env: buildContainerEnv({
989
+ caidoPort: CAIDO_PORT,
990
+ platform: process.platform,
991
+ uid: typeof process.getuid === "function" ? process.getuid() : void 0,
992
+ gid: typeof process.getgid === "function" ? process.getgid() : void 0
993
+ }),
994
+ bindMounts: allMounts,
995
+ caidoPort: CAIDO_PORT,
996
+ network: config.network,
997
+ caps: ["NET_ADMIN", "NET_RAW"],
998
+ extraHosts: { "host.docker.internal": "host-gateway" },
999
+ resourceLimits: {
1000
+ memLimit: config.memLimit,
1001
+ shmSize: config.shmSize,
1002
+ cpus: config.cpus,
1003
+ pidsLimit: config.pidsLimit
1004
+ },
1005
+ logMaxSize: config.logMaxSize,
1006
+ logMaxFile: config.logMaxFile,
1007
+ labels
1008
+ };
1009
+ await this.ensureImage(spec.image, cli);
1010
+ const created = await runCollectArgv(subprocess, buildCreateArgv(spec), cli);
1011
+ if (created.exitCode !== 0) throw new Error(`docker create failed (exit ${created.exitCode}): ${created.stderr.slice(0, 500)}`);
1012
+ containerId = created.stdout.trim().split("\n").at(-1)?.trim() ?? "";
1013
+ if (containerId === "") throw new Error("docker create produced no container id");
1014
+ const started = await runCollectArgv(subprocess, buildStartArgv(containerId), cli);
1015
+ if (started.exitCode !== 0) throw new Error(`docker start failed (exit ${started.exitCode}): ${started.stderr.slice(0, 500)}`);
1016
+ const hostBaseUrl = await this.resolveCaidoHostUrl(containerId, cli);
1017
+ const id = containerId;
1018
+ const bootstrap = new CaidoBootstrap((signal) => bootstrapCaido((command, timeoutMs) => runCollectArgv(subprocess, [
1019
+ "docker",
1020
+ "exec",
1021
+ "-i",
1022
+ id,
1023
+ "bash",
1024
+ "-lc",
1025
+ command
1026
+ ], {
1027
+ ...cli,
1028
+ timeoutMs
1029
+ }).then((result) => ({
1030
+ ok: result.exitCode === 0,
1031
+ exitCode: result.exitCode,
1032
+ stdout: result.stdout,
1033
+ stderr: result.stderr
1034
+ })), fetch, {
1035
+ containerBaseUrl: `http://127.0.0.1:${CAIDO_PORT}`,
1036
+ hostBaseUrl
1037
+ }, {
1038
+ attempts: config.caidoLoginAttempts,
1039
+ timeoutMs: config.caidoLoginTimeoutMs,
1040
+ sleep: (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)),
1041
+ signal
1042
+ }));
1043
+ const session = new DockerCliSandboxSession({
1044
+ subprocess,
1045
+ logger: this.ctx.logger,
1046
+ containerId,
1047
+ scanId: options.scanId,
1048
+ containerCaidoBaseUrl: `http://127.0.0.1:${CAIDO_PORT}`,
1049
+ hostCaidoBaseUrl: hostBaseUrl,
1050
+ bootstrap,
1051
+ stagingDir,
1052
+ graceMs: config.containerGraceMs,
1053
+ defaultExecTimeoutMs: config.defaultExecTimeoutMs,
1054
+ collectMaxBytes: config.execCollectMaxBytes
1055
+ });
1056
+ this.sessions.set(options.scanId, session);
1057
+ return session;
1058
+ } catch (error) {
1059
+ if (stagingDir !== void 0) await rm(stagingDir, {
1060
+ recursive: true,
1061
+ force: true
1062
+ }).catch(() => {});
1063
+ if (containerId !== void 0) await runCollectArgv(subprocess, [
1064
+ "docker",
1065
+ "rm",
1066
+ "-f",
1067
+ containerId
1068
+ ], cli).catch(() => void 0);
1069
+ throw error;
1070
+ }
1071
+ }
1072
+ /** Stop and forget one session (idempotent; strix `cleanup`). */
1073
+ async destroySession(scanId) {
1074
+ const session = this.sessions.get(scanId);
1075
+ if (session === void 0) {
1076
+ this.ctx.logger.debug(`pentest-sandbox: no session to clean for scan ${scanId}`);
1077
+ return;
1078
+ }
1079
+ this.sessions.delete(scanId);
1080
+ await session.stop();
1081
+ }
1082
+ /** Validate + stage extra files (strix skip-and-warn semantics). */
1083
+ async stageExtraFiles(options, workspaceRoot) {
1084
+ const extraFiles = options.extraFiles ?? [];
1085
+ if (extraFiles.length === 0) return { mounts: [] };
1086
+ const sourceRoots = options.sources?.map((source) => source.workspaceSubdir) ?? [];
1087
+ const placed = [];
1088
+ const items = [];
1089
+ for (const file of extraFiles) {
1090
+ const rel = extraFileRelPath(file.containerPath, workspaceRoot);
1091
+ if (rel === null) {
1092
+ this.ctx.logger.warn(`pentest-sandbox: skipping invalid extra file path ${file.containerPath}`);
1093
+ continue;
1094
+ }
1095
+ if (collidesWithRoots(rel, [...sourceRoots, ...placed])) {
1096
+ this.ctx.logger.warn(`pentest-sandbox: skipping colliding extra file ${file.containerPath}`);
1097
+ continue;
1098
+ }
1099
+ placed.push(rel);
1100
+ items.push({
1101
+ rel,
1102
+ content: typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content
1103
+ });
1104
+ }
1105
+ if (items.length === 0) return { mounts: [] };
1106
+ const stagingDir = await mkdtemp(`${tmpdir()}/pentest-extra-files-${stagingDirName(options.scanId)}-`);
1107
+ return {
1108
+ mounts: await stageExtraFiles(stagingDir, items, workspaceRoot),
1109
+ stagingDir
1110
+ };
1111
+ }
1112
+ /** Pull the image when missing (strix image_exists → pull). */
1113
+ async ensureImage(image, cli) {
1114
+ if ((await runCollectArgv(this.ctx.subprocess, buildImageInspectArgv(image), {
1115
+ ...cli,
1116
+ timeoutMs: 6e4
1117
+ })).exitCode === 0) return;
1118
+ this.ctx.logger.info(`pentest-sandbox: pulling image ${image}`);
1119
+ const pulled = await runCollectArgv(this.ctx.subprocess, buildPullArgv(image), {
1120
+ ...cli,
1121
+ timeoutMs: 18e5
1122
+ });
1123
+ if (pulled.exitCode !== 0) throw new Error(`docker pull failed (exit ${pulled.exitCode}): ${pulled.stderr.slice(0, 500)}`);
1124
+ }
1125
+ /** Resolve the host-side Caido base URL for a started container. */
1126
+ async resolveCaidoHostUrl(containerId, cli) {
1127
+ const config = this.config;
1128
+ if (config.network !== void 0 && config.network !== "") {
1129
+ const inspected = await runCollectArgv(this.ctx.subprocess, buildNetworkIpArgv(containerId, config.network), cli);
1130
+ if (inspected.exitCode !== 0) throw new Error(`docker inspect (network ip) failed (exit ${inspected.exitCode}): ${inspected.stderr.slice(0, 500)}`);
1131
+ const ip = inspected.stdout.trim();
1132
+ if (ip === "") throw new Error(`container has no address on network ${config.network}`);
1133
+ return `http://${ip.includes(":") ? `[${ip}]` : ip}:${CAIDO_PORT}`;
1134
+ }
1135
+ const port = await runCollectArgv(this.ctx.subprocess, buildPortArgv(containerId, CAIDO_PORT), cli);
1136
+ if (port.exitCode !== 0) throw new Error(`docker port failed (exit ${port.exitCode}): ${port.stderr.slice(0, 500)}`);
1137
+ const endpoint = parsePortOutput(port.stdout)[0];
1138
+ if (endpoint === void 0) throw new Error(`caido port ${CAIDO_PORT} is not published for container ${containerId}`);
1139
+ return `http://${endpoint.host}:${endpoint.port}`;
1140
+ }
1141
+ };
1142
+ //#endregion
1143
+ export { PentestSandboxService, PentestSandboxService as default, SandboxProcessId, SandboxSessionId };
1144
+
1145
+ //# sourceMappingURL=index.js.map