@cloudflare/sandbox 0.12.1 → 0.13.0-next.632.1

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.
@@ -1,4 +1,4 @@
1
- import { _ as GitLogger, b as getEnvString, c as parseSSEFrames, d as createNoOpLogger, f as TraceContext, g as DEFAULT_GIT_CLONE_TIMEOUT_MS, h as ResultImpl, i as isWSStreamChunk, l as shellEscape, m as Execution, n as isWSError, p as logCanonicalEvent, r as isWSResponse, t as generateRequestId, u as createLogger, v as extractRepoName, x as partitionEnvVars, y as filterEnvVars } from "./dist-B_eXrP83.js";
1
+ import { a as shellEscape, c as TraceContext, d as ResultImpl, f as filterEnvVars, i as parseSSEFrames, l as logCanonicalEvent, m as partitionEnvVars, o as createLogger, p as getEnvString, s as createNoOpLogger, u as Execution } from "./dist-mAH_7Ui7.js";
2
2
  import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-aRUdk9K8.js";
3
3
  import { Container, ContainerProxy, getContainer, switchPort } from "@cloudflare/containers";
4
4
  import { AwsClient } from "aws4fetch";
@@ -720,2719 +720,1064 @@ function createErrorFromResponse(errorResponse, options) {
720
720
  }
721
721
 
722
722
  //#endregion
723
- //#region src/response-retry.ts
724
- const DEFAULT_INITIAL_RETRY_DELAY_MS = 3e3;
725
- const DEFAULT_MAX_RETRY_DELAY_MS = 3e4;
726
- const RETRYABLE_WEBSOCKET_UPGRADE_STATUSES = new Set([
727
- 500,
728
- 502,
729
- 503,
730
- 504
731
- ]);
732
- function isRetryableWebSocketUpgradeResponse(response) {
733
- return RETRYABLE_WEBSOCKET_UPGRADE_STATUSES.has(response.status);
734
- }
723
+ //#region src/file-stream.ts
735
724
  /**
736
- * Retry Response-returning operations while their response remains retryable.
737
- * The retry budget covers the whole operation; each attempt owns any
738
- * per-request timeout inside the caller-provided `fetchResponse` function.
725
+ * Parse SSE (Server-Sent Events) lines from a stream
739
726
  */
