@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/src/session.ts ADDED
@@ -0,0 +1,444 @@
1
+ /**
2
+ * One docker-CLI sandbox session: exec/PTY command execution, file transfer
3
+ * via `docker cp`, lazy Caido endpoint, and strix-parity teardown. The
4
+ * subprocess seam arrives as a structural interface so the session is
5
+ * drivable from tests exactly like the S1 spike drove it. Teardown follows
6
+ * strix session_manager.cleanup order (staging → caido → container), each
7
+ * step best-effort with logging; host-side PTY trees are terminated first
8
+ * (spike finding D1.4: host terminate cannot reach daemon-owned container
9
+ * processes, so `docker rm -f` is the authoritative reaper).
10
+ * @module @gpzhang2001/sharpkit-sandbox/session
11
+ */
12
+
13
+ import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'
14
+ import { tmpdir } from 'node:os'
15
+ import { basename, dirname, join } from 'node:path'
16
+ import type {
17
+ SubprocessHandle,
18
+ SubprocessSpawnSpec,
19
+ SubprocessTerminalHandle,
20
+ SubprocessTerminalSpawnSpec,
21
+ } from '@deepseek-ai/dsh-subprocess'
22
+ import { CaidoBootstrap, type CaidoEndpoint } from './caido.ts'
23
+ import { SandboxProcessId, type SandboxSessionId } from './brand.ts'
24
+ import {
25
+ buildExecArgv,
26
+ buildExecTtyArgv,
27
+ buildGetFileArgv,
28
+ buildPutFileArgv,
29
+ buildRmArgv,
30
+ buildRmForceArgv,
31
+ buildStopArgv,
32
+ type SandboxBindMount,
33
+ } from './spec.ts'
34
+
35
+ /** Structural view of `ctx.subprocess` the session drives (S1 spike shape). */
36
+ export interface SubprocessLike {
37
+ spawn(spec: SubprocessSpawnSpec): SubprocessHandle
38
+ spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>
39
+ }
40
+
41
+ /** Logger subset (cordis logger satisfies this structurally). */
42
+ export interface SandboxLogger {
43
+ debug(message: string): void
44
+ info(message: string): void
45
+ warn(message: string): void
46
+ error(message: string): void
47
+ }
48
+
49
+ /** Options for a non-interactive exec. */
50
+ export interface SandboxExecOptions {
51
+ /** Working directory inside the container. */
52
+ readonly cwd?: string | undefined
53
+ /** Per-call timeout; a timed-out command is terminated and reported. */
54
+ readonly timeoutMs?: number | undefined
55
+ /** Cooperative cancellation: aborting terminates the tree (tool exec.signal). */
56
+ readonly signal?: AbortSignal | undefined
57
+ }
58
+
59
+ /** Result of a non-interactive exec. */
60
+ export interface SandboxExecResult {
61
+ readonly exitCode: number | null
62
+ readonly signal: string | null
63
+ readonly stdout: string
64
+ readonly stderr: string
65
+ /** True only when the deadline fired (caller cancellation is `aborted`). */
66
+ readonly timedOut: boolean
67
+ /** True when the caller's AbortSignal fired (cooperative cancellation). */
68
+ readonly aborted: boolean
69
+ }
70
+
71
+ /** One live PTY-backed interactive process. */
72
+ export interface SandboxTtyProcess {
73
+ readonly id: SandboxProcessId
74
+ readonly command: string
75
+ /** Feed characters into the process (Ctrl-C arrives as `\x03`, finding D1.1). */
76
+ write(chars: string): Promise<void>
77
+ /** Subscribe to decoded output chunks; returns an unsubscribe function. */
78
+ subscribe(listener: (chunk: string) => void): () => void
79
+ /** Resolves when the process exits. */
80
+ readonly done: Promise<SandboxExecResult>
81
+ /** Terminate the host-side tree (container residue is reaped by session stop). */
82
+ terminate(): Promise<void>
83
+ }
84
+
85
+ /** The session contract consumed by the suite's tool packages. */
86
+ export interface PentestSandboxSession {
87
+ readonly sessionId: SandboxSessionId
88
+ readonly scanId: string
89
+ readonly containerId: string
90
+ /** Await Caido readiness (the guest-login retry loop is the probe). */
91
+ ready(): Promise<void>
92
+ /** Run one command in a fresh non-interactive login shell. */
93
+ exec(command: string, options?: SandboxExecOptions): Promise<SandboxExecResult>
94
+ /** Start a cancellable no-timeout exec for background jobs. */
95
+ execJob(command: string, options?: { readonly cwd?: string | undefined }): SandboxExecProcess
96
+ /** Start one PTY-backed interactive process (REPL/ssh/msfconsole). */
97
+ execTty(command: string, options?: { readonly cwd?: string | undefined; readonly rows?: number | undefined; readonly cols?: number | undefined }): Promise<SandboxTtyProcess>
98
+ /** Write characters to a live PTY process. */
99
+ writeStdin(processId: SandboxProcessId, chars: string): Promise<void>
100
+ /** Copy a host file into the container. */
101
+ putFile(hostPath: string, containerPath: string): Promise<void>
102
+ /** Read a container file out as raw bytes (binary-safe via docker cp). */
103
+ getFile(containerPath: string): Promise<Uint8Array>
104
+ /** The host-side Caido endpoint (resolves readiness first). */
105
+ caidoEndpoint(): Promise<CaidoEndpoint>
106
+ /** Best-effort teardown: PTYs, staging dir, caido, container. */
107
+ stop(): Promise<void>
108
+ }
109
+
110
+ /** Collected output read helper: reader text or ''. */
111
+ function readerText(handle: SubprocessHandle, stream: 'stdout' | 'stderr'): string {
112
+ return handle.collected[stream]?.readFrom(0).text ?? ''
113
+ }
114
+
115
+ /**
116
+ * Run one argv to completion with collected output and a hard timeout that
117
+ * terminates (and joins) the tree — the primitive every docker CLI call in
118
+ * the session goes through. A timeout and a caller abort terminate the host
119
+ * tree identically but are reported separately (`timedOut` vs `aborted`);
120
+ * per spike finding D1.4, host-side termination cannot reach daemon-owned
121
+ * container processes — a timed-out `docker exec` may leave its command
122
+ * running inside the container until the session stops and reaps it.
123
+ * @param subprocess - the subprocess seam.
124
+ * @param argv - full argv, argv[0] a program (never shell-interpreted).
125
+ * @param options - timeout and terminate grace.
126
+ * @returns the collected outcome.
127
+ */
128
+ export async function runCollectArgv(
129
+ subprocess: SubprocessLike,
130
+ argv: readonly string[],
131
+ options: { readonly timeoutMs: number; readonly graceMs: number; readonly collectMaxBytes: number; readonly signal?: AbortSignal | undefined },
132
+ ): Promise<SandboxExecResult> {
133
+ const handle = subprocess.spawn({
134
+ argv,
135
+ cwd: process.cwd(),
136
+ stdio: {
137
+ stdin: 'ignore',
138
+ stdout: { maxBytes: options.collectMaxBytes },
139
+ stderr: { maxBytes: options.collectMaxBytes },
140
+ },
141
+ graceMs: options.graceMs,
142
+ })
143
+ let timedOut = false
144
+ let aborted = false
145
+ let timer: NodeJS.Timeout | undefined
146
+ let onAbort: (() => void) | undefined
147
+ const deadline = new Promise<'deadline'>(resolve => {
148
+ timer = setTimeout(() => {
149
+ timedOut = true
150
+ handle.terminate()
151
+ resolve('deadline')
152
+ }, options.timeoutMs)
153
+ const signal = options.signal
154
+ if (signal !== undefined) {
155
+ onAbort = () => {
156
+ aborted = true
157
+ handle.terminate()
158
+ resolve('deadline')
159
+ }
160
+ signal.addEventListener('abort', onAbort, { once: true })
161
+ }
162
+ })
163
+ const winner = await Promise.race([handle.done.then(outcome => ({ outcome })), deadline.then(() => 'deadline' as const)])
164
+ if (timer !== undefined) clearTimeout(timer)
165
+ if (onAbort !== undefined) options.signal?.removeEventListener('abort', onAbort)
166
+ const outcome = winner === 'deadline' ? await handle.done : winner.outcome
167
+ return {
168
+ exitCode: outcome.exitCode,
169
+ signal: outcome.signal,
170
+ stdout: readerText(handle, 'stdout'),
171
+ stderr: readerText(handle, 'stderr'),
172
+ timedOut,
173
+ aborted,
174
+ }
175
+ }
176
+
177
+ /** Internal record of one live PTY process. */
178
+ interface TtyRecord {
179
+ readonly process: SandboxTtyProcess
180
+ readonly handle: SubprocessTerminalHandle
181
+ readonly listeners: Set<(chunk: string) => void>
182
+ }
183
+
184
+ /** A cancellable long-running non-interactive exec (jobs integration). */
185
+ export interface SandboxExecProcess {
186
+ readonly processId: SandboxProcessId
187
+ /** Resolves with the full collected outcome after the tree settles. */
188
+ readonly done: Promise<SandboxExecResult>
189
+ /** Consuming delta of stdout since the previous call ('' when nothing new). */
190
+ readOutput(): string
191
+ /** Terminate the host-side tree; `done` then settles with the partial output. */
192
+ terminate(): void
193
+ }
194
+
195
+ /** Knobs the service hands each session (resolved Config values). */
196
+ export interface SandboxSessionDeps {
197
+ readonly subprocess: SubprocessLike
198
+ readonly logger: SandboxLogger
199
+ readonly containerId: string
200
+ readonly scanId: string
201
+ readonly containerCaidoBaseUrl: string
202
+ readonly hostCaidoBaseUrl: string
203
+ readonly bootstrap: CaidoBootstrap
204
+ /** Extra-file staging directory to remove on stop (undefined when none). */
205
+ readonly stagingDir?: string | undefined
206
+ readonly graceMs: number
207
+ readonly defaultExecTimeoutMs: number
208
+ readonly collectMaxBytes: number
209
+ }
210
+
211
+ /**
212
+ * The docker-CLI-backed {@link PentestSandboxSession}. Constructed by the
213
+ * service after the container is created and started; never constructed
214
+ * directly by consumers.
215
+ */
216
+ export class DockerCliSandboxSession implements PentestSandboxSession {
217
+ readonly sessionId: SandboxSessionId
218
+ readonly scanId: string
219
+ readonly containerId: string
220
+ private readonly deps: SandboxSessionDeps
221
+ private readonly ttyProcesses = new Map<SandboxProcessId, TtyRecord>()
222
+ private stopped = false
223
+
224
+ constructor(deps: SandboxSessionDeps) {
225
+ this.deps = deps
226
+ this.sessionId = crypto.randomUUID() as SandboxSessionId
227
+ this.scanId = deps.scanId
228
+ this.containerId = deps.containerId
229
+ }
230
+
231
+ async ready(): Promise<void> {
232
+ await this.deps.bootstrap.get()
233
+ }
234
+
235
+ async caidoEndpoint(): Promise<CaidoEndpoint> {
236
+ return this.deps.bootstrap.get()
237
+ }
238
+
239
+ async exec(command: string, options?: SandboxExecOptions): Promise<SandboxExecResult> {
240
+ if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`)
241
+ const timeoutMs = options?.timeoutMs ?? this.deps.defaultExecTimeoutMs
242
+ return runCollectArgv(this.deps.subprocess, buildExecArgv({ containerId: this.containerId, command, cwd: options?.cwd }), {
243
+ timeoutMs,
244
+ graceMs: this.deps.graceMs,
245
+ collectMaxBytes: this.deps.collectMaxBytes,
246
+ signal: options?.signal,
247
+ })
248
+ }
249
+
250
+ /**
251
+ * Start a cancellable long-running exec with no timeout (jobs own the
252
+ * lifetime): the caller polls {@link SandboxExecProcess.readOutput} deltas
253
+ * and terminates on cancel.
254
+ */
255
+ execJob(command: string, options?: { readonly cwd?: string | undefined }): SandboxExecProcess {
256
+ if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`)
257
+ const handle = this.deps.subprocess.spawn({
258
+ argv: buildExecArgv({ containerId: this.containerId, command, cwd: options?.cwd }),
259
+ cwd: process.cwd(),
260
+ stdio: {
261
+ stdin: 'ignore',
262
+ stdout: { maxBytes: this.deps.collectMaxBytes },
263
+ stderr: { maxBytes: this.deps.collectMaxBytes },
264
+ },
265
+ graceMs: this.deps.graceMs,
266
+ })
267
+ let offset = 0
268
+ const processId = SandboxProcessId(crypto.randomUUID())
269
+ const done = handle.done.then(outcome => {
270
+ const stdout = handle.collected.stdout?.readFrom(0).text ?? ''
271
+ const stderr = handle.collected.stderr?.readFrom(0).text ?? ''
272
+ return { exitCode: outcome.exitCode, signal: outcome.signal, stdout, stderr, timedOut: false, aborted: false }
273
+ })
274
+ return {
275
+ processId,
276
+ done,
277
+ readOutput: () => {
278
+ const read = handle.collected.stdout?.readFrom(offset)
279
+ if (read === undefined) return ''
280
+ offset = read.nextOffset
281
+ return read.text
282
+ },
283
+ terminate: () => {
284
+ handle.terminate()
285
+ },
286
+ }
287
+ }
288
+
289
+ async execTty(command: string, options?: { readonly cwd?: string | undefined; readonly rows?: number | undefined; readonly cols?: number | undefined }): Promise<SandboxTtyProcess> {
290
+ if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`)
291
+ const handle = await this.deps.subprocess.spawnTerminal({
292
+ argv: buildExecTtyArgv({ containerId: this.containerId, command, cwd: options?.cwd }),
293
+ cwd: process.cwd(),
294
+ rows: options?.rows ?? 24,
295
+ cols: options?.cols ?? 80,
296
+ graceMs: this.deps.graceMs,
297
+ })
298
+ const id = SandboxProcessId(crypto.randomUUID())
299
+ const listeners = new Set<(chunk: string) => void>()
300
+ const decoder = new TextDecoder()
301
+ handle.output.on('data', (chunk: Uint8Array) => {
302
+ const text = decoder.decode(chunk, { stream: true })
303
+ for (const listener of listeners) listener(text)
304
+ })
305
+ const done = handle.done.then(outcome => ({
306
+ exitCode: outcome.exitCode,
307
+ signal: outcome.signal,
308
+ stdout: '',
309
+ stderr: '',
310
+ timedOut: false,
311
+ aborted: false,
312
+ }))
313
+ const record: TtyRecord = {
314
+ handle,
315
+ listeners,
316
+ process: {
317
+ id,
318
+ command,
319
+ write: chars => handle.write(chars),
320
+ subscribe: listener => {
321
+ listeners.add(listener)
322
+ return () => {
323
+ listeners.delete(listener)
324
+ }
325
+ },
326
+ done,
327
+ terminate: () => handle.terminate(),
328
+ },
329
+ }
330
+ this.ttyProcesses.set(id, record)
331
+ void done.then(
332
+ () => {
333
+ this.ttyProcesses.delete(id)
334
+ },
335
+ () => {
336
+ this.ttyProcesses.delete(id)
337
+ },
338
+ )
339
+ return record.process
340
+ }
341
+
342
+ async writeStdin(processId: SandboxProcessId, chars: string): Promise<void> {
343
+ const record = this.ttyProcesses.get(processId)
344
+ if (record === undefined) throw new Error(`write_stdin: no live interactive process ${processId} in scan ${this.scanId}`)
345
+ await record.handle.write(chars)
346
+ }
347
+
348
+ async putFile(hostPath: string, containerPath: string): Promise<void> {
349
+ const result = await runCollectArgv(this.deps.subprocess, buildPutFileArgv(hostPath, this.containerId, containerPath), {
350
+ timeoutMs: this.deps.defaultExecTimeoutMs,
351
+ graceMs: this.deps.graceMs,
352
+ collectMaxBytes: this.deps.collectMaxBytes,
353
+ })
354
+ if (result.exitCode !== 0) throw new Error(`putFile failed (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`)
355
+ }
356
+
357
+ async getFile(containerPath: string): Promise<Uint8Array> {
358
+ const name = basename(containerPath)
359
+ if (name === '' || name === '/' || name === '.') throw new Error(`getFile: container path must name a file: ${containerPath}`)
360
+ const dir = await mkdtemp(join(tmpdir(), 'sharpkit-getfile-'))
361
+ try {
362
+ const hostPath = join(dir, name)
363
+ const result = await runCollectArgv(this.deps.subprocess, buildGetFileArgv(this.containerId, containerPath, hostPath), {
364
+ timeoutMs: this.deps.defaultExecTimeoutMs,
365
+ graceMs: this.deps.graceMs,
366
+ collectMaxBytes: this.deps.collectMaxBytes,
367
+ })
368
+ if (result.exitCode !== 0) throw new Error(`getFile failed (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`)
369
+ return new Uint8Array(await readFile(hostPath))
370
+ } finally {
371
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
372
+ }
373
+ }
374
+
375
+ async stop(): Promise<void> {
376
+ if (this.stopped) return
377
+ this.stopped = true
378
+ const log = this.deps.logger
379
+ for (const record of this.ttyProcesses.values()) {
380
+ try {
381
+ await record.handle.terminate()
382
+ } catch (error) {
383
+ log.debug(`stop(${this.scanId}): tty terminate raised: ${String(error)}`)
384
+ }
385
+ }
386
+ this.ttyProcesses.clear()
387
+ if (this.deps.stagingDir !== undefined) {
388
+ await rm(this.deps.stagingDir, { recursive: true, force: true }).catch(() => {})
389
+ }
390
+ this.deps.bootstrap.close()
391
+ try {
392
+ const stopped = await runCollectArgv(this.deps.subprocess, buildStopArgv(this.containerId, this.deps.graceMs), {
393
+ timeoutMs: this.deps.defaultExecTimeoutMs,
394
+ graceMs: this.deps.graceMs,
395
+ collectMaxBytes: this.deps.collectMaxBytes,
396
+ })
397
+ if (stopped.exitCode !== 0) log.warn(`stop(${this.scanId}): docker stop exit ${stopped.exitCode}: ${stopped.stderr.slice(0, 200)}`)
398
+ } catch (error) {
399
+ log.debug(`stop(${this.scanId}): docker stop raised: ${String(error)}`)
400
+ }
401
+ try {
402
+ const removed = await runCollectArgv(this.deps.subprocess, buildRmArgv(this.containerId), {
403
+ timeoutMs: this.deps.defaultExecTimeoutMs,
404
+ graceMs: this.deps.graceMs,
405
+ collectMaxBytes: this.deps.collectMaxBytes,
406
+ })
407
+ if (removed.exitCode !== 0) {
408
+ await runCollectArgv(this.deps.subprocess, buildRmForceArgv(this.containerId), {
409
+ timeoutMs: this.deps.defaultExecTimeoutMs,
410
+ graceMs: this.deps.graceMs,
411
+ collectMaxBytes: this.deps.collectMaxBytes,
412
+ })
413
+ }
414
+ } catch (error) {
415
+ log.error(`stop(${this.scanId}): container removal raised; container may need manual reaping: ${String(error)}`)
416
+ }
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Stage extra files for bind mounting (strix `build_extra_file_bind_mounts`):
422
+ * one numbered subdir per file, content written, mounted read-only at its
423
+ * workspace path.
424
+ * @param stagingDir - the session staging directory (already created).
425
+ * @param items - validated extra files: rel path + bytes.
426
+ * @param workspaceRoot - container workspace root (default `/workspace`).
427
+ * @returns the mounts, in placement order.
428
+ */
429
+ export async function stageExtraFiles(
430
+ stagingDir: string,
431
+ items: readonly { readonly rel: string; readonly content: Uint8Array }[],
432
+ workspaceRoot: string,
433
+ ): Promise<SandboxBindMount[]> {
434
+ const mounts: SandboxBindMount[] = []
435
+ let index = 0
436
+ for (const item of items) {
437
+ const staged = join(stagingDir, String(index), basename(item.rel))
438
+ await mkdir(dirname(staged), { recursive: true })
439
+ await writeFile(staged, item.content)
440
+ mounts.push({ source: staged, target: `${workspaceRoot}/${item.rel}`, readOnly: true })
441
+ index++
442
+ }
443
+ return mounts
444
+ }