@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/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
|
+
}
|