@namzu/sandbox 7.0.0 → 7.1.0
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/CHANGELOG.md +32 -0
- package/README.md +29 -1
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +95 -82
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +108 -114
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +117 -52
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/backends/firecracker/protocol.d.ts +19 -27
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js +57 -32
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +23 -4
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +153 -32
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/http-worker-client.d.ts +17 -0
- package/dist/backends/http-worker-client.d.ts.map +1 -0
- package/dist/backends/http-worker-client.js +173 -0
- package/dist/backends/http-worker-client.js.map +1 -0
- package/dist/backends/remote-execution-controller.d.ts +76 -0
- package/dist/backends/remote-execution-controller.d.ts.map +1 -0
- package/dist/backends/remote-execution-controller.js +294 -0
- package/dist/backends/remote-execution-controller.js.map +1 -0
- package/package.json +2 -2
- package/src/backends/aci-standby-pool/index.ts +101 -88
- package/src/backends/docker/index.ts +101 -132
- package/src/backends/firecracker/index.ts +117 -45
- package/src/backends/firecracker/protocol.ts +67 -33
- package/src/backends/firecracker/transport.ts +214 -39
- package/src/backends/http-worker-client.ts +230 -0
- package/src/backends/remote-execution-controller.ts +462 -0
|
@@ -58,7 +58,8 @@ import type {
|
|
|
58
58
|
} from '@namzu/sdk'
|
|
59
59
|
|
|
60
60
|
import type { AgentSnapshotRef, SandboxBackend, SandboxBackendOptions } from '../../index.js'
|
|
61
|
-
import { resolveReadinessOptions, runFailureCleanup } from '../readiness.js'
|
|
61
|
+
import { OperationDeadline, resolveReadinessOptions, runFailureCleanup } from '../readiness.js'
|
|
62
|
+
import { RemoteCancellationUnknownError } from '../remote-execution-controller.js'
|
|
62
63
|
import type {
|
|
63
64
|
MtlsClientMaterial,
|
|
64
65
|
SandboxAgentHandle,
|
|
@@ -143,6 +144,7 @@ export interface FirecrackerBackendInternalConfig {
|
|
|
143
144
|
const DEFAULT_AGENT_VSOCK_PORT = 1024
|
|
144
145
|
const DEFAULT_READY_TIMEOUT_MS = 60_000
|
|
145
146
|
const DEFAULT_READY_POLL_MS = 250
|
|
147
|
+
const RETIREMENT_TIMEOUT_MS = 5_000
|
|
146
148
|
|
|
147
149
|
/**
|
|
148
150
|
* Build a {@link SandboxBackend} backed by the owned Firecracker
|
|
@@ -220,6 +222,7 @@ async function orchestratorCall<T>(
|
|
|
220
222
|
body?: unknown,
|
|
221
223
|
mtls?: MtlsClientMaterial,
|
|
222
224
|
signal?: AbortSignal,
|
|
225
|
+
acceptedStatuses: readonly number[] = [],
|
|
223
226
|
): Promise<T | undefined> {
|
|
224
227
|
signal?.throwIfAborted()
|
|
225
228
|
const token = await getToken()
|
|
@@ -252,6 +255,11 @@ async function orchestratorCall<T>(
|
|
|
252
255
|
)
|
|
253
256
|
}
|
|
254
257
|
if (res.status < 200 || res.status >= 300) {
|
|
258
|
+
if (acceptedStatuses.includes(res.status)) {
|
|
259
|
+
await res.text()
|
|
260
|
+
signal?.throwIfAborted()
|
|
261
|
+
return undefined
|
|
262
|
+
}
|
|
255
263
|
const text = await res.text()
|
|
256
264
|
signal?.throwIfAborted()
|
|
257
265
|
throw new Error(`firecracker orchestrator ${method} ${url} → ${res.status}: ${text}`)
|
|
@@ -411,7 +419,10 @@ async function spawnFirecrackerSandbox(
|
|
|
411
419
|
)
|
|
412
420
|
const transport = new VsockAgentTransport(handle, config.transport ?? {})
|
|
413
421
|
|
|
414
|
-
const destroy = async (
|
|
422
|
+
const destroy = async (
|
|
423
|
+
signal?: AbortSignal,
|
|
424
|
+
acceptedStatuses: readonly number[] = [],
|
|
425
|
+
): Promise<void> => {
|
|
415
426
|
await orchestratorCall(
|
|
416
427
|
endpoint,
|
|
417
428
|
`/sandboxes/${encodeURIComponent(id)}:delete`,
|
|
@@ -420,6 +431,7 @@ async function spawnFirecrackerSandbox(
|
|
|
420
431
|
undefined,
|
|
421
432
|
config.controlPlaneMtls,
|
|
422
433
|
signal,
|
|
434
|
+
acceptedStatuses,
|
|
423
435
|
)
|
|
424
436
|
}
|
|
425
437
|
|
|
@@ -441,12 +453,88 @@ async function spawnFirecrackerSandbox(
|
|
|
441
453
|
throw err
|
|
442
454
|
}
|
|
443
455
|
|
|
444
|
-
|
|
456
|
+
type Lifecycle = 'active' | 'retiring' | 'destroyed'
|
|
457
|
+
let activeExecutions = 0
|
|
458
|
+
let lifecycle: Lifecycle = 'active'
|
|
459
|
+
let retirementPromise: Promise<{ readonly accepted: boolean; readonly error?: Error }> | undefined
|
|
460
|
+
let teardownPromise: Promise<void> | undefined
|
|
461
|
+
let teardownComplete = false
|
|
462
|
+
|
|
463
|
+
const assertActive = (): void => {
|
|
464
|
+
if (lifecycle !== 'active') {
|
|
465
|
+
throw new Error(`Sandbox ${id} is ${lifecycle}; no new guest operation can be admitted`)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const teardownSandbox = (signal?: AbortSignal): Promise<void> => {
|
|
470
|
+
lifecycle = 'retiring'
|
|
471
|
+
if (teardownComplete) return Promise.resolve()
|
|
472
|
+
if (teardownPromise) return teardownPromise
|
|
473
|
+
const attempt = destroy(signal, [404, 410])
|
|
474
|
+
const shared = attempt.then(
|
|
475
|
+
() => {
|
|
476
|
+
teardownComplete = true
|
|
477
|
+
lifecycle = 'destroyed'
|
|
478
|
+
},
|
|
479
|
+
(error: unknown) => {
|
|
480
|
+
if (teardownPromise === shared) teardownPromise = undefined
|
|
481
|
+
throw error
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
teardownPromise = shared
|
|
485
|
+
return shared
|
|
486
|
+
}
|
|
487
|
+
const retire = (): Promise<{ readonly accepted: boolean; readonly error?: Error }> => {
|
|
488
|
+
lifecycle = 'retiring'
|
|
489
|
+
if (retirementPromise) return retirementPromise
|
|
490
|
+
const deadline = new OperationDeadline(
|
|
491
|
+
RETIREMENT_TIMEOUT_MS,
|
|
492
|
+
`firecracker sandbox ${id} retirement`,
|
|
493
|
+
)
|
|
494
|
+
retirementPromise = deadline
|
|
495
|
+
.run(async (signal) => {
|
|
496
|
+
const joinedExistingAttempt = teardownPromise !== undefined
|
|
497
|
+
try {
|
|
498
|
+
await teardownSandbox(signal)
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (!joinedExistingAttempt || signal.aborted) throw error
|
|
501
|
+
await teardownSandbox(signal)
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
.then(() => {
|
|
505
|
+
return { accepted: true as const }
|
|
506
|
+
})
|
|
507
|
+
.catch((error: unknown) => {
|
|
508
|
+
const normalized = error instanceof Error ? error : new Error(String(error))
|
|
509
|
+
return { accepted: false as const, error: normalized }
|
|
510
|
+
})
|
|
511
|
+
return retirementPromise
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const classifyExecutionFailure = async (error: unknown): Promise<never> => {
|
|
515
|
+
if (error instanceof RemoteCancellationUnknownError) {
|
|
516
|
+
error.retirement = await retire()
|
|
517
|
+
}
|
|
518
|
+
throw error
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const runExecution = async <T>(operation: () => Promise<T>): Promise<T> => {
|
|
522
|
+
assertActive()
|
|
523
|
+
activeExecutions += 1
|
|
524
|
+
try {
|
|
525
|
+
return await operation()
|
|
526
|
+
} catch (error) {
|
|
527
|
+
return await classifyExecutionFailure(error)
|
|
528
|
+
} finally {
|
|
529
|
+
activeExecutions = Math.max(0, activeExecutions - 1)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
445
532
|
|
|
446
533
|
return {
|
|
447
534
|
id,
|
|
448
535
|
get status(): SandboxStatus {
|
|
449
|
-
return
|
|
536
|
+
if (lifecycle !== 'active') return 'destroyed'
|
|
537
|
+
return activeExecutions > 0 ? 'busy' : 'ready'
|
|
450
538
|
},
|
|
451
539
|
rootDir,
|
|
452
540
|
environment: detectEnvironment(),
|
|
@@ -456,67 +544,51 @@ async function spawnFirecrackerSandbox(
|
|
|
456
544
|
argv?: string[],
|
|
457
545
|
opts?: SandboxExecOptions,
|
|
458
546
|
): Promise<SandboxExecResult> {
|
|
459
|
-
|
|
460
|
-
// worth writing down because the obvious "fix" is worse than the
|
|
461
|
-
// gap. There is no cancel op on this wire — the guest agent takes
|
|
462
|
-
// an execute frame and answers when the command is done. Aborting
|
|
463
|
-
// the socket here would abandon the WAIT while the process keeps
|
|
464
|
-
// running inside the microVM, which is verbatim the failure
|
|
465
|
-
// `SandboxExecOptions.signal` exists to prevent, except it would
|
|
466
|
-
// then look honoured. Honouring it means a cancel op in the guest
|
|
467
|
-
// protocol; until then, ignoring it is the truthful behaviour the
|
|
468
|
-
// option's own contract allows.
|
|
469
|
-
status = 'busy'
|
|
470
|
-
try {
|
|
471
|
-
return await transport.execute({
|
|
472
|
-
command,
|
|
473
|
-
args: argv ?? [],
|
|
474
|
-
...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
475
|
-
...(opts?.env !== undefined ? { env: opts.env } : {}),
|
|
476
|
-
...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
|
|
477
|
-
})
|
|
478
|
-
} finally {
|
|
479
|
-
status = 'ready'
|
|
480
|
-
}
|
|
547
|
+
return await runExecution(async () => await transport.exec(command, argv, opts))
|
|
481
548
|
},
|
|
482
549
|
|
|
483
550
|
async writeFile(path: string, content: string | Buffer): Promise<void> {
|
|
551
|
+
assertActive()
|
|
484
552
|
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
|
|
485
553
|
await transport.writeFile(path, buf)
|
|
486
554
|
},
|
|
487
555
|
|
|
488
556
|
async readFile(path: string): Promise<Buffer> {
|
|
557
|
+
assertActive()
|
|
489
558
|
return await transport.readFile(path)
|
|
490
559
|
},
|
|
491
560
|
|
|
492
561
|
async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
562
|
+
return await runExecution(async () => {
|
|
563
|
+
// Same wire as docker/aci: `find -printf '%p\t%s\n'`, parse
|
|
564
|
+
// line-by-line, map a non-zero exit (missing root) to "empty".
|
|
565
|
+
const result = await transport.exec('find', [rootPath, '-type', 'f', '-printf', '%p\t%s\n'])
|
|
566
|
+
if (result.exitCode !== 0) return []
|
|
567
|
+
const entries: SandboxFileEntry[] = []
|
|
568
|
+
for (const rawLine of result.stdout.split('\n')) {
|
|
569
|
+
if (!rawLine) continue
|
|
570
|
+
const tab = rawLine.indexOf('\t')
|
|
571
|
+
if (tab < 0) continue
|
|
572
|
+
const filePath = rawLine.slice(0, tab)
|
|
573
|
+
const size = Number.parseInt(rawLine.slice(tab + 1), 10)
|
|
574
|
+
if (!filePath || !Number.isFinite(size)) continue
|
|
575
|
+
entries.push({ path: filePath, size })
|
|
576
|
+
}
|
|
577
|
+
return entries
|
|
498
578
|
})
|
|
499
|
-
if (result.exitCode !== 0) return []
|
|
500
|
-
const entries: SandboxFileEntry[] = []
|
|
501
|
-
for (const rawLine of result.stdout.split('\n')) {
|
|
502
|
-
if (!rawLine) continue
|
|
503
|
-
const tab = rawLine.indexOf('\t')
|
|
504
|
-
if (tab < 0) continue
|
|
505
|
-
const filePath = rawLine.slice(0, tab)
|
|
506
|
-
const size = Number.parseInt(rawLine.slice(tab + 1), 10)
|
|
507
|
-
if (!filePath || !Number.isFinite(size)) continue
|
|
508
|
-
entries.push({ path: filePath, size })
|
|
509
|
-
}
|
|
510
|
-
return entries
|
|
511
579
|
},
|
|
512
580
|
|
|
513
581
|
async destroy(options?: SandboxDestroyOptions): Promise<void> {
|
|
514
|
-
|
|
582
|
+
if (retirementPromise) {
|
|
583
|
+
const observation = await retirementPromise
|
|
584
|
+
if (observation.accepted) return
|
|
585
|
+
retirementPromise = undefined
|
|
586
|
+
}
|
|
515
587
|
// Let the orchestrator DELETE failure propagate — the
|
|
516
588
|
// Vandal-side lifecycle wraps this with logging, and a
|
|
517
589
|
// swallowed error here means orphaned microVMs (and their
|
|
518
590
|
// netns / UFFD handlers) pile up with no observability handle.
|
|
519
|
-
await
|
|
591
|
+
await teardownSandbox(options?.signal)
|
|
520
592
|
},
|
|
521
593
|
}
|
|
522
594
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The execution-data codec shared by both transports.
|
|
3
3
|
*
|
|
4
4
|
* The docker (`backends/docker/`) and ACI (`backends/aci-standby-pool/`)
|
|
5
5
|
* backends speak this contract over **HTTP**: a streaming NDJSON
|
|
@@ -9,23 +9,15 @@
|
|
|
9
9
|
* shapes** — only the transport changes from HTTP-over-TCP to
|
|
10
10
|
* framed-over-vsock (see `transport.ts`).
|
|
11
11
|
*
|
|
12
|
-
* This module
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* copies (they predate this module and stay UNTOUCHED per the P0
|
|
17
|
-
* scope); this module mirrors those shapes verbatim and is the
|
|
18
|
-
* canonical definition the vsock path consumes.
|
|
19
|
-
*
|
|
20
|
-
* Why mirror rather than refactor docker/aci onto it: the P0 scope is
|
|
21
|
-
* "do NOT touch docker or aci". Centralising the shapes here, with the
|
|
22
|
-
* inline docker copy pinned by `backends/docker/__tests__` and this
|
|
23
|
-
* module pinned by `backends/firecracker/__tests__`, keeps both honest
|
|
24
|
-
* without editing the frozen HTTP path. A later cleanup pass can fold
|
|
25
|
-
* docker/aci onto this module once it is no longer release-frozen.
|
|
12
|
+
* This module remains the pure codec consumed by the framed guest transport.
|
|
13
|
+
* The two HTTP backends share one strict HTTP client, and both transports put
|
|
14
|
+
* the same reserve-before-admission + idempotent-cancel state machine around
|
|
15
|
+
* these data events. HTTP uses endpoints; the framed guest uses dedicated ops.
|
|
26
16
|
*/
|
|
27
17
|
|
|
28
|
-
import type { SandboxExecResult } from '@namzu/sdk'
|
|
18
|
+
import type { SandboxExecOptions, SandboxExecResult } from '@namzu/sdk'
|
|
19
|
+
|
|
20
|
+
import { RemoteCommandError, RemoteProtocolError } from '../remote-execution-controller.js'
|
|
29
21
|
|
|
30
22
|
// ---------------------------------------------------------------------------
|
|
31
23
|
// Exec — request + the NDJSON event shapes (verbatim from worker/server.js)
|
|
@@ -38,6 +30,7 @@ import type { SandboxExecResult } from '@namzu/sdk'
|
|
|
38
30
|
* `SandboxExecOptions.timeout`.
|
|
39
31
|
*/
|
|
40
32
|
export interface ExecRequest {
|
|
33
|
+
readonly executionId?: string
|
|
41
34
|
readonly command: string
|
|
42
35
|
readonly args?: readonly string[]
|
|
43
36
|
readonly cwd?: string
|
|
@@ -62,7 +55,8 @@ export type ExecEvent =
|
|
|
62
55
|
readonly type: 'result'
|
|
63
56
|
readonly exitCode: number
|
|
64
57
|
readonly timedOut: boolean
|
|
65
|
-
readonly durationMs
|
|
58
|
+
readonly durationMs: number
|
|
59
|
+
readonly signal?: string
|
|
66
60
|
readonly stdoutTruncated?: boolean
|
|
67
61
|
readonly stderrTruncated?: boolean
|
|
68
62
|
}
|
|
@@ -113,9 +107,8 @@ export interface ReadFileResponse {
|
|
|
113
107
|
* terminal `result`, and **throw** on an `error` event.
|
|
114
108
|
*
|
|
115
109
|
* Transport-agnostic: feed it whole parsed {@link ExecEvent}s (the
|
|
116
|
-
* transport owns
|
|
117
|
-
*
|
|
118
|
-
* this accumulator, matching the docker loop's `SyntaxError` swallow.
|
|
110
|
+
* transport owns framing → strict JSON validation → here). Malformed or
|
|
111
|
+
* trailing events are protocol failures rather than silently discarded data.
|
|
119
112
|
*/
|
|
120
113
|
export class ExecResultAccumulator {
|
|
121
114
|
private stdout = ''
|
|
@@ -123,11 +116,16 @@ export class ExecResultAccumulator {
|
|
|
123
116
|
private exitCode = -1
|
|
124
117
|
private timedOut = false
|
|
125
118
|
private signal: string | undefined
|
|
119
|
+
private durationMs: number | undefined
|
|
120
|
+
private stdoutTruncated: boolean | undefined
|
|
121
|
+
private stderrTruncated: boolean | undefined
|
|
126
122
|
private settled = false
|
|
127
123
|
private readonly start: number
|
|
124
|
+
private readonly onOutput: SandboxExecOptions['onOutput']
|
|
128
125
|
|
|
129
|
-
constructor(start: number = Date.now()) {
|
|
126
|
+
constructor(start: number = Date.now(), onOutput?: SandboxExecOptions['onOutput']) {
|
|
130
127
|
this.start = start
|
|
128
|
+
this.onOutput = onOutput
|
|
131
129
|
}
|
|
132
130
|
|
|
133
131
|
/**
|
|
@@ -137,55 +135,91 @@ export class ExecResultAccumulator {
|
|
|
137
135
|
* docker loop uses (`throw new Error(event.error)`).
|
|
138
136
|
*/
|
|
139
137
|
push(event: ExecEvent): boolean {
|
|
138
|
+
if (this.settled) {
|
|
139
|
+
throw new RemoteProtocolError('exec stream emitted data after its terminal event')
|
|
140
|
+
}
|
|
140
141
|
if (event.type === 'stdout_delta') {
|
|
141
142
|
this.stdout += event.data
|
|
143
|
+
this.onOutput?.({ stream: 'stdout', data: event.data })
|
|
142
144
|
return false
|
|
143
145
|
}
|
|
144
146
|
if (event.type === 'stderr_delta') {
|
|
145
147
|
this.stderr += event.data
|
|
148
|
+
this.onOutput?.({ stream: 'stderr', data: event.data })
|
|
146
149
|
return false
|
|
147
150
|
}
|
|
148
151
|
if (event.type === 'result') {
|
|
149
152
|
this.exitCode = event.exitCode
|
|
150
153
|
this.timedOut = event.timedOut
|
|
154
|
+
this.durationMs = event.durationMs
|
|
155
|
+
this.signal = event.signal
|
|
156
|
+
this.stdoutTruncated = event.stdoutTruncated
|
|
157
|
+
this.stderrTruncated = event.stderrTruncated
|
|
151
158
|
this.settled = true
|
|
152
159
|
return true
|
|
153
160
|
}
|
|
154
161
|
// event.type === 'error'
|
|
155
|
-
throw new
|
|
162
|
+
throw new RemoteCommandError(event.error)
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
get done(): boolean {
|
|
159
166
|
return this.settled
|
|
160
167
|
}
|
|
161
168
|
|
|
162
|
-
/** Build the SDK-shaped result
|
|
169
|
+
/** Build the SDK-shaped result from the guest's terminal metadata. */
|
|
163
170
|
finish(): SandboxExecResult {
|
|
171
|
+
if (!this.settled || this.durationMs === undefined) {
|
|
172
|
+
throw new RemoteProtocolError('exec stream ended without exactly one result event')
|
|
173
|
+
}
|
|
164
174
|
return {
|
|
165
175
|
exitCode: this.exitCode,
|
|
166
176
|
stdout: this.stdout,
|
|
167
177
|
stderr: this.stderr,
|
|
168
178
|
...(this.signal ? { signal: this.signal } : {}),
|
|
169
179
|
timedOut: this.timedOut,
|
|
170
|
-
durationMs: Date.now() - this.start,
|
|
180
|
+
durationMs: this.durationMs ?? Date.now() - this.start,
|
|
181
|
+
...(this.stdoutTruncated !== undefined ? { stdoutTruncated: this.stdoutTruncated } : {}),
|
|
182
|
+
...(this.stderrTruncated !== undefined ? { stderrTruncated: this.stderrTruncated } : {}),
|
|
171
183
|
}
|
|
172
184
|
}
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
/**
|
|
176
|
-
* Parse a single NDJSON
|
|
177
|
-
*
|
|
178
|
-
* `SyntaxError` swallow). Non-`SyntaxError` problems are impossible
|
|
179
|
-
* here because we only `JSON.parse`; structural validation is by the
|
|
180
|
-
* `type` discriminator at the call site.
|
|
188
|
+
* Parse and structurally validate a single NDJSON event. Blank padding is
|
|
189
|
+
* ignored; malformed JSON and unknown/partial event shapes are refused.
|
|
181
190
|
*/
|
|
182
191
|
export function parseExecLine(line: string): ExecEvent | undefined {
|
|
183
192
|
const trimmed = line.trim()
|
|
184
193
|
if (!trimmed) return undefined
|
|
194
|
+
let parsed: unknown
|
|
185
195
|
try {
|
|
186
|
-
|
|
187
|
-
} catch (
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
parsed = JSON.parse(trimmed)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
throw new RemoteProtocolError(
|
|
199
|
+
`agent emitted malformed NDJSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
203
|
+
throw new RemoteProtocolError('agent emitted an event without an object body')
|
|
204
|
+
}
|
|
205
|
+
const event = parsed as Record<string, unknown>
|
|
206
|
+
if (
|
|
207
|
+
(event.type === 'stdout_delta' || event.type === 'stderr_delta') &&
|
|
208
|
+
typeof event.data === 'string'
|
|
209
|
+
) {
|
|
210
|
+
return event as ExecEvent
|
|
211
|
+
}
|
|
212
|
+
if (event.type === 'error' && typeof event.error === 'string') return event as ExecEvent
|
|
213
|
+
if (
|
|
214
|
+
event.type === 'result' &&
|
|
215
|
+
Number.isFinite(event.exitCode) &&
|
|
216
|
+
typeof event.timedOut === 'boolean' &&
|
|
217
|
+
Number.isFinite(event.durationMs) &&
|
|
218
|
+
(event.signal === undefined || typeof event.signal === 'string') &&
|
|
219
|
+
(event.stdoutTruncated === undefined || typeof event.stdoutTruncated === 'boolean') &&
|
|
220
|
+
(event.stderrTruncated === undefined || typeof event.stderrTruncated === 'boolean')
|
|
221
|
+
) {
|
|
222
|
+
return event as ExecEvent
|
|
190
223
|
}
|
|
224
|
+
throw new RemoteProtocolError(`agent emitted an invalid ${String(event.type)} event`)
|
|
191
225
|
}
|