@namzu/sandbox 11.0.0 → 12.0.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 (44) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +47 -7
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +3 -1
  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 +3 -1
  8. package/dist/backends/docker/index.js.map +1 -1
  9. package/dist/backends/firecracker/index.d.ts +15 -14
  10. package/dist/backends/firecracker/index.d.ts.map +1 -1
  11. package/dist/backends/firecracker/index.js +36 -28
  12. package/dist/backends/firecracker/index.js.map +1 -1
  13. package/dist/backends/firecracker/protocol.d.ts +58 -0
  14. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  15. package/dist/backends/firecracker/protocol.js.map +1 -1
  16. package/dist/backends/firecracker/transport.d.ts +23 -3
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +305 -5
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/http-worker-client.d.ts +3 -7
  21. package/dist/backends/http-worker-client.d.ts.map +1 -1
  22. package/dist/backends/http-worker-client.js +5 -9
  23. package/dist/backends/http-worker-client.js.map +1 -1
  24. package/dist/backends/readiness.d.ts.map +1 -1
  25. package/dist/backends/readiness.js +27 -6
  26. package/dist/backends/readiness.js.map +1 -1
  27. package/dist/backends/remote-execution-controller.d.ts +7 -10
  28. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  29. package/dist/backends/remote-execution-controller.js +7 -33
  30. package/dist/backends/remote-execution-controller.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -1
  34. package/dist/index.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/backends/aci-standby-pool/index.ts +5 -1
  37. package/src/backends/docker/index.ts +5 -1
  38. package/src/backends/firecracker/index.ts +52 -34
  39. package/src/backends/firecracker/protocol.ts +51 -0
  40. package/src/backends/firecracker/transport.ts +339 -8
  41. package/src/backends/http-worker-client.ts +5 -10
  42. package/src/backends/readiness.ts +38 -6
  43. package/src/backends/remote-execution-controller.ts +9 -55
  44. package/src/index.ts +1 -0
@@ -56,11 +56,18 @@
56
56
  import net from 'node:net'
57
57
  import tls from 'node:tls'
58
58
 
59
- import type { SandboxExecOptions, SandboxExecResult } from '@namzu/sdk'
59
+ import type {
60
+ OpenTerminalOptions,
61
+ SandboxExecOptions,
62
+ SandboxExecResult,
63
+ SandboxTcpConnectOptions,
64
+ SandboxTcpConnection,
65
+ TerminalSession,
66
+ } from '@namzu/sdk'
60
67
  import { OperationDeadline, OperationDeadlineExpired } from '../readiness.js'
