@namzu/sandbox 15.0.0 → 17.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 (47) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/README.md +223 -0
  3. package/dist/backends/aci-standby-pool/index.d.ts +22 -4
  4. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  5. package/dist/backends/aci-standby-pool/index.js +31 -7
  6. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  7. package/dist/backends/docker/index.d.ts +408 -26
  8. package/dist/backends/docker/index.d.ts.map +1 -1
  9. package/dist/backends/docker/index.js +1173 -168
  10. package/dist/backends/docker/index.js.map +1 -1
  11. package/dist/backends/firecracker/transport.d.ts +156 -1
  12. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  13. package/dist/backends/firecracker/transport.js +223 -29
  14. package/dist/backends/firecracker/transport.js.map +1 -1
  15. package/dist/backends/http-worker-client.d.ts +64 -2
  16. package/dist/backends/http-worker-client.d.ts.map +1 -1
  17. package/dist/backends/http-worker-client.js +78 -7
  18. package/dist/backends/http-worker-client.js.map +1 -1
  19. package/dist/backends/kubernetes/egress-policy.d.ts +193 -102
  20. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  21. package/dist/backends/kubernetes/egress-policy.js +321 -146
  22. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  23. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +6 -6
  24. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
  25. package/dist/backends/kubernetes/per-sandbox-policy.js +21 -53
  26. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
  27. package/dist/backends/kubernetes/transport.d.ts +7 -0
  28. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  29. package/dist/backends/kubernetes/transport.js.map +1 -1
  30. package/dist/egress/proxy.d.ts +47 -2
  31. package/dist/egress/proxy.d.ts.map +1 -1
  32. package/dist/egress/proxy.js +31 -7
  33. package/dist/egress/proxy.js.map +1 -1
  34. package/dist/index.d.ts +130 -6
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +55 -5
  37. package/dist/index.js.map +1 -1
  38. package/package.json +4 -4
  39. package/src/backends/aci-standby-pool/index.ts +37 -7
  40. package/src/backends/docker/index.ts +1475 -196
  41. package/src/backends/firecracker/transport.ts +387 -36
  42. package/src/backends/http-worker-client.ts +89 -5
  43. package/src/backends/kubernetes/egress-policy.ts +455 -187
  44. package/src/backends/kubernetes/per-sandbox-policy.ts +21 -66
  45. package/src/backends/kubernetes/transport.ts +7 -0
  46. package/src/egress/proxy.ts +65 -8
  47. package/src/index.ts +162 -5
@@ -58,6 +58,21 @@ import tls from 'node:tls';
58
58
  import { OperationDeadline, OperationDeadlineExpired } from '../readiness.js';
59
59
  import { REMOTE_EXECUTION_PROTOCOL_VERSION, RemoteCancellationUnknownError, RemoteExecutionController, RemoteProtocolError, } from '../remote-execution-controller.js';
60
60
  import { ExecResultAccumulator, MIN_STREAM_HEARTBEAT_MS, READ_FILE_STREAM_FEATURE, STREAM_HEARTBEAT_MAX_ECHO_FACTOR, STREAM_HEARTBEAT_MISS_LIMIT, WRITE_FILE_PARTS_FEATURE, parseExecLine, } from './protocol.js';
61
+ /** The reportable view of a ledger — see {@link FirecrackerTransportTiming}. */
62
+ function timingOf(ledger) {
63
+ return {
64
+ dialMs: ledger.dialMs,
65
+ reserveMs: ledger.reserveMs,
66
+ executeMs: ledger.executeMs,
67
+ drainMs: ledger.executeSettledAt > 0 ? Date.now() - ledger.executeSettledAt : 0,
68
+ // Spread conditionally, not set to a sentinel: "this phase was never
69
+ // reached" and "this phase took no measurable time" are different
70
+ // statements, and only an absent field says the first.
71
+ ...(ledger.firstFrameMs !== undefined ? { firstFrameMs: ledger.firstFrameMs } : {}),
72
+ ...(ledger.terminatorMs !== undefined ? { terminatorMs: ledger.terminatorMs } : {}),
73
+ ...(ledger.peerCloseMs !== undefined ? { peerCloseMs: ledger.peerCloseMs } : {}),
74
+ };
75
+ }
61
76
  const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
62
77
  const DEFAULT_CONNECT_RETRY_BUDGET_MS = 30_000;
63
78
  const DEFAULT_CONNECT_RETRY_INTERVAL_MS = 100;