740
- async function fetchWithResponseRetry(fetchResponse, options) {
741
- const startTime = Date.now();
742
- let attempt = 0;
743
- while (true) {
744
- const response = await fetchResponse();
745
- if (!options.shouldRetry(response)) return response;
746
- const elapsed = Date.now() - startTime;
747
- const remaining = options.retryTimeoutMs - elapsed;
748
- if (remaining <= options.minTimeForRetryMs) {
749
- options.onRetryExhausted?.({
750
- attempts: attempt + 1,
751
- elapsedMs: elapsed,
752
- response
753
- });
754
- return response;
727
+ async function* parseSSE(stream) {
728
+ const reader = stream.getReader();
729
+ const decoder = new TextDecoder();
730
+ let buffer = "";
731
+ let currentEvent = { data: [] };
732
+ try {
733
+ while (true) {
734
+ const { done, value } = await reader.read();
735
+ if (done) break;
736
+ buffer += decoder.decode(value, { stream: true });
737
+ const parsed = parseSSEFrames(buffer, currentEvent);
738
+ buffer = parsed.remaining;
739
+ currentEvent = parsed.currentEvent;
740
+ for (const frame of parsed.events) try {
741
+ yield JSON.parse(frame.data);
742
+ } catch {}
755
743
  }
756
- const delay = Math.min(DEFAULT_INITIAL_RETRY_DELAY_MS * 2 ** attempt, DEFAULT_MAX_RETRY_DELAY_MS);
757
- options.logger.info(options.retryLogMessage, {
758
- status: response.status,
759
- attempt: attempt + 1,
760
- delayMs: delay,
761
- remainingSec: Math.floor(remaining / 1e3),
762
- ...options.getRetryLogContext?.(response)
763
- });
764
- await new Promise((resolve) => setTimeout(resolve, delay));
765
- attempt++;
744
+ const finalParsed = parseSSEFrames(`${buffer}\n\n`, currentEvent);
745
+ for (const frame of finalParsed.events) try {
746
+ yield JSON.parse(frame.data);
747
+ } catch {}
748
+ } finally {
749
+ try {
750
+ await reader.cancel();
751
+ } catch {}
752
+ reader.releaseLock();
766
753
  }
767
754
  }
768
-
769
- //#endregion
770
- //#region src/clients/transport/base-transport.ts
771
755
  /**
772
- * Container startup retry configuration
756
+ * Stream a file from the sandbox with automatic base64 decoding for binary files
757
+ *
758
+ * @param stream - The ReadableStream from readFileStream()
759
+ * @returns AsyncGenerator that yields FileChunk (string for text, Uint8Array for binary)
760
+ *
761
+ * @example
762
+ * ```ts
763
+ * const stream = await sandbox.readFileStream('/path/to/file.png');
764
+ * for await (const chunk of streamFile(stream)) {
765
+ * if (chunk instanceof Uint8Array) {
766
+ * // Binary chunk
767
+ * console.log('Binary chunk:', chunk.length, 'bytes');
768
+ * } else {
769
+ * // Text chunk
770
+ * console.log('Text chunk:', chunk);
771
+ * }
772
+ * }
773
+ * ```
773
774
  */
774
- const DEFAULT_RETRY_TIMEOUT_MS$1 = 12e4;
775
- const MIN_TIME_FOR_RETRY_MS$1 = 15e3;
775
+ async function* streamFile(stream) {
776
+ let metadata = null;
777
+ for await (const event of parseSSE(stream)) switch (event.type) {
778
+ case "metadata":
779
+ metadata = {
780
+ mimeType: event.mimeType,
781
+ size: event.size,
782
+ isBinary: event.isBinary,
783
+ encoding: event.encoding
784
+ };
785
+ break;
786
+ case "chunk":
787
+ if (!metadata) throw new Error("Received chunk before metadata");
788
+ if (metadata.isBinary && metadata.encoding === "base64") {
789
+ const binaryString = atob(event.data);
790
+ const bytes = new Uint8Array(binaryString.length);
791
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
792
+ yield bytes;
793
+ } else yield event.data;
794
+ break;
795
+ case "complete":
796
+ if (!metadata) throw new Error("Stream completed without metadata");
797
+ return metadata;
798
+ case "error": throw new Error(`File streaming error: ${event.error}`);
799
+ }
800
+ throw new Error("Stream ended unexpectedly");
801
+ }
776
802
  /**
777
- * Abstract base transport with shared retry logic
803
+ * Collect an entire file into memory from a stream
804
+ *
805
+ * @param stream - The ReadableStream from readFileStream()
806
+ * @returns Object containing the file content and metadata
778
807
  *
779
- * Handles 503 retry for container startup - shared by all transports.
780
- * Subclasses implement the transport-specific fetch and stream logic.
808
+ * @example
809
+ * ```ts
810
+ * const stream = await sandbox.readFileStream('/path/to/file.txt');
811
+ * const { content, metadata } = await collectFile(stream);
812
+ * console.log('Content:', content);
813
+ * console.log('MIME type:', metadata.mimeType);
814
+ * ```
781
815
  */
782
- var BaseTransport = class {
783
- config;
784
- logger;
785
- retryTimeoutMs;
786
- constructor(config) {
787
- this.config = config;
788
- this.logger = config.logger ?? createNoOpLogger();
789
- this.retryTimeoutMs = config.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS$1;
790
- }
791
- setRetryTimeoutMs(ms) {
792
- this.retryTimeoutMs = ms;
793
- }
794
- getRetryTimeoutMs() {
795
- return this.retryTimeoutMs;
796
- }
797
- /**
798
- * Fetch with automatic retry for 503 (container starting)
799
- *
800
- * This is the primary entry point for making requests. It wraps the
801
- * transport-specific doFetch() with retry logic for container startup.
802
- */
803
- async fetch(path$1, options) {
804
- return fetchWithResponseRetry(() => this.doFetch(path$1, options), {
805
- retryTimeoutMs: this.retryTimeoutMs,
806
- minTimeForRetryMs: MIN_TIME_FOR_RETRY_MS$1,
807
- logger: this.logger,
808
- retryLogMessage: "Container not ready, retrying",
809
- shouldRetry: (response) => response.status === 503,
810
- getRetryLogContext: () => ({ mode: this.getMode() }),
811
- onRetryExhausted: ({ attempts, elapsedMs }) => {
812
- this.logger.error("Container failed to become ready", /* @__PURE__ */ new Error(`Failed after ${attempts} attempts over ${Math.floor(elapsedMs / 1e3)}s`));
813
- }
814
- });
815
- }
816
- /**
817
- * Build a URL targeting the container's HTTP server.
818
- */
819
- buildContainerUrl(path$1) {
820
- if (this.config.stub) return `http://localhost:${this.config.port || 3e3}${path$1}`;
821
- return `${this.config.baseUrl ?? `http://localhost:${this.config.port || 3e3}`}${path$1}`;
822
- }
823
- /**
824
- * Single HTTP request to the container — no WebSocket, no 503 retry.
825
- */
826
- httpFetch(path$1, options) {
827
- const url = this.buildContainerUrl(path$1);
828
- if (this.config.stub) return this.config.stub.containerFetch(url, options || {}, this.config.port);
829
- return globalThis.fetch(url, options);
816
+ async function collectFile(stream) {
817
+ const chunks = [];
818
+ const generator = streamFile(stream);
819
+ let result = await generator.next();
820
+ while (!result.done) {
821
+ chunks.push(result.value);
822
+ result = await generator.next();
830
823
  }
831
- /**
832
- * Streaming HTTP request to the container — no WebSocket, no 503 retry.
833
- */
834
- async httpFetchStream(path$1, body, method = "POST", headers) {
835
- const url = this.buildContainerUrl(path$1);
836
- const init = {
837
- method,
838
- headers: body && method === "POST" ? {
839
- ...headers,
840
- "Content-Type": "application/json"
841
- } : headers,
842
- body: body && method === "POST" ? JSON.stringify(body) : void 0
843
- };
844
- let response;
845
- if (this.config.stub) response = await this.config.stub.containerFetch(url, init, this.config.port);
846
- else response = await globalThis.fetch(url, init);
847
- if (!response.ok) {
848
- const errorBody = await response.text();
849
- throw new Error(`HTTP error! status: ${response.status} - ${errorBody}`);
824
+ const metadata = result.value;
825
+ if (!metadata) throw new Error("Failed to get file metadata");
826
+ if (metadata.isBinary) {
827
+ const totalLength = chunks.reduce((sum, chunk) => sum + (chunk instanceof Uint8Array ? chunk.length : 0), 0);
828
+ const combined = new Uint8Array(totalLength);
829
+ let offset = 0;
830
+ for (const chunk of chunks) if (chunk instanceof Uint8Array) {
831
+ combined.set(chunk, offset);
832
+ offset += chunk.length;
850
833
  }
851
- if (!response.body) throw new Error("No response body for streaming");
852
- return response.body;
853
- }
854
- };
834
+ return {
835
+ content: combined,
836
+ metadata
837
+ };
838
+ } else return {
839
+ content: chunks.filter((c) => typeof c === "string").join(""),
840
+ metadata
841
+ };
842
+ }
855
843
 
856
844
  //#endregion
857
- //#region src/clients/transport/http-transport.ts
845
+ //#region src/security.ts
858
846
  /**
859
- * HTTP transport implementation
860
- *
861
- * Uses standard fetch API for communication with the container.
862
- * HTTP is stateless, so connect/disconnect are no-ops.
847
+ * Security utilities for URL construction and input validation
863
848
  *
864
- * All HTTP request logic lives in {@link BaseTransport.httpFetch} and
865
- * {@link BaseTransport.httpFetchStream}; this subclass simply wires
866
- * the abstract `doFetch` / `fetchStream` hooks to those shared helpers.
849
+ * This module contains critical security functions to prevent:
850
+ * - URL injection attacks
851
+ * - SSRF (Server-Side Request Forgery) attacks
852
+ * - DNS rebinding attacks
853
+ * - Host header injection
854
+ * - Open redirect vulnerabilities
867
855
  */
868
- var HttpTransport = class extends BaseTransport {
869
- getMode() {
870
- return "http";
871
- }
872
- async connect() {}
873
- disconnect() {}
874
- isConnected() {
875
- return true;
876
- }
877
- async doFetch(path$1, options) {
878
- return this.httpFetch(path$1, options);
879
- }
880
- async fetchStream(path$1, body, method = "POST", headers) {
881
- return this.httpFetchStream(path$1, body, method, headers);
856
+ var SandboxSecurityError = class extends Error {
857
+ constructor(message, code) {
858
+ super(message);
859
+ this.code = code;
860
+ this.name = "SandboxSecurityError";
882
861
  }
883
862
  };
884
-
885
- //#endregion
886
- //#region src/clients/transport/ws-transport.ts
887
863
  /**
888
- * Default timeout values (all in milliseconds)
864
+ * Validates port numbers for sandbox services.
865
+ *
866
+ * Rules:
867
+ * - Range: 1024-65535 (privileged ports require root, which containers don't have)
868
+ * - Reserved: 3000 (sandbox control plane)
889
869
  */
890
- const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
891
- const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
892
- const DEFAULT_CONNECT_TIMEOUT_MS$1 = 3e4;
893
- const DEFAULT_IDLE_DISCONNECT_MS$1 = 1e3;
894
- const MIN_TIME_FOR_CONNECT_RETRY_MS = 15e3;
870
+ function validatePort(port) {
871
+ if (!Number.isInteger(port)) return false;
872
+ if (port < 1024 || port > 65535) return false;
873
+ if ([3e3].includes(port)) return false;
874
+ return true;
875
+ }
895
876
  /**
896
- * WebSocket transport implementation
897
- *
898
- * Multiplexes HTTP-like requests over a single WebSocket connection.
899
- * Useful when running inside Workers/DO where sub-request limits apply.
877
+ * Sanitizes and validates sandbox IDs for DNS compliance and security
878
+ * Only enforces critical requirements - allows maximum developer flexibility
900
879
  */
901
- var WebSocketTransport = class extends BaseTransport {
902
- ws = null;
903
- state = "disconnected";
904
- pendingRequests = /* @__PURE__ */ new Map();
905
- connectPromise = null;
906
- idleDisconnectTimer = null;
907
- boundHandleMessage;
908
- boundHandleClose;
909
- constructor(config) {
910
- super(config);
911
- if (!config.wsUrl) throw new Error("wsUrl is required for WebSocket transport");
912
- this.boundHandleMessage = this.handleMessage.bind(this);
913
- this.boundHandleClose = this.handleClose.bind(this);
914
- }
915
- getMode() {
916
- return "websocket";
917
- }
918
- /**
919
- * Check if WebSocket is connected
920
- */
921
- isConnected() {
922
- return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
923
- }
924
- /**
925
- * Connect to the WebSocket server
926
- *
927
- * The connection promise is assigned synchronously so concurrent
928
- * callers share the same connection attempt.
929
- */
930
- async connect() {
931
- this.clearIdleDisconnectTimer();
932
- if (this.isConnected()) return;
933
- if (this.connectPromise) return this.connectPromise;
934
- this.connectPromise = this.doConnect();
935
- try {
936
- await this.connectPromise;
937
- } finally {
938
- this.connectPromise = null;
939
- }
940
- }
941
- /**
942
- * Disconnect from the WebSocket server
943
- */
944
- disconnect() {
945
- this.cleanup();
946
- }
947
- /**
948
- * Whether a WebSocket connection is currently being established.
949
- *
950
- * When true, awaiting `connectPromise` from a nested call would deadlock:
951
- * the outer `connectViaFetch stub.fetch containerFetch
952
- * startAndWaitForPorts blockConcurrencyWhile(onStart)` chain may call
953
- * back into the SDK (e.g. `exec()`), which would await the same
954
- * `connectPromise` that cannot resolve until `onStart` returns.
955
- *
956
- * Callers use this to fall back to a direct HTTP request, which is safe
957
- * because `startAndWaitForPorts()` calls `setHealthy()` before invoking
958
- * `onStart()`, so `containerFetch()` routes directly to the container.
959
- */
960
- isWebSocketConnecting() {
961
- return this.state === "connecting";
962
- }
963
- /**
964
- * Transport-specific fetch implementation.
965
- * Converts WebSocket response to standard Response object.
966
- *
967
- * Falls back to HTTP while a WebSocket connection is being established
968
- * to avoid the re-entrant deadlock described in `isWebSocketConnecting()`.
969
- */
970
- async doFetch(path$1, options) {
971
- if (this.isWebSocketConnecting()) return this.httpFetch(path$1, options);
972
- await this.connect();
973
- const method = options?.method || "GET";
974
- const body = this.parseBody(options?.body);
975
- const headers = this.normalizeHeaders(options?.headers);
976
- const result = await this.request(method, path$1, body, headers, options?.requestTimeoutMs);
977
- return new Response(JSON.stringify(result.body), {
978
- status: result.status,
979
- headers: { "Content-Type": "application/json" }
980
- });
981
- }
982
- /**
983
- * Streaming fetch implementation.
984
- *
985
- * Delegates to `requestStream()`, which applies the re-entrancy guard.
986
- */
987
- async fetchStream(path$1, body, method = "POST", headers) {
988
- return this.requestStream(method, path$1, body, headers);
989
- }
990
- /**
991
- * Parse request body from RequestInit
992
- */
993
- parseBody(body) {
994
- if (!body) return;
995
- if (typeof body === "string") try {
996
- return JSON.parse(body);
997
- } catch (error) {
998
- throw new Error(`Request body must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
999
- }
1000
- throw new Error(`WebSocket transport only supports string bodies. Got: ${typeof body}`);
1001
- }
1002
- /**
1003
- * Normalize RequestInit headers into a plain object for WSRequest.
1004
- */
1005
- normalizeHeaders(headers) {
1006
- if (!headers) return;
1007
- const normalized = {};
1008
- new Headers(headers).forEach((value, key) => {
1009
- normalized[key] = value;
1010
- });
1011
- return Object.keys(normalized).length > 0 ? normalized : void 0;
880
+ function sanitizeSandboxId(id) {
881
+ if (!id || id.length > 63) throw new SandboxSecurityError("Sandbox ID must be 1-63 characters long.", "INVALID_SANDBOX_ID_LENGTH");
882
+ if (id.startsWith("-") || id.endsWith("-")) throw new SandboxSecurityError("Sandbox ID cannot start or end with hyphens (DNS requirement).", "INVALID_SANDBOX_ID_HYPHENS");
883
+ const reservedNames = [
884
+ "www",
885
+ "api",
886
+ "admin",
887
+ "root",
888
+ "system",
889
+ "cloudflare",
890
+ "workers"
891
+ ];
892
+ const lowerCaseId = id.toLowerCase();
893
+ if (reservedNames.includes(lowerCaseId)) throw new SandboxSecurityError(`Reserved sandbox ID '${id}' is not allowed.`, "RESERVED_SANDBOX_ID");
894
+ return id;
895
+ }
896
+ /**
897
+ * Validates language for code interpreter
898
+ * Only allows supported languages
899
+ */
900
+ function validateLanguage(language) {
901
+ if (!language) return;
902
+ const supportedLanguages = [
903
+ "python",
904
+ "python3",
905
+ "javascript",
906
+ "js",
907
+ "node",
908
+ "typescript",
909
+ "ts"
910
+ ];
911
+ const normalized = language.toLowerCase();
912
+ if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
913
+ }
914
+ /**
915
+ * Validates a single DNS label for use as a Cloudflare Tunnel hostname.
916
+ *
917
+ * Used by `sandbox.tunnels.get(port, { name })` to reject obviously-bad
918
+ * input client-side before any network call. Whether the chosen label is
919
+ * actually available under the configured zone is left to the Cloudflare
920
+ * API (returned as a typed error).
921
+ *
922
+ * Rules:
923
+ * - 1–63 characters
924
+ * - Lowercase letters, digits, and internal hyphens only
925
+ * - No leading or trailing hyphen
926
+ * - No dots — multi-label hostnames need a delegated subdomain zone or
927
+ * Advanced Certificate Manager, which are out of scope for this
928
+ * feature. Universal SSL only covers `<label>.<zone>`.
929
+ *
930
+ * Throws `SandboxSecurityError` on any violation. Designed to be called
931
+ * before any other tunnel work so callers see a fast, deterministic
932
+ * failure.
933
+ */
934
+ const TUNNEL_NAME_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
935
+ function validateTunnelName(name) {
936
+ if (typeof name !== "string") throw new SandboxSecurityError(`Tunnel name must be a string. Received: ${typeof name}`, "INVALID_TUNNEL_NAME");
937
+ if (name.length === 0 || name.length > 63) throw new SandboxSecurityError(`Tunnel name '${name}' must be 1–63 characters long.`, "INVALID_TUNNEL_NAME_LENGTH");
938
+ if (!TUNNEL_NAME_REGEX.test(name)) throw new SandboxSecurityError(`Tunnel name '${name}' is not a valid DNS label. Use lowercase letters, digits, and internal hyphens only (no dots, no leading/trailing hyphens).`, "INVALID_TUNNEL_NAME_FORMAT");
939
+ }
940
+
941
+ //#endregion
942
+ //#region src/interpreter.ts
943
+ var CodeInterpreter = class {
944
+ getInterpreterClient;
945
+ contexts = /* @__PURE__ */ new Map();
946
+ constructor(interpreterClient) {
947
+ this.getInterpreterClient = typeof interpreterClient === "function" ? interpreterClient : () => interpreterClient;
1012
948
  }
1013
949
  /**
1014
- * Internal connection logic
950
+ * Create a new code execution context
1015
951
  */
1016
- async doConnect() {
1017
- this.state = "connecting";
1018
- if (this.config.stub) await this.connectViaFetch();
1019
- else await this.connectViaWebSocket();
1020
- }
1021
- async fetchUpgradeWithRetry(attemptUpgrade) {
1022
- return fetchWithResponseRetry(attemptUpgrade, {
1023
- retryTimeoutMs: this.getRetryTimeoutMs(),
1024
- minTimeForRetryMs: MIN_TIME_FOR_CONNECT_RETRY_MS,
1025
- logger: this.logger,
1026
- retryLogMessage: "WebSocket upgrade returned retryable status, retrying",
1027
- shouldRetry: isRetryableWebSocketUpgradeResponse
1028
- });
952
+ async createCodeContext(options = {}) {
953
+ validateLanguage(options.language);
954
+ const context = await this.getInterpreterClient().createCodeContext(options);
955
+ this.contexts.set(context.id, context);
956
+ return context;
1029
957
  }
1030
958
  /**
1031
- * Connect using fetch-based WebSocket (Cloudflare Workers style)
1032
- * This is required when running inside a Durable Object.
1033
- *
1034
- * Uses stub.fetch() which routes WebSocket upgrade requests through the
1035
- * parent Container class that supports the WebSocket protocol.
959
+ * Run code with optional context
1036
960
  */
1037
- async connectViaFetch() {
1038
- const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS$1;
1039
- try {
1040
- const wsPath = new URL(this.config.wsUrl).pathname;
1041
- const httpUrl = `http://localhost:${this.config.port || 3e3}${wsPath}`;
1042
- const response = await this.fetchUpgradeWithRetry(() => this.fetchUpgradeAttempt(httpUrl, timeoutMs));
1043
- if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
1044
- const ws = response.webSocket;
1045
- if (!ws) throw new Error("No WebSocket in upgrade response");
1046
- ws.accept();
1047
- this.ws = ws;
1048
- this.state = "connected";
1049
- this.ws.addEventListener("close", this.boundHandleClose);
1050
- this.ws.addEventListener("message", this.boundHandleMessage);
1051
- this.logger.debug("WebSocket connected via fetch", { url: this.config.wsUrl });
1052
- } catch (error) {
1053
- this.state = "error";
1054
- this.logger.error("WebSocket fetch connection failed", error instanceof Error ? error : new Error(String(error)));
1055
- throw error;
1056
- }
1057
- }
1058
- async fetchUpgradeAttempt(httpUrl, timeoutMs) {
1059
- const controller = new AbortController();
1060
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
1061
- try {
1062
- const request = new Request(httpUrl, {
1063
- headers: {
1064
- Upgrade: "websocket",
1065
- Connection: "Upgrade"
1066
- },
1067
- signal: controller.signal
1068
- });
1069
- return await this.config.stub.fetch(request);
1070
- } finally {
1071
- clearTimeout(timeout);
961
+ async runCode(code, options = {}) {
962
+ let context = options.context;
963
+ if (!context) {
964
+ const language = options.language || "python";
965
+ context = await this.getOrCreateDefaultContext(language);
1072
966
  }
1073
- }
1074
- /**
1075
- * Connect using standard WebSocket API (browser/Node style)
1076
- */
1077
- connectViaWebSocket() {
1078
- return new Promise((resolve, reject) => {
1079
- const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS$1;
1080
- const timeout = setTimeout(() => {
1081
- this.cleanup();
1082
- reject(/* @__PURE__ */ new Error(`WebSocket connection timeout after ${timeoutMs}ms`));
1083
- }, timeoutMs);
1084
- try {
1085
- this.ws = new WebSocket(this.config.wsUrl);
1086
- const onOpen = () => {
1087
- clearTimeout(timeout);
1088
- this.ws?.removeEventListener("open", onOpen);
1089
- this.ws?.removeEventListener("error", onConnectError);
1090
- this.state = "connected";
1091
- this.logger.debug("WebSocket connected", { url: this.config.wsUrl });
1092
- resolve();
1093
- };
1094
- const onConnectError = () => {
1095
- clearTimeout(timeout);
1096
- this.ws?.removeEventListener("open", onOpen);
1097
- this.ws?.removeEventListener("error", onConnectError);
1098
- this.state = "error";
1099
- this.logger.error("WebSocket error", /* @__PURE__ */ new Error("WebSocket connection failed"));
1100
- reject(/* @__PURE__ */ new Error("WebSocket connection failed"));
1101
- };
1102
- this.ws.addEventListener("open", onOpen);
1103
- this.ws.addEventListener("error", onConnectError);
1104
- this.ws.addEventListener("close", this.boundHandleClose);
1105
- this.ws.addEventListener("message", this.boundHandleMessage);
1106
- } catch (error) {
1107
- clearTimeout(timeout);
1108
- this.state = "error";
1109
- reject(error);
1110
- }
1111
- });
1112
- }
1113
- /**
1114
- * Send a request and wait for response.
1115
- *
1116
- * Only reachable from `doFetch()`, which already applies the re-entrancy
1117
- * guard via `isWebSocketConnecting()`. The `connect()` call here handles
1118
- * the case where the WebSocket was closed between `doFetch` and `request`
1119
- * (idle disconnect).
1120
- */
1121
- async request(method, path$1, body, headers, requestTimeoutMs) {
1122
- await this.connect();
1123
- this.clearIdleDisconnectTimer();
1124
- const id = generateRequestId();
1125
- const request = {
1126
- type: "request",
1127
- id,
1128
- method,
1129
- path: path$1,
1130
- body,
1131
- headers
1132
- };
1133
- return new Promise((resolve, reject) => {
1134
- const timeoutMs = requestTimeoutMs ?? this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1135
- const timeoutId = setTimeout(() => {
1136
- this.pendingRequests.delete(id);
1137
- this.scheduleIdleDisconnect();
1138
- reject(/* @__PURE__ */ new Error(`Request timeout after ${timeoutMs}ms: ${method} ${path$1}`));
1139
- }, timeoutMs);
1140
- this.pendingRequests.set(id, {
1141
- resolve: (response) => {
1142
- clearTimeout(timeoutId);
1143
- this.pendingRequests.delete(id);
1144
- this.scheduleIdleDisconnect();
1145
- resolve({
1146
- status: response.status,
1147
- body: response.body
1148
- });
1149
- },
1150
- reject: (error) => {
1151
- clearTimeout(timeoutId);
1152
- this.pendingRequests.delete(id);
1153
- this.scheduleIdleDisconnect();
1154
- reject(error);
1155
- },
1156
- isStreaming: false,
1157
- timeoutId
1158
- });
1159
- try {
1160
- this.send(request);
1161
- } catch (error) {
1162
- clearTimeout(timeoutId);
1163
- this.pendingRequests.delete(id);
1164
- this.scheduleIdleDisconnect();
1165
- reject(error instanceof Error ? error : new Error(String(error)));
1166
- }
1167
- });
1168
- }
1169
- /**
1170
- * Send a streaming request and return a ReadableStream.
1171
- *
1172
- * The stream will receive data chunks as they arrive over the WebSocket.
1173
- * Format matches SSE for compatibility with existing streaming code.
1174
- *
1175
- * This method waits for the first message before returning. If the server
1176
- * responds with an error (non-streaming response), it throws immediately
1177
- * rather than returning a stream that will error later.
1178
- *
1179
- * Uses an inactivity timeout instead of a total-duration timeout so that
1180
- * long-running streams (e.g. execStream from an agent) stay alive as long
1181
- * as data is flowing. The timer resets on every chunk or response message.
1182
- *
1183
- * Falls back to HTTP while a WebSocket connection is being established
1184
- * to avoid the re-entrant deadlock described in `isWebSocketConnecting()`.
1185
- */
1186
- async requestStream(method, path$1, body, headers) {
1187
- if (this.isWebSocketConnecting()) return this.httpFetchStream(path$1, body, method, headers);
1188
- await this.connect();
1189
- this.clearIdleDisconnectTimer();
1190
- const id = generateRequestId();
1191
- const request = {
1192
- type: "request",
1193
- id,
1194
- method,
1195
- path: path$1,
1196
- body,
1197
- headers
1198
- };
1199
- const idleTimeoutMs = this.config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
1200
- return new Promise((resolveStream, rejectStream) => {
1201
- let streamController;
1202
- let firstMessageReceived = false;
1203
- const createIdleTimeout = () => {
1204
- return setTimeout(() => {
1205
- this.pendingRequests.delete(id);
1206
- this.scheduleIdleDisconnect();
1207
- const error = /* @__PURE__ */ new Error(`Stream idle timeout after ${idleTimeoutMs}ms: ${method} ${path$1}`);
1208
- if (firstMessageReceived) try {
1209
- streamController?.error(error);
1210
- } catch {}
1211
- else rejectStream(error);
1212
- }, idleTimeoutMs);
1213
- };
1214
- const timeoutId = createIdleTimeout();
1215
- const stream = new ReadableStream({
1216
- start: (controller) => {
1217
- streamController = controller;
1218
- },
1219
- cancel: () => {
1220
- const pending = this.pendingRequests.get(id);
1221
- if (pending?.timeoutId) clearTimeout(pending.timeoutId);
1222
- try {
1223
- this.send({
1224
- type: "cancel",
1225
- id
1226
- });
1227
- } catch (error) {
1228
- this.logger.debug("Failed to send stream cancel message", {
1229
- id,
1230
- error: error instanceof Error ? error.message : String(error)
1231
- });
1232
- }
1233
- this.pendingRequests.delete(id);
1234
- this.scheduleIdleDisconnect();
1235
- }
1236
- });
1237
- this.pendingRequests.set(id, {
1238
- resolve: (response) => {
1239
- const pending = this.pendingRequests.get(id);
1240
- if (pending?.timeoutId) clearTimeout(pending.timeoutId);
1241
- this.pendingRequests.delete(id);
1242
- this.scheduleIdleDisconnect();
1243
- if (!firstMessageReceived) {
1244
- firstMessageReceived = true;
1245
- if (response.status >= 400) rejectStream(/* @__PURE__ */ new Error(`Stream error: ${response.status} - ${JSON.stringify(response.body)}`));
1246
- else {
1247
- streamController?.close();
1248
- resolveStream(stream);
1249
- }
1250
- } else if (response.status >= 400) try {
1251
- streamController?.error(/* @__PURE__ */ new Error(`Stream error: ${response.status} - ${JSON.stringify(response.body)}`));
1252
- } catch {}
1253
- else streamController?.close();
1254
- },
1255
- reject: (error) => {
1256
- const pending = this.pendingRequests.get(id);
1257
- if (pending?.timeoutId) clearTimeout(pending.timeoutId);
1258
- this.pendingRequests.delete(id);
1259
- this.scheduleIdleDisconnect();
1260
- if (firstMessageReceived) try {
1261
- streamController?.error(error);
1262
- } catch {}
1263
- else rejectStream(error);
1264
- },
1265
- streamController: void 0,
1266
- isStreaming: true,
1267
- timeoutId,
1268
- onFirstChunk: () => {
1269
- if (!firstMessageReceived) {
1270
- firstMessageReceived = true;
1271
- const pending = this.pendingRequests.get(id);
1272
- if (pending) {
1273
- pending.streamController = streamController;
1274
- if (pending.bufferedChunks) {
1275
- try {
1276
- for (const buffered of pending.bufferedChunks) streamController.enqueue(buffered);
1277
- } catch (error) {
1278
- this.logger.debug("Failed to flush buffered chunks, cleaning up", {
1279
- id,
1280
- error: error instanceof Error ? error.message : String(error)
1281
- });
1282
- if (pending.timeoutId) clearTimeout(pending.timeoutId);
1283
- this.pendingRequests.delete(id);
1284
- this.scheduleIdleDisconnect();
1285
- }
1286
- pending.bufferedChunks = void 0;
1287
- }
1288
- }
1289
- resolveStream(stream);
1290
- }
1291
- }
1292
- });
1293
- try {
1294
- this.send(request);
1295
- } catch (error) {
1296
- clearTimeout(timeoutId);
1297
- this.pendingRequests.delete(id);
1298
- this.scheduleIdleDisconnect();
1299
- rejectStream(error instanceof Error ? error : new Error(String(error)));
967
+ const execution = new Execution(code, context);
968
+ await this.getInterpreterClient().runCodeStream(context.id, code, options.language, {
969
+ onStdout: (output) => {
970
+ execution.logs.stdout.push(output.text);
971
+ if (options.onStdout) return options.onStdout(output);
972
+ },
973
+ onStderr: (output) => {
974
+ execution.logs.stderr.push(output.text);
975
+ if (options.onStderr) return options.onStderr(output);
976
+ },
977
+ onResult: async (result) => {
978
+ execution.results.push(new ResultImpl(result));
979
+ if (options.onResult) return options.onResult(result);
980
+ },
981
+ onError: (error) => {
982
+ execution.error = error;
983
+ if (options.onError) return options.onError(error);
1300
984
  }
1301
985
  });
986
+ return execution;
1302
987
  }
1303
988
  /**
1304
- * Send a message over the WebSocket
1305
- */
1306
- send(message) {
1307
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) throw new Error("WebSocket not connected");
1308
- this.ws.send(JSON.stringify(message));
1309
- this.logger.debug("WebSocket sent", {
1310
- id: message.id,
1311
- type: message.type,
1312
- method: message.type === "request" ? message.method : void 0,
1313
- path: message.type === "request" ? message.path : void 0
1314
- });
1315
- }
1316
- /**
1317
- * Handle incoming WebSocket messages
1318
- */
1319
- handleMessage(event) {
1320
- try {
1321
- const message = JSON.parse(event.data);
1322
- if (isWSResponse(message)) this.handleResponse(message);
1323
- else if (isWSStreamChunk(message)) this.handleStreamChunk(message);
1324
- else if (isWSError(message)) this.handleError(message);
1325
- else this.logger.warn("Unknown WebSocket message type", { message });
1326
- } catch (error) {
1327
- this.logger.error("Failed to parse WebSocket message", error instanceof Error ? error : new Error(String(error)));
1328
- }
1329
- }
1330
- /**
1331
- * Handle a response message
1332
- */
1333
- handleResponse(response) {
1334
- const pending = this.pendingRequests.get(response.id);
1335
- if (!pending) {
1336
- this.logger.warn("Received response for unknown request", { id: response.id });
1337
- return;
1338
- }
1339
- this.logger.debug("WebSocket response", {
1340
- id: response.id,
1341
- status: response.status,
1342
- done: response.done
1343
- });
1344
- if (response.done) pending.resolve(response);
1345
- }
1346
- /**
1347
- * Handle a stream chunk message
1348
- *
1349
- * Resets the idle timeout on every chunk so that long-running streams
1350
- * with continuous output are not killed by the inactivity timer.
1351
- */
1352
- handleStreamChunk(chunk) {
1353
- const pending = this.pendingRequests.get(chunk.id);
1354
- if (!pending) {
1355
- this.logger.warn("Received stream chunk for unknown request", { id: chunk.id });
1356
- return;
1357
- }
1358
- if (pending.onFirstChunk) {
1359
- pending.onFirstChunk();
1360
- pending.onFirstChunk = void 0;
1361
- }
1362
- if (pending.isStreaming) this.resetStreamIdleTimeout(chunk.id, pending);
1363
- if (!pending.streamController) {
1364
- if (!pending.bufferedChunks) pending.bufferedChunks = [];
1365
- const encoder$1 = new TextEncoder();
1366
- let sseData$1;
1367
- if (chunk.event) sseData$1 = `event: ${chunk.event}\ndata: ${chunk.data}\n\n`;
1368
- else sseData$1 = `data: ${chunk.data}\n\n`;
1369
- pending.bufferedChunks.push(encoder$1.encode(sseData$1));
1370
- return;
1371
- }
1372
- const encoder = new TextEncoder();
1373
- let sseData;
1374
- if (chunk.event) sseData = `event: ${chunk.event}\ndata: ${chunk.data}\n\n`;
1375
- else sseData = `data: ${chunk.data}\n\n`;
1376
- try {
1377
- pending.streamController.enqueue(encoder.encode(sseData));
1378
- } catch (error) {
1379
- this.logger.debug("Failed to enqueue stream chunk, cleaning up", {
1380
- id: chunk.id,
1381
- error: error instanceof Error ? error.message : String(error)
1382
- });
1383
- if (pending.timeoutId) clearTimeout(pending.timeoutId);
1384
- this.pendingRequests.delete(chunk.id);
1385
- this.scheduleIdleDisconnect();
1386
- }
1387
- }
1388
- /**
1389
- * Reset the idle timeout for a streaming request.
1390
- * Called on every incoming chunk to keep the stream alive while data flows.
1391
- */
1392
- resetStreamIdleTimeout(id, pending) {
1393
- if (pending.timeoutId) clearTimeout(pending.timeoutId);
1394
- const idleTimeoutMs = this.config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
1395
- pending.timeoutId = setTimeout(() => {
1396
- this.pendingRequests.delete(id);
1397
- this.scheduleIdleDisconnect();
1398
- if (pending.streamController) try {
1399
- pending.streamController.error(/* @__PURE__ */ new Error(`Stream idle timeout after ${idleTimeoutMs}ms`));
1400
- } catch {}
1401
- }, idleTimeoutMs);
1402
- }
1403
- /**
1404
- * Handle an error message
989
+ * Run code and return a streaming response
1405
990
  */
1406
- handleError(error) {
1407
- if (error.id) {
1408
- const pending = this.pendingRequests.get(error.id);
1409
- if (pending) {
1410
- pending.reject(/* @__PURE__ */ new Error(`${error.code}: ${error.message}`));
1411
- return;
1412
- }
991
+ async runCodeStream(code, options = {}) {
992
+ let context = options.context;
993
+ if (!context) {
994
+ const language = options.language || "python";
995
+ context = await this.getOrCreateDefaultContext(language);
1413
996
  }
1414
- this.logger.error("WebSocket error message", new Error(error.message), {
1415
- code: error.code,
1416
- status: error.status
1417
- });
997
+ return this.getInterpreterClient().streamCode(context.id, code, options.language);
1418
998
  }
1419
999
  /**
1420
- * Handle WebSocket close
1000
+ * List all code contexts
1421
1001
  */
1422
- handleClose(event) {
1423
- this.state = "disconnected";
1424
- this.ws = null;
1425
- this.connectPromise = null;
1426
- const closeError = /* @__PURE__ */ new Error(`WebSocket closed: ${event.code} ${event.reason || "No reason"}`);
1427
- for (const [, pending] of this.pendingRequests) {
1428
- if (pending.timeoutId) clearTimeout(pending.timeoutId);
1429
- if (pending.streamController) try {
1430
- pending.streamController.error(closeError);
1431
- } catch {}
1432
- pending.reject(closeError);
1433
- }
1434
- this.pendingRequests.clear();
1002
+ async listCodeContexts() {
1003
+ const contexts = await this.getInterpreterClient().listCodeContexts();
1004
+ for (const context of contexts) this.contexts.set(context.id, context);
1005
+ return contexts;
1435
1006
  }
1436
1007
  /**
1437
- * Cleanup resources
1008
+ * Delete a code context
1438
1009
  */
1439
- cleanup() {
1440
- this.clearIdleDisconnectTimer();
1441
- if (this.ws) {
1442
- this.ws.removeEventListener("close", this.boundHandleClose);
1443
- this.ws.removeEventListener("message", this.boundHandleMessage);
1444
- this.ws.close();
1445
- this.ws = null;
1446
- }
1447
- this.state = "disconnected";
1448
- this.connectPromise = null;
1449
- for (const pending of this.pendingRequests.values()) if (pending.timeoutId) clearTimeout(pending.timeoutId);
1450
- this.pendingRequests.clear();
1451
- }
1452
- scheduleIdleDisconnect() {
1453
- if (!this.isConnected() || this.pendingRequests.size > 0) return;
1454
- this.clearIdleDisconnectTimer();
1455
- this.idleDisconnectTimer = setTimeout(() => {
1456
- this.idleDisconnectTimer = null;
1457
- if (this.pendingRequests.size === 0 && this.isConnected()) {
1458
- this.logger.debug("Disconnecting idle WebSocket transport");
1459
- this.cleanup();
1460
- }
1461
- }, DEFAULT_IDLE_DISCONNECT_MS$1);
1010
+ async deleteCodeContext(contextId) {
1011
+ await this.getInterpreterClient().deleteCodeContext(contextId);
1012
+ this.contexts.delete(contextId);
1462
1013
  }
1463
- clearIdleDisconnectTimer() {
1464
- if (this.idleDisconnectTimer) {
1465
- clearTimeout(this.idleDisconnectTimer);
1466
- this.idleDisconnectTimer = null;
1467
- }
1014
+ async getOrCreateDefaultContext(language) {
1015
+ for (const context of this.contexts.values()) if (context.language === language) return context;
1016
+ return this.createCodeContext({ language });
1468
1017
  }
1469
1018
  };
1470
1019
 
1471
1020
  //#endregion
1472
- //#region src/clients/transport/factory.ts
1473
- /**
1474
- * Create a route-based compatibility transport instance based on mode.
1475
- *
1476
- * Selects the HTTP or custom WebSocket transport for the route-based client
1477
- * layer.
1478
- *
1479
- * @example
1480
- * ```typescript
1481
- * // HTTP transport (default)
1482
- * const http = createTransport({
1483
- * mode: 'http',
1484
- * baseUrl: 'http://localhost:3000'
1485
- * });
1486
- *
1487
- * // WebSocket transport
1488
- * const ws = createTransport({
1489
- * mode: 'websocket',
1490
- * wsUrl: 'ws://localhost:3000/ws'
1491
- * });
1492
- * ```
1493
- */
1494
- function createTransport(options) {
1495
- switch (options.mode) {
1496
- case "http": return new HttpTransport(options);
1497
- case "websocket": return new WebSocketTransport(options);
1021
+ //#region src/pty/proxy.ts
1022
+ async function proxyTerminal(stub, sessionId, request, options) {
1023
+ if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId is required for terminal access");
1024
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") throw new Error("terminal() requires a WebSocket upgrade request");
1025
+ const params = new URLSearchParams({ sessionId });
1026
+ if (options?.cols) params.set("cols", String(options.cols));
1027
+ if (options?.rows) params.set("rows", String(options.rows));
1028
+ if (options?.shell) params.set("shell", options.shell);
1029
+ const ptyUrl = `http://localhost/ws/pty?${params}`;
1030
+ const ptyRequest = new Request(ptyUrl, request);
1031
+ return stub.fetch(switchPort(ptyRequest, 3e3));
1032
+ }
1033
+
1034
+ //#endregion
1035
+ //#region src/preview-proxy-protocol.ts
1036
+ /** @internal */
1037
+ const PREVIEW_PROXY_HEADER = "x-sandbox-preview-proxy";
1038
+ /** @internal */
1039
+ const PREVIEW_PROXY_PORT_HEADER = "x-sandbox-preview-port";
1040
+ /** @internal */
1041
+ const PREVIEW_PROXY_TOKEN_HEADER = "x-sandbox-preview-token";
1042
+ /** @internal */
1043
+ const PREVIEW_PROXY_SANDBOX_ID_HEADER = "x-sandbox-preview-sandbox-id";
1044
+ /** @internal */
1045
+ const PREVIEW_PROXY_HEADERS = [
1046
+ PREVIEW_PROXY_HEADER,
1047
+ PREVIEW_PROXY_PORT_HEADER,
1048
+ PREVIEW_PROXY_TOKEN_HEADER,
1049
+ PREVIEW_PROXY_SANDBOX_ID_HEADER
1050
+ ];
1051
+
1052
+ //#endregion
1053
+ //#region ../shared/src/backup.ts
1054
+ /**
1055
+ * Absolute directory prefixes supported by backup and restore operations.
1056
+ */
1057
+ const BACKUP_ALLOWED_PREFIXES = [
1058
+ "/workspace",
1059
+ "/home",
1060
+ "/tmp",
1061
+ "/var/tmp",
1062
+ "/app"
1063
+ ];
1064
+ function normalizeBackupExcludePattern(pattern) {
1065
+ let normalized = pattern;
1066
+ while (normalized.startsWith("**/")) normalized = normalized.slice(3);
1067
+ while (normalized.includes("/**/")) normalized = normalized.replace(/\/\*\*\//g, "/");
1068
+ if (normalized.endsWith("/**")) normalized = normalized.slice(0, -3);
1069
+ if (!normalized || normalized === "**") return null;
1070
+ return normalized;
1071
+ }
1072
+
1073
+ //#endregion
1074
+ //#region ../shared/src/internal.ts
1075
+ const DISABLE_SESSION_TOKEN = "__DISABLE_SESSION__";
1076
+
1077
+ //#endregion
1078
+ //#region src/response-retry.ts
1079
+ const DEFAULT_INITIAL_RETRY_DELAY_MS = 3e3;
1080
+ const DEFAULT_MAX_RETRY_DELAY_MS = 3e4;
1081
+ const RETRYABLE_WEBSOCKET_UPGRADE_STATUSES = new Set([
1082
+ 500,
1083
+ 502,
1084
+ 503,
1085
+ 504
1086
+ ]);
1087
+ function isRetryableWebSocketUpgradeResponse(response) {
1088
+ return RETRYABLE_WEBSOCKET_UPGRADE_STATUSES.has(response.status);
1089
+ }
1090
+ /**
1091
+ * Retry Response-returning operations while their response remains retryable.
1092
+ * The retry budget covers the whole operation; each attempt owns any
1093
+ * per-request timeout inside the caller-provided `fetchResponse` function.
1094
+ */
1095
+ async function fetchWithResponseRetry(fetchResponse, options) {
1096
+ const startTime = Date.now();
1097
+ let attempt = 0;
1098
+ while (true) {
1099
+ const response = await fetchResponse();
1100
+ if (!options.shouldRetry(response)) return response;
1101
+ const elapsed = Date.now() - startTime;
1102
+ const remaining = options.retryTimeoutMs - elapsed;
1103
+ if (remaining <= options.minTimeForRetryMs) {
1104
+ options.onRetryExhausted?.({
1105
+ attempts: attempt + 1,
1106
+ elapsedMs: elapsed,
1107
+ response
1108
+ });
1109
+ return response;
1110
+ }
1111
+ const delay = Math.min(DEFAULT_INITIAL_RETRY_DELAY_MS * 2 ** attempt, DEFAULT_MAX_RETRY_DELAY_MS);
1112
+ options.logger.info(options.retryLogMessage, {
1113
+ status: response.status,
1114
+ attempt: attempt + 1,
1115
+ delayMs: delay,
1116
+ remainingSec: Math.floor(remaining / 1e3),
1117
+ ...options.getRetryLogContext?.(response)
1118
+ });
1119
+ await new Promise((resolve) => setTimeout(resolve, delay));
1120
+ attempt++;
1498
1121
  }
1499
1122
  }
1500
1123
 
1501
1124
  //#endregion
1502
- //#region src/clients/base-client.ts
1125
+ //#region src/container-control/connection.ts
1126
+ const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
1127
+ const DEFAULT_RETRY_TIMEOUT_MS = 12e4;
1128
+ const MIN_TIME_FOR_RETRY_MS = 15e3;
1503
1129
  /**
1504
- * Abstract base class for route-based HTTP/WebSocket compatibility clients.
1505
- *
1506
- * Requests go through the Transport abstraction layer, which handles:
1507
- * - HTTP and WebSocket route-based modes transparently
1508
- * - Automatic retry for 503 errors while the container is starting
1509
- * - Streaming responses for the existing route API
1130
+ * Manages a capnweb WebSocket RPC session to the container.
1510
1131
  *
1511
- * DO-to-container control-channel capabilities live in `container-control/`.
1512
- * This layer supports the route-based compatibility API.
1132
+ * The RPC stub is created eagerly in the constructor using a deferred
1133
+ * transport. Calls made before `connect()` completes are queued in the
1134
+ * transport and flushed once the WebSocket is established.
1513
1135
  */
1514
- var BaseHttpClient = class {
1515
- options;
1516
- logger;
1136
+ var ContainerControlConnection = class {
1137
+ stub;
1138
+ session;
1517
1139
  transport;
1518
- constructor(options = {}) {
1519
- this.options = options;
1140
+ ws = null;
1141
+ connected = false;
1142
+ connectPromise = null;
1143
+ containerStub;
1144
+ port;
1145
+ logger;
1146
+ retryTimeoutMs;
1147
+ onClose;
1148
+ constructor(options) {
1149
+ this.containerStub = options.stub;
1150
+ this.port = options.port ?? 3e3;
1520
1151
  this.logger = options.logger ?? createNoOpLogger();
1521
- if (options.transport) this.transport = options.transport;
1522
- else this.transport = createTransport({
1523
- mode: options.transportMode ?? "http",
1524
- baseUrl: options.baseUrl ?? "http://localhost:3000",
1525
- wsUrl: options.wsUrl,
1526
- logger: this.logger,
1527
- stub: options.stub,
1528
- port: options.port,
1529
- retryTimeoutMs: options.retryTimeoutMs
1530
- });
1152
+ this.retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS;
1153
+ this.onClose = options.onClose;
1154
+ this.transport = new DeferredTransport();
1155
+ this.session = new RpcSession(this.transport, options.localMain);
1156
+ this.stub = this.session.getRemoteMain();
1531
1157
  }
1532
1158
  /**
1533
- * Update the transport's 503 retry budget
1159
+ * Get the typed RPC stub.
1160
+ *
1161
+ * The stub is available immediately — calls made before connect()
1162
+ * completes are queued in the deferred transport and flushed once
1163
+ * the WebSocket is established.
1534
1164
  */
1535
- setRetryTimeoutMs(ms) {
1536
- this.transport.setRetryTimeoutMs(ms);
1165
+ rpc() {
1166
+ if (!this.connected && !this.connectPromise) this.connect().catch(() => {});
1167
+ return this.stub;
1537
1168
  }
1538
1169
  /**
1539
- * Check if using WebSocket transport
1170
+ * Return capnweb session statistics. The `imports` and `exports` counts
1171
+ * reflect all in-flight RPC calls, streams, and peer-held references.
1172
+ * An idle session has imports <= 1 && exports <= 1 (the bootstrap stubs).
1540
1173
  */
1541
- isWebSocketMode() {
1542
- return this.transport.getMode() === "websocket";
1174
+ getStats() {
1175
+ return this.session.getStats();
1543
1176
  }
1544
- /**
1545
- * Core fetch method - delegates to Transport which handles retry logic
1546
- */
1547
- async doFetch(path$1, options) {
1548
- const { defaultHeaders } = this.options;
1549
- if (defaultHeaders) options = {
1550
- ...options,
1551
- headers: {
1552
- ...defaultHeaders,
1553
- ...options?.headers
1554
- }
1555
- };
1556
- return this.transport.fetch(path$1, options);
1177
+ isConnected() {
1178
+ return this.connected;
1557
1179
  }
1558
- /**
1559
- * Make a POST request with JSON body
1560
- */
1561
- async post(endpoint, data, responseHandler, requestOptions) {
1562
- const response = await this.doFetch(endpoint, {
1563
- method: "POST",
1564
- headers: { "Content-Type": "application/json" },
1565
- body: JSON.stringify(data),
1566
- ...requestOptions
1567
- });
1568
- return await this.handleResponse(response, responseHandler);
1180
+ async connect() {
1181
+ if (this.connected) return;
1182
+ if (this.connectPromise) return this.connectPromise;
1183
+ this.connectPromise = this.doConnect();
1184
+ try {
1185
+ await this.connectPromise;
1186
+ } finally {
1187
+ this.connectPromise = null;
1188
+ }
1569
1189
  }
1570
- /**
1571
- * Make a GET request
1572
- */
1573
- async get(endpoint, responseHandler) {
1574
- const response = await this.doFetch(endpoint, { method: "GET" });
1575
- return await this.handleResponse(response, responseHandler);
1190
+ disconnect() {
1191
+ try {
1192
+ this.stub[Symbol.dispose]?.();
1193
+ } catch {}
1194
+ if (this.ws) {
1195
+ this.ws.removeEventListener("close", this.onWebSocketClose);
1196
+ this.ws.removeEventListener("error", this.onWebSocketError);
1197
+ try {
1198
+ this.ws.close();
1199
+ } catch {}
1200
+ this.ws = null;
1201
+ }
1202
+ this.connected = false;
1203
+ this.connectPromise = null;
1576
1204
  }
1577
1205
  /**
1578
- * Make a DELETE request
1206
+ * Update the upgrade retry budget without recreating the connection. Takes
1207
+ * effect on the next `connect()`; an in-flight connect uses the value
1208
+ * captured at start.
1579
1209
  */
1580
- async delete(endpoint, responseHandler) {
1581
- const response = await this.doFetch(endpoint, { method: "DELETE" });
1582
- return await this.handleResponse(response, responseHandler);
1210
+ setRetryTimeoutMs(ms) {
1211
+ this.retryTimeoutMs = ms;
1583
1212
  }
1584
1213
  /**
1585
- * Handle HTTP response with error checking and parsing
1214
+ * Run the owner-provided `onClose` callback exactly once per call,
1215
+ * swallowing any errors so a buggy listener can't keep the connection
1216
+ * object in a half-torn-down state.
1586
1217
  */
1587
- async handleResponse(response, customHandler) {
1588
- if (!response.ok) await this.handleErrorResponse(response);
1589
- if (customHandler) return customHandler(response);
1218
+ fireOnClose() {
1219
+ if (!this.onClose) return;
1590
1220
  try {
1591
- return await response.json();
1592
- } catch (error) {
1593
- throw createErrorFromResponse({
1594
- code: ErrorCode.INVALID_JSON_RESPONSE,
1595
- message: `Invalid JSON response: ${error instanceof Error ? error.message : "Unknown parsing error"}`,
1596
- context: {},
1597
- httpStatus: response.status,
1598
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1599
- });
1221
+ this.onClose();
1222
+ } catch (err) {
1223
+ this.logger.warn("ContainerControlConnection onClose handler threw", { error: err instanceof Error ? err.message : String(err) });
1600
1224
  }
1601
1225
  }
1602
1226
  /**
1603
- * Handle error responses with consistent error throwing
1227
+ * WebSocket `close` listener. Defined as a bound arrow field so the
1228
+ * same reference can be passed to both `addEventListener` and
1229
+ * `removeEventListener` — a fresh anonymous lambda would silently
1230
+ * fail to unbind.
1231
+ */
1232
+ onWebSocketClose = () => {
1233
+ const wasConnected = this.connected;
1234
+ this.connected = false;
1235
+ this.ws = null;
1236
+ this.logger.debug("ContainerControlConnection WebSocket closed");
1237
+ if (wasConnected) this.fireOnClose();
1238
+ };
1239
+ /**
1240
+ * WebSocket `error` listener. Same field-form rationale as
1241
+ * {@link onWebSocketClose}.
1604
1242
  */
1605
- async handleErrorResponse(response) {
1606
- let errorData;
1243
+ onWebSocketError = () => {
1244
+ const wasConnected = this.connected;
1245
+ this.connected = false;
1246
+ this.ws = null;
1247
+ if (wasConnected) this.fireOnClose();
1248
+ };
1249
+ async doConnect() {
1607
1250
  try {
1608
- errorData = await response.json();
1609
- } catch {
1610
- errorData = {
1611
- code: ErrorCode.INTERNAL_ERROR,
1612
- message: `HTTP error! status: ${response.status}`,
1613
- context: { statusText: response.statusText },
1614
- httpStatus: response.status,
1615
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1616
- };
1251
+ const response = await this.fetchUpgradeWithRetry();
1252
+ if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
1253
+ const ws = response.webSocket;
1254
+ if (!ws) throw new Error("No WebSocket in upgrade response");
1255
+ ws.accept();
1256
+ ws.addEventListener("close", this.onWebSocketClose);
1257
+ ws.addEventListener("error", this.onWebSocketError);
1258
+ this.ws = ws;
1259
+ this.transport.activate(ws);
1260
+ this.connected = true;
1261
+ this.logger.debug("ContainerControlConnection established", { port: this.port });
1262
+ } catch (error) {
1263
+ this.connected = false;
1264
+ this.transport.abort(error);
1265
+ this.logger.error("ContainerControlConnection failed", error instanceof Error ? error : new Error(String(error)));
1266
+ this.fireOnClose();
1267
+ throw error;
1617
1268
  }
1618
- const error = createErrorFromResponse(errorData);
1619
- this.options.onError?.(errorData.message, void 0);
1620
- throw error;
1621
1269
  }
1622
1270
  /**
1623
- * Create a streaming response handler for Server-Sent Events
1271
+ * Issue WebSocket upgrade fetches, retrying transient control-plane
1272
+ * unavailability responses until either the upgrade succeeds, a
1273
+ * non-retryable status is returned, or the retry budget runs out.
1624
1274
  */
1625
- async handleStreamResponse(response) {
1626
- if (!response.ok) await this.handleErrorResponse(response);
1627
- if (!response.body) throw new Error("No response body for streaming");
1628
- return response.body;
1275
+ async fetchUpgradeWithRetry() {
1276
+ return fetchWithResponseRetry(() => this.fetchUpgradeAttempt(), {
1277
+ retryTimeoutMs: this.retryTimeoutMs,
1278
+ minTimeForRetryMs: MIN_TIME_FOR_RETRY_MS,
1279
+ logger: this.logger,
1280
+ retryLogMessage: "ContainerControlConnection upgrade returned retryable status, retrying",
1281
+ shouldRetry: isRetryableWebSocketUpgradeResponse
1282
+ });
1629
1283
  }
1630
1284
  /**
1631
- * Stream request handler
1632
- *
1633
- * HTTP mode uses doFetch + handleStreamResponse for typed error handling.
1634
- * For WebSocket mode, uses Transport's streaming support.
1635
- *
1636
- * @param path - The API path to call
1637
- * @param body - Optional request body (for POST requests)
1638
- * @param method - HTTP method (default: POST, use GET for process logs)
1285
+ * Single WebSocket-upgrade fetch attempt. Owns its own AbortController so
1286
+ * each retry gets a fresh per-attempt connect timeout independent of the
1287
+ * total retry budget.
1639
1288
  */
1640
- async doStreamFetch(path$1, body, method = "POST") {
1641
- const streamHeaders = method === "POST" ? {
1642
- ...this.options.defaultHeaders,
1643
- "Content-Type": "application/json"
1644
- } : this.options.defaultHeaders;
1645
- if (this.transport.getMode() === "websocket") return this.transport.fetchStream(path$1, body, method, streamHeaders);
1646
- const response = await this.doFetch(path$1, {
1647
- method,
1648
- headers: { "Content-Type": "application/json" },
1649
- body: body && method === "POST" ? JSON.stringify(body) : void 0
1650
- });
1651
- return await this.handleStreamResponse(response);
1652
- }
1653
- };
1654
-
1655
- //#endregion
1656
- //#region src/clients/backup-client.ts
1657
- /**
1658
- * Client for backup operations.
1659
- *
1660
- * Handles communication with the container's backup endpoints.
1661
- * The container creates/extracts squashfs archives locally.
1662
- * R2 upload/download is handled by the Sandbox DO, not by this client.
1663
- */
1664
- var BackupClient = class extends BaseHttpClient {
1665
- /**
1666
- * Tell the container to create a squashfs archive from a directory.
1667
- * @param dir - Directory to back up
1668
- * @param archivePath - Where the container should write the archive
1669
- * @param sessionId - Session context
1670
- */
1671
- async createArchive(dir, archivePath, sessionId, options) {
1672
- const data = {
1673
- dir,
1674
- archivePath,
1675
- gitignore: options?.gitignore ?? false,
1676
- excludes: options?.excludes ?? [],
1677
- compression: options?.compression,
1678
- sessionId
1679
- };
1680
- return await this.post("/api/backup/create", data);
1681
- }
1682
- /**
1683
- * Tell the container to restore a squashfs archive into a directory.
1684
- * @param dir - Target directory
1685
- * @param archivePath - Path to the archive file in the container
1686
- * @param sessionId - Session context
1687
- */
1688
- async restoreArchive(dir, archivePath, sessionId) {
1689
- const data = {
1690
- dir,
1691
- archivePath,
1692
- sessionId
1693
- };
1694
- return await this.post("/api/backup/restore", data);
1695
- }
1696
- async uploadParts(request, sessionId) {
1697
- return this.post("/api/backup/upload-parts", {
1698
- ...request,
1699
- sessionId: sessionId ?? request.sessionId
1700
- });
1701
- }
1702
- };
1703
-
1704
- //#endregion
1705
- //#region src/clients/command-client.ts
1706
- /**
1707
- * Client for command execution operations
1708
- */
1709
- var CommandClient = class extends BaseHttpClient {
1710
- /**
1711
- * Execute a command and return the complete result
1712
- * @param command - The command to execute
1713
- * @param sessionId - The session ID for this command execution
1714
- * @param timeoutMs - Optional timeout in milliseconds (unlimited by default)
1715
- * @param env - Optional environment variables for this command
1716
- * @param cwd - Optional working directory for this command
1717
- */
1718
- async execute(command, sessionId, options) {
1719
- try {
1720
- const data = {
1721
- command,
1722
- sessionId,
1723
- ...options?.timeoutMs !== void 0 && { timeoutMs: options.timeoutMs },
1724
- ...options?.env !== void 0 && { env: options.env },
1725
- ...options?.cwd !== void 0 && { cwd: options.cwd },
1726
- ...options?.origin !== void 0 && { origin: options.origin }
1727
- };
1728
- const response = await this.post("/api/execute", data);
1729
- this.options.onCommandComplete?.(response.success, response.exitCode, response.stdout, response.stderr, response.command);
1730
- return response;
1731
- } catch (error) {
1732
- this.options.onError?.(error instanceof Error ? error.message : String(error), command);
1733
- throw error;
1734
- }
1735
- }
1736
- /**
1737
- * Execute a command and return a stream of events
1738
- * @param command - The command to execute
1739
- * @param sessionId - The session ID for this command execution
1740
- * @param options - Optional per-command execution settings
1741
- */
1742
- async executeStream(command, sessionId, options) {
1289
+ async fetchUpgradeAttempt() {
1290
+ const controller = new AbortController();
1291
+ const timeout = setTimeout(() => controller.abort(), DEFAULT_CONNECT_TIMEOUT_MS);
1743
1292
  try {
1744
- const data = {
1745
- command,
1746
- sessionId,
1747
- ...options?.timeoutMs !== void 0 && { timeoutMs: options.timeoutMs },
1748
- ...options?.env !== void 0 && { env: options.env },
1749
- ...options?.cwd !== void 0 && { cwd: options.cwd },
1750
- ...options?.origin !== void 0 && { origin: options.origin }
1751
- };
1752
- return await this.doStreamFetch("/api/execute/stream", data);
1753
- } catch (error) {
1754
- this.options.onError?.(error instanceof Error ? error.message : String(error), command);
1755
- throw error;
1293
+ const url = `http://localhost:${this.port}/rpc`;
1294
+ const request = new Request(url, {
1295
+ headers: {
1296
+ Upgrade: "websocket",
1297
+ Connection: "Upgrade"
1298
+ },
1299
+ signal: controller.signal
1300
+ });
1301
+ return await this.containerStub.fetch(request);
1302
+ } finally {
1303
+ clearTimeout(timeout);
1756
1304
  }
1757
1305
  }
1758
1306
  };
1759
-
1760
- //#endregion
1761
- //#region src/clients/file-client.ts
1762
- /**
1763
- * Client for file system operations
1764
- */
1765
- var FileClient = class extends BaseHttpClient {
1766
- /**
1767
- * Create a directory
1768
- * @param path - Directory path to create
1769
- * @param sessionId - The session ID for this operation
1770
- * @param options - Optional settings (recursive)
1771
- */
1772
- async mkdir(path$1, sessionId, options) {
1773
- const data = {
1774
- path: path$1,
1775
- sessionId,
1776
- recursive: options?.recursive ?? false
1777
- };
1778
- return await this.post("/api/mkdir", data);
1779
- }
1780
- /**
1781
- * Write content to a file
1782
- * @param path - File path to write to
1783
- * @param content - Content to write
1784
- * @param sessionId - The session ID for this operation
1785
- * @param options - Optional settings (encoding)
1786
- */
1787
- async writeFile(path$1, content, sessionId, options) {
1788
- const data = {
1789
- path: path$1,
1790
- content,
1791
- sessionId,
1792
- encoding: options?.encoding
1793
- };
1794
- return await this.post("/api/write", data);
1795
- }
1796
- async readFile(path$1, sessionId, options) {
1797
- if (options?.encoding === "none") throw new Error("readFile with encoding: 'none' requires the rpc transport. Set SANDBOX_TRANSPORT=rpc.");
1798
- const data = {
1799
- path: path$1,
1800
- sessionId,
1801
- encoding: options?.encoding
1802
- };
1803
- return await this.post("/api/read", data);
1804
- }
1805
- /**
1806
- * Stream a file using Server-Sent Events
1807
- * Returns a ReadableStream of SSE events containing metadata, chunks, and completion
1808
- * @param path - File path to stream
1809
- * @param sessionId - The session ID for this operation
1810
- */
1811
- async readFileStream(path$1, sessionId) {
1812
- const data = {
1813
- path: path$1,
1814
- sessionId
1815
- };
1816
- return await this.doStreamFetch("/api/read/stream", data);
1817
- }
1818
- /**
1819
- * Delete a file
1820
- * @param path - File path to delete
1821
- * @param sessionId - The session ID for this operation
1822
- */
1823
- async deleteFile(path$1, sessionId) {
1824
- const data = {
1825
- path: path$1,
1826
- sessionId
1827
- };
1828
- return await this.post("/api/delete", data);
1829
- }
1830
- /**
1831
- * Rename a file
1832
- * @param path - Current file path
1833
- * @param newPath - New file path
1834
- * @param sessionId - The session ID for this operation
1835
- */
1836
- async renameFile(path$1, newPath, sessionId) {
1837
- const data = {
1838
- oldPath: path$1,
1839
- newPath,
1840
- sessionId
1841
- };
1842
- return await this.post("/api/rename", data);
1843
- }
1844
- /**
1845
- * Move a file
1846
- * @param path - Current file path
1847
- * @param newPath - Destination file path
1848
- * @param sessionId - The session ID for this operation
1849
- */
1850
- async moveFile(path$1, newPath, sessionId) {
1851
- const data = {
1852
- sourcePath: path$1,
1853
- destinationPath: newPath,
1854
- sessionId
1855
- };
1856
- return await this.post("/api/move", data);
1857
- }
1858
- /**
1859
- * List files in a directory
1860
- * @param path - Directory path to list
1861
- * @param sessionId - The session ID for this operation
1862
- * @param options - Optional settings (recursive, includeHidden)
1863
- */
1864
- async listFiles(path$1, sessionId, options) {
1865
- const data = {
1866
- path: path$1,
1867
- sessionId,
1868
- options: options || {}
1869
- };
1870
- return await this.post("/api/list-files", data);
1871
- }
1872
- /**
1873
- * Check if a file or directory exists
1874
- * @param path - Path to check
1875
- * @param sessionId - The session ID for this operation
1876
- */
1877
- async exists(path$1, sessionId) {
1878
- const data = {
1879
- path: path$1,
1880
- sessionId
1881
- };
1882
- return await this.post("/api/exists", data);
1883
- }
1884
- /**
1885
- * Write a file via a raw binary stream over the RPC transport.
1886
- * Throws on HTTP and WebSocket transports — use writeFile() with a string instead.
1887
- */
1888
- writeFileStream(_path, _content, _sessionId) {
1889
- throw new Error("writeFileStream requires the rpc transport. Set SANDBOX_TRANSPORT=rpc.");
1890
- }
1891
- };
1892
-
1893
- //#endregion
1894
- //#region src/clients/git-client.ts
1895
1307
  /**
1896
- * Client for Git repository operations
1308
+ * RPC transport that queues sends and blocks receives until a WebSocket
1309
+ * is provided via `activate()`. Allows the RPC stub to be created before
1310
+ * the connection is established — queued calls flush automatically.
1897
1311
  */
1898
- var GitClient = class GitClient extends BaseHttpClient {
1899
- static REQUEST_TIMEOUT_BUFFER_MS = 3e4;
1900
- constructor(options = {}) {
1901
- super(options);
1902
- this.logger = new GitLogger(this.logger);
1903
- }
1904
- /**
1905
- * Clone a Git repository
1906
- * @param repoUrl - URL of the Git repository to clone
1907
- * @param sessionId - The session ID for this operation
1908
- * @param options - Optional settings (branch, targetDir, depth, timeoutMs)
1909
- */
1910
- async checkout(repoUrl, sessionId, options) {
1911
- const timeoutMs = options?.timeoutMs ?? DEFAULT_GIT_CLONE_TIMEOUT_MS;
1912
- let targetDir = options?.targetDir;
1913
- if (!targetDir) targetDir = `/workspace/${extractRepoName(repoUrl)}`;
1914
- const data = {
1915
- repoUrl,
1916
- sessionId,
1917
- targetDir
1918
- };
1919
- if (options?.branch) data.branch = options.branch;
1920
- if (options?.depth !== void 0) {
1921
- if (!Number.isInteger(options.depth) || options.depth <= 0) throw new Error(`Invalid depth value: ${options.depth}. Must be a positive integer (e.g., 1, 5, 10).`);
1922
- data.depth = options.depth;
1923
- }
1924
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new Error(`Invalid timeout value: ${timeoutMs}. Must be a positive integer number of milliseconds.`);
1925
- data.timeoutMs = timeoutMs;
1926
- return await this.post("/api/git/checkout", data, void 0, { requestTimeoutMs: timeoutMs + GitClient.REQUEST_TIMEOUT_BUFFER_MS });
1927
- }
1928
- };
1929
-
1930
- //#endregion
1931
- //#region src/clients/interpreter-client.ts
1932
- var InterpreterClient = class extends BaseHttpClient {
1933
- maxRetries = 3;
1934
- retryDelayMs = 1e3;
1935
- async createCodeContext(options = {}) {
1936
- return this.executeWithRetry(async () => {
1937
- const response = await this.doFetch("/api/contexts", {
1938
- method: "POST",
1939
- headers: { "Content-Type": "application/json" },
1940
- body: JSON.stringify({
1941
- language: options.language || "python",
1942
- cwd: options.cwd || "/workspace",
1943
- env_vars: options.envVars
1944
- })
1945
- });
1946
- if (!response.ok) throw await this.parseErrorResponse(response);
1947
- const data = await response.json();
1948
- if (!data.success) throw new Error(`Failed to create context: ${JSON.stringify(data)}`);
1949
- return {
1950
- id: data.contextId,
1951
- language: data.language,
1952
- cwd: data.cwd || "/workspace",
1953
- createdAt: new Date(data.timestamp),
1954
- lastUsed: new Date(data.timestamp)
1955
- };
1312
+ var DeferredTransport = class {
1313
+ #ws = null;
1314
+ #sendQueue = [];
1315
+ #receiveQueue = [];
1316
+ #receiveResolver;
1317
+ #receiveRejecter;
1318
+ #error;
1319
+ activate(ws) {
1320
+ this.#ws = ws;
1321
+ ws.addEventListener("message", (event) => {
1322
+ if (this.#error) return;
1323
+ if (typeof event.data === "string") if (this.#receiveResolver) {
1324
+ this.#receiveResolver(event.data);
1325
+ this.#receiveResolver = void 0;
1326
+ this.#receiveRejecter = void 0;
1327
+ } else this.#receiveQueue.push(event.data);
1328
+ else this.#fail(/* @__PURE__ */ new TypeError("Received non-string message from WebSocket."));
1956
1329
  });
1957
- }
1958
- async runCodeStream(contextId, code, language, callbacks, timeoutMs) {
1959
- return this.executeWithRetry(async () => {
1960
- const stream = await this.doStreamFetch("/api/execute/code", {
1961
- context_id: contextId,
1962
- code,
1963
- language,
1964
- ...timeoutMs !== void 0 && { timeout_ms: timeoutMs }
1965
- });
1966
- for await (const chunk of this.readLines(stream)) await this.parseExecutionResult(chunk, callbacks);
1330
+ ws.addEventListener("close", (event) => {
1331
+ this.#fail(/* @__PURE__ */ new Error(`Peer closed WebSocket: ${event.code} ${event.reason}`));
1967
1332
  });
1968
- }
1969
- async listCodeContexts() {
1970
- return this.executeWithRetry(async () => {
1971
- const response = await this.doFetch("/api/contexts", {
1972
- method: "GET",
1973
- headers: { "Content-Type": "application/json" }
1974
- });
1975
- if (!response.ok) throw await this.parseErrorResponse(response);
1976
- const data = await response.json();
1977
- if (!data.success) throw new Error(`Failed to list contexts: ${JSON.stringify(data)}`);
1978
- return data.contexts.map((ctx) => ({
1979
- id: ctx.id,
1980
- language: ctx.language,
1981
- cwd: ctx.cwd || "/workspace",
1982
- createdAt: new Date(data.timestamp),
1983
- lastUsed: new Date(data.timestamp)
1984
- }));
1333
+ ws.addEventListener("error", () => {
1334
+ this.#fail(/* @__PURE__ */ new Error("WebSocket connection failed."));
1985
1335
  });
1336
+ for (const msg of this.#sendQueue) ws.send(msg);
1337
+ this.#sendQueue = [];
1986
1338
  }
1987
- async deleteCodeContext(contextId) {
1988
- return this.executeWithRetry(async () => {
1989
- const response = await this.doFetch(`/api/contexts/${contextId}`, {
1990
- method: "DELETE",
1991
- headers: { "Content-Type": "application/json" }
1992
- });
1993
- if (!response.ok) throw await this.parseErrorResponse(response);
1994
- });
1339
+ async send(message) {
1340
+ if (this.#ws) this.#ws.send(message);
1341
+ else this.#sendQueue.push(message);
1995
1342
  }
1996
- /**
1997
- * Get a raw stream for code execution.
1998
- * Used by CodeInterpreter.runCodeStreaming() for direct stream access.
1999
- */
2000
- async streamCode(contextId, code, language) {
2001
- return this.doStreamFetch("/api/execute/code", {
2002
- context_id: contextId,
2003
- code,
2004
- language
1343
+ async receive() {
1344
+ if (this.#receiveQueue.length > 0) return this.#receiveQueue.shift();
1345
+ if (this.#error) throw this.#error;
1346
+ return new Promise((resolve, reject) => {
1347
+ this.#receiveResolver = resolve;
1348
+ this.#receiveRejecter = reject;
2005
1349
  });
2006
1350
  }
2007
- /**
2008
- * Execute an operation with automatic retry for transient errors
2009
- */
2010
- async executeWithRetry(operation) {
2011
- let lastError;
2012
- for (let attempt = 0; attempt < this.maxRetries; attempt++) try {
2013
- return await operation();
2014
- } catch (error) {
2015
- lastError = error;
2016
- if (this.isRetryableError(error)) {
2017
- if (attempt < this.maxRetries - 1) {
2018
- const delay = this.retryDelayMs * 2 ** attempt + Math.random() * 1e3;
2019
- await new Promise((resolve) => setTimeout(resolve, delay));
2020
- continue;
2021
- }
2022
- }
2023
- throw error;
2024
- }
2025
- throw lastError || /* @__PURE__ */ new Error("Execution failed after retries");
2026
- }
2027
- isRetryableError(error) {
2028
- if (error instanceof InterpreterNotReadyError) return true;
2029
- if (error instanceof Error) return error.message.includes("not ready") || error.message.includes("initializing");
2030
- return false;
2031
- }
2032
- async parseErrorResponse(response) {
2033
- try {
2034
- return createErrorFromResponse(await response.json());
2035
- } catch {
2036
- return createErrorFromResponse({
2037
- code: ErrorCode.INTERNAL_ERROR,
2038
- message: `HTTP ${response.status}: ${response.statusText}`,
2039
- context: {},
2040
- httpStatus: response.status,
2041
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2042
- });
2043
- }
2044
- }
2045
- async *readLines(stream) {
2046
- const reader = stream.getReader();
2047
- let buffer = "";
2048
- try {
2049
- while (true) {
2050
- const { done, value } = await reader.read();
2051
- if (value) buffer += new TextDecoder().decode(value);
2052
- if (done) break;
2053
- let newlineIdx = buffer.indexOf("\n");
2054
- while (newlineIdx !== -1) {
2055
- yield buffer.slice(0, newlineIdx);
2056
- buffer = buffer.slice(newlineIdx + 1);
2057
- newlineIdx = buffer.indexOf("\n");
2058
- }
2059
- }
2060
- if (buffer.length > 0) yield buffer;
2061
- } finally {
2062
- try {
2063
- await reader.cancel();
2064
- } catch {}
2065
- reader.releaseLock();
1351
+ abort(reason) {
1352
+ this.#fail(reason instanceof Error ? reason : new Error(String(reason)));
1353
+ if (this.#ws) {
1354
+ const message = reason instanceof Error ? reason.message : String(reason);
1355
+ this.#ws.close(3e3, message);
2066
1356
  }
2067
1357
  }
2068
- async parseExecutionResult(line, callbacks) {
2069
- if (!line.trim()) return;
2070
- if (!line.startsWith("data: ")) return;
2071
- try {
2072
- const jsonData = line.substring(6);
2073
- const data = JSON.parse(jsonData);
2074
- switch (data.type) {
2075
- case "stdout":
2076
- if (callbacks.onStdout && data.text) await callbacks.onStdout({
2077
- text: data.text,
2078
- timestamp: data.timestamp || Date.now()
2079
- });
2080
- break;
2081
- case "stderr":
2082
- if (callbacks.onStderr && data.text) await callbacks.onStderr({
2083
- text: data.text,
2084
- timestamp: data.timestamp || Date.now()
2085
- });
2086
- break;
2087
- case "result":
2088
- if (callbacks.onResult) {
2089
- const result = new ResultImpl(data);
2090
- await callbacks.onResult(result);
2091
- }
2092
- break;
2093
- case "error":
2094
- if (callbacks.onError) await callbacks.onError({
2095
- name: data.ename || "Error",
2096
- message: data.evalue || "Unknown error",
2097
- traceback: data.traceback || []
2098
- });
2099
- break;
2100
- case "execution_complete": break;
2101
- }
2102
- } catch {}
1358
+ #fail(err) {
1359
+ if (this.#error) return;
1360
+ this.#error = err;
1361
+ this.#receiveRejecter?.(err);
1362
+ this.#receiveResolver = void 0;
1363
+ this.#receiveRejecter = void 0;
2103
1364
  }
2104
1365
  };
2105
1366
 
2106
1367
  //#endregion
2107
- //#region src/clients/port-client.ts
1368
+ //#region src/container-control/client.ts
1369
+ /** Close the idle capnweb WebSocket promptly so the DO can sleep. */
1370
+ const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
2108
1371
  /**
2109
- * Client for port readiness operations.
1372
+ * How often the busy/idle poller samples `getStats()`.
1373
+ *
1374
+ * Sets two worst-case bounds:
1375
+ *
1376
+ * 1. **Idle-detection lag.** Time between the session going idle on
1377
+ * the wire and the DO observing it (and arming the disconnect).
1378
+ * Bounded by `pollInterval`.
1379
+ * 2. **Activity-renewal lag while busy.** While a stream is active we
1380
+ * renew the DO's activity timeout once per tick. The alarm could
1381
+ * fire as late as `sleepAfter` after the last renew, so the
1382
+ * effective margin against a mid-stream sleep is
1383
+ * `sleepAfter - pollInterval`.
1384
+ *
1385
+ * **Invariant: `pollInterval` must be comfortably less than the
1386
+ * smallest configurable `sleepAfter`.** Aim for at least 2-3× headroom.
1387
+ * The minimum `sleepAfter` exercised by the E2E suite is 3s, so 1s gives
1388
+ * 3× margin and at least two renewals during a 3s window. If a smaller
1389
+ * `sleepAfter` is ever supported, drop this proportionally.
2110
1390
  */
2111
- var PortClient = class extends BaseHttpClient {
2112
- /**
2113
- * Watch a port for readiness via SSE stream
2114
- * @param request - Port watch configuration
2115
- * @returns SSE stream that emits PortWatchEvent objects
2116
- */
2117
- async watchPort(request) {
2118
- return await this.doStreamFetch("/api/port-watch", request);
2119
- }
2120
- };
2121
-
2122
- //#endregion
2123
- //#region src/clients/process-client.ts
1391
+ const BUSY_POLL_INTERVAL_MS = 1e3;
2124
1392
  /**
2125
- * Client for background process management
1393
+ * Baseline getStats() values for an idle session. The bootstrap stub on each
1394
+ * side accounts for 1 import and 1 export.
2126
1395
  */
2127
- var ProcessClient = class extends BaseHttpClient {
2128
- /**
2129
- * Start a background process
2130
- * @param command - Command to execute as a background process
2131
- * @param sessionId - The session ID for this operation
2132
- * @param options - Optional settings (processId)
2133
- */
2134
- async startProcess(command, sessionId, options) {
2135
- const data = {
2136
- command,
2137
- sessionId,
2138
- ...options?.origin !== void 0 && { origin: options.origin },
2139
- ...options?.processId !== void 0 && { processId: options.processId },
2140
- ...options?.timeoutMs !== void 0 && { timeoutMs: options.timeoutMs },
2141
- ...options?.env !== void 0 && { env: options.env },
2142
- ...options?.cwd !== void 0 && { cwd: options.cwd },
2143
- ...options?.encoding !== void 0 && { encoding: options.encoding },
2144
- ...options?.autoCleanup !== void 0 && { autoCleanup: options.autoCleanup }
2145
- };
2146
- return await this.post("/api/process/start", data);
2147
- }
2148
- /**
2149
- * List all processes (sandbox-scoped, not session-scoped)
2150
- */
2151
- async listProcesses() {
2152
- return await this.get(`/api/process/list`);
2153
- }
2154
- /**
2155
- * Get information about a specific process (sandbox-scoped, not session-scoped)
2156
- * @param processId - ID of the process to retrieve
2157
- */
2158
- async getProcess(processId) {
2159
- const url = `/api/process/${processId}`;
2160
- return await this.get(url);
2161
- }
2162
- /**
2163
- * Kill a specific process (sandbox-scoped, not session-scoped)
2164
- * @param processId - ID of the process to kill
2165
- */
2166
- async killProcess(processId) {
2167
- const url = `/api/process/${processId}`;
2168
- return await this.delete(url);
2169
- }
2170
- /**
2171
- * Kill all running processes (sandbox-scoped, not session-scoped)
2172
- */
2173
- async killAllProcesses() {
2174
- return await this.delete(`/api/process/kill-all`);
2175
- }
2176
- /**
2177
- * Get logs from a specific process (sandbox-scoped, not session-scoped)
2178
- * @param processId - ID of the process to get logs from
2179
- */
2180
- async getProcessLogs(processId) {
2181
- const url = `/api/process/${processId}/logs`;
2182
- return await this.get(url);
2183
- }
2184
- /**
2185
- * Stream logs from a specific process (sandbox-scoped, not session-scoped)
2186
- * @param processId - ID of the process to stream logs from
2187
- */
2188
- async streamProcessLogs(processId) {
2189
- const url = `/api/process/${processId}/stream`;
2190
- return await this.doStreamFetch(url, void 0, "GET");
2191
- }
2192
- };
2193
-
2194
- //#endregion
2195
- //#region src/clients/utility-client.ts
1396
+ const IDLE_IMPORT_THRESHOLD = 1;
1397
+ const IDLE_EXPORT_THRESHOLD = 1;
2196
1398
  /**
2197
- * Client for health checks and utility operations
1399
+ * Translate a capnweb-propagated error into a typed SandboxError.
1400
+ *
1401
+ * Two wire formats are supported for backward compatibility with older
1402
+ * container images:
1403
+ *
1404
+ * 1. Propagated error properties (capnweb >= 0.8.0). The container throws a
1405
+ * `ServiceError`-shaped object with own enumerable `code` and `details`
1406
+ * properties. capnweb walks `Object.keys()` and reconstructs those fields
1407
+ * on the SDK side.
1408
+ * 2. Legacy JSON-encoded message. Older containers encoded the structured
1409
+ * payload as a JSON string in `error.message`.
1410
+ *
1411
+ * The JSON-fallback branch can be removed once all older container images are
1412
+ * no longer in service.
2198
1413
  */
2199
- var UtilityClient = class extends BaseHttpClient {
2200
- /**
2201
- * Ping the sandbox to check if it's responsive
2202
- */
2203
- async ping() {
2204
- return (await this.get("/api/ping")).message;
2205
- }
2206
- /**
2207
- * Get list of available commands in the sandbox environment
2208
- */
2209
- async getCommands() {
2210
- return (await this.get("/api/commands")).availableCommands;
2211
- }
2212
- /**
2213
- * Create a new execution session
2214
- * @param options - Session configuration (id, env, cwd)
2215
- */
2216
- async createSession(options) {
2217
- return await this.post("/api/session/create", options);
2218
- }
2219
- /**
2220
- * Delete an execution session
2221
- * @param sessionId - Session ID to delete
2222
- */
2223
- async deleteSession(sessionId) {
2224
- return await this.post("/api/session/delete", { sessionId });
2225
- }
2226
- /**
2227
- * Get the container version
2228
- * Returns the version embedded in the Docker image during build
2229
- */
2230
- async getVersion() {
2231
- try {
2232
- return (await this.get("/api/version")).version;
2233
- } catch (error) {
2234
- this.logger.debug("Failed to get container version (may be old container)", { error });
2235
- return "unknown";
1414
+ function translateRPCError(error) {
1415
+ if (error instanceof Error) {
1416
+ const propagated = error;
1417
+ if (typeof propagated.code === "string" && Object.hasOwn(ErrorCode, propagated.code)) {
1418
+ const code = propagated.code;
1419
+ const context = propagated.details && typeof propagated.details === "object" ? propagated.details : {};
1420
+ throw createErrorFromResponse({
1421
+ code,
1422
+ message: error.message,
1423
+ context,
1424
+ httpStatus: getHttpStatus(code),
1425
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1426
+ });
2236
1427
  }
1428
+ let payload;
1429
+ try {
1430
+ payload = JSON.parse(error.message);
1431
+ } catch {}
1432
+ if (payload && typeof payload.code === "string" && typeof payload.message === "string") throw createErrorFromResponse({
1433
+ code: payload.code,
1434
+ message: payload.message,
1435
+ context: payload.context ?? {},
1436
+ httpStatus: getHttpStatus(payload.code),
1437
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1438
+ });
1439
+ throw createErrorFromResponse(buildTransportErrorResponse(error), { cause: error });
2237
1440
  }
2238
- listSessions() {
2239
- throw new Error("listSessions requires the RPC transport. Set SANDBOX_TRANSPORT=rpc.");
1441
+ throw createErrorFromResponse(buildTransportErrorResponse(new Error(String(error))), { cause: error });
1442
+ }
1443
+ /**
1444
+ * Inspect a transport-level Error's message and produce the ErrorResponse
1445
+ * that becomes an RPCTransportError. Pattern strings are pinned to the exact
1446
+ * messages emitted by capnweb's WebSocket transport (see capnweb's
1447
+ * src/websocket.ts) and our DeferredTransport in container-control/connection.ts —
1448
+ * notably the trailing period in `WebSocket connection failed.` matches
1449
+ * capnweb verbatim. The DeferredTransport tests in
1450
+ * tests/container-connection.test.ts pin the literal strings.
1451
+ */
1452
+ function buildTransportErrorResponse(error) {
1453
+ const message = error.message;
1454
+ const errorName = error.name;
1455
+ let kind = "unknown";
1456
+ let closeCode;
1457
+ let closeReason;
1458
+ if (errorName === "TypeError") kind = "invalid_frame";
1459
+ else if (errorName === "SyntaxError") kind = "protocol_error";
1460
+ else {
1461
+ const peerCloseMatch = message.match(/^Peer closed WebSocket: (\d+) ?(.*)$/);
1462
+ if (peerCloseMatch) {
1463
+ kind = "peer_closed";
1464
+ closeCode = Number(peerCloseMatch[1]);
1465
+ closeReason = peerCloseMatch[2] || void 0;
1466
+ } else if (message === "WebSocket connection failed.") kind = "connection_failed";
1467
+ else if (message.startsWith("WebSocket upgrade failed")) kind = "upgrade_failed";
1468
+ else if (message === "No WebSocket in upgrade response") kind = "upgrade_failed";
1469
+ else if (message === "RPC session was shut down by disposing the main stub" || message === "RPC was canceled because the RpcPromise was disposed.") kind = "session_disposed";
2240
1470
  }
2241
- };
2242
-
2243
- //#endregion
2244
- //#region src/clients/watch-client.ts
1471
+ const context = {
1472
+ kind,
1473
+ originalMessage: message,
1474
+ errorName,
1475
+ ...closeCode !== void 0 ? { closeCode } : {},
1476
+ ...closeReason !== void 0 ? { closeReason } : {}
1477
+ };
1478
+ return {
1479
+ code: ErrorCode.RPC_TRANSPORT_ERROR,
1480
+ message,
1481
+ context,
1482
+ httpStatus: getHttpStatus(ErrorCode.RPC_TRANSPORT_ERROR),
1483
+ suggestion: getSuggestion(ErrorCode.RPC_TRANSPORT_ERROR, context),
1484
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1485
+ };
1486
+ }
2245
1487
  /**
2246
- * Client for file watch operations
2247
- * Uses inotify under the hood for native filesystem event notifications
1488
+ * Wrap a capnweb RPC stub so that every method call translates errors
1489
+ * from the JSON wire format into typed SandboxError instances and signals
1490
+ * activity at call start.
1491
+ *
1492
+ * `onCallStarted` fires synchronously when an RPC method is invoked. The
1493
+ * ContainerControlClient uses this to renew the DO's activity timeout
1494
+ * immediately, so even a call that completes entirely between two
1495
+ * busy-poll ticks still pushes the sleepAfter deadline forward.
2248
1496
  *
2249
- * @internal This client is used internally by the SDK.
2250
- * Users should use `sandbox.watch()` or `sandbox.checkChanges()` instead.
1497
+ * Note: there is no `onCallSettled` hook. A method whose returned promise
1498
+ * resolves with a `ReadableStream` is *not* finished when the promise
1499
+ * settles — capnweb keeps the export alive until the stream ends. The
1500
+ * busy/idle poll on `getStats()` is the source of truth for that.
2251
1501
  */
2252
- var WatchClient = class extends BaseHttpClient {
2253
- /**
2254
- * Check whether a path changed since a previously returned version.
2255
- */
2256
- async checkChanges(request) {
2257
- return this.post("/api/watch/check", request);
2258
- }
2259
- /**
2260
- * Start watching a directory for changes.
2261
- * The returned promise resolves only after the watcher is established
2262
- * on the filesystem (i.e. the `watching` SSE event has been received).
2263
- * The returned stream still contains the `watching` event so consumers
2264
- * using `parseSSEStream` will see the full event sequence.
2265
- *
2266
- * @param request - Watch request with path and options
2267
- */
2268
- async watch(request) {
2269
- const stream = await this.doStreamFetch("/api/watch", request);
2270
- return await this.waitForReadiness(stream);
2271
- }
2272
- /**
2273
- * Read SSE chunks until the `watching` event appears, then return a
2274
- * wrapper stream that replays the buffered chunks followed by the
2275
- * remaining original stream data.
2276
- */
2277
- async waitForReadiness(stream) {
2278
- const reader = stream.getReader();
2279
- const bufferedChunks = [];
2280
- const decoder = new TextDecoder();
2281
- let buffer = "";
2282
- let currentEvent = { data: [] };
2283
- let watcherReady = false;
2284
- const processEventData = (eventData) => {
2285
- let event;
1502
+ function wrapStub(stub, onCallStarted) {
1503
+ return new Proxy(stub, { get(target, prop, receiver) {
1504
+ const value = Reflect.get(target, prop, receiver);
1505
+ if (typeof value !== "function") return value;
1506
+ return (...args) => {
1507
+ onCallStarted();
2286
1508
  try {
2287
- event = JSON.parse(eventData);
2288
- } catch {
2289
- return;
1509
+ const result = Reflect.apply(value, target, args);
1510
+ if (result != null && typeof result.then === "function") return result.catch(translateRPCError);
1511
+ return result;
1512
+ } catch (err) {
1513
+ translateRPCError(err);
2290
1514
  }
2291
- if (event.type === "watching") watcherReady = true;
2292
- if (event.type === "error") throw new Error(event.error || "Watch failed to establish");
2293
1515
  };
2294
- try {
2295
- while (!watcherReady) {
2296
- const { done, value } = await reader.read();
2297
- if (done) {
2298
- const finalParsed = parseSSEFrames(`${buffer}\n\n`, currentEvent);
2299
- for (const frame of finalParsed.events) {
2300
- processEventData(frame.data);
2301
- if (watcherReady) break;
2302
- }
2303
- if (watcherReady) break;
2304
- throw new Error("Watch stream ended before watcher was established");
2305
- }
2306
- bufferedChunks.push(value);
2307
- buffer += decoder.decode(value, { stream: true });
2308
- const parsed = parseSSEFrames(buffer, currentEvent);
2309
- buffer = parsed.remaining;
2310
- currentEvent = parsed.currentEvent;
2311
- for (const frame of parsed.events) {
2312
- processEventData(frame.data);
2313
- if (watcherReady) break;
2314
- }
2315
- }
2316
- } catch (error) {
2317
- reader.cancel().catch(() => {});
2318
- throw error;
2319
- }
2320
- let replayIndex = 0;
2321
- return new ReadableStream({
2322
- pull(controller) {
2323
- if (replayIndex < bufferedChunks.length) {
2324
- controller.enqueue(bufferedChunks[replayIndex++]);
2325
- return;
2326
- }
2327
- return reader.read().then(({ done: d, value: v }) => {
2328
- if (d) {
2329
- controller.close();
2330
- return;
2331
- }
2332
- controller.enqueue(v);
2333
- });
2334
- },
2335
- cancel() {
2336
- return reader.cancel();
2337
- }
2338
- });
2339
- }
2340
- };
2341
-
2342
- //#endregion
2343
- //#region src/clients/sandbox-client.ts
1516
+ } });
1517
+ }
2344
1518
  /**
2345
- * Route-based compatibility sandbox client that composes all domain-specific
2346
- * HTTP API clients.
1519
+ * Sandbox control facade backed by direct capnweb RPC.
2347
1520
  *
2348
- * This client supports the route-based HTTP and custom WebSocket transports.
2349
- * The primary DO-to-container control path is ContainerControlClient under
2350
- * `container-control/`. This client supports route-based compatibility,
2351
- * debugging, local development, and fallback behavior.
1521
+ * All operations call the container's SandboxAPI control interface directly
1522
+ * over capnweb, bypassing the HTTP handler/router layer entirely.
1523
+ *
1524
+ * Manages its own WebSocket lifecycle: a fresh `ContainerControlConnection` is
1525
+ * created on demand and torn down after `idleDisconnectMs` of inactivity.
1526
+ * Busy/idle detection relies on `RpcSession.getStats()` which tracks all
1527
+ * in-flight RPC calls and stream exports — including long-lived streaming
1528
+ * RPCs that would be invisible to a simple per-call request counter (see
1529
+ * the file-level comment for the full rationale).
2352
1530
  */
2353
- var SandboxClient = class {
2354
- backup;
2355
- commands;
2356
- files;
2357
- processes;
2358
- ports;
2359
- git;
2360
- interpreter;
2361
- utils;
2362
- watch;
2363
- /**
2364
- * Tunnels are RPC-only the route-based transport does not implement them.
2365
- * This getter exists so the `PublicKeys<SandboxClient> satisfies
2366
- * PublicKeys<SandboxAPI>` compile-time check holds. Calling any method on
2367
- * the returned proxy throws a clear `RPC transport required` error.
2368
- */
2369
- tunnels = createTunnelsNotImplemented();
2370
- transport = null;
1531
+ var ContainerControlClient = class {
1532
+ connOptions;
1533
+ idleDisconnectMs;
1534
+ busyPollIntervalMs;
1535
+ logger;
1536
+ onActivity;
1537
+ onSessionBusy;
1538
+ onSessionIdle;
1539
+ conn = null;
1540
+ idleTimer = null;
1541
+ busyPollTimer = null;
1542
+ /** Tracks whether we currently believe the session is busy. */
1543
+ busy = false;
2371
1544
  constructor(options) {
2372
- if (options.transportMode === "websocket" && options.wsUrl) this.transport = createTransport({
2373
- mode: options.transportMode,
2374
- wsUrl: options.wsUrl,
2375
- baseUrl: options.baseUrl,
2376
- logger: options.logger,
1545
+ this.connOptions = {
2377
1546
  stub: options.stub,
2378
1547
  port: options.port,
2379
- retryTimeoutMs: options.retryTimeoutMs
2380
- });
2381
- const clientOptions = {
2382
- baseUrl: "http://localhost:3000",
2383
- ...options,
2384
- transport: this.transport ?? options.transport
1548
+ localMain: options.localMain,
1549
+ logger: options.logger,
1550
+ retryTimeoutMs: options.retryTimeoutMs,
1551
+ onClose: () => {
1552
+ if (this.conn) this.destroyConnection();
1553
+ }
2385
1554
  };
2386
- this.backup = new BackupClient(clientOptions);
2387
- this.commands = new CommandClient(clientOptions);
2388
- this.files = new FileClient(clientOptions);
2389
- this.processes = new ProcessClient(clientOptions);
2390
- this.ports = new PortClient(clientOptions);
2391
- this.git = new GitClient(clientOptions);
2392
- this.interpreter = new InterpreterClient(clientOptions);
2393
- this.utils = new UtilityClient(clientOptions);
2394
- this.watch = new WatchClient(clientOptions);
1555
+ this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
1556
+ this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
1557
+ this.logger = options.logger ?? createNoOpLogger();
1558
+ this.onActivity = options.onActivity;
1559
+ this.onSessionBusy = options.onSessionBusy;
1560
+ this.onSessionIdle = options.onSessionIdle;
2395
1561
  }
2396
1562
  /**
2397
- * Update the transport retry budget without recreating the client.
2398
- *
2399
- * In WebSocket mode a single shared transport is used, so one update covers
2400
- * every sub-client. In HTTP mode each sub-client owns its own transport, so
2401
- * all of them are updated individually.
1563
+ * Return the current connection, creating one when the client is disconnected.
1564
+ * Starts the busy-poll timer the first time a connection is materialized.
2402
1565
  */
2403
- setRetryTimeoutMs(ms) {
2404
- if (this.transport) this.transport.setRetryTimeoutMs(ms);
2405
- else {
2406
- this.backup.setRetryTimeoutMs(ms);
2407
- this.commands.setRetryTimeoutMs(ms);
2408
- this.files.setRetryTimeoutMs(ms);
2409
- this.processes.setRetryTimeoutMs(ms);
2410
- this.ports.setRetryTimeoutMs(ms);
2411
- this.git.setRetryTimeoutMs(ms);
2412
- this.interpreter.setRetryTimeoutMs(ms);
2413
- this.utils.setRetryTimeoutMs(ms);
2414
- this.watch.setRetryTimeoutMs(ms);
1566
+ getConnection() {
1567
+ if (!this.conn) {
1568
+ this.conn = new ContainerControlConnection(this.connOptions);
1569
+ this.startBusyPoll();
2415
1570
  }
1571
+ return this.conn;
2416
1572
  }
2417
1573
  /**
2418
- * Get the current transport mode
2419
- */
2420
- getTransportMode() {
2421
- return this.transport?.getMode() ?? "http";
2422
- }
2423
- /**
2424
- * Check if WebSocket is connected (only relevant in WebSocket mode)
2425
- */
2426
- isWebSocketConnected() {
2427
- return this.transport?.isConnected() ?? false;
2428
- }
2429
- /**
2430
- * Connect WebSocket transport (no-op in HTTP mode)
2431
- * Called automatically on first request, but can be called explicitly
2432
- * to establish connection upfront.
1574
+ * Called synchronously at the start of each RPC method invocation.
1575
+ * Renews the DO activity timeout so the sleepAfter alarm is pushed
1576
+ * forward before the container processes the call.
2433
1577
  */
2434
- async connect() {
2435
- if (this.transport) await this.transport.connect();
2436
- }
1578
+ renewActivity = () => {
1579
+ this.onActivity?.();
1580
+ };
2437
1581
  /**
2438
- * Disconnect WebSocket transport (no-op in HTTP mode)
2439
- * Should be called when the sandbox is destroyed.
2440
- */
2441
- disconnect() {
2442
- if (this.transport) this.transport.disconnect();
2443
- }
2444
- };
2445
- function createTunnelsNotImplemented() {
2446
- const message = "sandbox.tunnels.* requires the RPC transport. Enable it with transport: \"rpc\" in sandbox options.";
2447
- return new Proxy({}, { get() {
2448
- return () => {
2449
- throw new Error(message);
2450
- };
2451
- } });
2452
- }
2453
-
2454
- //#endregion
2455
- //#region ../shared/src/backup.ts
2456
- /**
2457
- * Absolute directory prefixes supported by backup and restore operations.
2458
- */
2459
- const BACKUP_ALLOWED_PREFIXES = [
2460
- "/workspace",
2461
- "/home",
2462
- "/tmp",
2463
- "/var/tmp",
2464
- "/app"
2465
- ];
2466
- function normalizeBackupExcludePattern(pattern) {
2467
- let normalized = pattern;
2468
- while (normalized.startsWith("**/")) normalized = normalized.slice(3);
2469
- while (normalized.includes("/**/")) normalized = normalized.replace(/\/\*\*\//g, "/");
2470
- if (normalized.endsWith("/**")) normalized = normalized.slice(0, -3);
2471
- if (!normalized || normalized === "**") return null;
2472
- return normalized;
2473
- }
2474
-
2475
- //#endregion
2476
- //#region ../shared/src/internal.ts
2477
- const DISABLE_SESSION_TOKEN = "__DISABLE_SESSION__";
2478
-
2479
- //#endregion
2480
- //#region src/container-control/connection.ts
2481
- const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
2482
- const DEFAULT_RETRY_TIMEOUT_MS = 12e4;
2483
- const MIN_TIME_FOR_RETRY_MS = 15e3;
2484
- /**
2485
- * Manages a capnweb WebSocket RPC session to the container.
2486
- *
2487
- * The RPC stub is created eagerly in the constructor using a deferred
2488
- * transport. Calls made before `connect()` completes are queued in the
2489
- * transport and flushed once the WebSocket is established.
2490
- */
2491
- var ContainerControlConnection = class {
2492
- stub;
2493
- session;
2494
- transport;
2495
- ws = null;
2496
- connected = false;
2497
- connectPromise = null;
2498
- containerStub;
2499
- port;
2500
- logger;
2501
- retryTimeoutMs;
2502
- onClose;
2503
- constructor(options) {
2504
- this.containerStub = options.stub;
2505
- this.port = options.port ?? 3e3;
2506
- this.logger = options.logger ?? createNoOpLogger();
2507
- this.retryTimeoutMs = options.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS;
2508
- this.onClose = options.onClose;
2509
- this.transport = new DeferredTransport();
2510
- this.session = new RpcSession(this.transport, options.localMain);
2511
- this.stub = this.session.getRemoteMain();
2512
- }
2513
- /**
2514
- * Get the typed RPC stub.
1582
+ * Sample `getStats()` and update busy/idle state. While busy, renews the
1583
+ * activity timeout each tick so an in-flight stream keeps pushing the
1584
+ * sleepAfter deadline forward. On the busy → idle edge, fires
1585
+ * `onSessionIdle` and schedules the WebSocket disconnect.
2515
1586
  *
2516
- * The stub is available immediately calls made before connect()
2517
- * completes are queued in the deferred transport and flushed once
2518
- * the WebSocket is established.
2519
- */
2520
- rpc() {
2521
- if (!this.connected && !this.connectPromise) this.connect().catch(() => {});
2522
- return this.stub;
2523
- }
2524
- /**
2525
- * Return capnweb session statistics. The `imports` and `exports` counts
2526
- * reflect all in-flight RPC calls, streams, and peer-held references.
2527
- * An idle session has imports <= 1 && exports <= 1 (the bootstrap stubs).
2528
- */
2529
- getStats() {
2530
- return this.session.getStats();
2531
- }
2532
- isConnected() {
2533
- return this.connected;
2534
- }
2535
- async connect() {
2536
- if (this.connected) return;
2537
- if (this.connectPromise) return this.connectPromise;
2538
- this.connectPromise = this.doConnect();
2539
- try {
2540
- await this.connectPromise;
2541
- } finally {
2542
- this.connectPromise = null;
2543
- }
2544
- }
2545
- disconnect() {
2546
- try {
2547
- this.stub[Symbol.dispose]?.();
2548
- } catch {}
2549
- if (this.ws) {
2550
- this.ws.removeEventListener("close", this.onWebSocketClose);
2551
- this.ws.removeEventListener("error", this.onWebSocketError);
2552
- try {
2553
- this.ws.close();
2554
- } catch {}
2555
- this.ws = null;
2556
- }
2557
- this.connected = false;
2558
- this.connectPromise = null;
2559
- }
2560
- /**
2561
- * Update the upgrade retry budget without recreating the connection. Takes
2562
- * effect on the next `connect()`; an in-flight connect uses the value
2563
- * captured at start. Mirrors `WebSocketTransport.setRetryTimeoutMs`.
2564
- */
2565
- setRetryTimeoutMs(ms) {
2566
- this.retryTimeoutMs = ms;
2567
- }
2568
- /**
2569
- * Run the owner-provided `onClose` callback exactly once per call,
2570
- * swallowing any errors so a buggy listener can't keep the connection
2571
- * object in a half-torn-down state.
2572
- */
2573
- fireOnClose() {
2574
- if (!this.onClose) return;
2575
- try {
2576
- this.onClose();
2577
- } catch (err) {
2578
- this.logger.warn("ContainerControlConnection onClose handler threw", { error: err instanceof Error ? err.message : String(err) });
2579
- }
2580
- }
2581
- /**
2582
- * WebSocket `close` listener. Defined as a bound arrow field so the
2583
- * same reference can be passed to both `addEventListener` and
2584
- * `removeEventListener` — a fresh anonymous lambda would silently
2585
- * fail to unbind.
2586
- */
2587
- onWebSocketClose = () => {
2588
- const wasConnected = this.connected;
2589
- this.connected = false;
2590
- this.ws = null;
2591
- this.logger.debug("ContainerControlConnection WebSocket closed");
2592
- if (wasConnected) this.fireOnClose();
2593
- };
2594
- /**
2595
- * WebSocket `error` listener. Same field-form rationale as
2596
- * {@link onWebSocketClose}.
1587
+ * If the WebSocket has dropped underneath us (container crash, network
1588
+ * blip) we tear the connection down here. `destroyConnection()` fires
1589
+ * `onSessionIdle` if we were busy, so the DO's inflight counter doesn't
1590
+ * stay pinned forever waiting for a peer that's never going to reply.
2597
1591
  */
2598
- onWebSocketError = () => {
2599
- const wasConnected = this.connected;
2600
- this.connected = false;
2601
- this.ws = null;
2602
- if (wasConnected) this.fireOnClose();
1592
+ pollBusyState = () => {
1593
+ const conn = this.conn;
1594
+ if (!conn) return;
1595
+ if (!conn.isConnected()) return;
1596
+ const { imports, exports } = conn.getStats();
1597
+ if (imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD) {
1598
+ if (!this.busy) {
1599
+ this.busy = true;
1600
+ this.onSessionBusy?.();
1601
+ }
1602
+ this.onActivity?.();
1603
+ this.clearIdleTimer();
1604
+ } else if (this.busy) {
1605
+ this.busy = false;
1606
+ this.onSessionIdle?.();
1607
+ this.scheduleIdleDisconnect();
1608
+ } else if (!this.idleTimer) this.scheduleIdleDisconnect();
2603
1609
  };
2604
- async doConnect() {
2605
- try {
2606
- const response = await this.fetchUpgradeWithRetry();
2607
- if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
2608
- const ws = response.webSocket;
2609
- if (!ws) throw new Error("No WebSocket in upgrade response");
2610
- ws.accept();
2611
- ws.addEventListener("close", this.onWebSocketClose);
2612
- ws.addEventListener("error", this.onWebSocketError);
2613
- this.ws = ws;
2614
- this.transport.activate(ws);
2615
- this.connected = true;
2616
- this.logger.debug("ContainerControlConnection established", { port: this.port });
2617
- } catch (error) {
2618
- this.connected = false;
2619
- this.transport.abort(error);
2620
- this.logger.error("ContainerControlConnection failed", error instanceof Error ? error : new Error(String(error)));
2621
- throw error;
2622
- }
2623
- }
2624
- /**
2625
- * Issue WebSocket upgrade fetches, retrying transient control-plane
2626
- * unavailability responses until either the upgrade succeeds, a
2627
- * non-retryable status is returned, or the retry budget runs out.
2628
- */
2629
- async fetchUpgradeWithRetry() {
2630
- return fetchWithResponseRetry(() => this.fetchUpgradeAttempt(), {
2631
- retryTimeoutMs: this.retryTimeoutMs,
2632
- minTimeForRetryMs: MIN_TIME_FOR_RETRY_MS,
2633
- logger: this.logger,
2634
- retryLogMessage: "ContainerControlConnection upgrade returned retryable status, retrying",
2635
- shouldRetry: isRetryableWebSocketUpgradeResponse
2636
- });
1610
+ startBusyPoll() {
1611
+ if (this.busyPollTimer) return;
1612
+ this.busyPollTimer = setInterval(this.pollBusyState, this.busyPollIntervalMs);
2637
1613
  }
2638
- /**
2639
- * Single WebSocket-upgrade fetch attempt. Owns its own AbortController so
2640
- * each retry gets a fresh per-attempt connect timeout independent of the
2641
- * total retry budget.
2642
- */
2643
- async fetchUpgradeAttempt() {
2644
- const controller = new AbortController();
2645
- const timeout = setTimeout(() => controller.abort(), DEFAULT_CONNECT_TIMEOUT_MS);
2646
- try {
2647
- const url = `http://localhost:${this.port}/rpc`;
2648
- const request = new Request(url, {
2649
- headers: {
2650
- Upgrade: "websocket",
2651
- Connection: "Upgrade"
2652
- },
2653
- signal: controller.signal
2654
- });
2655
- return await this.containerStub.fetch(request);
2656
- } finally {
2657
- clearTimeout(timeout);
1614
+ stopBusyPoll() {
1615
+ if (this.busyPollTimer) {
1616
+ clearInterval(this.busyPollTimer);
1617
+ this.busyPollTimer = null;
2658
1618
  }
2659
- }
2660
- };
2661
- /**
2662
- * RPC transport that queues sends and blocks receives until a WebSocket
2663
- * is provided via `activate()`. Allows the RPC stub to be created before
2664
- * the connection is established — queued calls flush automatically.
2665
- */
2666
- var DeferredTransport = class {
2667
- #ws = null;
2668
- #sendQueue = [];
2669
- #receiveQueue = [];
2670
- #receiveResolver;
2671
- #receiveRejecter;
2672
- #error;
2673
- activate(ws) {
2674
- this.#ws = ws;
2675
- ws.addEventListener("message", (event) => {
2676
- if (this.#error) return;
2677
- if (typeof event.data === "string") if (this.#receiveResolver) {
2678
- this.#receiveResolver(event.data);
2679
- this.#receiveResolver = void 0;
2680
- this.#receiveRejecter = void 0;
2681
- } else this.#receiveQueue.push(event.data);
2682
- else this.#fail(/* @__PURE__ */ new TypeError("Received non-string message from WebSocket."));
2683
- });
2684
- ws.addEventListener("close", (event) => {
2685
- this.#fail(/* @__PURE__ */ new Error(`Peer closed WebSocket: ${event.code} ${event.reason}`));
2686
- });
2687
- ws.addEventListener("error", () => {
2688
- this.#fail(/* @__PURE__ */ new Error("WebSocket connection failed."));
2689
- });
2690
- for (const msg of this.#sendQueue) ws.send(msg);
2691
- this.#sendQueue = [];
2692
- }
2693
- async send(message) {
2694
- if (this.#ws) this.#ws.send(message);
2695
- else this.#sendQueue.push(message);
2696
- }
2697
- async receive() {
2698
- if (this.#receiveQueue.length > 0) return this.#receiveQueue.shift();
2699
- if (this.#error) throw this.#error;
2700
- return new Promise((resolve, reject) => {
2701
- this.#receiveResolver = resolve;
2702
- this.#receiveRejecter = reject;
2703
- });
2704
- }
2705
- abort(reason) {
2706
- this.#fail(reason instanceof Error ? reason : new Error(String(reason)));
2707
- if (this.#ws) {
2708
- const message = reason instanceof Error ? reason.message : String(reason);
2709
- this.#ws.close(3e3, message);
2710
- }
2711
- }
2712
- #fail(err) {
2713
- if (this.#error) return;
2714
- this.#error = err;
2715
- this.#receiveRejecter?.(err);
2716
- this.#receiveResolver = void 0;
2717
- this.#receiveRejecter = void 0;
2718
- }
2719
- };
2720
-
2721
- //#endregion
2722
- //#region src/container-control/client.ts
2723
- /** Close the idle capnweb WebSocket promptly so the DO can sleep. */
2724
- const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
2725
- /**
2726
- * How often the busy/idle poller samples `getStats()`.
2727
- *
2728
- * Sets two worst-case bounds:
2729
- *
2730
- * 1. **Idle-detection lag.** Time between the session going idle on
2731
- * the wire and the DO observing it (and arming the disconnect).
2732
- * Bounded by `pollInterval`.
2733
- * 2. **Activity-renewal lag while busy.** While a stream is active we
2734
- * renew the DO's activity timeout once per tick. The alarm could
2735
- * fire as late as `sleepAfter` after the last renew, so the
2736
- * effective margin against a mid-stream sleep is
2737
- * `sleepAfter - pollInterval`.
2738
- *
2739
- * **Invariant: `pollInterval` must be comfortably less than the
2740
- * smallest configurable `sleepAfter`.** Aim for at least 2-3× headroom.
2741
- * The minimum `sleepAfter` exercised by the E2E suite is 3s, so 1s gives
2742
- * 3× margin and at least two renewals during a 3s window. If a smaller
2743
- * `sleepAfter` is ever supported, drop this proportionally.
2744
- */
2745
- const BUSY_POLL_INTERVAL_MS = 1e3;
2746
- /**
2747
- * Baseline getStats() values for an idle session. The bootstrap stub on each
2748
- * side accounts for 1 import and 1 export.
2749
- */
2750
- const IDLE_IMPORT_THRESHOLD = 1;
2751
- const IDLE_EXPORT_THRESHOLD = 1;
2752
- /**
2753
- * Translate a capnweb-propagated error into a typed SandboxError.
2754
- *
2755
- * Two wire formats are supported for backward compatibility with older
2756
- * container images:
2757
- *
2758
- * 1. Propagated error properties (capnweb >= 0.8.0). The container throws a
2759
- * `ServiceError`-shaped object with own enumerable `code` and `details`
2760
- * properties. capnweb walks `Object.keys()` and reconstructs those fields
2761
- * on the SDK side.
2762
- * 2. Legacy JSON-encoded message. Older containers encoded the structured
2763
- * payload as a JSON string in `error.message`.
2764
- *
2765
- * The JSON-fallback branch can be removed once all older container images are
2766
- * no longer in service.
2767
- */
2768
- function translateRPCError(error) {
2769
- if (error instanceof Error) {
2770
- const propagated = error;
2771
- if (typeof propagated.code === "string" && Object.hasOwn(ErrorCode, propagated.code)) {
2772
- const code = propagated.code;
2773
- const context = propagated.details && typeof propagated.details === "object" ? propagated.details : {};
2774
- throw createErrorFromResponse({
2775
- code,
2776
- message: error.message,
2777
- context,
2778
- httpStatus: getHttpStatus(code),
2779
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2780
- });
2781
- }
2782
- let payload;
2783
- try {
2784
- payload = JSON.parse(error.message);
2785
- } catch {}
2786
- if (payload && typeof payload.code === "string" && typeof payload.message === "string") throw createErrorFromResponse({
2787
- code: payload.code,
2788
- message: payload.message,
2789
- context: payload.context ?? {},
2790
- httpStatus: getHttpStatus(payload.code),
2791
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2792
- });
2793
- throw createErrorFromResponse(buildTransportErrorResponse(error), { cause: error });
2794
- }
2795
- throw createErrorFromResponse(buildTransportErrorResponse(new Error(String(error))), { cause: error });
2796
- }
2797
- /**
2798
- * Inspect a transport-level Error's message and produce the ErrorResponse
2799
- * that becomes an RPCTransportError. Pattern strings are pinned to the exact
2800
- * messages emitted by capnweb's WebSocketTransport (see capnweb's
2801
- * src/websocket.ts) and our DeferredTransport in container-control/connection.ts —
2802
- * notably the trailing period in `WebSocket connection failed.` matches
2803
- * capnweb verbatim. The DeferredTransport tests in
2804
- * tests/container-connection.test.ts pin the literal strings.
2805
- */
2806
- function buildTransportErrorResponse(error) {
2807
- const message = error.message;
2808
- const errorName = error.name;
2809
- let kind = "unknown";
2810
- let closeCode;
2811
- let closeReason;
2812
- if (errorName === "TypeError") kind = "invalid_frame";
2813
- else if (errorName === "SyntaxError") kind = "protocol_error";
2814
- else {
2815
- const peerCloseMatch = message.match(/^Peer closed WebSocket: (\d+) ?(.*)$/);
2816
- if (peerCloseMatch) {
2817
- kind = "peer_closed";
2818
- closeCode = Number(peerCloseMatch[1]);
2819
- closeReason = peerCloseMatch[2] || void 0;
2820
- } else if (message === "WebSocket connection failed.") kind = "connection_failed";
2821
- else if (message.startsWith("WebSocket upgrade failed")) kind = "upgrade_failed";
2822
- else if (message === "No WebSocket in upgrade response") kind = "upgrade_failed";
2823
- else if (message === "RPC session was shut down by disposing the main stub" || message === "RPC was canceled because the RpcPromise was disposed.") kind = "session_disposed";
2824
- }
2825
- const context = {
2826
- kind,
2827
- originalMessage: message,
2828
- errorName,
2829
- ...closeCode !== void 0 ? { closeCode } : {},
2830
- ...closeReason !== void 0 ? { closeReason } : {}
2831
- };
2832
- return {
2833
- code: ErrorCode.RPC_TRANSPORT_ERROR,
2834
- message,
2835
- context,
2836
- httpStatus: getHttpStatus(ErrorCode.RPC_TRANSPORT_ERROR),
2837
- suggestion: getSuggestion(ErrorCode.RPC_TRANSPORT_ERROR, context),
2838
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2839
- };
2840
- }
2841
- /**
2842
- * Wrap a capnweb RPC stub so that every method call translates errors
2843
- * from the JSON wire format into typed SandboxError instances and signals
2844
- * activity at call start.
2845
- *
2846
- * `onCallStarted` fires synchronously when an RPC method is invoked. The
2847
- * ContainerControlClient uses this to renew the DO's activity timeout
2848
- * immediately, so even a call that completes entirely between two
2849
- * busy-poll ticks still pushes the sleepAfter deadline forward.
2850
- *
2851
- * Note: there is no `onCallSettled` hook. A method whose returned promise
2852
- * resolves with a `ReadableStream` is *not* finished when the promise
2853
- * settles — capnweb keeps the export alive until the stream ends. The
2854
- * busy/idle poll on `getStats()` is the source of truth for that.
2855
- */
2856
- function wrapStub(stub, onCallStarted) {
2857
- return new Proxy(stub, { get(target, prop, receiver) {
2858
- const value = Reflect.get(target, prop, receiver);
2859
- if (typeof value !== "function") return value;
2860
- return (...args) => {
2861
- onCallStarted();
2862
- try {
2863
- const result = Reflect.apply(value, target, args);
2864
- if (result != null && typeof result.then === "function") return result.catch(translateRPCError);
2865
- return result;
2866
- } catch (err) {
2867
- translateRPCError(err);
2868
- }
2869
- };
2870
- } });
2871
- }
2872
- /**
2873
- * SandboxClient-compatible facade backed by direct capnweb RPC.
2874
- *
2875
- * All operations call the container's SandboxAPI control interface directly
2876
- * over capnweb, bypassing the HTTP handler/router layer entirely.
2877
- *
2878
- * Manages its own WebSocket lifecycle: a fresh `ContainerControlConnection` is
2879
- * created on demand and torn down after `idleDisconnectMs` of inactivity.
2880
- * Busy/idle detection relies on `RpcSession.getStats()` which tracks all
2881
- * in-flight RPC calls and stream exports — including long-lived streaming
2882
- * RPCs that would be invisible to a simple per-call request counter (see
2883
- * the file-level comment for the full rationale).
2884
- */
2885
- var ContainerControlClient = class {
2886
- connOptions;
2887
- idleDisconnectMs;
2888
- busyPollIntervalMs;
2889
- logger;
2890
- onActivity;
2891
- onSessionBusy;
2892
- onSessionIdle;
2893
- conn = null;
2894
- idleTimer = null;
2895
- busyPollTimer = null;
2896
- /** Tracks whether we currently believe the session is busy. */
2897
- busy = false;
2898
- constructor(options) {
2899
- this.connOptions = {
2900
- stub: options.stub,
2901
- port: options.port,
2902
- localMain: options.localMain,
2903
- logger: options.logger,
2904
- retryTimeoutMs: options.retryTimeoutMs,
2905
- onClose: () => {
2906
- if (this.conn) this.destroyConnection();
2907
- }
2908
- };
2909
- this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
2910
- this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
2911
- this.logger = options.logger ?? createNoOpLogger();
2912
- this.onActivity = options.onActivity;
2913
- this.onSessionBusy = options.onSessionBusy;
2914
- this.onSessionIdle = options.onSessionIdle;
2915
- }
2916
- /**
2917
- * Return the current connection, creating one when the client is disconnected.
2918
- * Starts the busy-poll timer the first time a connection is materialized.
2919
- */
2920
- getConnection() {
2921
- if (!this.conn) {
2922
- this.conn = new ContainerControlConnection(this.connOptions);
2923
- this.startBusyPoll();
2924
- }
2925
- return this.conn;
2926
- }
2927
- /**
2928
- * Called synchronously at the start of each RPC method invocation.
2929
- * Renews the DO activity timeout so the sleepAfter alarm is pushed
2930
- * forward before the container processes the call.
2931
- */
2932
- renewActivity = () => {
2933
- this.onActivity?.();
2934
- };
2935
- /**
2936
- * Sample `getStats()` and update busy/idle state. While busy, renews the
2937
- * activity timeout each tick so an in-flight stream keeps pushing the
2938
- * sleepAfter deadline forward. On the busy → idle edge, fires
2939
- * `onSessionIdle` and schedules the WebSocket disconnect.
2940
- *
2941
- * If the WebSocket has dropped underneath us (container crash, network
2942
- * blip) we tear the connection down here. `destroyConnection()` fires
2943
- * `onSessionIdle` if we were busy, so the DO's inflight counter doesn't
2944
- * stay pinned forever waiting for a peer that's never going to reply.
2945
- */
2946
- pollBusyState = () => {
2947
- const conn = this.conn;
2948
- if (!conn) return;
2949
- if (!conn.isConnected()) return;
2950
- const { imports, exports } = conn.getStats();
2951
- if (imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD) {
2952
- if (!this.busy) {
2953
- this.busy = true;
2954
- this.onSessionBusy?.();
2955
- }
2956
- this.onActivity?.();
2957
- this.clearIdleTimer();
2958
- } else if (this.busy) {
2959
- this.busy = false;
2960
- this.onSessionIdle?.();
2961
- this.scheduleIdleDisconnect();
2962
- } else if (!this.idleTimer) this.scheduleIdleDisconnect();
2963
- };
2964
- startBusyPoll() {
2965
- if (this.busyPollTimer) return;
2966
- this.busyPollTimer = setInterval(this.pollBusyState, this.busyPollIntervalMs);
2967
- }
2968
- stopBusyPoll() {
2969
- if (this.busyPollTimer) {
2970
- clearInterval(this.busyPollTimer);
2971
- this.busyPollTimer = null;
2972
- }
2973
- }
2974
- scheduleIdleDisconnect() {
2975
- this.clearIdleTimer();
2976
- this.idleTimer = setTimeout(() => {
2977
- this.idleTimer = null;
2978
- const conn = this.conn;
2979
- if (!conn || !conn.isConnected()) return;
2980
- const { imports, exports } = conn.getStats();
2981
- if (imports <= IDLE_IMPORT_THRESHOLD && exports <= IDLE_EXPORT_THRESHOLD) {
2982
- this.logger.debug("Disconnecting idle RPC connection");
2983
- this.destroyConnection();
2984
- }
2985
- }, this.idleDisconnectMs);
2986
- }
2987
- clearIdleTimer() {
2988
- if (this.idleTimer) {
2989
- clearTimeout(this.idleTimer);
2990
- this.idleTimer = null;
2991
- }
2992
- }
2993
- destroyConnection() {
2994
- this.stopBusyPoll();
2995
- this.clearIdleTimer();
2996
- if (this.busy) {
2997
- this.busy = false;
2998
- this.onSessionIdle?.();
2999
- }
3000
- if (this.conn) {
3001
- this.conn.disconnect();
3002
- this.conn = null;
3003
- }
3004
- }
3005
- get commands() {
3006
- return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
3007
- }
3008
- get files() {
3009
- return wrapStub(this.getConnection().rpc().files, this.renewActivity);
3010
- }
3011
- get processes() {
3012
- return wrapStub(this.getConnection().rpc().processes, this.renewActivity);
3013
- }
3014
- get ports() {
3015
- return wrapStub(this.getConnection().rpc().ports, this.renewActivity);
3016
- }
3017
- get git() {
3018
- return wrapStub(this.getConnection().rpc().git, this.renewActivity);
3019
- }
3020
- get utils() {
3021
- return wrapStub(this.getConnection().rpc().utils, this.renewActivity);
3022
- }
3023
- get backup() {
3024
- return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
3025
- }
3026
- get watch() {
3027
- return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
3028
- }
3029
- get tunnels() {
3030
- return wrapStub(this.getConnection().rpc().tunnels, this.renewActivity);
3031
- }
3032
- get interpreter() {
3033
- return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
3034
- }
3035
- /**
3036
- * Update the upgrade retry budget. Applies to the current connection
3037
- * (if any) and is remembered for any future connections created after the
3038
- * client is torn down and reconnected.
3039
- */
3040
- setRetryTimeoutMs(ms) {
3041
- this.connOptions.retryTimeoutMs = ms;
3042
- this.conn?.setRetryTimeoutMs(ms);
3043
- }
3044
- getTransportMode() {
3045
- return "rpc";
3046
- }
3047
- isWebSocketConnected() {
3048
- return this.conn?.isConnected() ?? false;
3049
- }
3050
- async connect() {
3051
- await this.getConnection().connect();
3052
- }
3053
- disconnect() {
3054
- this.destroyConnection();
3055
- }
3056
- };
3057
-
3058
- //#endregion
3059
- //#region src/current-runtime-identity.ts
3060
- const CURRENT_RUNTIME_IDENTITY_STORAGE_KEY = "currentRuntimeIdentity";
3061
- var RuntimeIdentityInactiveError = class extends Error {
3062
- constructor() {
3063
- super("Runtime identity is no longer active");
3064
- this.name = "RuntimeIdentityInactiveError";
3065
- }
3066
- };
3067
- var RuntimeIdentity = class {
3068
- id;
3069
- constructor(record) {
3070
- this.id = record.id;
3071
- }
3072
- owns(record) {
3073
- return record.runtimeIdentityID === this.id;
3074
- }
3075
- scope(value) {
3076
- return {
3077
- ...value,
3078
- runtimeIdentityID: this.id
3079
- };
3080
- }
3081
- };
3082
- var CurrentRuntimeIdentity = class {
3083
- /**
3084
- * Runtime identity is stored in Durable Object storage so a reconstructed DO
3085
- * can still recognize the live container runtime it owns. In-memory state is
3086
- * only a cache and cannot define runtime-scoped correctness.
3087
- */
3088
- constructor(storage, getContainerState, isContainerRunning) {
3089
- this.storage = storage;
3090
- this.getContainerState = getContainerState;
3091
- this.isContainerRunning = isContainerRunning;
3092
- }
3093
- async get() {
3094
- const status = await this.getStatus();
3095
- return status.status === "active" ? status.runtime : null;
3096
- }
3097
- async getStatus() {
3098
- const state = await this.getContainerState();
3099
- if (state.status !== "healthy") return {
3100
- status: "inactive",
3101
- reason: "runtime-not-healthy",
3102
- containerStatus: state.status
3103
- };
3104
- if (!this.isContainerRunning()) return {
3105
- status: "inactive",
3106
- reason: "runtime-not-running",
3107
- containerStatus: state.status
3108
- };
3109
- const runtime = await this.getStored();
3110
- if (!runtime) return {
3111
- status: "inactive",
3112
- reason: "missing-runtime-id",
3113
- containerStatus: state.status
3114
- };
3115
- return {
3116
- status: "active",
3117
- runtime,
3118
- containerStatus: state.status
3119
- };
3120
- }
3121
- async getStored(storage = this.storage) {
3122
- const record = await storage.get(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY) ?? null;
3123
- return record ? new RuntimeIdentity(record) : null;
3124
- }
3125
- async markStarted() {
3126
- const record = { id: crypto.randomUUID() };
3127
- await this.storage.put(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY, record);
3128
- return new RuntimeIdentity(record);
3129
- }
3130
- async clear() {
3131
- await this.storage.delete(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY);
3132
- }
3133
- async isActive(runtime) {
3134
- return (await this.get())?.id === runtime.id;
3135
- }
3136
- async assertActive(runtime) {
3137
- if (!await this.isActive(runtime)) throw new RuntimeIdentityInactiveError();
3138
- }
3139
- };
3140
-
3141
- //#endregion
3142
- //#region src/file-stream.ts
3143
- /**
3144
- * Parse SSE (Server-Sent Events) lines from a stream
3145
- */
3146
- async function* parseSSE(stream) {
3147
- const reader = stream.getReader();
3148
- const decoder = new TextDecoder();
3149
- let buffer = "";
3150
- let currentEvent = { data: [] };
3151
- try {
3152
- while (true) {
3153
- const { done, value } = await reader.read();
3154
- if (done) break;
3155
- buffer += decoder.decode(value, { stream: true });
3156
- const parsed = parseSSEFrames(buffer, currentEvent);
3157
- buffer = parsed.remaining;
3158
- currentEvent = parsed.currentEvent;
3159
- for (const frame of parsed.events) try {
3160
- yield JSON.parse(frame.data);
3161
- } catch {}
3162
- }
3163
- const finalParsed = parseSSEFrames(`${buffer}\n\n`, currentEvent);
3164
- for (const frame of finalParsed.events) try {
3165
- yield JSON.parse(frame.data);
3166
- } catch {}
3167
- } finally {
3168
- try {
3169
- await reader.cancel();
3170
- } catch {}
3171
- reader.releaseLock();
3172
- }
3173
- }
3174
- /**
3175
- * Stream a file from the sandbox with automatic base64 decoding for binary files
3176
- *
3177
- * @param stream - The ReadableStream from readFileStream()
3178
- * @returns AsyncGenerator that yields FileChunk (string for text, Uint8Array for binary)
3179
- *
3180
- * @example
3181
- * ```ts
3182
- * const stream = await sandbox.readFileStream('/path/to/file.png');
3183
- * for await (const chunk of streamFile(stream)) {
3184
- * if (chunk instanceof Uint8Array) {
3185
- * // Binary chunk
3186
- * console.log('Binary chunk:', chunk.length, 'bytes');
3187
- * } else {
3188
- * // Text chunk
3189
- * console.log('Text chunk:', chunk);
3190
- * }
3191
- * }
3192
- * ```
3193
- */
3194
- async function* streamFile(stream) {
3195
- let metadata = null;
3196
- for await (const event of parseSSE(stream)) switch (event.type) {
3197
- case "metadata":
3198
- metadata = {
3199
- mimeType: event.mimeType,
3200
- size: event.size,
3201
- isBinary: event.isBinary,
3202
- encoding: event.encoding
3203
- };
3204
- break;
3205
- case "chunk":
3206
- if (!metadata) throw new Error("Received chunk before metadata");
3207
- if (metadata.isBinary && metadata.encoding === "base64") {
3208
- const binaryString = atob(event.data);
3209
- const bytes = new Uint8Array(binaryString.length);
3210
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
3211
- yield bytes;
3212
- } else yield event.data;
3213
- break;
3214
- case "complete":
3215
- if (!metadata) throw new Error("Stream completed without metadata");
3216
- return metadata;
3217
- case "error": throw new Error(`File streaming error: ${event.error}`);
3218
- }
3219
- throw new Error("Stream ended unexpectedly");
3220
- }
3221
- /**
3222
- * Collect an entire file into memory from a stream
3223
- *
3224
- * @param stream - The ReadableStream from readFileStream()
3225
- * @returns Object containing the file content and metadata
3226
- *
3227
- * @example
3228
- * ```ts
3229
- * const stream = await sandbox.readFileStream('/path/to/file.txt');
3230
- * const { content, metadata } = await collectFile(stream);
3231
- * console.log('Content:', content);
3232
- * console.log('MIME type:', metadata.mimeType);
3233
- * ```
3234
- */
3235
- async function collectFile(stream) {
3236
- const chunks = [];
3237
- const generator = streamFile(stream);
3238
- let result = await generator.next();
3239
- while (!result.done) {
3240
- chunks.push(result.value);
3241
- result = await generator.next();
3242
- }
3243
- const metadata = result.value;
3244
- if (!metadata) throw new Error("Failed to get file metadata");
3245
- if (metadata.isBinary) {
3246
- const totalLength = chunks.reduce((sum, chunk) => sum + (chunk instanceof Uint8Array ? chunk.length : 0), 0);
3247
- const combined = new Uint8Array(totalLength);
3248
- let offset = 0;
3249
- for (const chunk of chunks) if (chunk instanceof Uint8Array) {
3250
- combined.set(chunk, offset);
3251
- offset += chunk.length;
3252
- }
3253
- return {
3254
- content: combined,
3255
- metadata
3256
- };
3257
- } else return {
3258
- content: chunks.filter((c) => typeof c === "string").join(""),
3259
- metadata
3260
- };
3261
- }
3262
-
3263
- //#endregion
3264
- //#region src/security.ts
3265
- /**
3266
- * Security utilities for URL construction and input validation
3267
- *
3268
- * This module contains critical security functions to prevent:
3269
- * - URL injection attacks
3270
- * - SSRF (Server-Side Request Forgery) attacks
3271
- * - DNS rebinding attacks
3272
- * - Host header injection
3273
- * - Open redirect vulnerabilities
3274
- */
3275
- var SandboxSecurityError = class extends Error {
3276
- constructor(message, code) {
3277
- super(message);
3278
- this.code = code;
3279
- this.name = "SandboxSecurityError";
3280
- }
3281
- };
3282
- /**
3283
- * Validates port numbers for sandbox services.
3284
- *
3285
- * Rules:
3286
- * - Range: 1024-65535 (privileged ports require root, which containers don't have)
3287
- * - Reserved: 3000 (sandbox control plane)
3288
- */
3289
- function validatePort(port) {
3290
- if (!Number.isInteger(port)) return false;
3291
- if (port < 1024 || port > 65535) return false;
3292
- if ([3e3].includes(port)) return false;
3293
- return true;
3294
- }
3295
- /**
3296
- * Sanitizes and validates sandbox IDs for DNS compliance and security
3297
- * Only enforces critical requirements - allows maximum developer flexibility
3298
- */
3299
- function sanitizeSandboxId(id) {
3300
- if (!id || id.length > 63) throw new SandboxSecurityError("Sandbox ID must be 1-63 characters long.", "INVALID_SANDBOX_ID_LENGTH");
3301
- if (id.startsWith("-") || id.endsWith("-")) throw new SandboxSecurityError("Sandbox ID cannot start or end with hyphens (DNS requirement).", "INVALID_SANDBOX_ID_HYPHENS");
3302
- const reservedNames = [
3303
- "www",
3304
- "api",
3305
- "admin",
3306
- "root",
3307
- "system",
3308
- "cloudflare",
3309
- "workers"
3310
- ];
3311
- const lowerCaseId = id.toLowerCase();
3312
- if (reservedNames.includes(lowerCaseId)) throw new SandboxSecurityError(`Reserved sandbox ID '${id}' is not allowed.`, "RESERVED_SANDBOX_ID");
3313
- return id;
3314
- }
3315
- /**
3316
- * Validates language for code interpreter
3317
- * Only allows supported languages
3318
- */
3319
- function validateLanguage(language) {
3320
- if (!language) return;
3321
- const supportedLanguages = [
3322
- "python",
3323
- "python3",
3324
- "javascript",
3325
- "js",
3326
- "node",
3327
- "typescript",
3328
- "ts"
3329
- ];
3330
- const normalized = language.toLowerCase();
3331
- if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
3332
- }
3333
- /**
3334
- * Validates a single DNS label for use as a Cloudflare Tunnel hostname.
3335
- *
3336
- * Used by `sandbox.tunnels.get(port, { name })` to reject obviously-bad
3337
- * input client-side before any network call. Whether the chosen label is
3338
- * actually available under the configured zone is left to the Cloudflare
3339
- * API (returned as a typed error).
3340
- *
3341
- * Rules:
3342
- * - 1–63 characters
3343
- * - Lowercase letters, digits, and internal hyphens only
3344
- * - No leading or trailing hyphen
3345
- * - No dots — multi-label hostnames need a delegated subdomain zone or
3346
- * Advanced Certificate Manager, which are out of scope for this
3347
- * feature. Universal SSL only covers `<label>.<zone>`.
3348
- *
3349
- * Throws `SandboxSecurityError` on any violation. Designed to be called
3350
- * before any other tunnel work so callers see a fast, deterministic
3351
- * failure.
3352
- */
3353
- const TUNNEL_NAME_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
3354
- function validateTunnelName(name) {
3355
- if (typeof name !== "string") throw new SandboxSecurityError(`Tunnel name must be a string. Received: ${typeof name}`, "INVALID_TUNNEL_NAME");
3356
- if (name.length === 0 || name.length > 63) throw new SandboxSecurityError(`Tunnel name '${name}' must be 1–63 characters long.`, "INVALID_TUNNEL_NAME_LENGTH");
3357
- if (!TUNNEL_NAME_REGEX.test(name)) throw new SandboxSecurityError(`Tunnel name '${name}' is not a valid DNS label. Use lowercase letters, digits, and internal hyphens only (no dots, no leading/trailing hyphens).`, "INVALID_TUNNEL_NAME_FORMAT");
3358
- }
3359
-
3360
- //#endregion
3361
- //#region src/interpreter.ts
3362
- var CodeInterpreter = class {
3363
- getInterpreterClient;
3364
- contexts = /* @__PURE__ */ new Map();
3365
- constructor(interpreterClient) {
3366
- this.getInterpreterClient = typeof interpreterClient === "function" ? interpreterClient : () => interpreterClient;
3367
- }
3368
- /**
3369
- * Create a new code execution context
3370
- */
3371
- async createCodeContext(options = {}) {
3372
- validateLanguage(options.language);
3373
- const context = await this.getInterpreterClient().createCodeContext(options);
3374
- this.contexts.set(context.id, context);
3375
- return context;
3376
- }
3377
- /**
3378
- * Run code with optional context
3379
- */
3380
- async runCode(code, options = {}) {
3381
- let context = options.context;
3382
- if (!context) {
3383
- const language = options.language || "python";
3384
- context = await this.getOrCreateDefaultContext(language);
3385
- }
3386
- const execution = new Execution(code, context);
3387
- await this.getInterpreterClient().runCodeStream(context.id, code, options.language, {
3388
- onStdout: (output) => {
3389
- execution.logs.stdout.push(output.text);
3390
- if (options.onStdout) return options.onStdout(output);
3391
- },
3392
- onStderr: (output) => {
3393
- execution.logs.stderr.push(output.text);
3394
- if (options.onStderr) return options.onStderr(output);
3395
- },
3396
- onResult: async (result) => {
3397
- execution.results.push(new ResultImpl(result));
3398
- if (options.onResult) return options.onResult(result);
3399
- },
3400
- onError: (error) => {
3401
- execution.error = error;
3402
- if (options.onError) return options.onError(error);
1619
+ }
1620
+ scheduleIdleDisconnect() {
1621
+ this.clearIdleTimer();
1622
+ this.idleTimer = setTimeout(() => {
1623
+ this.idleTimer = null;
1624
+ const conn = this.conn;
1625
+ if (!conn || !conn.isConnected()) return;
1626
+ const { imports, exports } = conn.getStats();
1627
+ if (imports <= IDLE_IMPORT_THRESHOLD && exports <= IDLE_EXPORT_THRESHOLD) {
1628
+ this.logger.debug("Disconnecting idle RPC connection");
1629
+ this.destroyConnection();
3403
1630
  }
3404
- });
3405
- return execution;
1631
+ }, this.idleDisconnectMs);
3406
1632
  }
3407
- /**
3408
- * Run code and return a streaming response
3409
- */
3410
- async runCodeStream(code, options = {}) {
3411
- let context = options.context;
3412
- if (!context) {
3413
- const language = options.language || "python";
3414
- context = await this.getOrCreateDefaultContext(language);
1633
+ clearIdleTimer() {
1634
+ if (this.idleTimer) {
1635
+ clearTimeout(this.idleTimer);
1636
+ this.idleTimer = null;
3415
1637
  }
3416
- return this.getInterpreterClient().streamCode(context.id, code, options.language);
1638
+ }
1639
+ destroyConnection() {
1640
+ this.stopBusyPoll();
1641
+ this.clearIdleTimer();
1642
+ if (this.busy) {
1643
+ this.busy = false;
1644
+ this.onSessionIdle?.();
1645
+ }
1646
+ if (this.conn) {
1647
+ this.conn.disconnect();
1648
+ this.conn = null;
1649
+ }
1650
+ }
1651
+ get commands() {
1652
+ return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
1653
+ }
1654
+ get files() {
1655
+ return wrapStub(this.getConnection().rpc().files, this.renewActivity);
1656
+ }
1657
+ get processes() {
1658
+ return wrapStub(this.getConnection().rpc().processes, this.renewActivity);
1659
+ }
1660
+ get ports() {
1661
+ return wrapStub(this.getConnection().rpc().ports, this.renewActivity);
1662
+ }
1663
+ get git() {
1664
+ return wrapStub(this.getConnection().rpc().git, this.renewActivity);
1665
+ }
1666
+ get utils() {
1667
+ return wrapStub(this.getConnection().rpc().utils, this.renewActivity);
1668
+ }
1669
+ get backup() {
1670
+ return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
1671
+ }
1672
+ get watch() {
1673
+ return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
1674
+ }
1675
+ get tunnels() {
1676
+ return wrapStub(this.getConnection().rpc().tunnels, this.renewActivity);
1677
+ }
1678
+ get interpreter() {
1679
+ return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
3417
1680
  }
3418
1681
  /**
3419
- * List all code contexts
1682
+ * Update the upgrade retry budget. Applies to the current connection
1683
+ * (if any) and is remembered for any future connections created after the
1684
+ * client is torn down and reconnected.
3420
1685
  */
3421
- async listCodeContexts() {
3422
- const contexts = await this.getInterpreterClient().listCodeContexts();
3423
- for (const context of contexts) this.contexts.set(context.id, context);
3424
- return contexts;
1686
+ setRetryTimeoutMs(ms) {
1687
+ this.connOptions.retryTimeoutMs = ms;
1688
+ this.conn?.setRetryTimeoutMs(ms);
1689
+ }
1690
+ isWebSocketConnected() {
1691
+ return this.conn?.isConnected() ?? false;
1692
+ }
1693
+ async connect() {
1694
+ await this.getConnection().connect();
1695
+ }
1696
+ disconnect() {
1697
+ this.destroyConnection();
1698
+ }
1699
+ };
1700
+
1701
+ //#endregion
1702
+ //#region src/current-runtime-identity.ts
1703
+ const CURRENT_RUNTIME_IDENTITY_STORAGE_KEY = "currentRuntimeIdentity";
1704
+ var RuntimeIdentityInactiveError = class extends Error {
1705
+ constructor() {
1706
+ super("Runtime identity is no longer active");
1707
+ this.name = "RuntimeIdentityInactiveError";
1708
+ }
1709
+ };
1710
+ var RuntimeIdentity = class {
1711
+ id;
1712
+ constructor(record) {
1713
+ this.id = record.id;
1714
+ }
1715
+ owns(record) {
1716
+ return record.runtimeIdentityID === this.id;
1717
+ }
1718
+ scope(value) {
1719
+ return {
1720
+ ...value,
1721
+ runtimeIdentityID: this.id
1722
+ };
3425
1723
  }
1724
+ };
1725
+ var CurrentRuntimeIdentity = class {
3426
1726
  /**
3427
- * Delete a code context
1727
+ * Runtime identity is stored in Durable Object storage so a reconstructed DO
1728
+ * can still recognize the live container runtime it owns. In-memory state is
1729
+ * only a cache and cannot define runtime-scoped correctness.
3428
1730
  */
3429
- async deleteCodeContext(contextId) {
3430
- await this.getInterpreterClient().deleteCodeContext(contextId);
3431
- this.contexts.delete(contextId);
1731
+ constructor(storage, getContainerState, isContainerRunning) {
1732
+ this.storage = storage;
1733
+ this.getContainerState = getContainerState;
1734
+ this.isContainerRunning = isContainerRunning;
3432
1735
  }
3433
- async getOrCreateDefaultContext(language) {
3434
- for (const context of this.contexts.values()) if (context.language === language) return context;
3435
- return this.createCodeContext({ language });
1736
+ async get() {
1737
+ const status = await this.getStatus();
1738
+ return status.status === "active" ? status.runtime : null;
1739
+ }
1740
+ async getStatus() {
1741
+ const state = await this.getContainerState();
1742
+ if (state.status !== "healthy") return {
1743
+ status: "inactive",
1744
+ reason: "runtime-not-healthy",
1745
+ containerStatus: state.status
1746
+ };
1747
+ if (!this.isContainerRunning()) return {
1748
+ status: "inactive",
1749
+ reason: "runtime-not-running",
1750
+ containerStatus: state.status
1751
+ };
1752
+ const runtime = await this.getStored();
1753
+ if (!runtime) return {
1754
+ status: "inactive",
1755
+ reason: "missing-runtime-id",
1756
+ containerStatus: state.status
1757
+ };
1758
+ return {
1759
+ status: "active",
1760
+ runtime,
1761
+ containerStatus: state.status
1762
+ };
1763
+ }
1764
+ async getStored(storage = this.storage) {
1765
+ const record = await storage.get(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY) ?? null;
1766
+ return record ? new RuntimeIdentity(record) : null;
1767
+ }
1768
+ async markStarted() {
1769
+ const record = { id: crypto.randomUUID() };
1770
+ await this.storage.put(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY, record);
1771
+ return new RuntimeIdentity(record);
1772
+ }
1773
+ async clear() {
1774
+ await this.storage.delete(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY);
1775
+ }
1776
+ async isActive(runtime) {
1777
+ return (await this.get())?.id === runtime.id;
1778
+ }
1779
+ async assertActive(runtime) {
1780
+ if (!await this.isActive(runtime)) throw new RuntimeIdentityInactiveError();
3436
1781
  }
3437
1782
  };
3438
1783
 
@@ -4099,24 +2444,6 @@ function bridgePreviewWebSocket(response, lifecycle, settleForward) {
4099
2444
  });
4100
2445
  }
4101
2446
 
4102
- //#endregion
4103
- //#region src/preview-proxy-protocol.ts
4104
- /** @internal */
4105
- const PREVIEW_PROXY_HEADER = "x-sandbox-preview-proxy";
4106
- /** @internal */
4107
- const PREVIEW_PROXY_PORT_HEADER = "x-sandbox-preview-port";
4108
- /** @internal */
4109
- const PREVIEW_PROXY_TOKEN_HEADER = "x-sandbox-preview-token";
4110
- /** @internal */
4111
- const PREVIEW_PROXY_SANDBOX_ID_HEADER = "x-sandbox-preview-sandbox-id";
4112
- /** @internal */
4113
- const PREVIEW_PROXY_HEADERS = [
4114
- PREVIEW_PROXY_HEADER,
4115
- PREVIEW_PROXY_PORT_HEADER,
4116
- PREVIEW_PROXY_TOKEN_HEADER,
4117
- PREVIEW_PROXY_SANDBOX_ID_HEADER
4118
- ];
4119
-
4120
2447
  //#endregion
4121
2448
  //#region src/preview-url.ts
4122
2449
  function isLocalhostPattern(hostname) {
@@ -4129,20 +2456,6 @@ function isLocalhostPattern(hostname) {
4129
2456
  return hostPart === "localhost" || hostPart === "127.0.0.1" || hostPart === "0.0.0.0";
4130
2457
  }
4131
2458
 
4132
- //#endregion
4133
- //#region src/pty/proxy.ts
4134
- async function proxyTerminal(stub, sessionId, request, options) {
4135
- if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId is required for terminal access");
4136
- if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") throw new Error("terminal() requires a WebSocket upgrade request");
4137
- const params = new URLSearchParams({ sessionId });
4138
- if (options?.cols) params.set("cols", String(options.cols));
4139
- if (options?.rows) params.set("rows", String(options.rows));
4140
- if (options?.shell) params.set("shell", options.shell);
4141
- const ptyUrl = `http://localhost/ws/pty?${params}`;
4142
- const ptyRequest = new Request(ptyUrl, request);
4143
- return stub.fetch(switchPort(ptyRequest, 3e3));
4144
- }
4145
-
4146
2459
  //#endregion
4147
2460
  //#region src/storage-mount/r2-egress-handler.ts
4148
2461
  const XML_NS = "xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"";
@@ -5521,10 +3834,9 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
5521
3834
  * 4. spawn cloudflared inside the container
5522
3835
  * 5. persist the record + meta
5523
3836
  *
5524
- * Failure between (2) and (5) intentionally leaves the Cloudflare-side
5525
- * resources in place so a retry can re-discover them via
5526
- * `findTunnelByName` and the DNS reuse path. See
5527
- * `.plans/09-named-tunnel-api.md § Retry-friendly failure model`.
3837
+ * Failure between (2) and (5) leaves the Cloudflare-side resources in
3838
+ * place. Later calls re-discover the tunnel with `findTunnelByName` and
3839
+ * reuse the DNS record through the CNAME upsert path.
5528
3840
  */
5529
3841
  async #provisionNamedTunnel(port, name) {
5530
3842
  if (!this.#host.sandboxId) throw new Error("Named tunnels require host.sandboxId on the tunnels handler.");
@@ -5803,11 +4115,9 @@ function createTunnelsHandler(host) {
5803
4115
  * cache hit falls through to `#provisionNamedTunnel` to respawn
5804
4116
  * `cloudflared`.
5805
4117
  *
5806
- * Crucially, named-tunnel metadata (including `dnsRecordId`) is
5807
- * preserved so `destroy(port)` and `sandbox.destroy()` can still clean
5808
- * up the Cloudflare-side resources after a restart. Wiping meta
5809
- * unconditionally — the previous behaviour — silently leaked the tunnel
5810
- * and DNS record on every restart.
4118
+ * Named-tunnel metadata, including `dnsRecordId`, is preserved so
4119
+ * `destroy(port)` and `sandbox.destroy()` can clean up Cloudflare-side
4120
+ * resources after a restart.
5811
4121
  */
5812
4122
  async function pruneTunnelsForRestart(storage) {
5813
4123
  await storage.transaction(async (txn) => {
@@ -5834,7 +4144,7 @@ async function pruneTunnelsForRestart(storage) {
5834
4144
  * This file is auto-updated by .github/changeset-version.ts during releases
5835
4145
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
5836
4146
  */
5837
- const SDK_VERSION = "0.12.1";
4147
+ const SDK_VERSION = "0.13.0-next.632.1";
5838
4148
 
5839
4149
  //#endregion
5840
4150
  //#region src/sandbox.ts
@@ -5968,11 +4278,10 @@ function buildSandboxConfiguration(effectiveId, options, cached) {
5968
4278
  if (options?.sleepAfter !== void 0 && cached?.sleepAfter !== options.sleepAfter) configuration.sleepAfter = options.sleepAfter;
5969
4279
  if (options?.keepAlive !== void 0 && cached?.keepAlive !== options.keepAlive) configuration.keepAlive = options.keepAlive;
5970
4280
  if (options?.containerTimeouts && !sameContainerTimeouts(cached?.containerTimeouts, options.containerTimeouts)) configuration.containerTimeouts = options.containerTimeouts;
5971
- if (options?.transport !== void 0 && cached?.transport !== options.transport) configuration.transport = options.transport;
5972
4281
  return configuration;
5973
4282
  }
5974
4283
  function hasSandboxConfiguration(configuration) {
5975
- return configuration.sandboxName !== void 0 || configuration.sleepAfter !== void 0 || configuration.keepAlive !== void 0 || configuration.containerTimeouts !== void 0 || configuration.transport !== void 0;
4284
+ return configuration.sandboxName !== void 0 || configuration.sleepAfter !== void 0 || configuration.keepAlive !== void 0 || configuration.containerTimeouts !== void 0;
5976
4285
  }
5977
4286
  function mergeSandboxConfiguration(cached, configuration) {
5978
4287
  return {
@@ -5983,8 +4292,7 @@ function mergeSandboxConfiguration(cached, configuration) {
5983
4292
  },
5984
4293
  ...configuration.sleepAfter !== void 0 && { sleepAfter: configuration.sleepAfter },
5985
4294
  ...configuration.keepAlive !== void 0 && { keepAlive: configuration.keepAlive },
5986
- ...configuration.containerTimeouts !== void 0 && { containerTimeouts: configuration.containerTimeouts },
5987
- ...configuration.transport !== void 0 && { transport: configuration.transport }
4295
+ ...configuration.containerTimeouts !== void 0 && { containerTimeouts: configuration.containerTimeouts }
5988
4296
  };
5989
4297
  }
5990
4298
  function applySandboxConfiguration(stub, configuration) {
@@ -5994,7 +4302,6 @@ function applySandboxConfiguration(stub, configuration) {
5994
4302
  if (configuration.sleepAfter !== void 0) operations.push(stub.setSleepAfter?.(configuration.sleepAfter) ?? Promise.resolve());
5995
4303
  if (configuration.keepAlive !== void 0) operations.push(stub.setKeepAlive?.(configuration.keepAlive) ?? Promise.resolve());
5996
4304
  if (configuration.containerTimeouts !== void 0) operations.push(stub.setContainerTimeouts?.(configuration.containerTimeouts) ?? Promise.resolve());
5997
- if (configuration.transport !== void 0) operations.push(stub.setTransport?.(configuration.transport) ?? Promise.resolve());
5998
4305
  return Promise.all(operations).then(() => void 0);
5999
4306
  }
6000
4307
  function getSandbox(ns, id, options) {
@@ -6120,14 +4427,6 @@ var Sandbox = class Sandbox extends Container {
6120
4427
  activeMounts = /* @__PURE__ */ new Map();
6121
4428
  mountOperationQueue = Promise.resolve();
6122
4429
  currentRuntime;
6123
- transport = "http";
6124
- /**
6125
- * True once transport has been written to storage at least once (either
6126
- * via setTransport or restored on cold start). Gates the idempotency
6127
- * check so a first explicit call persists even when the requested value
6128
- * already equals the env-derived in-memory default.
6129
- */
6130
- hasStoredTransport = false;
6131
4430
  backupBucket = null;
6132
4431
  /**
6133
4432
  * Serializes backup operations to prevent concurrent create/restore on the same sandbox.
@@ -6202,63 +4501,40 @@ var Sandbox = class Sandbox extends Container {
6202
4501
  return fn.apply(client, args);
6203
4502
  }
6204
4503
  /**
6205
- * Compute the transport retry budget from current container timeouts.
4504
+ * Compute the control-channel upgrade retry budget from current container
4505
+ * timeouts.
6206
4506
  *
6207
4507
  * The budget covers the full container startup window (instance provisioning
6208
- * + port readiness) plus a 30s margin for the maximum single backoff delay
6209
- * (capped at 30s in BaseTransport). The 120s floor preserves the previous
6210
- * default for short timeout configurations.
4508
+ * + port readiness) plus a 30s margin for the maximum single backoff delay.
4509
+ * The 120s floor preserves the default for short timeout configurations.
6211
4510
  */
6212
4511
  computeRetryTimeoutMs() {
6213
4512
  const startupBudgetMs = this.containerTimeouts.instanceGetTimeoutMS + this.containerTimeouts.portReadyTimeoutMS;
6214
4513
  return Math.max(12e4, startupBudgetMs + 3e4);
6215
4514
  }
6216
4515
  /**
6217
- * Create the route-based compatibility client with current HTTP/WebSocket
6218
- * transport settings.
4516
+ * Create the single control-plane client used for all SDK operations.
6219
4517
  */
6220
- createSandboxClient() {
6221
- return new SandboxClient({
6222
- logger: this.logger,
6223
- port: 3e3,
4518
+ createClient() {
4519
+ const self = this;
4520
+ return new ContainerControlClient({
6224
4521
  stub: this,
4522
+ port: 3e3,
4523
+ logger: this.logger,
6225
4524
  retryTimeoutMs: this.computeRetryTimeoutMs(),
6226
- defaultHeaders: { "X-Sandbox-Id": this.ctx.id.toString() },
6227
- ...this.transport === "websocket" && {
6228
- transportMode: "websocket",
6229
- wsUrl: "ws://localhost:3000/ws"
4525
+ localMain: this.controlCallback,
4526
+ onActivity: () => {
4527
+ this.renewActivityTimeout();
4528
+ },
4529
+ onSessionBusy: () => {
4530
+ self.inflightRequests = (self.inflightRequests ?? 0) + 1;
4531
+ },
4532
+ onSessionIdle: () => {
4533
+ self.inflightRequests = Math.max(0, (self.inflightRequests ?? 0) - 1);
4534
+ if (self.inflightRequests === 0) this.renewActivityTimeout();
6230
4535
  }
6231
4536
  });
6232
4537
  }
6233
- /**
6234
- * Create the appropriate client for the configured control path.
6235
- *
6236
- * `rpc` currently selects the primary container-control client. `http` and
6237
- * `websocket` select the route-based compatibility client.
6238
- */
6239
- createClientForTransport(transport) {
6240
- if (transport === "rpc") {
6241
- const self = this;
6242
- return new ContainerControlClient({
6243
- stub: this,
6244
- port: 3e3,
6245
- logger: this.logger,
6246
- retryTimeoutMs: this.computeRetryTimeoutMs(),
6247
- localMain: this.controlCallback,
6248
- onActivity: () => {
6249
- this.renewActivityTimeout();
6250
- },
6251
- onSessionBusy: () => {
6252
- self.inflightRequests++;
6253
- },
6254
- onSessionIdle: () => {
6255
- self.inflightRequests = Math.max(0, self.inflightRequests - 1);
6256
- if (self.inflightRequests === 0) this.renewActivityTimeout();
6257
- }
6258
- });
6259
- }
6260
- return this.createSandboxClient();
6261
- }
6262
4538
  constructor(ctx, env$1) {
6263
4539
  super(ctx, env$1);
6264
4540
  const envObj = env$1;
@@ -6271,10 +4547,6 @@ var Sandbox = class Sandbox extends Container {
6271
4547
  sandboxId: this.ctx.id.toString()
6272
4548
  });
6273
4549
  this.currentRuntime = new CurrentRuntimeIdentity(this.ctx.storage, () => this.getState(), () => this.ctx.container?.running === true);
6274
- const transportEnv = envObj?.SANDBOX_TRANSPORT;
6275
- if (transportEnv === "websocket" || transportEnv === "rpc") this.transport = transportEnv;
6276
- else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "rpc". Defaulting to "http".`);
6277
- this.logger.info(`Using ${this.transport} transport`);
6278
4550
  const backupBucket = envObj?.BACKUP_BUCKET;
6279
4551
  if (isR2Bucket(backupBucket)) this.backupBucket = backupBucket;
6280
4552
  this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_R2_ACCOUNT_ID") ?? getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
@@ -6333,7 +4605,7 @@ var Sandbox = class Sandbox extends Container {
6333
4605
  secretAccessKey: this.r2SecretAccessKey
6334
4606
  });
6335
4607
  this.controlCallback = new SandboxControlCallbackImpl(() => this.tunnelExitHandler, this.logger);
6336
- this.client = this.createClientForTransport(this.transport);
4608
+ this.client = this.createClient();
6337
4609
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
6338
4610
  this.ctx.blockConcurrencyWhile(async () => {
6339
4611
  this.sandboxName = await this.ctx.storage.get("sandboxName") ?? null;
@@ -6354,18 +4626,6 @@ var Sandbox = class Sandbox extends Container {
6354
4626
  this.sleepAfter = storedSleepAfter;
6355
4627
  this.renewActivityTimeout();
6356
4628
  }
6357
- const storedTransport = await this.ctx.storage.get("transport");
6358
- if (storedTransport && storedTransport !== this.transport) {
6359
- this.transport = storedTransport;
6360
- const previousClient = this.client;
6361
- this.client = this.createClientForTransport(storedTransport);
6362
- this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
6363
- this.tunnelsHandler = null;
6364
- this.tunnelExitHandler = null;
6365
- this.destroyAllTunnels = null;
6366
- previousClient.disconnect();
6367
- }
6368
- if (storedTransport) this.hasStoredTransport = true;
6369
4629
  if (this.interceptHttps) this.envVars = {
6370
4630
  ...this.envVars,
6371
4631
  SANDBOX_INTERCEPT_HTTPS: "1"
@@ -6384,7 +4644,6 @@ var Sandbox = class Sandbox extends Container {
6384
4644
  if (configuration.sleepAfter !== void 0) await this.setSleepAfter(configuration.sleepAfter);
6385
4645
  if (configuration.keepAlive !== void 0) await this.setKeepAlive(configuration.keepAlive);
6386
4646
  if (configuration.containerTimeouts !== void 0) await this.setContainerTimeouts(configuration.containerTimeouts);
6387
- if (configuration.transport !== void 0) await this.setTransport(configuration.transport);
6388
4647
  }
6389
4648
  async setSleepAfter(sleepAfter) {
6390
4649
  if (this.sleepAfter === sleepAfter) return;
@@ -6430,25 +4689,6 @@ var Sandbox = class Sandbox extends Container {
6430
4689
  this.client.setRetryTimeoutMs(this.computeRetryTimeoutMs());
6431
4690
  this.logger.debug("Container timeouts updated", this.containerTimeouts);
6432
4691
  }
6433
- async setTransport(transport) {
6434
- if (transport !== "http" && transport !== "websocket" && transport !== "rpc") {
6435
- this.logger.warn(`Invalid transport value: "${transport}". Must be "http", "websocket", or "rpc". Ignoring.`);
6436
- return;
6437
- }
6438
- if (this.hasStoredTransport && this.transport === transport) return;
6439
- await this.ctx.storage.put("transport", transport);
6440
- const previousClient = this.client;
6441
- this.transport = transport;
6442
- this.hasStoredTransport = true;
6443
- this.client = this.createClientForTransport(transport);
6444
- this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
6445
- this.tunnelsHandler = null;
6446
- this.tunnelExitHandler = null;
6447
- this.destroyAllTunnels = null;
6448
- previousClient.disconnect();
6449
- this.renewActivityTimeout();
6450
- this.logger.debug("Transport updated", { transport });
6451
- }
6452
4692
  validateTimeout(value, name, min, max) {
6453
4693
  if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) throw new Error(`${name} must be a valid finite number, got ${value}`);
6454
4694
  if (value < min || value > max) throw new Error(`${name} must be between ${min}-${max}ms, got ${value}ms`);
@@ -8465,7 +6705,8 @@ var Sandbox = class Sandbox extends Container {
8465
6705
  }
8466
6706
  /**
8467
6707
  * Namespaced tunnel API. Quick tunnels are zero-config preview URLs
8468
- * backed by Cloudflare's trycloudflare service.
6708
+ * backed by Cloudflare's trycloudflare service. Named tunnels bind a
6709
+ * stable hostname under the configured Cloudflare zone.
8469
6710
  *
8470
6711
  * - `tunnels.get(port)` — idempotent. Returns the cached tunnel for
8471
6712
  * `port` if one exists in DO storage, otherwise spawns a fresh
@@ -8475,12 +6716,11 @@ var Sandbox = class Sandbox extends Container {
8475
6716
  * - `tunnels.destroy(portOrInfo)` — tear down by port number or by
8476
6717
  * the record returned from `get()`.
8477
6718
  *
8478
- * Storage is cleared on container restart (`onStart`), so URLs do
8479
- * not survive a container restart the next `get(port)` call will
8480
- * spawn a fresh tunnel with a new URL.
8481
- *
8482
- * Requires the RPC transport. Calling this on a route-based transport
8483
- * throws "RPC transport required".
6719
+ * Container restarts drop quick-tunnel records because their
6720
+ * `*.trycloudflare.com` URLs are tied to the dead cloudflared process.
6721
+ * Named-tunnel records stay in storage and are marked for respawn so the
6722
+ * next `get(port, { name })` call reuses the Cloudflare tunnel and DNS
6723
+ * record while starting a fresh cloudflared process.
8484
6724
  */
8485
6725
  get tunnels() {
8486
6726
  this.ensureTunnelsBuilt();
@@ -8489,8 +6729,7 @@ var Sandbox = class Sandbox extends Container {
8489
6729
  /**
8490
6730
  * Lazily construct both the public tunnels handler and its sibling
8491
6731
  * exit-handler callback. Called from the `tunnels` getter on first
8492
- * access and on every access after a transport swap clears both
8493
- * fields.
6732
+ * access.
8494
6733
  */
8495
6734
  ensureTunnelsBuilt() {
8496
6735
  if (this.tunnelsHandler) return;
@@ -9857,34 +8096,18 @@ var Sandbox = class Sandbox extends Container {
9857
8096
  backupSession = await this.ensureBackupSession();
9858
8097
  const archivePath = `${BACKUP_CONTAINER_DIR}/${id}.sqsh`;
9859
8098
  await this.execWithSession(`mkdir -p ${BACKUP_CONTAINER_DIR}`, backupSession, { origin: "internal" });
9860
- if (this.transport === "rpc") {
9861
- const body = archiveObject.body;
9862
- if (!body) throw new BackupRestoreError({
9863
- message: `R2 archive object has no body stream for backup ${id}`,
9864
- code: ErrorCode.BACKUP_RESTORE_FAILED,
9865
- httpStatus: 500,
9866
- context: {
9867
- dir,
9868
- backupId: id
9869
- },
9870
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
9871
- });
9872
- await this.client.files.writeFileStream(archivePath, body, backupSession);
9873
- } else {
9874
- const archiveBuffer = await archiveObject.arrayBuffer();
9875
- const base64Content = Buffer.from(archiveBuffer).toString("base64");
9876
- const writeResult = await this.client.files.writeFile(archivePath, base64Content, backupSession, { encoding: "base64" });
9877
- if (!writeResult.success) throw new BackupRestoreError({
9878
- message: `Failed to write backup archive to ${archivePath}: ${"error" in writeResult && typeof writeResult.error === "object" && writeResult.error !== null && "message" in writeResult.error && typeof writeResult.error.message === "string" ? writeResult.error.message : `File write returned success: false for '${archivePath}'`}`,
9879
- code: ErrorCode.BACKUP_RESTORE_FAILED,
9880
- httpStatus: 500,
9881
- context: {
9882
- dir,
9883
- backupId: id
9884
- },
9885
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
9886
- });
9887
- }
8099
+ const body = archiveObject.body;
8100
+ if (!body) throw new BackupRestoreError({
8101
+ message: `R2 archive object has no body stream for backup ${id}`,
8102
+ code: ErrorCode.BACKUP_RESTORE_FAILED,
8103
+ httpStatus: 500,
8104
+ context: {
8105
+ dir,
8106
+ backupId: id
8107
+ },
8108
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
8109
+ });
8110
+ await this.client.files.writeFileStream(archivePath, body, backupSession);
9888
8111
  const extractResult = await this.execWithSession(`/usr/bin/unsquashfs -f -d ${shellEscape(dir)} ${shellEscape(archivePath)}`, backupSession, { origin: "internal" });
9889
8112
  if (extractResult.exitCode !== 0) throw new BackupRestoreError({
9890
8113
  message: `unsquashfs extraction failed (exit code ${extractResult.exitCode}): ${extractResult.stderr}`,
@@ -9984,5 +8207,5 @@ var Sandbox = class Sandbox extends Container {
9984
8207
  };
9985
8208
 
9986
8209
  //#endregion
9987
- export { FileClient as A, RPCTransportError as B, collectFile as C, ProcessClient as D, UtilityClient as E, BackupNotFoundError as F, BackupRestoreError as I, InvalidBackupConfigError as L, BackupClient as M, BackupCreateError as N, PortClient as O, BackupExpiredError as P, ProcessExitedBeforeReadyError as R, validateTunnelName as S, SandboxClient as T, SessionTerminatedError as V, responseToAsyncIterable as _, PREVIEW_PROXY_HEADER as a, sanitizeSandboxId as b, PREVIEW_PROXY_SANDBOX_ID_HEADER as c, BucketUnmountError as d, InvalidMountConfigError as f, parseSSEStream as g, asyncIterableToSSEStream as h, proxyTerminal as i, CommandClient as j, GitClient as k, PREVIEW_PROXY_TOKEN_HEADER as l, S3FSMountError as m, Sandbox as n, PREVIEW_PROXY_HEADERS as o, MissingCredentialsError as p, getSandbox as r, PREVIEW_PROXY_PORT_HEADER as s, ContainerProxy$1 as t, BucketMountError as u, CodeInterpreter as v, streamFile as w, validatePort as x, SandboxSecurityError as y, ProcessReadyTimeoutError as z };
9988
- //# sourceMappingURL=sandbox-DKG3H156.js.map
8210
+ export { ProcessExitedBeforeReadyError as A, collectFile as C, BackupNotFoundError as D, BackupExpiredError as E, RPCTransportError as M, SessionTerminatedError as N, BackupRestoreError as O, validateTunnelName as S, BackupCreateError as T, proxyTerminal as _, BucketUnmountError as a, sanitizeSandboxId as b, S3FSMountError as c, responseToAsyncIterable as d, PREVIEW_PROXY_HEADER as f, PREVIEW_PROXY_TOKEN_HEADER as g, PREVIEW_PROXY_SANDBOX_ID_HEADER as h, BucketMountError as i, ProcessReadyTimeoutError as j, InvalidBackupConfigError as k, asyncIterableToSSEStream as l, PREVIEW_PROXY_PORT_HEADER as m, Sandbox as n, InvalidMountConfigError as o, PREVIEW_PROXY_HEADERS as p, getSandbox as r, MissingCredentialsError as s, ContainerProxy$1 as t, parseSSEStream as u, CodeInterpreter as v, streamFile as w, validatePort as x, SandboxSecurityError as y };
8211
+ //# sourceMappingURL=sandbox-B7ewRruX.js.map