61
68
  import {
69
+ REMOTE_EXECUTION_PROTOCOL_VERSION,
62
70
  RemoteCancellationUnknownError,
63
- RemoteCancellationUnsupportedError,
64
71
  type RemoteExecutionAdapter,
65
72
  RemoteExecutionController,
66
73
  RemoteProtocolError,
@@ -70,6 +77,12 @@ import {
70
77
  ExecResultAccumulator,
71
78
  type ReadFileRequest,
72
79
  type ReadFileResponse,
80
+ type TcpConnectRequest,
81
+ type TcpInputEvent,
82
+ type TcpOutputEvent,
83
+ type TerminalInputEvent,
84
+ type TerminalOpenRequest,
85
+ type TerminalOutputEvent,
73
86
  type WriteFileRequest,
74
87
  type WriteFileResponse,
75
88
  parseExecLine,
@@ -170,6 +183,8 @@ export type AgentRequest =
170
183
  }
171
184
  | { readonly op: 'read-file'; readonly body: ReadFileRequest }
172
185
  | { readonly op: 'write-file'; readonly body: WriteFileRequest }
186
+ | { readonly op: 'terminal'; readonly body: TerminalOpenRequest }
187
+ | { readonly op: 'tcp-connect'; readonly body: TcpConnectRequest }
173
188
  | { readonly op: 'healthz' }
174
189
 
175
190
  export interface VsockTransportOptions {
@@ -202,6 +217,9 @@ const EXECUTION_TRANSPORT_GRACE_MS = 10_000
202
217
  const POST_RESPONSE_CLOSE_TIMEOUT_MS = 1_000
203
218
  const MAX_TIMER_DELAY_MS = 2_147_483_647
204
219
 
220
+ /** Exact guest wire version accepted by this Firecracker transport. */
221
+ export const FIRECRACKER_AGENT_PROTOCOL_VERSION = REMOTE_EXECUTION_PROTOCOL_VERSION
222
+
205
223
  /** Framing: 8 hex digits of payload byte length, then `\n`, then payload. */
206
224
  const LENGTH_PREFIX_HEX = 8
207
225
 
@@ -724,8 +742,8 @@ export class VsockAgentTransport {
724
742
  typeof response.error === 'string' &&
725
743
  response.error.startsWith('unknown_op:')
726
744
  ) {
727
- throw new RemoteCancellationUnsupportedError(
728
- 'This microVM agent does not support the execution-cancellation lease protocol. Rebuild the guest image before passing SandboxExecOptions.signal; refusing rather than pretending cancellation is active.',
745
+ throw new RemoteProtocolError(
746
+ `The microVM guest does not implement Firecracker agent protocol ${FIRECRACKER_AGENT_PROTOCOL_VERSION}. Rebuild the golden image from the same Namzu release before admitting commands.`,
729
747
  )
730
748
  }
731
749
  if (response.ok === false && response.error === 'agent_retiring') {
@@ -740,13 +758,25 @@ export class VsockAgentTransport {
740
758
  return await this.request<unknown>({ op: 'cancel-execution', body: { executionId } }, signal)
741
759
  }
742
760
 
743
- /** Liveness probe. Returns true on an `{ ok: true }` healthz reply. */
761
+ /** Readiness probe. A healthy guest must also speak the exact host protocol. */
744
762
  async healthz(signal?: AbortSignal): Promise<boolean> {
745
763
  try {
746
- const res = await this.request<{ ok?: boolean }>({ op: 'healthz' }, signal)
747
- return res.ok === true
748
- } catch {
764
+ const res = await this.request<{ ok?: boolean; protocolVersion?: unknown }>(
765
+ { op: 'healthz' },
766
+ signal,
767
+ )
768
+ if (res.ok !== true) return false
769
+ if (res.protocolVersion !== FIRECRACKER_AGENT_PROTOCOL_VERSION) {
770
+ const actual =
771
+ res.protocolVersion === undefined ? 'missing' : JSON.stringify(res.protocolVersion)
772
+ throw new RemoteProtocolError(
773
+ `Firecracker guest protocol version mismatch: expected ${FIRECRACKER_AGENT_PROTOCOL_VERSION}, received ${actual}. Rebuild the golden image from the same Namzu release.`,
774
+ )
775
+ }
776
+ return true
777
+ } catch (error) {
749
778
  if (signal?.aborted) throw signal.reason
779
+ if (error instanceof RemoteProtocolError) throw error
750
780
  return false
751
781
  }
752
782
  }
@@ -770,6 +800,7 @@ export class VsockAgentTransport {
770
800
  lastErr = new Error('healthz returned not-ok')
771
801
  } catch (err) {
772
802
  lastErr = err
803
+ if (err instanceof RemoteProtocolError) throw err
773
804
  if (err instanceof OperationDeadlineExpired) break
774
805
  }
775
806
  try {
@@ -806,6 +837,306 @@ export class VsockAgentTransport {
806
837
  }
807
838
  return Buffer.from(res.content, 'base64')
808
839
  }
840
+
841
+ /**
842
+ * Open a real PTY owned by the in-VM agent.
843
+ *
844
+ * Unlike `execute`, this keeps one framed connection open for the complete
845
+ * interactive lifetime: guest output and exit events flow toward the host,
846
+ * while input/resize/kill events flow back on the same ordered stream. The
847
+ * browser never reaches this transport directly; the runtime gateway owns
848
+ * the session and its authenticated WebSocket attachment.
849
+ */
850
+ async openTerminal(options: OpenTerminalOptions): Promise<TerminalSession> {
851
+ const socket = await this.dial()
852
+ const request: TerminalOpenRequest = {
853
+ ...(options.command !== undefined ? { command: options.command } : {}),
854
+ ...(options.args !== undefined ? { args: options.args } : {}),
855
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
856
+ ...(options.env !== undefined ? { env: { ...options.env } } : {}),
857
+ cols: options.size.cols,
858
+ rows: options.size.rows,
859
+ }
860
+
861
+ return await new Promise<TerminalSession>((resolve, reject) => {
862
+ const KILL_GRACE_MS = 5_000
863
+ const reader = new FrameReader()
864
+ const listeners = new Set<(chunk: string) => void>()
865
+ const buffered: string[] = []
866
+ let bufferedBytes = 0
867
+ let ready = false
868
+ let settled = false
869
+ let killTimer: ReturnType<typeof setTimeout> | undefined
870
+ let resolveExit!: (event: { exitCode: number; signal?: number }) => void
871
+ const exited = new Promise<{ exitCode: number; signal?: number }>((done) => {
872
+ resolveExit = done
873
+ })
874
+ const idle = new IdleTimer(this.readIdleTimeoutMs, () => {
875
+ finish(
876
+ new Error(
877
+ `vsock transport: terminal read idle timeout after ${this.readIdleTimeoutMs}ms`,
878
+ ),
879
+ )
880
+ })
881
+
882
+ const finish = (
883
+ error: Error | null,
884
+ exit: { exitCode: number; signal?: number } = { exitCode: -1 },
885
+ ) => {
886
+ if (settled) return
887
+ settled = true
888
+ idle.clear()
889
+ if (killTimer) clearTimeout(killTimer)
890
+ socket.destroy()
891
+ listeners.clear()
892
+ resolveExit(exit)
893
+ if (!ready) reject(error ?? new Error('terminal exited before readiness'))
894
+ }
895
+
896
+ const send = (event: TerminalInputEvent) => {
897
+ if (settled) return
898
+ socket.write(frame(JSON.stringify(event)))
899
+ }
900
+
901
+ const session: TerminalSession = {
902
+ write(data) {
903
+ send({ type: 'input', data })
904
+ },
905
+ resize(size) {
906
+ send({ type: 'resize', cols: size.cols, rows: size.rows })
907
+ },
908
+ onData(listener) {
909
+ listeners.add(listener)
910
+ if (buffered.length > 0) {
911
+ const pending = buffered.splice(0)
912
+ bufferedBytes = 0
913
+ queueMicrotask(() => {
914
+ if (!listeners.has(listener)) return
915
+ for (const chunk of pending) listener(chunk)
916
+ })
917
+ }
918
+ return () => listeners.delete(listener)
919
+ },
920
+ exited,
921
+ kill(signal) {
922
+ send({ type: 'kill', ...(signal !== undefined ? { signal } : {}) })
923
+ // A wedged guest must not pin sandbox.destroy() forever. The normal
924
+ // path reports the real exit; the deadline only severs an
925
+ // unresponsive transport so the owning microVM can be reclaimed.
926
+ if (!settled && !killTimer) {
927
+ killTimer = setTimeout(() => finish(null), KILL_GRACE_MS)
928
+ killTimer.unref?.()
929
+ }
930
+ },
931
+ }
932
+
933
+ socket.on('data', (chunk: Buffer) => {
934
+ if (!ready) idle.bump()
935
+ let payloads: string[]
936
+ try {
937
+ payloads = reader.push(chunk)
938
+ } catch (err) {
939
+ finish(err instanceof Error ? err : new Error(String(err)))
940
+ return
941
+ }
942
+ for (const payload of payloads) {
943
+ let event: TerminalOutputEvent
944
+ try {
945
+ event = JSON.parse(payload) as TerminalOutputEvent
946
+ } catch (err) {
947
+ finish(err instanceof Error ? err : new Error(String(err)))
948
+ return
949
+ }
950
+ if (event.type === 'ready') {
951
+ if (!ready) {
952
+ ready = true
953
+ // Once ready, an interactive shell may legitimately sit silent
954
+ // for hours. Runtime/session TTL owns idle cleanup; a transport
955
+ // read timer would incorrectly kill a healthy quiet terminal.
956
+ idle.clear()
957
+ resolve(session)
958
+ }
959
+ continue
960
+ }
961
+ if (event.type === 'data') {
962
+ if (listeners.size === 0) {
963
+ buffered.push(event.data)
964
+ bufferedBytes += Buffer.byteLength(event.data)
965
+ while (bufferedBytes > 1024 * 1024 && buffered.length > 1) {
966
+ bufferedBytes -= Buffer.byteLength(buffered.shift() ?? '')
967
+ }
968
+ } else {
969
+ for (const listener of listeners) listener(event.data)
970
+ }
971
+ continue
972
+ }
973
+ if (event.type === 'exit') {
974
+ finish(null, {
975
+ exitCode: event.exitCode,
976
+ ...(event.signal !== undefined ? { signal: event.signal } : {}),
977
+ })
978
+ return
979
+ }
980
+ finish(new Error(event.error))
981
+ return
982
+ }
983
+ })
984
+ socket.once('error', (err) => finish(err))
985
+ socket.once('close', () =>
986
+ finish(new Error('vsock transport: terminal socket closed before exit')),
987
+ )
988
+ idle.bump()
989
+ socket.write(
990
+ frame(
991
+ JSON.stringify({
992
+ op: 'terminal',
993
+ body: request,
994
+ } satisfies AgentRequest),
995
+ ),
996
+ )
997
+ })
998
+ }
999
+
1000
+ /** Open one TCP stream to a service listening on guest loopback. */
1001
+ async openTcpConnection(options: SandboxTcpConnectOptions): Promise<SandboxTcpConnection> {
1002
+ if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65_535) {
1003
+ throw new Error('tcp connection port must be an integer in [1, 65535]')
1004
+ }
1005
+ const host = options.host ?? '127.0.0.1'
1006
+ if (host !== '127.0.0.1' && host !== '::1') {
1007
+ throw new Error('firecracker TCP connections are restricted to guest loopback')
1008
+ }
1009
+ const socket = await this.dial()
1010
+ const request: TcpConnectRequest = { host, port: options.port }
1011
+
1012
+ return await new Promise<SandboxTcpConnection>((resolve, reject) => {
1013
+ const reader = new FrameReader()
1014
+ const listeners = new Set<(chunk: Uint8Array) => void>()
1015
+ const buffered: Buffer[] = []
1016
+ let bufferedBytes = 0
1017
+ let ready = false
1018
+ let settled = false
1019
+ let resolveClosed!: () => void
1020
+ const closed = new Promise<void>((done) => {
1021
+ resolveClosed = done
1022
+ })
1023
+ const idle = new IdleTimer(this.readIdleTimeoutMs, () => {
1024
+ finish(
1025
+ new Error(`vsock transport: TCP connect idle timeout after ${this.readIdleTimeoutMs}ms`),
1026
+ )
1027
+ })
1028
+
1029
+ const finish = (error: Error | null) => {
1030
+ if (settled) return
1031
+ settled = true
1032
+ idle.clear()
1033
+ socket.destroy()
1034
+ listeners.clear()
1035
+ resolveClosed()
1036
+ if (!ready) reject(error ?? new Error('TCP stream closed before readiness'))
1037
+ }
1038
+
1039
+ const send = (event: TcpInputEvent): boolean => {
1040
+ return !settled && socket.write(frame(JSON.stringify(event)))
1041
+ }
1042
+
1043
+ const connection: SandboxTcpConnection = {
1044
+ write(data) {
1045
+ const bytes = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data)
1046
+ return send({ type: 'data', data: bytes.toString('base64') })
1047
+ },
1048
+ end() {
1049
+ send({ type: 'end' })
1050
+ },
1051
+ destroy() {
1052
+ send({ type: 'destroy' })
1053
+ finish(null)
1054
+ },
1055
+ pause() {
1056
+ socket.pause()
1057
+ },
1058
+ resume() {
1059
+ if (!settled) socket.resume()
1060
+ },
1061
+ onData(listener) {
1062
+ listeners.add(listener)
1063
+ if (buffered.length > 0) {
1064
+ const pending = buffered.splice(0)
1065
+ bufferedBytes = 0
1066
+ queueMicrotask(() => {
1067
+ if (!listeners.has(listener)) return
1068
+ for (const chunk of pending) listener(chunk)
1069
+ })
1070
+ }
1071
+ return () => listeners.delete(listener)
1072
+ },
1073
+ onDrain(listener) {
1074
+ socket.on('drain', listener)
1075
+ return () => socket.off('drain', listener)
1076
+ },
1077
+ closed,
1078
+ }
1079
+
1080
+ socket.on('data', (chunk: Buffer) => {
1081
+ if (!ready) idle.bump()
1082
+ let payloads: string[]
1083
+ try {
1084
+ payloads = reader.push(chunk)
1085
+ } catch (error) {
1086
+ finish(error instanceof Error ? error : new Error(String(error)))
1087
+ return
1088
+ }
1089
+ for (const payload of payloads) {
1090
+ let event: TcpOutputEvent
1091
+ try {
1092
+ event = JSON.parse(payload) as TcpOutputEvent
1093
+ } catch (error) {
1094
+ finish(error instanceof Error ? error : new Error(String(error)))
1095
+ return
1096
+ }
1097
+ if (event.type === 'ready') {
1098
+ if (!ready) {
1099
+ ready = true
1100
+ idle.clear()
1101
+ resolve(connection)
1102
+ }
1103
+ continue
1104
+ }
1105
+ if (event.type === 'data') {
1106
+ const bytes = Buffer.from(event.data, 'base64')
1107
+ if (listeners.size === 0) {
1108
+ buffered.push(bytes)
1109
+ bufferedBytes += bytes.byteLength
1110
+ while (bufferedBytes > 1024 * 1024 && buffered.length > 1) {
1111
+ bufferedBytes -= buffered.shift()?.byteLength ?? 0
1112
+ }
1113
+ } else {
1114
+ for (const listener of listeners) listener(bytes)
1115
+ }
1116
+ continue
1117
+ }
1118
+ if (event.type === 'end') {
1119
+ finish(null)
1120
+ continue
1121
+ }
1122
+ finish(new Error(event.error))
1123
+ }
1124
+ })
1125
+ socket.once('error', (error) => finish(error))
1126
+ socket.once('close', () =>
1127
+ finish(ready ? null : new Error('vsock TCP socket closed before readiness')),
1128
+ )
1129
+ idle.bump()
1130
+ socket.write(
1131
+ frame(
1132
+ JSON.stringify({
1133
+ op: 'tcp-connect',
1134
+ body: request,
1135
+ } satisfies AgentRequest),
1136
+ ),
1137
+ )
1138
+ })
1139
+ }
809
1140
  }
810
1141
 
811
1142
  // ---------------------------------------------------------------------------
@@ -1,7 +1,6 @@
1
1
  import { type SandboxExecOptions, type SandboxExecResult, withHint } from '@namzu/sdk'
2
2
 
3
3
  import {
4
- RemoteCancellationUnsupportedError,
5
4
  RemoteCommandError,
6
5
  type RemoteExecutionAdapter,
7
6
  RemoteExecutionController,
@@ -162,8 +161,8 @@ async function readExecution(
162
161
  }
163
162
 
164
163
  /**
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.
164
+ * A per-sandbox HTTP worker client. Every command must reserve an identity
165
+ * through the exact worker protocol before it can be admitted.
167
166
  */
168
167
  export class HttpWorkerClient {
169
168
  private readonly controller: RemoteExecutionController
@@ -177,8 +176,8 @@ export class HttpWorkerClient {
177
176
  signal,
178
177
  })
179
178
  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.',
179
+ throw new RemoteProtocolError(
180
+ 'The sandbox worker does not implement the required execution protocol. Rebuild the worker image or standby-pool profile from the same Namzu release before admitting commands.',
182
181
  )
183
182
  }
184
183
  if (!response.ok) {
@@ -215,11 +214,7 @@ export class HttpWorkerClient {
215
214
  }
216
215
  }
217
216
 
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
- */
217
+ /** Convenience entry point for focused consumers. */
223
218
  export async function execViaHttpWorker(
224
219
  baseUrl: string,
225
220
  command: string,
@@ -7,6 +7,11 @@
7
7
  * transports can release their sockets too.
8
8
  */
9
9
 
10
+ import {
11
+ REMOTE_EXECUTION_PROTOCOL_VERSION,
12
+ RemoteProtocolError,
13
+ } from './remote-execution-controller.js'
14
+
10
15
  const MAX_NODE_TIMER_MS = 2_147_483_647
11
16
 
12
17
  /**
@@ -142,15 +147,42 @@ export async function probeHttpHealth(
142
147
  ): Promise<{ readonly ok: boolean; readonly status: number }> {
143
148
  signal.throwIfAborted()
144
149
  const response = await fetch(url, { signal })
145
- const result = { ok: response.ok, status: response.status }
150
+ if (!response.ok) {
151
+ try {
152
+ await response.body?.cancel()
153
+ } catch {
154
+ // The status is already known. A body cancellation failure must not
155
+ // hide it; the owning deadline still aborts the transport if needed.
156
+ }
157
+ signal.throwIfAborted()
158
+ return { ok: false, status: response.status }
159
+ }
160
+
161
+ let payload: unknown
146
162
  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.
163
+ payload = await response.json()
164
+ } catch (error) {
165
+ throw new RemoteProtocolError(
166
+ `Sandbox worker health response is not valid JSON for protocol ${REMOTE_EXECUTION_PROTOCOL_VERSION}: ${error instanceof Error ? error.message : String(error)}`,
167
+ )
151
168
  }
152
169
  signal.throwIfAborted()
153
- return result
170
+ const health = payload as { ok?: unknown; protocolVersion?: unknown }
171
+ if (
172
+ !health ||
173
+ typeof health !== 'object' ||
174
+ health.ok !== true ||
175
+ health.protocolVersion !== REMOTE_EXECUTION_PROTOCOL_VERSION
176
+ ) {
177
+ const actual =
178
+ health && typeof health === 'object' && health.protocolVersion !== undefined
179
+ ? JSON.stringify(health.protocolVersion)
180
+ : 'missing'
181
+ throw new RemoteProtocolError(
182
+ `Sandbox worker protocol version mismatch: expected ${REMOTE_EXECUTION_PROTOCOL_VERSION}, received ${actual}. Rebuild the worker image from the same Namzu release.`,
183
+ )
184
+ }
185
+ return { ok: true, status: response.status }
154
186
  }
155
187
 
156
188
  /**
@@ -9,9 +9,12 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647
9
9
  const EXECUTION_ID_PATTERN =
10
10
  /^exec_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
11
11
 
12
+ /** Exact wire version implemented by the shipped worker and microVM guest. */
13
+ export const REMOTE_EXECUTION_PROTOCOL_VERSION = 2 as const
14
+
12
15
  export interface RemoteReservation {
13
16
  readonly ok: true
14
- readonly protocolVersion: 2
17
+ readonly protocolVersion: typeof REMOTE_EXECUTION_PROTOCOL_VERSION
15
18
  readonly executionId: string
16
19
  readonly leaseExpiresAt: number
17
20
  }
@@ -37,8 +40,6 @@ export class RemoteCommandError extends Error {}
37
40
 
38
41
  export class RemoteProtocolError extends Error {}
39
42
 
40
- export class RemoteCancellationUnsupportedError extends Error {}
41
-
42
43
  export interface SandboxRetirementObservation {
43
44
  readonly accepted: boolean
44
45
  readonly error?: Error
@@ -106,7 +107,7 @@ function parseReservation(value: unknown): RemoteReservation {
106
107
  const reservation = value as Record<string, unknown>
107
108
  if (
108
109
  reservation.ok !== true ||
109
- reservation.protocolVersion !== 2 ||
110
+ reservation.protocolVersion !== REMOTE_EXECUTION_PROTOCOL_VERSION ||
110
111
  !EXECUTION_ID_PATTERN.test(String(reservation.executionId ?? '')) ||
111
112
  !Number.isFinite(reservation.leaseExpiresAt)
112
113
  ) {
@@ -223,17 +224,13 @@ async function bounded<T>(
223
224
  }
224
225
  }
225
226
 
226
- type Capability = 'unknown' | 'supported' | 'unsupported'
227
-
228
227
  /**
229
- * One state machine for remote command ownership. A v2 peer reserves every
230
- * command, even when the caller supplied no signal, so transport loss can be
231
- * reconciled by identity. Legacy execution is entered only after an explicit
232
- * unsupported response from the peer and is fenced as unknown on any failure.
228
+ * One state machine for remote command ownership. Every supported peer reserves
229
+ * every command before admission, even when the caller supplied no signal, so
230
+ * transport loss can be reconciled by identity. An old peer is refused; an
231
+ * identity-less remote command is never started.
233
232
  */
234
233
  export class RemoteExecutionController<Context = undefined> {
235
- private capability: Capability = 'unknown'
236
- private unsupportedError: RemoteCancellationUnsupportedError | undefined
237
234
  private readonly controlRequestTimeoutMs: number
238
235
  private readonly cancelConfirmTimeoutMs: number
239
236
  private readonly resultDrainTimeoutMs: number
@@ -261,17 +258,6 @@ export class RemoteExecutionController<Context = undefined> {
261
258
  ): Promise<SandboxExecResult> {
262
259
  const startedAt = Date.now()
263
260
  if (opts?.signal?.aborted) return cancelledBeforeStart(startedAt)
264
- if (this.capability === 'unsupported') {
265
- if (opts?.signal) {
266
- throw (
267
- this.unsupportedError ??
268
- new RemoteCancellationUnsupportedError(
269
- `${this.adapter.label} was classified as cancellation-unsupported`,
270
- )
271
- )
272
- }
273
- return await this.executeLegacy(command, argv, opts, context)
274
- }
275
261
 
276
262
  let reservation: RemoteReservation
277
263
  try {
@@ -283,46 +269,14 @@ export class RemoteExecutionController<Context = undefined> {
283
269
  opts?.signal,
284
270
  ),
285
271
  )
286
- this.capability = 'supported'
287
272
  } catch (error) {
288
273
  if (opts?.signal?.aborted) return cancelledBeforeStart(startedAt)
289
- if (error instanceof RemoteCancellationUnsupportedError) {
290
- this.capability = 'unsupported'
291
- this.unsupportedError = error
292
- if (opts?.signal) throw error
293
- return await this.executeLegacy(command, argv, opts, context)
294
- }
295
274
  throw error
296
275
  }
297
276
 
298
277
  return await this.executeReserved(reservation, command, argv, opts, startedAt, context)
299
278
  }
300
279
 
301
- private async executeLegacy(
302
- command: string,
303
- argv: string[] | undefined,
304
- opts: SandboxExecOptions | undefined,
305
- context: Context | undefined,
306
- ): Promise<SandboxExecResult> {
307
- const timeoutMs = observationDeadlineMs(
308
- opts?.timeout,
309
- this.defaultExecutionTimeoutMs,
310
- this.executionObservationGraceMs,
311
- )
312
- try {
313
- return await bounded(
314
- `${this.adapter.label} legacy execution observation`,
315
- timeoutMs,
316
- (signal) => this.adapter.execute(undefined, command, argv, opts, signal, context),
317
- )
318
- } catch (error) {
319
- throw new RemoteCancellationUnknownError(
320
- `The legacy ${this.adapter.label} execution failed after admission without an execution id: ${error instanceof Error ? error.message : String(error)}. The remote outcome is unknown and the sandbox must not be reused.`,
321
- { cause: error },
322
- )
323
- }
324
- }
325
-
326
280
  private async requestCancellation(
327
281
  executionId: string,
328
282
  ): Promise<RemoteCancellationAcknowledgement> {
package/src/index.ts CHANGED
@@ -78,6 +78,7 @@ export type {
78
78
  OrchestratorTokenProvider,
79
79
  } from './backends/firecracker/index.js'
80
80
  export {
81
+ FIRECRACKER_AGENT_PROTOCOL_VERSION,
81
82
  type SandboxAgentHandle,
82
83
  type VsockTransportOptions,
83
84
  VsockAgentTransport,