@@ -68,6 +83,37 @@ const DEFAULT_EXECUTION_TIMEOUT_MS = 5 * 60_000;
68
83
  // bounded TERM -> KILL confirmation window so a quiet but correctly
69
84
  // terminating command can still deliver its terminal frame and output tail.
70
85
  const EXECUTION_TRANSPORT_GRACE_MS = 10_000;
86
+ /**
87
+ * How long a reply that has already TERMINATED waits for the peer's own close
88
+ * before this transport gives up on it. Three callers, all of them read-reply
89
+ * loops that resolve on that close: {@link VsockAgentTransport.request} (one
90
+ * control or file reply), {@link VsockAgentTransport.executeRaw} (an exec's
91
+ * zero-length terminator frame) and `streamFramedRequest` (a
92
+ * `read-file-stream`'s end frame).
93
+ *
94
+ * **A reject-only guard, and deliberately not a budget to spend.** It can
95
+ * only turn a socket whose peer never closes into a named failure
96
+ * (`exec peer did not close after terminator`, or the `stream`/`control`
97
+ * wordings); it can never resolve a call, because every resolution path in
98
+ * this file requires the peer's `close` event. Raising it therefore buys no
99
+ * slow success — it delays a diagnosis — and lowering it fails a peer that
100
+ * was merely slow to close. It is not a wait any caller is expected to pay:
101
+ * a call that resolves has paid a real close, and how long the peer took to
102
+ * deliver it is reported as
103
+ * {@link FirecrackerTransportTiming.peerCloseMs}.
104
+ *
105
+ * **The contract this states for a host-owned relay.** After writing the
106
+ * terminator frame the agent calls `socket.end()` — a half-close — and this
107
+ * transport resolves the call on the resulting `close`. A relay between the
108
+ * guest and this process that forwards the payload but holds the FIN (its
109
+ * own idle timer, a full-duplex buffering policy, a proxy that waits for the
110
+ * guest process to exit) transfers that wait onto every call: under this
111
+ * bound it is reported as `peerCloseMs`, and at or past it the call REJECTS
112
+ * by name, with no `peerCloseMs` in the report — the number a host would
113
+ * otherwise be reading is one this transport chose, and it does not supply
114
+ * it. A relay that forwards the FIN promptly costs the guest's RTT and
115
+ * nothing else.
116
+ */
71
117
  const POST_RESPONSE_CLOSE_TIMEOUT_MS = 1_000;
72
118
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
73
119
  /**
@@ -344,7 +390,7 @@ export class VsockAgentTransport {
344
390
  */
345
391
  guestFeatureList;
346
392
  permanentDialFailure;
