@namzu/sandbox 6.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +29 -1
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +163 -111
  5. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  6. package/dist/backends/docker/index.d.ts.map +1 -1
  7. package/dist/backends/docker/index.js +212 -149
  8. package/dist/backends/docker/index.js.map +1 -1
  9. package/dist/backends/firecracker/index.d.ts.map +1 -1
  10. package/dist/backends/firecracker/index.js +154 -71
  11. package/dist/backends/firecracker/index.js.map +1 -1
  12. package/dist/backends/firecracker/protocol.d.ts +19 -27
  13. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  14. package/dist/backends/firecracker/protocol.js +57 -32
  15. package/dist/backends/firecracker/protocol.js.map +1 -1
  16. package/dist/backends/firecracker/transport.d.ts +26 -7
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +235 -72
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/http-worker-client.d.ts +17 -0
  21. package/dist/backends/http-worker-client.d.ts.map +1 -0
  22. package/dist/backends/http-worker-client.js +173 -0
  23. package/dist/backends/http-worker-client.js.map +1 -0
  24. package/dist/backends/readiness.d.ts +43 -0
  25. package/dist/backends/readiness.d.ts.map +1 -0
  26. package/dist/backends/readiness.js +150 -0
  27. package/dist/backends/readiness.js.map +1 -0
  28. package/dist/backends/remote-execution-controller.d.ts +76 -0
  29. package/dist/backends/remote-execution-controller.d.ts.map +1 -0
  30. package/dist/backends/remote-execution-controller.js +294 -0
  31. package/dist/backends/remote-execution-controller.js.map +1 -0
  32. package/dist/egress/proxy.d.ts.map +1 -1
  33. package/dist/egress/proxy.js +20 -3
  34. package/dist/egress/proxy.js.map +1 -1
  35. package/dist/index.d.ts +18 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +9 -0
  38. package/dist/index.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/backends/aci-standby-pool/index.ts +199 -117
  41. package/src/backends/docker/index.ts +235 -175
  42. package/src/backends/firecracker/index.ts +163 -63
  43. package/src/backends/firecracker/protocol.ts +67 -33
  44. package/src/backends/firecracker/transport.ts +301 -77
  45. package/src/backends/http-worker-client.ts +230 -0
  46. package/src/backends/readiness.ts +173 -0
  47. package/src/backends/remote-execution-controller.ts +462 -0
  48. package/src/egress/proxy.ts +18 -3
  49. package/src/index.ts +27 -0
