@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/LICENSE +201 -0
- package/README.md +50 -0
- package/THIRD_PARTY_NOTICES.md +48 -0
- package/lib/index.d.ts +321 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1145 -0
- package/lib/index.js.map +1 -0
- package/package.json +48 -0
- package/src/brand.ts +24 -0
- package/src/caido.ts +257 -0
- package/src/index.ts +366 -0
- package/src/mounts.ts +197 -0
- package/src/session.ts +444 -0
- package/src/spec.ts +263 -0
package/lib/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/caido.ts","../src/mounts.ts","../src/spec.ts","../src/brand.ts","../src/session.ts","../src/index.ts"],"sourcesContent":["/**\n * Caido proxy bootstrap, ported from strix caido_bootstrap.py + caido_handle.py:\n * guest login via `curl` executed INSIDE the container (the retry loop is the\n * readiness probe — there is no separate TCP healthcheck), then project\n * create/select over GraphQL against the host-side published endpoint using\n * global fetch. The bootstrap runs concurrently with scan start; consumers\n * resolve it lazily, and one caller's cancellation cannot cancel the shared\n * task (the underlying promise is shared, matching asyncio.shield semantics).\n * @module @gpzhang2001/sharpkit-sandbox/caido\n */\n\n/** A ready-to-use host-side Caido endpoint. */\nexport interface CaidoEndpoint {\n /** Base URL of the published Caido GraphQL endpoint (no trailing slash). */\n readonly baseUrl: string\n /** Guest access token for the `Authorization: Bearer` header. */\n readonly token: string\n /** Id of the created sandbox project. */\n readonly projectId: string\n}\n\n/** Minimal exec contract the bootstrap needs (container-internal command). */\nexport interface CaidoExecFn {\n (\n command: string,\n timeoutMs: number,\n ): Promise<{ readonly ok: boolean; readonly exitCode: number | null; readonly stdout: string; readonly stderr: string }>\n}\n\n/** Minimal fetch contract (global fetch shape) for GraphQL calls. */\nexport interface CaidoFetchFn {\n (\n url: string,\n init: { readonly method: 'POST'; readonly headers: Readonly<Record<string, string>>; readonly body: string; readonly signal: AbortSignal },\n ): Promise<{ readonly status: number; readonly text: () => Promise<string> }>\n}\n\n/** Minimal awaited-fetch shape after `.text()`. */\nexport type CaidoFetchResponse = Awaited<ReturnType<CaidoFetchFn>>\n\n/** Injected clock for the retry backoff (tests pass a no-op). */\nexport type CaidoSleepFn = (ms: number) => Promise<void>\n\n/** Exact login mutation body strix posts (caido_bootstrap.py `_LOGIN_AS_GUEST_BODY`). */\nconst LOGIN_AS_GUEST_QUERY = 'mutation LoginAsGuest { loginAsGuest { token { accessToken } } }'\n\n/** Minimal CreateProject mutation (error identified by typename only — the\n * payload error union has no shared `code` field in Caido 0.56.0's schema). */\nconst CREATE_PROJECT_DOC = 'mutation CreateProject($input: CreateProjectInput!) { createProject(input: $input) { error { __typename } project { id name temporary } } }'\n\n/** Minimal SelectProject mutation (typename-only error, same schema reason). */\nconst SELECT_PROJECT_DOC = 'mutation SelectProject($id: ID!) { selectProject(id: $id) { currentProject { project { id } } error { __typename } } }'\n\n/** Project identity strix creates in every sandbox (protocol constant, not a tunable). */\nconst PROJECT_NAME = 'sandbox'\n\n/**\n * Build the container-internal curl login command (strix caido_bootstrap.py:46-57).\n * @param containerBaseUrl - the in-container Caido base URL (`http://127.0.0.1:48080`).\n * @returns the shell command string for exec.\n */\nexport function loginCurlCommand(containerBaseUrl: string): string {\n const body = JSON.stringify({ query: LOGIN_AS_GUEST_QUERY })\n return `curl -fsS -X POST -H \"Content-Type: application/json\" -d ${JSON.stringify(body)} ${containerBaseUrl}/graphql`\n}\n\n/**\n * Extract the guest token from a login response payload.\n * @param stdout - raw curl stdout.\n * @returns the access token.\n * @throws when the payload is unparseable or carries no token (strix error wording).\n */\nexport function parseLoginToken(stdout: string): string {\n let payload: unknown\n try {\n payload = JSON.parse(stdout)\n } catch (error) {\n throw new Error(`unparseable response: ${String(error)}: '${stdout}'`)\n }\n const token = pathOf(payload, ['data', 'loginAsGuest', 'token', 'accessToken'])\n if (typeof token !== 'string' || token === '') {\n throw new Error(`loginAsGuest returned no token: ${JSON.stringify(payload)}`)\n }\n return token\n}\n\n/** Best-effort nested property lookup on an unknown JSON value. */\nfunction pathOf(value: unknown, path: readonly string[]): unknown {\n let current: unknown = value\n for (const key of path) {\n if (typeof current !== 'object' || current === null) return undefined\n current = (current as Record<string, unknown>)[key]\n }\n return current\n}\n\n/**\n * Run the guest login with retries (strix `_login_as_guest`: attempts with\n * capped linear backoff 2,4,6,8,8… seconds; per-attempt exec timeout).\n * @param exec - container exec channel.\n * @param containerBaseUrl - in-container Caido base URL.\n * @param options - attempts/timeout/backoff knobs and injected sleep.\n * @returns the access token.\n * @throws when every attempt fails (strix error wording).\n */\nexport async function loginAsGuest(\n exec: CaidoExecFn,\n containerBaseUrl: string,\n options: {\n readonly attempts: number\n readonly timeoutMs: number\n readonly sleep: CaidoSleepFn\n },\n): Promise<string> {\n const command = loginCurlCommand(containerBaseUrl)\n let lastError = 'no attempt made'\n for (let attempt = 1; attempt <= options.attempts; attempt++) {\n try {\n const result = await exec(command, options.timeoutMs)\n if (result.ok) return parseLoginToken(result.stdout)\n lastError = `curl exit ${result.exitCode === null ? 'unknown' : result.exitCode}: ${result.stderr.slice(0, 200)}`\n } catch (error) {\n // Token-structure failures from parseLoginToken land here and are retryable.\n lastError = String(error instanceof Error ? error.message : error)\n }\n if (attempt < options.attempts) await options.sleep(Math.min(2_000 * attempt, 8_000))\n }\n throw new Error(`loginAsGuest failed after ${options.attempts} attempts: ${lastError}`)\n}\n\n/** One GraphQL response half: user error or payload. */\ninterface GraphQLErrorShape {\n readonly __typename: string\n readonly code?: string\n}\n\n/**\n * POST one GraphQL document with bearer auth and return the `data` object.\n * @param fetchFn - fetch channel.\n * @param baseUrl - host-side Caido base URL.\n * @param token - bearer token.\n * @param doc - the GraphQL document.\n * @param variables - operation variables.\n * @param signal - cancellation for teardown.\n */\nasync function graphql(\n fetchFn: CaidoFetchFn,\n baseUrl: string,\n token: string,\n doc: string,\n variables: Readonly<Record<string, unknown>>,\n signal: AbortSignal,\n): Promise<Record<string, unknown>> {\n const response = await fetchFn(`${baseUrl}/graphql`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: JSON.stringify({ query: doc, variables }),\n signal,\n })\n const text = await response.text()\n if (response.status !== 200) throw new Error(`caido graphql HTTP ${response.status}: ${text.slice(0, 200)}`)\n let payload: unknown\n try {\n payload = JSON.parse(text)\n } catch (error) {\n throw new Error(`caido graphql unparseable response: ${String(error)}`)\n }\n const data = pathOf(payload, ['data'])\n if (typeof data !== 'object' || data === null) {\n const errors = pathOf(payload, ['errors'])\n const detail = Array.isArray(errors) ? JSON.stringify(errors).slice(0, 300) : text.slice(0, 200)\n throw new Error(`caido graphql carried no data: ${detail}`)\n }\n return data as Record<string, unknown>\n}\n\n/** Extract the sibling error of an operation result, for error messages. */\nfunction errorOf(entry: unknown): GraphQLErrorShape | undefined {\n if (typeof entry !== 'object' || entry === null) return undefined\n const error = (entry as Record<string, unknown>)['error']\n if (typeof error !== 'object' || error === null) return undefined\n return error as GraphQLErrorShape\n}\n\n/**\n * Full bootstrap: guest login, then create the temporary sandbox project and\n * select it (strix `bootstrap_caido`).\n * @param exec - container exec channel (login curl runs in-container).\n * @param fetchFn - host-side fetch channel (project calls).\n * @param urls - container and host base URLs.\n * @param options - retry/timeout knobs and injected sleep.\n * @returns the ready endpoint.\n */\nexport async function bootstrapCaido(\n exec: CaidoExecFn,\n fetchFn: CaidoFetchFn,\n urls: { readonly containerBaseUrl: string; readonly hostBaseUrl: string },\n options: {\n readonly attempts: number\n readonly timeoutMs: number\n readonly sleep: CaidoSleepFn\n readonly signal: AbortSignal\n },\n): Promise<CaidoEndpoint> {\n const token = await loginAsGuest(exec, urls.containerBaseUrl, options)\n const createData = await graphql(fetchFn, urls.hostBaseUrl, token, CREATE_PROJECT_DOC, { input: { name: PROJECT_NAME, temporary: true } }, options.signal)\n const createError = errorOf(createData['createProject'])\n if (createError !== undefined) throw new Error(`createProject failed: ${createError.__typename}${createError.code === undefined ? '' : ` (${createError.code})`}`)\n const projectId = pathOf(createData['createProject'], ['project', 'id'])\n if (typeof projectId !== 'string' || projectId === '') throw new Error('createProject returned no project id')\n const selectData = await graphql(fetchFn, urls.hostBaseUrl, token, SELECT_PROJECT_DOC, { id: projectId }, options.signal)\n const selectError = errorOf(selectData['selectProject'])\n if (selectError !== undefined) throw new Error(`selectProject failed: ${selectError.__typename}${selectError.code === undefined ? '' : ` (${selectError.code})`}`)\n return { baseUrl: urls.hostBaseUrl, token, projectId }\n}\n\n/**\n * Shared, lazily-resolved bootstrap task (strix `CaidoBootstrapHandle`): the\n * promise is created once and shared, so individual consumer cancellations\n * cannot cancel the shared bootstrap; `close()` aborts it for teardown.\n */\nexport class CaidoBootstrap {\n private readonly endpoint: Promise<CaidoEndpoint>\n private readonly controller = new AbortController()\n private settled: CaidoEndpoint | undefined\n\n constructor(\n start: (signal: AbortSignal) => Promise<CaidoEndpoint>,\n ) {\n this.endpoint = start(this.controller.signal).then(\n endpoint => {\n this.settled = endpoint\n return endpoint\n },\n error => {\n throw error\n },\n )\n // Keep the shared task alive even if every consumer drops their reference.\n void this.endpoint.catch(() => {})\n }\n\n /** Resolve the endpoint; rejects with the bootstrap failure, shared by all callers. */\n get(): Promise<CaidoEndpoint> {\n return this.endpoint\n }\n\n /** The resolved endpoint, or undefined while pending/failed (strix `peek`). */\n peek(): CaidoEndpoint | undefined {\n return this.settled\n }\n\n /** Abort a pending bootstrap; failures are swallowed (teardown path). */\n close(): void {\n this.controller.abort()\n }\n}\n","/**\n * Bind-mount assembly: workspace source mapping, protected-metadata mounts\n * (`.git` / `.agents` / `.codex`, git-worktree gitdir pointers), extra-file\n * staging path rules, and collision detection. Faithful port of strix\n * session_manager.py `build_bind_mounts` (:54-66), `_metadata_mounts`\n * (:230-245), `_gitdir_from_pointer` (:248-260), `_extra_file_rel_path`\n * (:80-97), `_collides_with_source_root` (:111-124), and the staging-dir\n * sanitizer (:172-180). Filesystem facts arrive through an injectable probe\n * so every branch is unit-testable without touching a real tree.\n * @module @gpzhang2001/sharpkit-sandbox/mounts\n */\n\nimport type { SandboxBindMount } from './spec.ts'\n\n/** One source tree mounted into the sandbox workspace. */\nexport interface SandboxSourceSpec {\n /** Directory name under the workspace root (`/workspace/<subdir>`). */\n readonly workspaceSubdir: string\n /** Host path of the tree to mount. */\n readonly sourcePath: string\n /** Mount `.git`/`.agents`/`.codex` (and worktree gitdir) read-only on top. */\n readonly protectMetadata?: boolean\n}\n\n/** Injected filesystem facts; the real service passes node:fs-backed probes. */\nexport interface FsProbe {\n /** Resolve a path to its canonical absolute form (symlinks followed). */\n resolve(path: string): string\n exists(path: string): boolean\n isDirectory(path: string): boolean\n /** Whether the path exists as a regular file (a worktree `.git` pointer). */\n isFile(path: string): boolean\n /** Read a small text file, or null when unreadable (pointer parse is best-effort). */\n readTextFile(path: string): string | null\n}\n\n/** Metadata names that get their own read-only overlay mount. */\nexport const PROTECTED_METADATA_NAMES = ['.git', '.agents', '.codex'] as const\n\n/**\n * Whether `child` is `parent` itself or underneath it, POSIX-style.\n * @param parent - candidate ancestor path, already canonical.\n * @param child - candidate descendant path, already canonical.\n */\nexport function isSubpath(parent: string, child: string): boolean {\n return child === parent || child.startsWith(`${parent}/`)\n}\n\n/**\n * Parse a git worktree `.git` pointer file for its `gitdir:` line.\n * @param content - the raw pointer file text.\n * @param base - directory of the pointer file, for relative gitdir values.\n * @param resolve - canonicalizer for the candidate path.\n * @returns the resolved gitdir, or null when absent/malformed.\n */\nexport function parseGitdirPointer(content: string, base: string, resolve: (path: string) => string): string | null {\n for (const rawLine of content.split('\\n')) {\n const line = rawLine.trimEnd()\n const separator = line.indexOf(':')\n if (separator === -1) continue\n const prefix = line.slice(0, separator).trim()\n if (prefix !== 'gitdir') continue\n const value = line.slice(separator + 1).trim()\n if (value === '') continue\n return resolve(value.startsWith('/') ? value : `${base}/${value}`)\n }\n return null\n}\n\n/**\n * Build the read-only metadata overlay mounts for one source tree (strix\n * `_metadata_mounts`): each protected name that exists in the tree is mounted\n * read-only at `<target>/<name>`; a file-shaped `.git` (worktree pointer)\n * additionally gets its resolved gitdir mounted read-only when the gitdir\n * stays inside the tree.\n * @param tree - canonical host path of the source tree.\n * @param target - container mount target of the tree (e.g. `/workspace/app`).\n * @param probe - filesystem facts.\n * @returns the overlay mounts (possibly empty).\n */\nexport function metadataMounts(tree: string, target: string, probe: FsProbe): SandboxBindMount[] {\n const mounts: SandboxBindMount[] = []\n for (const name of PROTECTED_METADATA_NAMES) {\n const path = `${tree}/${name}`\n if (!probe.exists(path)) continue\n const isDir = probe.isDirectory(path)\n if (!isDir && !probe.isFile(path)) continue\n const resolved = probe.resolve(path)\n if (!isSubpath(tree, resolved)) continue\n mounts.push({ source: resolved, target: `${target}/${name}`, readOnly: true })\n if (!isDir) {\n const content = probe.readTextFile(path)\n if (content === null) continue\n const gitdir = parseGitdirPointer(content, path.substring(0, path.lastIndexOf('/')), probe.resolve)\n if (gitdir === null || !probe.exists(gitdir) || !isSubpath(tree, gitdir)) continue\n const relative = gitdir.slice(tree.length + 1)\n mounts.push({ source: gitdir, target: `${target}/${relative}`, readOnly: true })\n }\n }\n return mounts\n}\n\n/**\n * Build every bind mount for the session's sources: workspace mounts plus\n * metadata overlays, sorted shallowest-target-first so nested targets land on\n * top (strix sort by `/` count).\n * @param sources - the source specs; entries missing either path part are skipped.\n * @param probe - filesystem facts.\n * @param workspaceRoot - container workspace root (default `/workspace`).\n * @returns the sorted mounts.\n */\nexport function buildBindMounts(\n sources: readonly SandboxSourceSpec[],\n probe: FsProbe,\n workspaceRoot: string,\n): SandboxBindMount[] {\n const mounts: SandboxBindMount[] = []\n for (const source of sources) {\n if (source.workspaceSubdir === '' || source.sourcePath === '') continue\n const resolved = probe.resolve(source.sourcePath)\n const target = `${workspaceRoot}/${source.workspaceSubdir}`\n mounts.push({ source: resolved, target, readOnly: false })\n if (source.protectMetadata === true) mounts.push(...metadataMounts(resolved, target, probe))\n }\n return mounts.sort((a, b) => targetDepth(a.target) - targetDepth(b.target) || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))\n}\n\n/** Path depth = number of `/` separators (strix ordering metric). */\nfunction targetDepth(target: string): number {\n let depth = 0\n for (const char of target) {\n if (char === '/') depth++\n }\n return depth\n}\n\n/**\n * Validate an extra file's container path (strix `_extra_file_rel_path`):\n * must live under the workspace root, have no empty/`.`/`..` segments, and\n * carry no control characters.\n * @param containerPath - the requested absolute container path.\n * @param workspaceRoot - container workspace root (default `/workspace`).\n * @returns the workspace-relative path, or null when invalid.\n */\nexport function extraFileRelPath(containerPath: string, workspaceRoot: string): string | null {\n const prefix = `${workspaceRoot}/`\n if (!containerPath.startsWith(prefix)) return null\n const rel = containerPath.slice(prefix.length).replace(/^\\/+/, '')\n if (rel === '') return null\n const segments = rel.split('/')\n for (const segment of segments) {\n if (segment === '' || segment === '.' || segment === '..') return null\n for (const char of segment) {\n const code = char.codePointAt(0)\n if (code === undefined || code < 0x20 || code === 0x7f) return null\n }\n }\n return rel\n}\n\n/**\n * Whether a candidate workspace-relative path collides with any source root\n * or previously placed extra file (strix `_collides_with_source_root`,\n * ancestor relationships included).\n * @param rel - candidate workspace-relative path.\n * @param roots - existing roots (subdirs and placed extra files), workspace-relative.\n */\nexport function collidesWithRoots(rel: string, roots: readonly string[]): boolean {\n return roots.some(root => rel === root || rel.startsWith(`${root}/`) || root.startsWith(`${rel}/`))\n}\n\n/**\n * Sanitize a scan id for a staging directory name (strix keeps `[alnum]-_.`,\n * everything else becomes `-`).\n * @param scanId - the raw scan id.\n * @returns the sanitized name fragment (empty collapses to a single `-`).\n */\nexport function stagingDirName(scanId: string): string {\n let safe = ''\n for (const char of scanId) {\n safe += /[A-Za-z0-9]/.test(char) || char === '-' || char === '_' || char === '.' ? char : '-'\n }\n return safe === '' ? '-' : safe\n}\n\n/**\n * The staged host path for one extra file (strix: numbered subdir + basename,\n * so same-basename files cannot clobber each other).\n * @param stagingDir - the session's staging directory.\n * @param index - the extra file's placement index.\n * @param rel - the validated workspace-relative path.\n * @returns the host file path to write the content to.\n */\nexport function stagedFilePath(stagingDir: string, index: number, rel: string): string {\n const basename = rel.slice(rel.lastIndexOf('/') + 1)\n return `${stagingDir}/${index}/${basename}`\n}\n","/**\n * Pure spec→argv builders for the docker CLI sandbox (decision D1: argv\n * constructed directly, no dockerode/docker SDK). Every function here is a\n * total pure function over its inputs — no I/O, no clock, no environment —\n * so the docker contract is exhaustively unit-testable (M1 DoD: 100%\n * branch coverage on this module). Behavioral parity references are the\n * strix runtime sources: session_manager.py env/port handling and\n * docker_client.py `_create_container` (caps, extra_hosts, resource/log\n * limits, 127.0.0.1-ephemeral port publishing, network mode).\n * @module @gpzhang2001/sharpkit-sandbox/spec\n */\n\n/** One host→container bind mount (`docker create -v source:target[:ro]`). */\nexport interface SandboxBindMount {\n readonly source: string\n readonly target: string\n readonly readOnly: boolean\n}\n\n/** Opt-in resource limits (strix: STRIX_SANDBOX_{MEM_LIMIT,SHM_SIZE,CPUS,PIDS_LIMIT}). */\nexport interface SandboxResourceLimits {\n /** Docker memory string, e.g. `\"2g\"`. */\n readonly memLimit?: string | undefined\n /** Docker shm size string, e.g. `\"512m\"`. */\n readonly shmSize?: string | undefined\n /** CPU count as a decimal fraction/multiple, e.g. `1.5`. */\n readonly cpus?: number | undefined\n /** Process-id limit inside the container. */\n readonly pidsLimit?: number | undefined\n}\n\n/** Everything `docker create` needs; assembled by the service layer. */\nexport interface SandboxCreateSpec {\n /** Image reference, e.g. `ghcr.io/gpzhang2001/sharpkit-sandbox:1.0.0-fork2`. */\n readonly image: string\n /** Keep-alive command after the image (`[\"tail\",\"-f\",\"/dev/null\"]` in strix). */\n readonly command: readonly string[]\n /** Container environment (`-e k=v` per entry; insertion order preserved). */\n readonly env: Readonly<Record<string, string>>\n /** Bind mounts, already sorted shallowest-target-first (strix ordering). */\n readonly bindMounts: readonly SandboxBindMount[]\n /** Container-side Caido port; published to `127.0.0.1` ephemeral unless network is set. */\n readonly caidoPort: number\n /** Attach to an existing docker network and publish no ports (strix sandbox-network mode). */\n readonly network?: string | undefined\n /** Linux capabilities appended after any defaults (`--cap-add`). */\n readonly caps?: readonly string[]\n /** Extra /etc/hosts entries (`--add-host k=v`). */\n readonly extraHosts?: Readonly<Record<string, string>>\n /** Optional resource limits. */\n readonly resourceLimits?: SandboxResourceLimits\n /** Log rotation max-size; disables log opts when one of 0/off/none/unlimited (strix default \"50m\"). */\n readonly logMaxSize?: string\n /** Log rotation file count (strix default 3). */\n readonly logMaxFile?: number\n /** Container labels (`--label k=v`; strix: sharpkit-run-id / sharpkit-run-type). */\n readonly labels?: Readonly<Record<string, string>>\n}\n\n/** Values that disable the json-file log rotation opts (strix `_apply_log_limits`). */\nconst LOG_DISABLED_SIZES = new Set(['0', 'off', 'none', 'unlimited'])\n\n/**\n * Build the `docker create` argv for a sandbox spec. Env/hosts/labels are\n * emitted in sorted-key order for deterministic tests; mounts keep their\n * (pre-sorted) order.\n * @param spec - the assembled create spec.\n * @returns the full argv, `[\"docker\",\"create\",…flags,image,…command]`.\n */\nexport function buildCreateArgv(spec: SandboxCreateSpec): string[] {\n const argv: string[] = ['docker', 'create']\n for (const cap of spec.caps ?? []) argv.push('--cap-add', cap)\n for (const key of Object.keys(spec.extraHosts ?? {}).sort()) argv.push('--add-host', `${key}=${spec.extraHosts?.[key]}`)\n for (const key of Object.keys(spec.env).sort()) argv.push('-e', `${key}=${spec.env[key]}`)\n for (const mount of spec.bindMounts) argv.push('-v', mount.readOnly ? `${mount.source}:${mount.target}:ro` : `${mount.source}:${mount.target}`)\n if (spec.network !== undefined && spec.network !== '') {\n // Sandbox-network mode: no published ports; consumers dial the container IP.\n argv.push('--network', spec.network)\n } else {\n argv.push('-p', `127.0.0.1::${spec.caidoPort}`)\n }\n const limits = spec.resourceLimits\n if (limits?.memLimit !== undefined && limits.memLimit !== '') argv.push('--memory', limits.memLimit)\n if (limits?.shmSize !== undefined && limits.shmSize !== '') argv.push('--shm-size', limits.shmSize)\n if (limits?.cpus !== undefined && limits.cpus > 0) argv.push('--cpus', String(limits.cpus))\n if (limits?.pidsLimit !== undefined && Number.isInteger(limits.pidsLimit) && limits.pidsLimit > 0) argv.push('--pids-limit', String(limits.pidsLimit))\n if (logRotationEnabled(spec.logMaxSize)) {\n argv.push('--log-driver', 'json-file', '--log-opt', `max-size=${spec.logMaxSize}`, '--log-opt', `max-file=${spec.logMaxFile ?? 3}`)\n }\n for (const key of Object.keys(spec.labels ?? {}).sort()) argv.push('--label', `${key}=${spec.labels?.[key]}`)\n argv.push(spec.image, ...spec.command)\n return argv\n}\n\n/**\n * Whether json-file rotation opts should be emitted for a max-size value.\n * @param logMaxSize - the configured max-size; absent disables (docker default, unbounded).\n * @returns true when rotation opts must be emitted.\n */\nexport function logRotationEnabled(logMaxSize: string | undefined): boolean {\n return logMaxSize !== undefined && logMaxSize !== '' && !LOG_DISABLED_SIZES.has(logMaxSize.toLowerCase())\n}\n\n/**\n * Build the container environment (strix session_manager.py:316-329 parity).\n * Proxy vars point at the in-container Caido so all container HTTP traffic is\n * interceptable; NO_PROXY keeps CDP/localhost traffic out of the proxy.\n * @param options - ports/identity inputs; uid/gid only on Linux (ownership remap).\n * @returns the env record in stable insertion order.\n */\nexport function buildContainerEnv(options: {\n readonly caidoPort: number\n readonly platform: NodeJS.Platform\n readonly uid?: number | undefined\n readonly gid?: number | undefined\n}): Record<string, string> {\n const proxy = `http://127.0.0.1:${options.caidoPort}`\n const env: Record<string, string> = {\n PYTHONUNBUFFERED: '1',\n HOST_GATEWAY: 'host.docker.internal',\n http_proxy: proxy,\n https_proxy: proxy,\n ALL_PROXY: proxy,\n NO_PROXY: 'localhost,127.0.0.1',\n }\n if (options.platform === 'linux' && options.uid !== undefined && options.uid > 0) {\n env.SHARPKIT_HOST_UID = String(options.uid)\n env.SHARPKIT_HOST_GID = String(options.gid ?? options.uid)\n }\n return env\n}\n\n/** Options for a non-interactive `docker exec` argv. */\nexport interface SandboxExecArgvOptions {\n readonly containerId: string\n readonly command: string\n /** Working directory inside the container (`-w`), typically under /workspace. */\n readonly cwd?: string | undefined\n}\n\n/**\n * Build the non-interactive exec argv: a fresh login shell per call\n * (`bash -lc`), matching the S1 spike and the image's login-shell PATH fixups.\n */\nexport function buildExecArgv(options: SandboxExecArgvOptions): string[] {\n const argv = ['docker', 'exec', '-i']\n if (options.cwd !== undefined) argv.push('-w', options.cwd)\n argv.push(options.containerId, 'bash', '-lc', options.command)\n return argv\n}\n\n/** Options for a PTY-backed interactive `docker exec` argv. */\nexport interface SandboxTtyArgvOptions {\n readonly containerId: string\n readonly command: string\n readonly cwd?: string | undefined\n}\n\n/**\n * Build the PTY exec argv (`docker exec -it`); the host-side PTY is provided\n * by `ctx.subprocess.spawnTerminal` and Ctrl-C is delivered as `\\x03`\n * (spike finding D1.1).\n */\nexport function buildExecTtyArgv(options: SandboxTtyArgvOptions): string[] {\n const argv = ['docker', 'exec', '-it']\n if (options.cwd !== undefined) argv.push('-w', options.cwd)\n argv.push(options.containerId, 'bash', '-lc', options.command)\n return argv\n}\n\n/** A resolved host-side endpoint of a published container port. */\nexport interface SandboxHostEndpoint {\n readonly host: string\n readonly port: number\n}\n\n/**\n * Build the argv resolving a published port's host endpoint (`docker port`).\n */\nexport function buildPortArgv(containerId: string, port: number): string[] {\n return ['docker', 'port', containerId, String(port)]\n}\n\n/**\n * Parse `docker port` output into endpoints, preferring IPv4 (strix publishes\n * to 127.0.0.1). IPv6 literals arrive bracketed and are returned bracket-free\n * with `bracketedIPv6` only when the raw host contains `:`.\n * @param output - the raw `docker port` stdout (zero or more lines).\n * @returns endpoints, IPv4 entries first; empty when nothing is published.\n */\nexport function parsePortOutput(output: string): SandboxHostEndpoint[] {\n const endpoints: SandboxHostEndpoint[] = []\n for (const rawLine of output.split('\\n')) {\n const line = rawLine.trim()\n if (line === '') continue\n // Form: \"127.0.0.1:49153\" or \"[::1]:49153\".\n const lastColon = line.lastIndexOf(':')\n if (lastColon === -1) continue\n const port = Number.parseInt(line.slice(lastColon + 1), 10)\n if (!Number.isInteger(port) || port <= 0 || port > 65535) continue\n let host = line.slice(0, lastColon)\n if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)\n if (host === '') continue\n endpoints.push({ host, port })\n }\n const preferred = endpoints.filter(endpoint => !endpoint.host.includes(':'))\n const ipv6 = endpoints.filter(endpoint => endpoint.host.includes(':'))\n return [...preferred, ...ipv6]\n}\n\n/**\n * Build the argv resolving the container's IP on a sandbox network (strix\n * `StrixDockerSandboxSession._resolve_exposed_port`, network mode).\n */\nexport function buildNetworkIpArgv(containerId: string, network: string): string[] {\n return ['docker', 'inspect', '--format', `{{.NetworkSettings.Networks.${network}.IPAddress}}`, containerId]\n}\n\n/** Container-side path reference for `docker cp` (`<id>:<path>`). */\nexport function containerRef(containerId: string, containerPath: string): string {\n return `${containerId}:${containerPath}`\n}\n\n/** Build the `docker cp` argv copying a host file into the container. */\nexport function buildPutFileArgv(hostPath: string, containerId: string, containerPath: string): string[] {\n return ['docker', 'cp', hostPath, containerRef(containerId, containerPath)]\n}\n\n/** Build the `docker cp` argv copying a container file out to a host path. */\nexport function buildGetFileArgv(containerId: string, containerPath: string, hostPath: string): string[] {\n return ['docker', 'cp', containerRef(containerId, containerPath), hostPath]\n}\n\n/** Build the graceful stop argv (`docker stop -t <seconds>`). */\nexport function buildStopArgv(containerId: string, graceMs: number): string[] {\n const seconds = Math.max(0, Math.round(graceMs / 1000))\n return ['docker', 'stop', '-t', String(seconds), containerId]\n}\n\n/** Build the plain remove argv; the caller escalates to force-remove on failure. */\nexport function buildRmArgv(containerId: string): string[] {\n return ['docker', 'rm', containerId]\n}\n\n/** Build the force-remove argv (fallback path and failure cleanup). */\nexport function buildRmForceArgv(containerId: string): string[] {\n return ['docker', 'rm', '-f', containerId]\n}\n\n/** Build the argv checking whether an image is present locally. */\nexport function buildImageInspectArgv(image: string): string[] {\n return ['docker', 'image', 'inspect', image]\n}\n\n/** Build the pull argv (strix pulls only when the image is missing). */\nexport function buildPullArgv(image: string): string[] {\n return ['docker', 'pull', image]\n}\n\n/** Build the start argv. */\nexport function buildStartArgv(containerId: string): string[] {\n return ['docker', 'start', containerId]\n}\n","/**\n * Opaque cross-boundary ids for the pentest sandbox seam. Kept in a leaf\n * module so consumers can import the types without dragging in runtime code\n * (`@deepseek-ai/dsh-brand` pattern, cf. jobs' JobId).\n * @module @gpzhang2001/sharpkit-sandbox/brand\n */\n\nimport type { Branded } from '@deepseek-ai/dsh-brand'\n\n/** Opaque id of one sandbox session (container + Caido bootstrap). */\nexport type SandboxSessionId = Branded<'SandboxSessionId'>\n\n/** Opaque id of one PTY-backed interactive process inside a session. */\nexport type SandboxProcessId = Branded<'SandboxProcessId'>\n\n/** Brand a raw string as a {@link SandboxSessionId}. */\nexport function SandboxSessionId(id: string): SandboxSessionId {\n return id as SandboxSessionId\n}\n\n/** Brand a raw string as a {@link SandboxProcessId}. */\nexport function SandboxProcessId(id: string): SandboxProcessId {\n return id as SandboxProcessId\n}\n","/**\n * One docker-CLI sandbox session: exec/PTY command execution, file transfer\n * via `docker cp`, lazy Caido endpoint, and strix-parity teardown. The\n * subprocess seam arrives as a structural interface so the session is\n * drivable from tests exactly like the S1 spike drove it. Teardown follows\n * strix session_manager.cleanup order (staging → caido → container), each\n * step best-effort with logging; host-side PTY trees are terminated first\n * (spike finding D1.4: host terminate cannot reach daemon-owned container\n * processes, so `docker rm -f` is the authoritative reaper).\n * @module @gpzhang2001/sharpkit-sandbox/session\n */\n\nimport { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { basename, dirname, join } from 'node:path'\nimport type {\n SubprocessHandle,\n SubprocessSpawnSpec,\n SubprocessTerminalHandle,\n SubprocessTerminalSpawnSpec,\n} from '@deepseek-ai/dsh-subprocess'\nimport { CaidoBootstrap, type CaidoEndpoint } from './caido.ts'\nimport { SandboxProcessId, type SandboxSessionId } from './brand.ts'\nimport {\n buildExecArgv,\n buildExecTtyArgv,\n buildGetFileArgv,\n buildPutFileArgv,\n buildRmArgv,\n buildRmForceArgv,\n buildStopArgv,\n type SandboxBindMount,\n} from './spec.ts'\n\n/** Structural view of `ctx.subprocess` the session drives (S1 spike shape). */\nexport interface SubprocessLike {\n spawn(spec: SubprocessSpawnSpec): SubprocessHandle\n spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>\n}\n\n/** Logger subset (cordis logger satisfies this structurally). */\nexport interface SandboxLogger {\n debug(message: string): void\n info(message: string): void\n warn(message: string): void\n error(message: string): void\n}\n\n/** Options for a non-interactive exec. */\nexport interface SandboxExecOptions {\n /** Working directory inside the container. */\n readonly cwd?: string | undefined\n /** Per-call timeout; a timed-out command is terminated and reported. */\n readonly timeoutMs?: number | undefined\n /** Cooperative cancellation: aborting terminates the tree (tool exec.signal). */\n readonly signal?: AbortSignal | undefined\n}\n\n/** Result of a non-interactive exec. */\nexport interface SandboxExecResult {\n readonly exitCode: number | null\n readonly signal: string | null\n readonly stdout: string\n readonly stderr: string\n /** True only when the deadline fired (caller cancellation is `aborted`). */\n readonly timedOut: boolean\n /** True when the caller's AbortSignal fired (cooperative cancellation). */\n readonly aborted: boolean\n}\n\n/** One live PTY-backed interactive process. */\nexport interface SandboxTtyProcess {\n readonly id: SandboxProcessId\n readonly command: string\n /** Feed characters into the process (Ctrl-C arrives as `\\x03`, finding D1.1). */\n write(chars: string): Promise<void>\n /** Subscribe to decoded output chunks; returns an unsubscribe function. */\n subscribe(listener: (chunk: string) => void): () => void\n /** Resolves when the process exits. */\n readonly done: Promise<SandboxExecResult>\n /** Terminate the host-side tree (container residue is reaped by session stop). */\n terminate(): Promise<void>\n}\n\n/** The session contract consumed by the suite's tool packages. */\nexport interface PentestSandboxSession {\n readonly sessionId: SandboxSessionId\n readonly scanId: string\n readonly containerId: string\n /** Await Caido readiness (the guest-login retry loop is the probe). */\n ready(): Promise<void>\n /** Run one command in a fresh non-interactive login shell. */\n exec(command: string, options?: SandboxExecOptions): Promise<SandboxExecResult>\n /** Start a cancellable no-timeout exec for background jobs. */\n execJob(command: string, options?: { readonly cwd?: string | undefined }): SandboxExecProcess\n /** Start one PTY-backed interactive process (REPL/ssh/msfconsole). */\n execTty(command: string, options?: { readonly cwd?: string | undefined; readonly rows?: number | undefined; readonly cols?: number | undefined }): Promise<SandboxTtyProcess>\n /** Write characters to a live PTY process. */\n writeStdin(processId: SandboxProcessId, chars: string): Promise<void>\n /** Copy a host file into the container. */\n putFile(hostPath: string, containerPath: string): Promise<void>\n /** Read a container file out as raw bytes (binary-safe via docker cp). */\n getFile(containerPath: string): Promise<Uint8Array>\n /** The host-side Caido endpoint (resolves readiness first). */\n caidoEndpoint(): Promise<CaidoEndpoint>\n /** Best-effort teardown: PTYs, staging dir, caido, container. */\n stop(): Promise<void>\n}\n\n/** Collected output read helper: reader text or ''. */\nfunction readerText(handle: SubprocessHandle, stream: 'stdout' | 'stderr'): string {\n return handle.collected[stream]?.readFrom(0).text ?? ''\n}\n\n/**\n * Run one argv to completion with collected output and a hard timeout that\n * terminates (and joins) the tree — the primitive every docker CLI call in\n * the session goes through. A timeout and a caller abort terminate the host\n * tree identically but are reported separately (`timedOut` vs `aborted`);\n * per spike finding D1.4, host-side termination cannot reach daemon-owned\n * container processes — a timed-out `docker exec` may leave its command\n * running inside the container until the session stops and reaps it.\n * @param subprocess - the subprocess seam.\n * @param argv - full argv, argv[0] a program (never shell-interpreted).\n * @param options - timeout and terminate grace.\n * @returns the collected outcome.\n */\nexport async function runCollectArgv(\n subprocess: SubprocessLike,\n argv: readonly string[],\n options: { readonly timeoutMs: number; readonly graceMs: number; readonly collectMaxBytes: number; readonly signal?: AbortSignal | undefined },\n): Promise<SandboxExecResult> {\n const handle = subprocess.spawn({\n argv,\n cwd: process.cwd(),\n stdio: {\n stdin: 'ignore',\n stdout: { maxBytes: options.collectMaxBytes },\n stderr: { maxBytes: options.collectMaxBytes },\n },\n graceMs: options.graceMs,\n })\n let timedOut = false\n let aborted = false\n let timer: NodeJS.Timeout | undefined\n let onAbort: (() => void) | undefined\n const deadline = new Promise<'deadline'>(resolve => {\n timer = setTimeout(() => {\n timedOut = true\n handle.terminate()\n resolve('deadline')\n }, options.timeoutMs)\n const signal = options.signal\n if (signal !== undefined) {\n onAbort = () => {\n aborted = true\n handle.terminate()\n resolve('deadline')\n }\n signal.addEventListener('abort', onAbort, { once: true })\n }\n })\n const winner = await Promise.race([handle.done.then(outcome => ({ outcome })), deadline.then(() => 'deadline' as const)])\n if (timer !== undefined) clearTimeout(timer)\n if (onAbort !== undefined) options.signal?.removeEventListener('abort', onAbort)\n const outcome = winner === 'deadline' ? await handle.done : winner.outcome\n return {\n exitCode: outcome.exitCode,\n signal: outcome.signal,\n stdout: readerText(handle, 'stdout'),\n stderr: readerText(handle, 'stderr'),\n timedOut,\n aborted,\n }\n}\n\n/** Internal record of one live PTY process. */\ninterface TtyRecord {\n readonly process: SandboxTtyProcess\n readonly handle: SubprocessTerminalHandle\n readonly listeners: Set<(chunk: string) => void>\n}\n\n/** A cancellable long-running non-interactive exec (jobs integration). */\nexport interface SandboxExecProcess {\n readonly processId: SandboxProcessId\n /** Resolves with the full collected outcome after the tree settles. */\n readonly done: Promise<SandboxExecResult>\n /** Consuming delta of stdout since the previous call ('' when nothing new). */\n readOutput(): string\n /** Terminate the host-side tree; `done` then settles with the partial output. */\n terminate(): void\n}\n\n/** Knobs the service hands each session (resolved Config values). */\nexport interface SandboxSessionDeps {\n readonly subprocess: SubprocessLike\n readonly logger: SandboxLogger\n readonly containerId: string\n readonly scanId: string\n readonly containerCaidoBaseUrl: string\n readonly hostCaidoBaseUrl: string\n readonly bootstrap: CaidoBootstrap\n /** Extra-file staging directory to remove on stop (undefined when none). */\n readonly stagingDir?: string | undefined\n readonly graceMs: number\n readonly defaultExecTimeoutMs: number\n readonly collectMaxBytes: number\n}\n\n/**\n * The docker-CLI-backed {@link PentestSandboxSession}. Constructed by the\n * service after the container is created and started; never constructed\n * directly by consumers.\n */\nexport class DockerCliSandboxSession implements PentestSandboxSession {\n readonly sessionId: SandboxSessionId\n readonly scanId: string\n readonly containerId: string\n private readonly deps: SandboxSessionDeps\n private readonly ttyProcesses = new Map<SandboxProcessId, TtyRecord>()\n private stopped = false\n\n constructor(deps: SandboxSessionDeps) {\n this.deps = deps\n this.sessionId = crypto.randomUUID() as SandboxSessionId\n this.scanId = deps.scanId\n this.containerId = deps.containerId\n }\n\n async ready(): Promise<void> {\n await this.deps.bootstrap.get()\n }\n\n async caidoEndpoint(): Promise<CaidoEndpoint> {\n return this.deps.bootstrap.get()\n }\n\n async exec(command: string, options?: SandboxExecOptions): Promise<SandboxExecResult> {\n if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`)\n const timeoutMs = options?.timeoutMs ?? this.deps.defaultExecTimeoutMs\n return runCollectArgv(this.deps.subprocess, buildExecArgv({ containerId: this.containerId, command, cwd: options?.cwd }), {\n timeoutMs,\n graceMs: this.deps.graceMs,\n collectMaxBytes: this.deps.collectMaxBytes,\n signal: options?.signal,\n })\n }\n\n /**\n * Start a cancellable long-running exec with no timeout (jobs own the\n * lifetime): the caller polls {@link SandboxExecProcess.readOutput} deltas\n * and terminates on cancel.\n */\n execJob(command: string, options?: { readonly cwd?: string | undefined }): SandboxExecProcess {\n if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`)\n const handle = this.deps.subprocess.spawn({\n argv: buildExecArgv({ containerId: this.containerId, command, cwd: options?.cwd }),\n cwd: process.cwd(),\n stdio: {\n stdin: 'ignore',\n stdout: { maxBytes: this.deps.collectMaxBytes },\n stderr: { maxBytes: this.deps.collectMaxBytes },\n },\n graceMs: this.deps.graceMs,\n })\n let offset = 0\n const processId = SandboxProcessId(crypto.randomUUID())\n const done = handle.done.then(outcome => {\n const stdout = handle.collected.stdout?.readFrom(0).text ?? ''\n const stderr = handle.collected.stderr?.readFrom(0).text ?? ''\n return { exitCode: outcome.exitCode, signal: outcome.signal, stdout, stderr, timedOut: false, aborted: false }\n })\n return {\n processId,\n done,\n readOutput: () => {\n const read = handle.collected.stdout?.readFrom(offset)\n if (read === undefined) return ''\n offset = read.nextOffset\n return read.text\n },\n terminate: () => {\n handle.terminate()\n },\n }\n }\n\n async execTty(command: string, options?: { readonly cwd?: string | undefined; readonly rows?: number | undefined; readonly cols?: number | undefined }): Promise<SandboxTtyProcess> {\n if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`)\n const handle = await this.deps.subprocess.spawnTerminal({\n argv: buildExecTtyArgv({ containerId: this.containerId, command, cwd: options?.cwd }),\n cwd: process.cwd(),\n rows: options?.rows ?? 24,\n cols: options?.cols ?? 80,\n graceMs: this.deps.graceMs,\n })\n const id = SandboxProcessId(crypto.randomUUID())\n const listeners = new Set<(chunk: string) => void>()\n const decoder = new TextDecoder()\n handle.output.on('data', (chunk: Uint8Array) => {\n const text = decoder.decode(chunk, { stream: true })\n for (const listener of listeners) listener(text)\n })\n const done = handle.done.then(outcome => ({\n exitCode: outcome.exitCode,\n signal: outcome.signal,\n stdout: '',\n stderr: '',\n timedOut: false,\n aborted: false,\n }))\n const record: TtyRecord = {\n handle,\n listeners,\n process: {\n id,\n command,\n write: chars => handle.write(chars),\n subscribe: listener => {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n },\n done,\n terminate: () => handle.terminate(),\n },\n }\n this.ttyProcesses.set(id, record)\n void done.then(\n () => {\n this.ttyProcesses.delete(id)\n },\n () => {\n this.ttyProcesses.delete(id)\n },\n )\n return record.process\n }\n\n async writeStdin(processId: SandboxProcessId, chars: string): Promise<void> {\n const record = this.ttyProcesses.get(processId)\n if (record === undefined) throw new Error(`write_stdin: no live interactive process ${processId} in scan ${this.scanId}`)\n await record.handle.write(chars)\n }\n\n async putFile(hostPath: string, containerPath: string): Promise<void> {\n const result = await runCollectArgv(this.deps.subprocess, buildPutFileArgv(hostPath, this.containerId, containerPath), {\n timeoutMs: this.deps.defaultExecTimeoutMs,\n graceMs: this.deps.graceMs,\n collectMaxBytes: this.deps.collectMaxBytes,\n })\n if (result.exitCode !== 0) throw new Error(`putFile failed (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`)\n }\n\n async getFile(containerPath: string): Promise<Uint8Array> {\n const name = basename(containerPath)\n if (name === '' || name === '/' || name === '.') throw new Error(`getFile: container path must name a file: ${containerPath}`)\n const dir = await mkdtemp(join(tmpdir(), 'sharpkit-getfile-'))\n try {\n const hostPath = join(dir, name)\n const result = await runCollectArgv(this.deps.subprocess, buildGetFileArgv(this.containerId, containerPath, hostPath), {\n timeoutMs: this.deps.defaultExecTimeoutMs,\n graceMs: this.deps.graceMs,\n collectMaxBytes: this.deps.collectMaxBytes,\n })\n if (result.exitCode !== 0) throw new Error(`getFile failed (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`)\n return new Uint8Array(await readFile(hostPath))\n } finally {\n await rm(dir, { recursive: true, force: true }).catch(() => {})\n }\n }\n\n async stop(): Promise<void> {\n if (this.stopped) return\n this.stopped = true\n const log = this.deps.logger\n for (const record of this.ttyProcesses.values()) {\n try {\n await record.handle.terminate()\n } catch (error) {\n log.debug(`stop(${this.scanId}): tty terminate raised: ${String(error)}`)\n }\n }\n this.ttyProcesses.clear()\n if (this.deps.stagingDir !== undefined) {\n await rm(this.deps.stagingDir, { recursive: true, force: true }).catch(() => {})\n }\n this.deps.bootstrap.close()\n try {\n const stopped = await runCollectArgv(this.deps.subprocess, buildStopArgv(this.containerId, this.deps.graceMs), {\n timeoutMs: this.deps.defaultExecTimeoutMs,\n graceMs: this.deps.graceMs,\n collectMaxBytes: this.deps.collectMaxBytes,\n })\n if (stopped.exitCode !== 0) log.warn(`stop(${this.scanId}): docker stop exit ${stopped.exitCode}: ${stopped.stderr.slice(0, 200)}`)\n } catch (error) {\n log.debug(`stop(${this.scanId}): docker stop raised: ${String(error)}`)\n }\n try {\n const removed = await runCollectArgv(this.deps.subprocess, buildRmArgv(this.containerId), {\n timeoutMs: this.deps.defaultExecTimeoutMs,\n graceMs: this.deps.graceMs,\n collectMaxBytes: this.deps.collectMaxBytes,\n })\n if (removed.exitCode !== 0) {\n await runCollectArgv(this.deps.subprocess, buildRmForceArgv(this.containerId), {\n timeoutMs: this.deps.defaultExecTimeoutMs,\n graceMs: this.deps.graceMs,\n collectMaxBytes: this.deps.collectMaxBytes,\n })\n }\n } catch (error) {\n log.error(`stop(${this.scanId}): container removal raised; container may need manual reaping: ${String(error)}`)\n }\n }\n}\n\n/**\n * Stage extra files for bind mounting (strix `build_extra_file_bind_mounts`):\n * one numbered subdir per file, content written, mounted read-only at its\n * workspace path.\n * @param stagingDir - the session staging directory (already created).\n * @param items - validated extra files: rel path + bytes.\n * @param workspaceRoot - container workspace root (default `/workspace`).\n * @returns the mounts, in placement order.\n */\nexport async function stageExtraFiles(\n stagingDir: string,\n items: readonly { readonly rel: string; readonly content: Uint8Array }[],\n workspaceRoot: string,\n): Promise<SandboxBindMount[]> {\n const mounts: SandboxBindMount[] = []\n let index = 0\n for (const item of items) {\n const staged = join(stagingDir, String(index), basename(item.rel))\n await mkdir(dirname(staged), { recursive: true })\n await writeFile(staged, item.content)\n mounts.push({ source: staged, target: `${workspaceRoot}/${item.rel}`, readOnly: true })\n index++\n }\n return mounts\n}\n","/**\n * Docker sandbox capability for the sharpkit pentest suite: the\n * `ctx.pentestSandbox` service (decision D1: `ctx.subprocess` + docker CLI,\n * argv built by the pure spec module). Port of strix runtime/session_manager\n * `create_or_reuse` + `cleanup` semantics: sessions cached by scan id,\n * container lifecycle over the CLI, extra files staged under the temp dir\n * (a remote docker daemon resolves bind sources on its own filesystem), and\n * a lazy Caido bootstrap that runs concurrently with scan start. Teardown is\n * registered once via ctx.effect and stops every live session best-effort.\n * @module @gpzhang2001/sharpkit-sandbox\n */\n\nimport { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'\nimport { mkdtemp, rm } from 'node:fs/promises'\nimport { homedir, tmpdir } from 'node:os'\nimport { join } from 'node:path'\nimport { Context, Service } from '@deepseek-ai/cordis'\nimport type Schema from '@deepseek-ai/schemastery'\nimport z from '@deepseek-ai/schemastery'\nimport { bootstrapCaido, CaidoBootstrap } from './caido.ts'\nimport {\n buildBindMounts,\n collidesWithRoots,\n extraFileRelPath,\n stagingDirName,\n type FsProbe,\n type SandboxSourceSpec,\n} from './mounts.ts'\nimport {\n buildCreateArgv,\n buildContainerEnv,\n buildImageInspectArgv,\n buildNetworkIpArgv,\n buildPortArgv,\n buildPullArgv,\n buildStartArgv,\n parsePortOutput,\n type SandboxCreateSpec,\n} from './spec.ts'\nimport {\n DockerCliSandboxSession,\n runCollectArgv,\n stageExtraFiles,\n type PentestSandboxSession,\n} from './session.ts'\n\nexport type {\n PentestSandboxSession,\n SandboxExecOptions,\n SandboxExecResult,\n SandboxTtyProcess,\n} from './session.ts'\nexport type { CaidoBootstrap, CaidoEndpoint } from './caido.ts'\nexport type { SandboxSourceSpec } from './mounts.ts'\nexport { SandboxProcessId, SandboxSessionId } from './brand.ts'\nexport type { SandboxProcessId as SandboxProcessIdType, SandboxSessionId as SandboxSessionIdType } from './brand.ts'\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n pentestSandbox: PentestSandboxService\n }\n}\n\n/** One extra file materialized into the workspace before container start. */\nexport interface SandboxExtraFile {\n /** Absolute container path under the workspace root. */\n readonly containerPath: string\n /** File content; strings are encoded UTF-8. */\n readonly content: string | Uint8Array\n}\n\n/** Options for {@link PentestSandboxService.createSession}. */\nexport interface SandboxSessionOptions {\n /** Cache key; repeated ids reuse the live session (strix create_or_reuse). */\n readonly scanId: string\n /** Source trees to bind-mount read-write into the workspace. */\n readonly sources?: readonly SandboxSourceSpec[]\n /** Extra files staged on the host and mounted read-only into the workspace. */\n readonly extraFiles?: readonly SandboxExtraFile[]\n}\n\n/** Deployment-tunable configuration (cordis resolves defaults before apply). */\nexport interface Config {\n /** Sandbox image reference. */\n readonly image?: string\n /** Terminate grace for every docker CLI tree the session owns. */\n readonly containerGraceMs?: number\n /** Container workspace root (protocol constant with the image). */\n readonly workspaceRoot?: string\n /** Existing docker network to attach to (publishes no ports; strix sandbox-network mode). */\n readonly network?: string\n /** Resource limits (strix STRIX_SANDBOX_* env knobs, now config). */\n readonly memLimit?: string\n readonly shmSize?: string\n readonly cpus?: number\n readonly pidsLimit?: number\n /** Log rotation; max-size 0/off/none/unlimited disables (strix default \"50m\"/3). */\n readonly logMaxSize?: string\n readonly logMaxFile?: number\n /** Run labels forwarded to the container (strix STRIX_RUN_ID/RUN_TYPE). */\n readonly runLabelId?: string\n readonly runLabelType?: string\n /** Caido guest-login retry budget (the readiness probe). */\n readonly caidoLoginAttempts?: number\n /** Per-attempt login exec timeout. */\n readonly caidoLoginTimeoutMs?: number\n /** Default timeout for exec and docker CLI helper calls. */\n readonly defaultExecTimeoutMs?: number\n /** Collect window for exec stdout/stderr (bytes; larger streams go lossy). */\n readonly execCollectMaxBytes?: number\n}\n\n/** Resolved shape cordis hands the constructor after schema defaults. */\ntype ResolvedConfig = Required<Omit<Config, 'network' | 'memLimit' | 'shmSize' | 'cpus' | 'pidsLimit' | 'runLabelId' | 'runLabelType'>>\n & Pick<Config, 'network' | 'memLimit' | 'shmSize' | 'cpus' | 'pidsLimit' | 'runLabelId' | 'runLabelType'>\n\n/** Container-side Caido port (protocol constant with the image, not a tunable). */\nconst CAIDO_PORT = 48080\n\n/** Keep-alive command (protocol with the image entrypoint, which execs it). */\nconst KEEPALIVE_COMMAND = ['tail', '-f', '/dev/null'] as const\n\n/**\n * The pentest sandbox service: creates, caches, and reuses docker-CLI\n * sandbox sessions. Load as a plugin after a subprocess provider; it\n * registers as `ctx.pentestSandbox` (one per context).\n */\nexport class PentestSandboxService extends Service {\n static inject = ['subprocess']\n\n static Config: Schema<Config> = z.object({\n image: z.string().default('ghcr.io/gpzhang2001/sharpkit-sandbox:1.0.0-fork2'),\n containerGraceMs: z.number().default(10_000),\n workspaceRoot: z.string().default('/workspace'),\n network: z.string(),\n memLimit: z.string(),\n shmSize: z.string(),\n cpus: z.number(),\n pidsLimit: z.number(),\n logMaxSize: z.string().default('50m'),\n logMaxFile: z.number().default(3),\n runLabelId: z.string(),\n runLabelType: z.string(),\n caidoLoginAttempts: z.number().default(10),\n caidoLoginTimeoutMs: z.number().default(15_000),\n defaultExecTimeoutMs: z.number().default(120_000),\n execCollectMaxBytes: z.number().default(1_048_576),\n })\n\n private readonly config: ResolvedConfig\n private readonly sessions = new Map<string, PentestSandboxSession>()\n\n /** node:fs-backed mount facts; a handful of sync calls on a few paths. */\n private readonly probe: FsProbe = {\n // pathlib.Path.resolve parity: expanduser + symlink-following canonicalization.\n resolve: path => realpathSync(path.startsWith('~/') ? join(homedir(), path.slice(2)) : path),\n exists: path => existsSync(path),\n isDirectory: path => {\n try {\n return statSync(path).isDirectory()\n } catch {\n return false\n }\n },\n isFile: path => {\n try {\n return statSync(path).isFile()\n } catch {\n return false\n }\n },\n readTextFile: path => {\n try {\n return readFileSync(path, 'utf8')\n } catch {\n return null\n }\n },\n }\n\n constructor(ctx: Context, config: Config = {}) {\n super(ctx, 'pentestSandbox')\n this.config = config as ResolvedConfig\n void ctx.effect(() => async () => {\n for (const session of this.sessions.values()) {\n await session.stop()\n }\n this.sessions.clear()\n }, 'pentest-sandbox session teardown')\n }\n\n /**\n * Create (or reuse) the sandbox session for a scan id — strix\n * `create_or_reuse` parity, including lazy Caido bootstrap.\n * @param options - scan identity, sources, extra files.\n * @returns the live session.\n */\n async createSession(options: SandboxSessionOptions): Promise<PentestSandboxSession> {\n const cached = this.sessions.get(options.scanId)\n if (cached !== undefined) {\n this.ctx.logger.debug(`pentest-sandbox: reusing session for scan ${options.scanId}`)\n return cached\n }\n const config = this.config\n const subprocess = this.ctx.subprocess\n const cli = { timeoutMs: config.defaultExecTimeoutMs, graceMs: config.containerGraceMs, collectMaxBytes: config.execCollectMaxBytes }\n let stagingDir: string | undefined\n let containerId: string | undefined\n try {\n const mounts = buildBindMounts(options.sources ?? [], this.probe, config.workspaceRoot)\n const extraMounts = await this.stageExtraFiles(options, config.workspaceRoot)\n if (extraMounts.stagingDir !== undefined) stagingDir = extraMounts.stagingDir\n const allMounts = [...mounts, ...extraMounts.mounts].sort(\n (a, b) => a.target.split('/').length - b.target.split('/').length || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0),\n )\n const labels: Record<string, string> = {}\n if (config.runLabelId !== undefined) labels['sharpkit-run-id'] = config.runLabelId\n if (config.runLabelType !== undefined) labels['sharpkit-run-type'] = config.runLabelType\n const spec: SandboxCreateSpec = {\n image: config.image,\n command: KEEPALIVE_COMMAND,\n env: buildContainerEnv({\n caidoPort: CAIDO_PORT,\n platform: process.platform,\n uid: typeof process.getuid === 'function' ? process.getuid() : undefined,\n gid: typeof process.getgid === 'function' ? process.getgid() : undefined,\n }),\n bindMounts: allMounts,\n caidoPort: CAIDO_PORT,\n network: config.network,\n caps: ['NET_ADMIN', 'NET_RAW'],\n extraHosts: { 'host.docker.internal': 'host-gateway' },\n resourceLimits: {\n memLimit: config.memLimit,\n shmSize: config.shmSize,\n cpus: config.cpus,\n pidsLimit: config.pidsLimit,\n },\n logMaxSize: config.logMaxSize,\n logMaxFile: config.logMaxFile,\n labels,\n }\n await this.ensureImage(spec.image, cli)\n const created = await runCollectArgv(subprocess, buildCreateArgv(spec), cli)\n if (created.exitCode !== 0) throw new Error(`docker create failed (exit ${created.exitCode}): ${created.stderr.slice(0, 500)}`)\n containerId = created.stdout.trim().split('\\n').at(-1)?.trim() ?? ''\n if (containerId === '') throw new Error('docker create produced no container id')\n const started = await runCollectArgv(subprocess, buildStartArgv(containerId), cli)\n if (started.exitCode !== 0) throw new Error(`docker start failed (exit ${started.exitCode}): ${started.stderr.slice(0, 500)}`)\n const hostBaseUrl = await this.resolveCaidoHostUrl(containerId, cli)\n const id = containerId\n const bootstrap = new CaidoBootstrap(signal =>\n bootstrapCaido(\n (command, timeoutMs) =>\n runCollectArgv(subprocess, ['docker', 'exec', '-i', id, 'bash', '-lc', command], { ...cli, timeoutMs }).then(result => ({\n ok: result.exitCode === 0,\n exitCode: result.exitCode,\n stdout: result.stdout,\n stderr: result.stderr,\n })),\n fetch,\n { containerBaseUrl: `http://127.0.0.1:${CAIDO_PORT}`, hostBaseUrl },\n {\n attempts: config.caidoLoginAttempts,\n timeoutMs: config.caidoLoginTimeoutMs,\n sleep: ms => new Promise(resolveSleep => setTimeout(resolveSleep, ms)),\n signal,\n },\n ),\n )\n const session = new DockerCliSandboxSession({\n subprocess,\n logger: this.ctx.logger,\n containerId,\n scanId: options.scanId,\n containerCaidoBaseUrl: `http://127.0.0.1:${CAIDO_PORT}`,\n hostCaidoBaseUrl: hostBaseUrl,\n bootstrap,\n stagingDir,\n graceMs: config.containerGraceMs,\n defaultExecTimeoutMs: config.defaultExecTimeoutMs,\n collectMaxBytes: config.execCollectMaxBytes,\n })\n this.sessions.set(options.scanId, session)\n return session\n } catch (error) {\n // strix parity: drop staging, best-effort container removal, re-raise.\n if (stagingDir !== undefined) await rm(stagingDir, { recursive: true, force: true }).catch(() => {})\n if (containerId !== undefined) {\n await runCollectArgv(subprocess, ['docker', 'rm', '-f', containerId], cli).catch(() => undefined)\n }\n throw error\n }\n }\n\n /** Stop and forget one session (idempotent; strix `cleanup`). */\n async destroySession(scanId: string): Promise<void> {\n const session = this.sessions.get(scanId)\n if (session === undefined) {\n this.ctx.logger.debug(`pentest-sandbox: no session to clean for scan ${scanId}`)\n return\n }\n this.sessions.delete(scanId)\n await session.stop()\n }\n\n /** Validate + stage extra files (strix skip-and-warn semantics). */\n private async stageExtraFiles(\n options: SandboxSessionOptions,\n workspaceRoot: string,\n ): Promise<{ mounts: ReturnType<typeof buildBindMounts>; stagingDir?: string }> {\n const extraFiles = options.extraFiles ?? []\n if (extraFiles.length === 0) return { mounts: [] }\n const sourceRoots = options.sources?.map(source => source.workspaceSubdir) ?? []\n const placed: string[] = []\n const items: { rel: string; content: Uint8Array }[] = []\n for (const file of extraFiles) {\n const rel = extraFileRelPath(file.containerPath, workspaceRoot)\n if (rel === null) {\n this.ctx.logger.warn(`pentest-sandbox: skipping invalid extra file path ${file.containerPath}`)\n continue\n }\n if (collidesWithRoots(rel, [...sourceRoots, ...placed])) {\n this.ctx.logger.warn(`pentest-sandbox: skipping colliding extra file ${file.containerPath}`)\n continue\n }\n placed.push(rel)\n items.push({ rel, content: typeof file.content === 'string' ? new TextEncoder().encode(file.content) : file.content })\n }\n if (items.length === 0) return { mounts: [] }\n const stagingDir = await mkdtemp(`${tmpdir()}/pentest-extra-files-${stagingDirName(options.scanId)}-`)\n return { mounts: await stageExtraFiles(stagingDir, items, workspaceRoot), stagingDir }\n }\n\n /** Pull the image when missing (strix image_exists → pull). */\n private async ensureImage(image: string, cli: { timeoutMs: number; graceMs: number; collectMaxBytes: number }): Promise<void> {\n const present = await runCollectArgv(this.ctx.subprocess, buildImageInspectArgv(image), { ...cli, timeoutMs: 60_000 })\n if (present.exitCode === 0) return\n this.ctx.logger.info(`pentest-sandbox: pulling image ${image}`)\n const pulled = await runCollectArgv(this.ctx.subprocess, buildPullArgv(image), { ...cli, timeoutMs: 1_800_000 })\n if (pulled.exitCode !== 0) throw new Error(`docker pull failed (exit ${pulled.exitCode}): ${pulled.stderr.slice(0, 500)}`)\n }\n\n /** Resolve the host-side Caido base URL for a started container. */\n private async resolveCaidoHostUrl(\n containerId: string,\n cli: { timeoutMs: number; graceMs: number; collectMaxBytes: number },\n ): Promise<string> {\n const config = this.config\n if (config.network !== undefined && config.network !== '') {\n const inspected = await runCollectArgv(this.ctx.subprocess, buildNetworkIpArgv(containerId, config.network), cli)\n if (inspected.exitCode !== 0) throw new Error(`docker inspect (network ip) failed (exit ${inspected.exitCode}): ${inspected.stderr.slice(0, 500)}`)\n const ip = inspected.stdout.trim()\n if (ip === '') throw new Error(`container has no address on network ${config.network}`)\n const host = ip.includes(':') ? `[${ip}]` : ip\n return `http://${host}:${CAIDO_PORT}`\n }\n const port = await runCollectArgv(this.ctx.subprocess, buildPortArgv(containerId, CAIDO_PORT), cli)\n if (port.exitCode !== 0) throw new Error(`docker port failed (exit ${port.exitCode}): ${port.stderr.slice(0, 500)}`)\n const endpoint = parsePortOutput(port.stdout)[0]\n if (endpoint === undefined) throw new Error(`caido port ${CAIDO_PORT} is not published for container ${containerId}`)\n return `http://${endpoint.host}:${endpoint.port}`\n }\n}\n\nexport default PentestSandboxService\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gpzhang2001/sharpkit-sandbox",
|
|
3
|
+
"description": "Docker sandbox capability for the sharpkit pentest suite: container lifecycle, exec (incl. PTY), file transfer, port resolution, Caido bootstrap",
|
|
4
|
+
"version": "0.2.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/gpzhang2001/sharpkit.git",
|
|
11
|
+
"directory": "packages/sandbox"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"license": "Apache-2.0",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./lib/index.d.ts",
|
|
18
|
+
"default": "./lib/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./src/*": "./src/*",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib",
|
|
25
|
+
"src",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"THIRD_PARTY_NOTICES.md"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@deepseek-ai/schemastery": "3.18.2"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
34
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.2-rc.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
38
|
+
"@deepseek-ai/dsh-brand": "0.1.2-rc.1",
|
|
39
|
+
"@deepseek-ai/dsh-subprocess": "0.1.2-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-subprocess-local": "0.1.2-rc.1",
|
|
41
|
+
"@deepseek-ai/dsh-timeout": "0.1.2-rc.1"
|
|
42
|
+
},
|
|
43
|
+
"main": "lib/index.js",
|
|
44
|
+
"types": "lib/index.d.ts",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "cp ../../LICENSE ../../THIRD_PARTY_NOTICES.md . && tsdown && mv -f lib/index.ts lib/index.d.ts && mv -f lib/index.ts.map lib/index.d.ts.map"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/brand.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque cross-boundary ids for the pentest sandbox seam. Kept in a leaf
|
|
3
|
+
* module so consumers can import the types without dragging in runtime code
|
|
4
|
+
* (`@deepseek-ai/dsh-brand` pattern, cf. jobs' JobId).
|
|
5
|
+
* @module @gpzhang2001/sharpkit-sandbox/brand
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Branded } from '@deepseek-ai/dsh-brand'
|
|
9
|
+
|
|
10
|
+
/** Opaque id of one sandbox session (container + Caido bootstrap). */
|
|
11
|
+
export type SandboxSessionId = Branded<'SandboxSessionId'>
|
|
12
|
+
|
|
13
|
+
/** Opaque id of one PTY-backed interactive process inside a session. */
|
|
14
|
+
export type SandboxProcessId = Branded<'SandboxProcessId'>
|
|
15
|
+
|
|
16
|
+
/** Brand a raw string as a {@link SandboxSessionId}. */
|
|
17
|
+
export function SandboxSessionId(id: string): SandboxSessionId {
|
|
18
|
+
return id as SandboxSessionId
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Brand a raw string as a {@link SandboxProcessId}. */
|
|
22
|
+
export function SandboxProcessId(id: string): SandboxProcessId {
|
|
23
|
+
return id as SandboxProcessId
|
|
24
|
+
}
|
package/src/caido.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caido proxy bootstrap, ported from strix caido_bootstrap.py + caido_handle.py:
|
|
3
|
+
* guest login via `curl` executed INSIDE the container (the retry loop is the
|
|
4
|
+
* readiness probe — there is no separate TCP healthcheck), then project
|
|
5
|
+
* create/select over GraphQL against the host-side published endpoint using
|
|
6
|
+
* global fetch. The bootstrap runs concurrently with scan start; consumers
|
|
7
|
+
* resolve it lazily, and one caller's cancellation cannot cancel the shared
|
|
8
|
+
* task (the underlying promise is shared, matching asyncio.shield semantics).
|
|
9
|
+
* @module @gpzhang2001/sharpkit-sandbox/caido
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A ready-to-use host-side Caido endpoint. */
|
|
13
|
+
export interface CaidoEndpoint {
|
|
14
|
+
/** Base URL of the published Caido GraphQL endpoint (no trailing slash). */
|
|
15
|
+
readonly baseUrl: string
|
|
16
|
+
/** Guest access token for the `Authorization: Bearer` header. */
|
|
17
|
+
readonly token: string
|
|
18
|
+
/** Id of the created sandbox project. */
|
|
19
|
+
readonly projectId: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Minimal exec contract the bootstrap needs (container-internal command). */
|
|
23
|
+
export interface CaidoExecFn {
|
|
24
|
+
(
|
|
25
|
+
command: string,
|
|
26
|
+
timeoutMs: number,
|
|
27
|
+
): Promise<{ readonly ok: boolean; readonly exitCode: number | null; readonly stdout: string; readonly stderr: string }>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Minimal fetch contract (global fetch shape) for GraphQL calls. */
|
|
31
|
+
export interface CaidoFetchFn {
|
|
32
|
+
(
|
|
33
|
+
url: string,
|
|
34
|
+
init: { readonly method: 'POST'; readonly headers: Readonly<Record<string, string>>; readonly body: string; readonly signal: AbortSignal },
|
|
35
|
+
): Promise<{ readonly status: number; readonly text: () => Promise<string> }>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Minimal awaited-fetch shape after `.text()`. */
|
|
39
|
+
export type CaidoFetchResponse = Awaited<ReturnType<CaidoFetchFn>>
|
|
40
|
+
|
|
41
|
+
/** Injected clock for the retry backoff (tests pass a no-op). */
|
|
42
|
+
export type CaidoSleepFn = (ms: number) => Promise<void>
|
|
43
|
+
|
|
44
|
+
/** Exact login mutation body strix posts (caido_bootstrap.py `_LOGIN_AS_GUEST_BODY`). */
|
|
45
|
+
const LOGIN_AS_GUEST_QUERY = 'mutation LoginAsGuest { loginAsGuest { token { accessToken } } }'
|
|
46
|
+
|
|
47
|
+
/** Minimal CreateProject mutation (error identified by typename only — the
|
|
48
|
+
* payload error union has no shared `code` field in Caido 0.56.0's schema). */
|
|
49
|
+
const CREATE_PROJECT_DOC = 'mutation CreateProject($input: CreateProjectInput!) { createProject(input: $input) { error { __typename } project { id name temporary } } }'
|
|
50
|
+
|
|
51
|
+
/** Minimal SelectProject mutation (typename-only error, same schema reason). */
|
|
52
|
+
const SELECT_PROJECT_DOC = 'mutation SelectProject($id: ID!) { selectProject(id: $id) { currentProject { project { id } } error { __typename } } }'
|
|
53
|
+
|
|
54
|
+
/** Project identity strix creates in every sandbox (protocol constant, not a tunable). */
|
|
55
|
+
const PROJECT_NAME = 'sandbox'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the container-internal curl login command (strix caido_bootstrap.py:46-57).
|
|
59
|
+
* @param containerBaseUrl - the in-container Caido base URL (`http://127.0.0.1:48080`).
|
|
60
|
+
* @returns the shell command string for exec.
|
|
61
|
+
*/
|
|
62
|
+
export function loginCurlCommand(containerBaseUrl: string): string {
|
|
63
|
+
const body = JSON.stringify({ query: LOGIN_AS_GUEST_QUERY })
|
|
64
|
+
return `curl -fsS -X POST -H "Content-Type: application/json" -d ${JSON.stringify(body)} ${containerBaseUrl}/graphql`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Extract the guest token from a login response payload.
|
|
69
|
+
* @param stdout - raw curl stdout.
|
|
70
|
+
* @returns the access token.
|
|
71
|
+
* @throws when the payload is unparseable or carries no token (strix error wording).
|
|
72
|
+
*/
|
|
73
|
+
export function parseLoginToken(stdout: string): string {
|
|
74
|
+
let payload: unknown
|
|
75
|
+
try {
|
|
76
|
+
payload = JSON.parse(stdout)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new Error(`unparseable response: ${String(error)}: '${stdout}'`)
|
|
79
|
+
}
|
|
80
|
+
const token = pathOf(payload, ['data', 'loginAsGuest', 'token', 'accessToken'])
|
|
81
|
+
if (typeof token !== 'string' || token === '') {
|
|
82
|
+
throw new Error(`loginAsGuest returned no token: ${JSON.stringify(payload)}`)
|
|
83
|
+
}
|
|
84
|
+
return token
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Best-effort nested property lookup on an unknown JSON value. */
|
|
88
|
+
function pathOf(value: unknown, path: readonly string[]): unknown {
|
|
89
|
+
let current: unknown = value
|
|
90
|
+
for (const key of path) {
|
|
91
|
+
if (typeof current !== 'object' || current === null) return undefined
|
|
92
|
+
current = (current as Record<string, unknown>)[key]
|
|
93
|
+
}
|
|
94
|
+
return current
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run the guest login with retries (strix `_login_as_guest`: attempts with
|
|
99
|
+
* capped linear backoff 2,4,6,8,8… seconds; per-attempt exec timeout).
|
|
100
|
+
* @param exec - container exec channel.
|
|
101
|
+
* @param containerBaseUrl - in-container Caido base URL.
|
|
102
|
+
* @param options - attempts/timeout/backoff knobs and injected sleep.
|
|
103
|
+
* @returns the access token.
|
|
104
|
+
* @throws when every attempt fails (strix error wording).
|
|
105
|
+
*/
|
|
106
|
+
export async function loginAsGuest(
|
|
107
|
+
exec: CaidoExecFn,
|
|
108
|
+
containerBaseUrl: string,
|
|
109
|
+
options: {
|
|
110
|
+
readonly attempts: number
|
|
111
|
+
readonly timeoutMs: number
|
|
112
|
+
readonly sleep: CaidoSleepFn
|
|
113
|
+
},
|
|
114
|
+
): Promise<string> {
|
|
115
|
+
const command = loginCurlCommand(containerBaseUrl)
|
|
116
|
+
let lastError = 'no attempt made'
|
|
117
|
+
for (let attempt = 1; attempt <= options.attempts; attempt++) {
|
|
118
|
+
try {
|
|
119
|
+
const result = await exec(command, options.timeoutMs)
|
|
120
|
+
if (result.ok) return parseLoginToken(result.stdout)
|
|
121
|
+
lastError = `curl exit ${result.exitCode === null ? 'unknown' : result.exitCode}: ${result.stderr.slice(0, 200)}`
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// Token-structure failures from parseLoginToken land here and are retryable.
|
|
124
|
+
lastError = String(error instanceof Error ? error.message : error)
|
|
125
|
+
}
|
|
126
|
+
if (attempt < options.attempts) await options.sleep(Math.min(2_000 * attempt, 8_000))
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`loginAsGuest failed after ${options.attempts} attempts: ${lastError}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One GraphQL response half: user error or payload. */
|
|
132
|
+
interface GraphQLErrorShape {
|
|
133
|
+
readonly __typename: string
|
|
134
|
+
readonly code?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* POST one GraphQL document with bearer auth and return the `data` object.
|
|
139
|
+
* @param fetchFn - fetch channel.
|
|
140
|
+
* @param baseUrl - host-side Caido base URL.
|
|
141
|
+
* @param token - bearer token.
|
|
142
|
+
* @param doc - the GraphQL document.
|
|
143
|
+
* @param variables - operation variables.
|
|
144
|
+
* @param signal - cancellation for teardown.
|
|
145
|
+
*/
|
|
146
|
+
async function graphql(
|
|
147
|
+
fetchFn: CaidoFetchFn,
|
|
148
|
+
baseUrl: string,
|
|
149
|
+
token: string,
|
|
150
|
+
doc: string,
|
|
151
|
+
variables: Readonly<Record<string, unknown>>,
|
|
152
|
+
signal: AbortSignal,
|
|
153
|
+
): Promise<Record<string, unknown>> {
|
|
154
|
+
const response = await fetchFn(`${baseUrl}/graphql`, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
157
|
+
body: JSON.stringify({ query: doc, variables }),
|
|
158
|
+
signal,
|
|
159
|
+
})
|
|
160
|
+
const text = await response.text()
|
|
161
|
+
if (response.status !== 200) throw new Error(`caido graphql HTTP ${response.status}: ${text.slice(0, 200)}`)
|
|
162
|
+
let payload: unknown
|
|
163
|
+
try {
|
|
164
|
+
payload = JSON.parse(text)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new Error(`caido graphql unparseable response: ${String(error)}`)
|
|
167
|
+
}
|
|
168
|
+
const data = pathOf(payload, ['data'])
|
|
169
|
+
if (typeof data !== 'object' || data === null) {
|
|
170
|
+
const errors = pathOf(payload, ['errors'])
|
|
171
|
+
const detail = Array.isArray(errors) ? JSON.stringify(errors).slice(0, 300) : text.slice(0, 200)
|
|
172
|
+
throw new Error(`caido graphql carried no data: ${detail}`)
|
|
173
|
+
}
|
|
174
|
+
return data as Record<string, unknown>
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Extract the sibling error of an operation result, for error messages. */
|
|
178
|
+
function errorOf(entry: unknown): GraphQLErrorShape | undefined {
|
|
179
|
+
if (typeof entry !== 'object' || entry === null) return undefined
|
|
180
|
+
const error = (entry as Record<string, unknown>)['error']
|
|
181
|
+
if (typeof error !== 'object' || error === null) return undefined
|
|
182
|
+
return error as GraphQLErrorShape
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Full bootstrap: guest login, then create the temporary sandbox project and
|
|
187
|
+
* select it (strix `bootstrap_caido`).
|
|
188
|
+
* @param exec - container exec channel (login curl runs in-container).
|
|
189
|
+
* @param fetchFn - host-side fetch channel (project calls).
|
|
190
|
+
* @param urls - container and host base URLs.
|
|
191
|
+
* @param options - retry/timeout knobs and injected sleep.
|
|
192
|
+
* @returns the ready endpoint.
|
|
193
|
+
*/
|
|
194
|
+
export async function bootstrapCaido(
|
|
195
|
+
exec: CaidoExecFn,
|
|
196
|
+
fetchFn: CaidoFetchFn,
|
|
197
|
+
urls: { readonly containerBaseUrl: string; readonly hostBaseUrl: string },
|
|
198
|
+
options: {
|
|
199
|
+
readonly attempts: number
|
|
200
|
+
readonly timeoutMs: number
|
|
201
|
+
readonly sleep: CaidoSleepFn
|
|
202
|
+
readonly signal: AbortSignal
|
|
203
|
+
},
|
|
204
|
+
): Promise<CaidoEndpoint> {
|
|
205
|
+
const token = await loginAsGuest(exec, urls.containerBaseUrl, options)
|
|
206
|
+
const createData = await graphql(fetchFn, urls.hostBaseUrl, token, CREATE_PROJECT_DOC, { input: { name: PROJECT_NAME, temporary: true } }, options.signal)
|
|
207
|
+
const createError = errorOf(createData['createProject'])
|
|
208
|
+
if (createError !== undefined) throw new Error(`createProject failed: ${createError.__typename}${createError.code === undefined ? '' : ` (${createError.code})`}`)
|
|
209
|
+
const projectId = pathOf(createData['createProject'], ['project', 'id'])
|
|
210
|
+
if (typeof projectId !== 'string' || projectId === '') throw new Error('createProject returned no project id')
|
|
211
|
+
const selectData = await graphql(fetchFn, urls.hostBaseUrl, token, SELECT_PROJECT_DOC, { id: projectId }, options.signal)
|
|
212
|
+
const selectError = errorOf(selectData['selectProject'])
|
|
213
|
+
if (selectError !== undefined) throw new Error(`selectProject failed: ${selectError.__typename}${selectError.code === undefined ? '' : ` (${selectError.code})`}`)
|
|
214
|
+
return { baseUrl: urls.hostBaseUrl, token, projectId }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Shared, lazily-resolved bootstrap task (strix `CaidoBootstrapHandle`): the
|
|
219
|
+
* promise is created once and shared, so individual consumer cancellations
|
|
220
|
+
* cannot cancel the shared bootstrap; `close()` aborts it for teardown.
|
|
221
|
+
*/
|
|
222
|
+
export class CaidoBootstrap {
|
|
223
|
+
private readonly endpoint: Promise<CaidoEndpoint>
|
|
224
|
+
private readonly controller = new AbortController()
|
|
225
|
+
private settled: CaidoEndpoint | undefined
|
|
226
|
+
|
|
227
|
+
constructor(
|
|
228
|
+
start: (signal: AbortSignal) => Promise<CaidoEndpoint>,
|
|
229
|
+
) {
|
|
230
|
+
this.endpoint = start(this.controller.signal).then(
|
|
231
|
+
endpoint => {
|
|
232
|
+
this.settled = endpoint
|
|
233
|
+
return endpoint
|
|
234
|
+
},
|
|
235
|
+
error => {
|
|
236
|
+
throw error
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
// Keep the shared task alive even if every consumer drops their reference.
|
|
240
|
+
void this.endpoint.catch(() => {})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Resolve the endpoint; rejects with the bootstrap failure, shared by all callers. */
|
|
244
|
+
get(): Promise<CaidoEndpoint> {
|
|
245
|
+
return this.endpoint
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The resolved endpoint, or undefined while pending/failed (strix `peek`). */
|
|
249
|
+
peek(): CaidoEndpoint | undefined {
|
|
250
|
+
return this.settled
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Abort a pending bootstrap; failures are swallowed (teardown path). */
|
|
254
|
+
close(): void {
|
|
255
|
+
this.controller.abort()
|
|
256
|
+
}
|
|
257
|
+
}
|