347
- executionController;
393
+ onExecTiming;
348
394
  constructor(handle, options = {}) {
349
395
  this.handle = handle;
350
396
  this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
@@ -363,24 +409,65 @@ export class VsockAgentTransport {
363
409
  this.writeFilePartBytes = Math.max(1, Math.floor(options.writeFilePartBytes));
364
410
  }
365
411
  this.permanentDialFailure = options.permanentDialFailure;
366
- const adapter = {
412
+ this.onExecTiming = options.onExecTiming;
413
+ }
414
+ /**
415
+ * The reserve-before-admission triple this transport hands the shared
416
+ * {@link RemoteExecutionController}, with `ledger` — when a timing hook
417
+ * asked for one — accumulating the phases it wraps.
418
+ *
419
+ * A FACTORY rather than a constructor-built field, because the ledger is
420
+ * per call: this transport holds no cross-call connection state (it dials
421
+ * fresh every time), so building an adapter per `exec()` is free and
422
+ * makes concurrent `exec()` calls correctly independent — each gets its
423
+ * own accumulator, with no shared mutable field for two in-flight calls
424
+ * to race on. Same arrangement, and the same reason, as the kubernetes
425
+ * tier's `KubernetesAgentTransport.exec`.
426
+ */
427
+ executionAdapter(ledger) {
428
+ return {
367
429
  label: 'framed microVM agent',
368
- reserve: async (signal) => await this.reserveExecution(signal),
369
- cancel: async (executionId, signal) => await this.cancelExecution(executionId, signal),
370
- execute: async (executionId, command, argv, opts, signal, context) => await this.executeRaw({
371
- ...(executionId ? { executionId } : {}),
372
- command,
373
- args: argv ?? [],
374
- ...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
375
- ...(opts?.env !== undefined ? { env: opts.env } : {}),
376
- ...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
377
- ...(context?.stdin !== undefined ? { stdin: context.stdin } : {}),
378
- ...(context?.maxOutputBytes !== undefined
379
- ? { maxOutputBytes: context.maxOutputBytes }
380
- : {}),
381
- }, opts, signal),
430
+ reserve: async (signal) => {
431
+ if (ledger === undefined)
432
+ return await this.reserveExecution(signal);
433
+ const startedAt = Date.now();
434
+ try {
435
+ return await this.reserveExecution(signal, ledger);
436
+ }
437
+ finally {
438
+ ledger.reserveMs += Date.now() - startedAt;
439
+ }
440
+ },
441
+ cancel: async (executionId, signal) => await this.cancelExecution(executionId, signal, ledger),
442
+ execute: async (executionId, command, argv, opts, signal, context) => {
443
+ const body = {
444
+ ...(executionId ? { executionId } : {}),
445
+ command,
446
+ args: argv ?? [],
447
+ ...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
448
+ ...(opts?.env !== undefined ? { env: opts.env } : {}),
449
+ ...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
450
+ ...(context?.stdin !== undefined ? { stdin: context.stdin } : {}),
451
+ ...(context?.maxOutputBytes !== undefined
452
+ ? { maxOutputBytes: context.maxOutputBytes }
453
+ : {}),
454
+ };
455
+ if (ledger === undefined)
456
+ return await this.executeRaw(body, opts, signal);
457
+ const startedAt = Date.now();
458
+ try {
459
+ return await this.executeRaw(body, opts, signal, ledger);
460
+ }
461
+ finally {
462
+ ledger.executeMs += Date.now() - startedAt;
463
+ // Stamped in the `finally` so it is set on the failure path
464
+ // too: a drain interval is only worth reporting when the
465
+ // round trip settled, and "settled" includes "settled by
466
+ // rejecting".
467
+ ledger.executeSettledAt = Date.now();
468
+ }
469
+ },
382
470
  };
383
- this.executionController = new RemoteExecutionController(adapter);
384
471
  }
385
472
  /**
386
473
  * Dial the agent with the resume-survival retry budget. Resolves a
@@ -390,7 +477,7 @@ export class VsockAgentTransport {
390
477
  * {@link VsockTransportOptions.permanentDialFailure} says this particular
391
478
  * failure is not one waiting will cure.
392
479
  */
393
- async dial(signal) {
480
+ async dial(signal, ledger) {
394
481
  const deadline = Date.now() + this.connectRetryBudgetMs;
395
482
  const dialStartedAt = Date.now();
396
483
  let lastErr;
@@ -403,7 +490,14 @@ export class VsockAgentTransport {
403
490
  // is precisely the case a watcher needs to hear about.
404
491
  this.onDialAttempt?.();
405
492
  const socket = await this.connectOnce(signal);
406
- this.onDial?.(Date.now() - dialStartedAt);
493
+ const durationMs = Date.now() - dialStartedAt;
494
+ this.onDial?.(durationMs);
495
+ // The same interval the hook above reports, folded into the
496
+ // call's own ledger: a caller asking where an exec's wall time
497
+ // went wants it as one number per call, and this dial belongs
498
+ // to that call.
499
+ if (ledger !== undefined)
500
+ ledger.dialMs += durationMs;
407
501
  return socket;
408
502
  }
409
503
  catch (err) {
@@ -675,10 +769,21 @@ export class VsockAgentTransport {
675
769
  * is torn down rather than wedging the caller.
676
770
  */
677
771
  async request(req, signal) {
772
+ return await this.requestFramed(req, signal);
773
+ }
774
+ /**
775
+ * {@link request}, with the one thing the public signature has no place
776
+ * for: the exec call's timing ledger, so the reserve round trip that
777
+ * reaches the guest through this method is counted against the call that
778
+ * paid for its dial. Private rather than a third parameter, because a
779
+ * public method whose extra argument exists only for internal
780
+ * instrumentation is a parameter a caller can only misuse.
781
+ */
782
+ async requestFramed(req, signal, ledger) {
678
783
  const envelope = this.withCredential(req);
679
784
  const payload = JSON.stringify(envelope);
680
785
  this.assertPreauthBudget(payload);
681
- const socket = await this.dial(signal);
786
+ const socket = await this.dial(signal, ledger);
682
787
  return await new Promise((resolve, reject) => {
683
788
  const reader = new FrameReader();
684
789
  let settled = false;
@@ -757,12 +862,21 @@ export class VsockAgentTransport {
757
862
  * {@link SandboxExecResult} via the shared {@link ExecResultAccumulator}.
758
863
  * The agent terminates the stream with a zero-length frame.
759
864
  */
760
- async executeRaw(body, opts, signal) {
865
+ async executeRaw(body, opts, signal, ledger) {
761
866
  const envelope = this.withCredential({ op: 'execute', body });
762
867
  const payload = JSON.stringify(envelope);
763
868
  this.assertPreauthBudget(payload);
764
- const socket = await this.dial(signal);
869
+ const socket = await this.dial(signal, ledger);
765
870
  const start = Date.now();
871
+ // The instant the request goes on the wire. The three execute
872
+ // sub-phases of {@link FirecrackerTransportTiming} are measured from
873
+ // here, so they answer "how long after the guest was asked" rather
874
+ // than "how long after this call began" — the dial that precedes it
875
+ // is already counted in `dialMs` and inside `executeMs`.
876
+ let writtenAt = 0;
877
+ // Stamped once, when the terminator frame is parsed, so `peerCloseMs`
878
+ // measures the peer's close and not the whole round trip.
879
+ let terminatorAt = 0;
766
880
  return await new Promise((resolve, reject) => {
767
881
  const reader = new FrameReader();
768
882
  const acc = new ExecResultAccumulator(start, opts?.onOutput);
@@ -804,6 +918,12 @@ export class VsockAgentTransport {
804
918
  finish(err instanceof Error ? err : new Error(String(err)));
805
919
  return;
806
920
  }
921
+ // Recorded before the terminator check below and before any
922
+ // parsing: the first frame ARRIVED, which stays true even if
923
+ // this call goes on to reject over the frame's contents.
924
+ if (ledger !== undefined && ledger.firstFrameMs === undefined && frames.length > 0) {
925
+ ledger.firstFrameMs = Date.now() - writtenAt;
926
+ }
807
927
  for (const payload of frames) {
808
928
  if (terminated) {
809
929
  finish(new Error('vsock transport: exec stream emitted data after its terminator'));
@@ -828,6 +948,14 @@ export class VsockAgentTransport {
828
948
  }
829
949
  }
830
950
  if (terminated) {
951
+ // The terminator WAS read, so it is reported even if this
952
+ // call is about to reject over what followed it: the
953
+ // question these numbers answer is where the time went,
954
+ // not whether the call ended well.
955
+ if (ledger !== undefined && ledger.terminatorMs === undefined) {
956
+ terminatorAt = Date.now();
957
+ ledger.terminatorMs = terminatorAt - writtenAt;
958
+ }
831
959
  if (reader.bufferedBytes > 0) {
832
960
  finish(new Error('vsock transport: exec stream has trailing partial data'));
833
961
  return;
@@ -839,8 +967,25 @@ export class VsockAgentTransport {
839
967
  });
840
968
  socket.once('error', (err) => finish(err));
841
969
  socket.once('close', () => {
842
- if (terminated && terminalResult)
970
+ // A close that arrives with this call ALREADY settled is one
971
+ // this transport caused itself: `finish` is the only writer of
972
+ // `settled` and its next statement is `socket.destroy()`, so
973
+ // the guard timer, the observation timer and the caller's abort
974
+ // each destroy the socket and then react to the `close` that
975
+ // destroy emits. Crediting the peer for it would report
976
+ // {@link POST_RESPONSE_CLOSE_TIMEOUT_MS} — a constant this
977
+ // transport chose — as a duration the peer took, in exactly the
978
+ // case a host is reading the field to diagnose. The same rule
979
+ // `readFileFrames` applies in the same position: an end this
980
+ // side already reached is not news.
981
+ if (settled)
982
+ return;
983
+ if (terminated && terminalResult) {
984
+ if (ledger !== undefined && terminatorAt > 0) {
985
+ ledger.peerCloseMs = Date.now() - terminatorAt;
986
+ }
843
987
  finish(null, terminalResult);
988
+ }
844
989
  else
845
990
  finish(new Error('vsock transport: socket closed before exec stream terminator'));
846
991
  });
@@ -849,6 +994,7 @@ export class VsockAgentTransport {
849
994
  return;
850
995
  }
851
996
  signal?.addEventListener('abort', abort, { once: true });
997
+ writtenAt = Date.now();
852
998
  socket.write(frame(payload));
853
999
  });
854
1000
  }
@@ -862,7 +1008,7 @@ export class VsockAgentTransport {
862
1008
  if (body.executionId !== undefined) {
863
1009
  throw new RemoteProtocolError('VsockAgentTransport.execute does not accept caller-owned execution ids');
864
1010
  }
865
- return await this.executionController.exec(body.command, body.args ? [...body.args] : undefined, {
1011
+ return await this.runExec(body.command, body.args ? [...body.args] : undefined, {
866
1012
  ...opts,
867
1013
  ...(body.cwd !== undefined ? { cwd: body.cwd } : {}),
868
1014
  ...(body.env !== undefined ? { env: body.env } : {}),
@@ -874,7 +1020,55 @@ export class VsockAgentTransport {
874
1020
  });
875
1021
  }
876
1022
  async exec(command, argv, opts) {
877
- return await this.executionController.exec(command, argv, opts);
1023
+ return await this.runExec(command, argv, opts);
1024
+ }
1025
+ /**
1026
+ * One command through a call-scoped adapter + controller, reporting the
1027
+ * call's phases to {@link VsockTransportOptions.onExecTiming} when a host
1028
+ * asked for them.
1029
+ *
1030
+ * The ledger is created HERE and nowhere else — because it is per call,
1031
+ * not per transport, so two concurrent `exec()` calls each get their own
1032
+ * accumulator. Constructing a controller per call costs an allocation and
1033
+ * nothing else: the controller's only per-instance state is its readonly
1034
+ * configuration, so a fresh one behaves exactly like a shared one, and
1035
+ * this transport dials fresh per request either way.
1036
+ *
1037
+ * The hook fires in a `finally`, on the failure paths as well as the
1038
+ * happy one: an exec that rejected after its reserve round trip is
1039
+ * precisely the call whose phase breakdown a host needs. Measuring into a
1040
+ * ledger that nobody reads is what "no hook, no cost" means here — with
1041
+ * no hook there is no ledger, and the `undefined` checks in `dial` and
1042
+ * `executeRaw` skip the clock reads entirely.
1043
+ */
1044
+ async runExec(command, argv, opts, context) {
1045
+ const hook = this.onExecTiming;
1046
+ if (hook === undefined) {
1047
+ return await new RemoteExecutionController(this.executionAdapter()).exec(command, argv, opts, context);
1048
+ }
1049
+ const ledger = {
1050
+ dialMs: 0,
1051
+ reserveMs: 0,
1052
+ executeMs: 0,
1053
+ executeSettledAt: 0,
1054
+ };
1055
+ try {
1056
+ return await new RemoteExecutionController(this.executionAdapter(ledger)).exec(command, argv, opts, context);
1057
+ }
1058
+ finally {
1059
+ // Caught, not propagated: this hook is an OBSERVER, and a listener
1060
+ // that throws must not turn a command that ran into a caller's
1061
+ // exception — the rule {@link VsockTransportOptions.onGuestReply}
1062
+ // already states for this transport's other observers. A `finally`
1063
+ // that let it through would replace the call's own error with the
1064
+ // listener's, which is the worst version of that failure.
1065
+ try {
1066
+ hook(timingOf(ledger));
1067
+ }
1068
+ catch {
1069
+ // The listener's problem, and only the listener's.
1070
+ }
1071
+ }
878
1072
  }
879
1073
  /**
880
1074
  * The raw `/execute` primitive with NO admission/reservation
@@ -892,8 +1086,8 @@ export class VsockAgentTransport {
892
1086
  async executeStreamed(body, opts, signal) {
893
1087
  return await this.executeRaw(body, opts, signal);
894
1088
  }
895
- async reserveExecution(signal) {
896
- const response = await this.request({ op: 'reserve-execution' }, signal);
1089
+ async reserveExecution(signal, ledger) {
1090
+ const response = await this.requestFramed({ op: 'reserve-execution' }, signal, ledger);
897
1091
  if (response.ok === false &&
898
1092
  typeof response.error === 'string' &&
899
1093
  response.error.startsWith('unknown_op:')) {
@@ -904,8 +1098,8 @@ export class VsockAgentTransport {
904
1098
  }
905
1099
  return response;
906
1100
  }
907
- async cancelExecution(executionId, signal) {
908
- return await this.request({ op: 'cancel-execution', body: { executionId } }, signal);
1101
+ async cancelExecution(executionId, signal, ledger) {
1102
+ return await this.requestFramed({ op: 'cancel-execution', body: { executionId } }, signal, ledger);
909
1103
  }
910
1104
  /** Readiness probe. A healthy guest must also speak the exact host protocol. */
911
1105
  async healthz(signal) {