@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.
- package/CHANGELOG.md +15 -0
- package/README.md +47 -7
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +3 -1
- 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 +3 -1
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts +15 -14
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +36 -28
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/backends/firecracker/protocol.d.ts +58 -0
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +23 -3
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +305 -5
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/http-worker-client.d.ts +3 -7
- package/dist/backends/http-worker-client.d.ts.map +1 -1
- package/dist/backends/http-worker-client.js +5 -9
- package/dist/backends/http-worker-client.js.map +1 -1
- package/dist/backends/readiness.d.ts.map +1 -1
- package/dist/backends/readiness.js +27 -6
- package/dist/backends/readiness.js.map +1 -1
- package/dist/backends/remote-execution-controller.d.ts +7 -10
- package/dist/backends/remote-execution-controller.d.ts.map +1 -1
- package/dist/backends/remote-execution-controller.js +7 -33
- package/dist/backends/remote-execution-controller.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/backends/aci-standby-pool/index.ts +5 -1
- package/src/backends/docker/index.ts +5 -1
- package/src/backends/firecracker/index.ts +52 -34
- package/src/backends/firecracker/protocol.ts +51 -0
- package/src/backends/firecracker/transport.ts +339 -8
- package/src/backends/http-worker-client.ts +5 -10
- package/src/backends/readiness.ts +38 -6
- package/src/backends/remote-execution-controller.ts +9 -55
- 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 {
|
|
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
|
|
728
|
-
|
|
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
|
-
/**
|
|
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 }>(
|
|
747
|
-
|
|
748
|
-
|
|
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.
|
|
166
|
-
*
|
|
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
|
|
181
|
-
'
|
|
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
|
-
|
|
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.
|
|
148
|
-
} catch {
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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:
|
|
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 !==
|
|
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.
|
|
230
|
-
* command, even when the caller supplied no signal, so
|
|
231
|
-
* reconciled by identity.
|
|
232
|
-
*
|
|
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> {
|