@@ -0,0 +1,230 @@
1
+ import { type SandboxExecOptions, type SandboxExecResult, withHint } from '@namzu/sdk'
2
+
3
+ import {
4
+ RemoteCancellationUnsupportedError,
5
+ RemoteCommandError,
6
+ type RemoteExecutionAdapter,
7
+ RemoteExecutionController,
8
+ RemoteProtocolError,
9
+ } from './remote-execution-controller.js'
10
+
11
+ type WorkerEvent =
12
+ | { readonly type: 'stdout_delta'; readonly data: string }
13
+ | { readonly type: 'stderr_delta'; readonly data: string }
14
+ | {
15
+ readonly type: 'result'
16
+ readonly exitCode: number
17
+ readonly timedOut: boolean
18
+ readonly durationMs: number
19
+ readonly signal?: string
20
+ readonly stdoutTruncated?: boolean
21
+ readonly stderrTruncated?: boolean
22
+ }
23
+ | { readonly type: 'error'; readonly error: string }
24
+
25
+ function parseWorkerEvent(line: string): WorkerEvent {
26
+ let parsed: unknown
27
+ try {
28
+ parsed = JSON.parse(line)
29
+ } catch (error) {
30
+ throw new RemoteProtocolError(
31
+ `worker emitted malformed NDJSON: ${error instanceof Error ? error.message : String(error)}`,
32
+ )
33
+ }
34
+ if (!parsed || typeof parsed !== 'object') {
35
+ throw new RemoteProtocolError('worker emitted an event without an object body')
36
+ }
37
+ const event = parsed as Record<string, unknown>
38
+ if (
39
+ (event.type === 'stdout_delta' || event.type === 'stderr_delta') &&
40
+ typeof event.data === 'string'
41
+ ) {
42
+ return event as WorkerEvent
43
+ }
44
+ if (event.type === 'error' && typeof event.error === 'string') return event as WorkerEvent
45
+ if (
46
+ event.type === 'result' &&
47
+ Number.isFinite(event.exitCode) &&
48
+ typeof event.timedOut === 'boolean' &&
49
+ Number.isFinite(event.durationMs) &&
50
+ (event.signal === undefined || typeof event.signal === 'string') &&
51
+ (event.stdoutTruncated === undefined || typeof event.stdoutTruncated === 'boolean') &&
52
+ (event.stderrTruncated === undefined || typeof event.stderrTruncated === 'boolean')
53
+ ) {
54
+ return event as WorkerEvent
55
+ }
56
+ throw new RemoteProtocolError(`worker emitted an invalid ${String(event.type)} event`)
57
+ }
58
+
59
+ async function readExecution(
60
+ baseUrl: string,
61
+ executionId: string | undefined,
62
+ command: string,
63
+ argv: string[] | undefined,
64
+ opts: SandboxExecOptions | undefined,
65
+ transportSignal: AbortSignal,
66
+ ): Promise<SandboxExecResult> {
67
+ let response: Response
68
+ try {
69
+ response = await fetch(`${baseUrl}/execute`, {
70
+ method: 'POST',
71
+ headers: { 'content-type': 'application/json' },
72
+ signal: transportSignal,
73
+ body: JSON.stringify({
74
+ ...(executionId ? { executionId } : {}),
75
+ command,
76
+ args: argv ?? [],
77
+ cwd: opts?.cwd,
78
+ env: opts?.env,
79
+ timeoutMs: opts?.timeout,
80
+ }),
81
+ })
82
+ } catch (error) {
83
+ const cause = error instanceof Error ? error.cause : undefined
84
+ const causeMessage =
85
+ cause instanceof Error
86
+ ? `${cause.message}${(cause as Error & { code?: string }).code ? ` (${(cause as Error & { code?: string }).code})` : ''}`
87
+ : cause
88
+ ? String(cause)
89
+ : 'unknown'
90
+ throw withHint(
91
+ new Error(
92
+ `namzu-sandbox /execute fetch failed (baseUrl=${baseUrl}): ${error instanceof Error ? error.message : String(error)} — cause: ${causeMessage}`,
93
+ { cause: error },
94
+ ),
95
+ 'The worker was reachable when the sandbox started, so it has most likely exited, been killed, or become unreachable since. Check the container logs and runtime exit state.',
96
+ )
97
+ }
98
+ if (!response.ok || !response.body) {
99
+ throw new Error(`execute failed: HTTP ${response.status} ${await response.text()}`)
100
+ }
101
+
102
+ const decoder = new TextDecoder()
103
+ const reader = response.body.getReader()
104
+ let buffered = ''
105
+ let stdout = ''
106
+ let stderr = ''
107
+ let terminal: Extract<WorkerEvent, { type: 'result' }> | undefined
108
+ let terminalCount = 0
109
+
110
+ const consume = (rawLine: string): void => {
111
+ if (!rawLine.trim()) return
112
+ const event = parseWorkerEvent(rawLine)
113
+ if (terminalCount > 0) {
114
+ throw new RemoteProtocolError('worker emitted data after its terminal event')
115
+ }
116
+ if (event.type === 'stdout_delta') {
117
+ stdout += event.data
118
+ opts?.onOutput?.({ stream: 'stdout', data: event.data })
119
+ return
120
+ }
121
+ if (event.type === 'stderr_delta') {
122
+ stderr += event.data
123
+ opts?.onOutput?.({ stream: 'stderr', data: event.data })
124
+ return
125
+ }
126
+ terminalCount += 1
127
+ if (event.type === 'error') throw new RemoteCommandError(event.error)
128
+ terminal = event
129
+ }
130
+
131
+ for (;;) {
132
+ const { value, done } = await reader.read()
133
+ if (done) break
134
+ buffered += decoder.decode(value, { stream: true })
135
+ let newline = buffered.indexOf('\n')
136
+ while (newline !== -1) {
137
+ consume(buffered.slice(0, newline))
138
+ buffered = buffered.slice(newline + 1)
139
+ newline = buffered.indexOf('\n')
140
+ }
141
+ }
142
+ buffered += decoder.decode()
143
+ if (buffered.trim()) consume(buffered)
144
+ if (terminalCount !== 1 || !terminal) {
145
+ throw new RemoteProtocolError('worker response ended without exactly one result event')
146
+ }
147
+
148
+ return {
149
+ exitCode: terminal.exitCode,
150
+ stdout,
151
+ stderr,
152
+ ...(terminal.signal ? { signal: terminal.signal } : {}),
153
+ timedOut: terminal.timedOut,
154
+ durationMs: terminal.durationMs,
155
+ ...(terminal.stdoutTruncated !== undefined
156
+ ? { stdoutTruncated: terminal.stdoutTruncated }
157
+ : {}),
158
+ ...(terminal.stderrTruncated !== undefined
159
+ ? { stderrTruncated: terminal.stderrTruncated }
160
+ : {}),
161
+ }
162
+ }
163
+
164
+ /**
165
+ * A per-sandbox HTTP worker client. The controller caches protocol support for
166
+ * that worker and gives every v2 command an identity before it can be admitted.
167
+ */
168
+ export class HttpWorkerClient {
169
+ private readonly controller: RemoteExecutionController
170
+
171
+ constructor(baseUrl: string) {
172
+ const adapter: RemoteExecutionAdapter = {
173
+ label: 'HTTP worker',
174
+ reserve: async (signal) => {
175
+ const response = await fetch(`${baseUrl}/executions/reserve`, {
176
+ method: 'POST',
177
+ signal,
178
+ })
179
+ if (response.status === 404) {
180
+ throw new RemoteCancellationUnsupportedError(
181
+ 'This sandbox worker does not support the execution-cancellation lease protocol. Rebuild the worker image or standby-pool profile before passing SandboxExecOptions.signal; refusing rather than pretending cancellation is active.',
182
+ )
183
+ }
184
+ if (!response.ok) {
185
+ throw new Error(
186
+ `execution reservation failed: HTTP ${response.status} ${await response.text()}`,
187
+ )
188
+ }
189
+ return await response.json()
190
+ },
191
+ cancel: async (executionId, signal) => {
192
+ const response = await fetch(`${baseUrl}/cancel`, {
193
+ method: 'POST',
194
+ headers: { 'content-type': 'application/json' },
195
+ body: JSON.stringify({ executionId }),
196
+ signal,
197
+ })
198
+ if (!response.ok) {
199
+ throw new Error(`cancel failed: HTTP ${response.status} ${await response.text()}`)
200
+ }
201
+ return await response.json()
202
+ },
203
+ execute: async (executionId, command, argv, opts, signal) =>
204
+ await readExecution(baseUrl, executionId, command, argv, opts, signal),
205
+ }
206
+ this.controller = new RemoteExecutionController(adapter)
207
+ }
208
+
209
+ async exec(
210
+ command: string,
211
+ argv: string[] | undefined,
212
+ opts: SandboxExecOptions | undefined,
213
+ ): Promise<SandboxExecResult> {
214
+ return await this.controller.exec(command, argv, opts)
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Compatibility entry point for focused consumers. Sandbox backends keep one
220
+ * {@link HttpWorkerClient} per remote sandbox so capability state is not shared
221
+ * across peers and is not re-probed for every command.
222
+ */
223
+ export async function execViaHttpWorker(
224
+ baseUrl: string,
225
+ command: string,
226
+ argv: string[] | undefined,
227
+ opts: SandboxExecOptions | undefined,
228
+ ): Promise<SandboxExecResult> {
229
+ return await new HttpWorkerClient(baseUrl).exec(command, argv, opts)
230
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * One owner for backend readiness clocks.
3
+ *
4
+ * A timestamp checked between retries is not a deadline when the thing inside
5
+ * a retry can remain pending. This owner races every foreign promise against
6
+ * the remaining operation budget and supplies a private signal so cooperative
7
+ * transports can release their sockets too.
8
+ */
9
+
10
+ const MAX_NODE_TIMER_MS = 2_147_483_647
11
+
12
+ /**
13
+ * Readiness failure teardown is deliberately a second, short budget. The
14
+ * health deadline has already expired, so pretending cleanup fits inside it
15
+ * either skips cleanup entirely or keeps `create()` pending without a bound.
16
+ */
17
+ export const FAILURE_CLEANUP_GRACE_MS = 1_000
18
+
19
+ export interface ReadinessOptions {
20
+ readonly timeoutMs: number
21
+ readonly pollIntervalMs: number
22
+ }
23
+
24
+ export class OperationDeadlineExpired extends Error {
25
+ constructor(readonly label: string) {
26
+ super(`${label} deadline expired`)
27
+ this.name = 'OperationDeadlineExpired'
28
+ }
29
+ }
30
+
31
+ function assertTimerValue(field: string, value: number): void {
32
+ if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_NODE_TIMER_MS) {
33
+ throw new RangeError(
34
+ `${field} must be a positive safe integer no greater than ${MAX_NODE_TIMER_MS}; received ${String(value)}`,
35
+ )
36
+ }
37
+ }
38
+
39
+ export function resolveReadinessOptions(
40
+ owner: string,
41
+ timeoutMs: number | undefined,
42
+ pollIntervalMs: number | undefined,
43
+ defaults: ReadinessOptions,
44
+ ): ReadinessOptions {
45
+ const resolved = {
46
+ timeoutMs: timeoutMs ?? defaults.timeoutMs,
47
+ pollIntervalMs: pollIntervalMs ?? defaults.pollIntervalMs,
48
+ }
49
+ assertTimerValue(`${owner}.readyTimeoutMs`, resolved.timeoutMs)
50
+ assertTimerValue(`${owner}.readyPollIntervalMs`, resolved.pollIntervalMs)
51
+ return resolved
52
+ }
53
+
54
+ export class OperationDeadline {
55
+ private readonly expiresAt: number
56
+
57
+ constructor(
58
+ timeoutMs: number,
59
+ readonly label: string,
60
+ private readonly callerSignal?: AbortSignal,
61
+ ) {
62
+ assertTimerValue(`${label} timeout`, timeoutMs)
63
+ this.expiresAt = performance.now() + timeoutMs
64
+ }
65
+
66
+ remainingMs(): number {
67
+ return Math.max(0, this.expiresAt - performance.now())
68
+ }
69
+
70
+ async run<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
71
+ this.callerSignal?.throwIfAborted()
72
+ const remaining = this.remainingMs()
73
+ if (remaining <= 0) throw new OperationDeadlineExpired(this.label)
74
+
75
+ const controller = new AbortController()
76
+ const expired = new OperationDeadlineExpired(this.label)
77
+ let timer: ReturnType<typeof setTimeout> | undefined
78
+ let onCallerAbort: (() => void) | undefined
79
+ const cancelled = this.callerSignal
80
+ ? new Promise<never>((_resolve, reject) => {
81
+ onCallerAbort = () => {
82
+ const reason = this.callerSignal?.reason
83
+ controller.abort(reason)
84
+ reject(reason)
85
+ }
86
+ this.callerSignal?.addEventListener('abort', onCallerAbort, {
87
+ once: true,
88
+ })
89
+ })
90
+ : undefined
91
+ const expiry = new Promise<never>((_resolve, reject) => {
92
+ timer = setTimeout(() => {
93
+ controller.abort(expired)
94
+ reject(expired)
95
+ }, remaining)
96
+ })
97
+ const pending = Promise.resolve().then(() => operation(controller.signal))
98
+
99
+ try {
100
+ const value = await Promise.race(cancelled ? [pending, expiry, cancelled] : [pending, expiry])
101
+ // Publication fence: a foreign promise that settles at the boundary
102
+ // does not get to turn an already-expired attempt into readiness.
103
+ if (this.callerSignal?.aborted) throw this.callerSignal.reason
104
+ if (controller.signal.aborted || this.remainingMs() <= 0) {
105
+ controller.abort(expired)
106
+ throw expired
107
+ }
108
+ return value
109
+ } finally {
110
+ if (timer !== undefined) clearTimeout(timer)
111
+ if (onCallerAbort) this.callerSignal?.removeEventListener('abort', onCallerAbort)
112
+ }
113
+ }
114
+
115
+ async delay(maximumMs: number): Promise<void> {
116
+ await this.run(
117
+ (signal) =>
118
+ new Promise<void>((resolve, reject) => {
119
+ if (signal.aborted) {
120
+ reject(signal.reason)
121
+ return
122
+ }
123
+ const finish = (err?: unknown) => {
124
+ clearTimeout(timer)
125
+ signal.removeEventListener('abort', abort)
126
+ if (err === undefined) resolve()
127
+ else reject(err)
128
+ }
129
+ const abort = () => {
130
+ finish(signal.reason)
131
+ }
132
+ const timer = setTimeout(() => finish(), maximumMs)
133
+ signal.addEventListener('abort', abort, { once: true })
134
+ }),
135
+ )
136
+ }
137
+ }
138
+
139
+ export async function probeHttpHealth(
140
+ url: string,
141
+ signal: AbortSignal,
142
+ ): Promise<{ readonly ok: boolean; readonly status: number }> {
143
+ signal.throwIfAborted()
144
+ const response = await fetch(url, { signal })
145
+ const result = { ok: response.ok, status: response.status }
146
+ try {
147
+ await response.body?.cancel()
148
+ } catch {
149
+ // The status is already known. A body cancellation failure must not
150
+ // hide it; the owning deadline still aborts the transport if needed.
151
+ }
152
+ signal.throwIfAborted()
153
+ return result
154
+ }
155
+
156
+ /**
157
+ * Failure cleanup must never replace or indefinitely delay the primary
158
+ * readiness error. Cooperative cleanup observes the signal; an implementation
159
+ * that ignores it is still detached from the caller by the independent race.
160
+ */
161
+ export async function runFailureCleanup(
162
+ cleanup: (signal: AbortSignal) => Promise<void>,
163
+ graceMs = FAILURE_CLEANUP_GRACE_MS,
164
+ ): Promise<void> {
165
+ const deadline = new OperationDeadline(graceMs, 'sandbox failure cleanup')
166
+ try {
167
+ await deadline.run(cleanup)
168
+ } catch {
169
+ // The readiness failure remains primary. Promise.race installed a
170
+ // rejection observer on the losing cleanup promise, so a late failure
171
+ // cannot become an unhandled rejection.
172
+ }
173
+ }