@cloudflare/sandbox 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/index.js +3 -3
- package/dist/{contexts-D6kt6WyG.d.ts → contexts-D_shbnJs.d.ts} +32 -2
- package/dist/contexts-D_shbnJs.d.ts.map +1 -0
- package/dist/{dist-Ilf8VjmX.js → dist-B_eXrP83.js} +35 -48
- package/dist/dist-B_eXrP83.js.map +1 -0
- package/dist/errors-CBi-O-pF.js +227 -0
- package/dist/errors-CBi-O-pF.js.map +1 -0
- package/dist/index.d.ts +27 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/openai/index.d.ts +1 -1
- package/dist/openai/index.js +1 -1
- package/dist/opencode/index.d.ts +2 -2
- package/dist/opencode/index.js +2 -2
- package/dist/{sandbox-Cf_Wjrzq.js → sandbox-CReFGUtF.js} +734 -64
- package/dist/sandbox-CReFGUtF.js.map +1 -0
- package/dist/{sandbox-Chr1Ebo-.d.ts → sandbox-YMrVC62F.d.ts} +453 -20
- package/dist/sandbox-YMrVC62F.d.ts.map +1 -0
- package/package.json +2 -1
- package/dist/contexts-D6kt6WyG.d.ts.map +0 -1
- package/dist/dist-Ilf8VjmX.js.map +0 -1
- package/dist/errors-Dk2rApYI.js +0 -162
- package/dist/errors-Dk2rApYI.js.map +0 -1
- package/dist/sandbox-Cf_Wjrzq.js.map +0 -1
- package/dist/sandbox-Chr1Ebo-.d.ts.map +0 -1
|
@@ -1,7 +1,8 @@
|
|
|
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-
|
|
2
|
-
import {
|
|
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";
|
|
2
|
+
import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-CBi-O-pF.js";
|
|
3
3
|
import { Container, getContainer, switchPort } from "@cloudflare/containers";
|
|
4
4
|
import { AwsClient } from "aws4fetch";
|
|
5
|
+
import { RpcSession } from "capnweb";
|
|
5
6
|
import path from "node:path/posix";
|
|
6
7
|
|
|
7
8
|
//#region src/errors/classes.ts
|
|
@@ -10,8 +11,8 @@ import path from "node:path/posix";
|
|
|
10
11
|
* Preserves all error information from container
|
|
11
12
|
*/
|
|
12
13
|
var SandboxError = class extends Error {
|
|
13
|
-
constructor(errorResponse) {
|
|
14
|
-
super(errorResponse.message);
|
|
14
|
+
constructor(errorResponse, options) {
|
|
15
|
+
super(errorResponse.message, options);
|
|
15
16
|
this.errorResponse = errorResponse;
|
|
16
17
|
this.name = "SandboxError";
|
|
17
18
|
}
|
|
@@ -194,6 +195,9 @@ var SessionAlreadyExistsError = class extends SandboxError {
|
|
|
194
195
|
get sessionId() {
|
|
195
196
|
return this.context.sessionId;
|
|
196
197
|
}
|
|
198
|
+
get containerPlacementId() {
|
|
199
|
+
return this.context.containerPlacementId;
|
|
200
|
+
}
|
|
197
201
|
};
|
|
198
202
|
/**
|
|
199
203
|
* Error thrown when a session was destroyed while a command was executing
|
|
@@ -660,6 +664,30 @@ var DesktopInvalidCoordinatesError = class extends SandboxError {
|
|
|
660
664
|
this.name = "DesktopInvalidCoordinatesError";
|
|
661
665
|
}
|
|
662
666
|
};
|
|
667
|
+
/**
|
|
668
|
+
* Raised when the capnweb WebSocket session itself fails on the SDK side.
|
|
669
|
+
* Unlike the rest of the SandboxError tree, the container never produces
|
|
670
|
+
* this error — it is synthesised by `translateRPCError` from the plain
|
|
671
|
+
* Errors capnweb / DeferredTransport raise when the connection dies.
|
|
672
|
+
*
|
|
673
|
+
* `kind` distinguishes the failure mode (peer close, upgrade failed, etc.)
|
|
674
|
+
* so callers can branch on a structured code instead of substring-matching
|
|
675
|
+
* on the message.
|
|
676
|
+
*
|
|
677
|
+
* Always retryable: the SDK opens a fresh connection on the next call.
|
|
678
|
+
*/
|
|
679
|
+
var RPCTransportError = class extends SandboxError {
|
|
680
|
+
constructor(errorResponse, options) {
|
|
681
|
+
super(errorResponse, options);
|
|
682
|
+
this.name = "RPCTransportError";
|
|
683
|
+
}
|
|
684
|
+
get kind() {
|
|
685
|
+
return this.errorResponse.context.kind;
|
|
686
|
+
}
|
|
687
|
+
get originalMessage() {
|
|
688
|
+
return this.errorResponse.context.originalMessage;
|
|
689
|
+
}
|
|
690
|
+
};
|
|
663
691
|
|
|
664
692
|
//#endregion
|
|
665
693
|
//#region src/errors/adapter.ts
|
|
@@ -667,7 +695,7 @@ var DesktopInvalidCoordinatesError = class extends SandboxError {
|
|
|
667
695
|
* Convert ErrorResponse to appropriate Error class
|
|
668
696
|
* Simple switch statement - we trust the container sends correct context
|
|
669
697
|
*/
|
|
670
|
-
function createErrorFromResponse(errorResponse) {
|
|
698
|
+
function createErrorFromResponse(errorResponse, options) {
|
|
671
699
|
switch (errorResponse.code) {
|
|
672
700
|
case ErrorCode.FILE_NOT_FOUND: return new FileNotFoundError(errorResponse);
|
|
673
701
|
case ErrorCode.FILE_EXISTS: return new FileExistsError(errorResponse);
|
|
@@ -723,6 +751,7 @@ function createErrorFromResponse(errorResponse) {
|
|
|
723
751
|
case ErrorCode.DESKTOP_PROCESS_CRASHED: return new DesktopProcessCrashedError(errorResponse);
|
|
724
752
|
case ErrorCode.DESKTOP_INVALID_OPTIONS: return new DesktopInvalidOptionsError(errorResponse);
|
|
725
753
|
case ErrorCode.DESKTOP_INVALID_COORDINATES: return new DesktopInvalidCoordinatesError(errorResponse);
|
|
754
|
+
case ErrorCode.RPC_TRANSPORT_ERROR: return new RPCTransportError(errorResponse, options);
|
|
726
755
|
case ErrorCode.VALIDATION_FAILED: return new ValidationFailedError(errorResponse);
|
|
727
756
|
case ErrorCode.INVALID_JSON_RESPONSE:
|
|
728
757
|
case ErrorCode.UNKNOWN_ERROR:
|
|
@@ -873,8 +902,8 @@ var HttpTransport = class extends BaseTransport {
|
|
|
873
902
|
*/
|
|
874
903
|
const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
|
|
875
904
|
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
876
|
-
const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
877
|
-
const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
|
|
905
|
+
const DEFAULT_CONNECT_TIMEOUT_MS$1 = 3e4;
|
|
906
|
+
const DEFAULT_IDLE_DISCONNECT_MS$1 = 1e3;
|
|
878
907
|
const MIN_TIME_FOR_CONNECT_RETRY_MS = 15e3;
|
|
879
908
|
/**
|
|
880
909
|
* WebSocket transport implementation
|
|
@@ -1030,7 +1059,7 @@ var WebSocketTransport = class extends BaseTransport {
|
|
|
1030
1059
|
* parent Container class that supports the WebSocket protocol.
|
|
1031
1060
|
*/
|
|
1032
1061
|
async connectViaFetch() {
|
|
1033
|
-
const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
1062
|
+
const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS$1;
|
|
1034
1063
|
try {
|
|
1035
1064
|
const wsPath = new URL(this.config.wsUrl).pathname;
|
|
1036
1065
|
const httpUrl = `http://localhost:${this.config.port || 3e3}${wsPath}`;
|
|
@@ -1071,7 +1100,7 @@ var WebSocketTransport = class extends BaseTransport {
|
|
|
1071
1100
|
*/
|
|
1072
1101
|
connectViaWebSocket() {
|
|
1073
1102
|
return new Promise((resolve, reject) => {
|
|
1074
|
-
const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
1103
|
+
const timeoutMs = this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS$1;
|
|
1075
1104
|
const timeout = setTimeout(() => {
|
|
1076
1105
|
this.cleanup();
|
|
1077
1106
|
reject(/* @__PURE__ */ new Error(`WebSocket connection timeout after ${timeoutMs}ms`));
|
|
@@ -1453,7 +1482,7 @@ var WebSocketTransport = class extends BaseTransport {
|
|
|
1453
1482
|
this.logger.debug("Disconnecting idle WebSocket transport");
|
|
1454
1483
|
this.cleanup();
|
|
1455
1484
|
}
|
|
1456
|
-
}, DEFAULT_IDLE_DISCONNECT_MS);
|
|
1485
|
+
}, DEFAULT_IDLE_DISCONNECT_MS$1);
|
|
1457
1486
|
}
|
|
1458
1487
|
clearIdleDisconnectTimer() {
|
|
1459
1488
|
if (this.idleDisconnectTimer) {
|
|
@@ -1663,12 +1692,12 @@ var BackupClient = class extends BaseHttpClient {
|
|
|
1663
1692
|
* @param archivePath - Where the container should write the archive
|
|
1664
1693
|
* @param sessionId - Session context
|
|
1665
1694
|
*/
|
|
1666
|
-
async createArchive(dir, archivePath, sessionId,
|
|
1695
|
+
async createArchive(dir, archivePath, sessionId, options) {
|
|
1667
1696
|
const data = {
|
|
1668
1697
|
dir,
|
|
1669
1698
|
archivePath,
|
|
1670
|
-
gitignore,
|
|
1671
|
-
excludes,
|
|
1699
|
+
gitignore: options?.gitignore ?? false,
|
|
1700
|
+
excludes: options?.excludes ?? [],
|
|
1672
1701
|
sessionId
|
|
1673
1702
|
};
|
|
1674
1703
|
return await this.post("/api/backup/create", data);
|
|
@@ -2589,14 +2618,13 @@ var WatchClient = class extends BaseHttpClient {
|
|
|
2589
2618
|
//#endregion
|
|
2590
2619
|
//#region src/clients/sandbox-client.ts
|
|
2591
2620
|
/**
|
|
2592
|
-
* Main sandbox client that composes all domain-specific clients
|
|
2593
|
-
* Provides organized access to all sandbox functionality
|
|
2621
|
+
* Main sandbox client that composes all domain-specific clients.
|
|
2622
|
+
* Provides organized access to all sandbox functionality.
|
|
2594
2623
|
*
|
|
2595
2624
|
* Supports two transport modes:
|
|
2596
2625
|
* - HTTP (default): Each request is a separate HTTP call
|
|
2597
|
-
* - WebSocket: All requests multiplexed over a single connection
|
|
2598
|
-
*
|
|
2599
|
-
* WebSocket mode reduces sub-request count when running inside Workers/Durable Objects.
|
|
2626
|
+
* - WebSocket: All requests multiplexed over a single connection,
|
|
2627
|
+
* reducing sub-request count inside Workers/Durable Objects
|
|
2600
2628
|
*/
|
|
2601
2629
|
var SandboxClient = class {
|
|
2602
2630
|
backup;
|
|
@@ -2612,7 +2640,7 @@ var SandboxClient = class {
|
|
|
2612
2640
|
transport = null;
|
|
2613
2641
|
constructor(options) {
|
|
2614
2642
|
if (options.transportMode === "websocket" && options.wsUrl) this.transport = createTransport({
|
|
2615
|
-
mode:
|
|
2643
|
+
mode: options.transportMode,
|
|
2616
2644
|
wsUrl: options.wsUrl,
|
|
2617
2645
|
baseUrl: options.baseUrl,
|
|
2618
2646
|
logger: options.logger,
|
|
@@ -2671,6 +2699,16 @@ var SandboxClient = class {
|
|
|
2671
2699
|
return this.transport?.isConnected() ?? false;
|
|
2672
2700
|
}
|
|
2673
2701
|
/**
|
|
2702
|
+
* Stream a file directly to the container over a binary RPC channel.
|
|
2703
|
+
*
|
|
2704
|
+
* Requires the capnweb transport (`useWebSocket: 'rpc'`). Calling this
|
|
2705
|
+
* method with the HTTP or WebSocket transports throws an error because those
|
|
2706
|
+
* transports do not support binary streaming.
|
|
2707
|
+
*/
|
|
2708
|
+
writeFileStream(_path, _content, _sessionId) {
|
|
2709
|
+
throw new Error("writeFileStream requires the RPC transport. Enable it with transport: \"rpc\" in sandbox options.");
|
|
2710
|
+
}
|
|
2711
|
+
/**
|
|
2674
2712
|
* Connect WebSocket transport (no-op in HTTP mode)
|
|
2675
2713
|
* Called automatically on first request, but can be called explicitly
|
|
2676
2714
|
* to establish connection upfront.
|
|
@@ -2699,6 +2737,507 @@ const BACKUP_ALLOWED_PREFIXES = [
|
|
|
2699
2737
|
"/var/tmp",
|
|
2700
2738
|
"/app"
|
|
2701
2739
|
];
|
|
2740
|
+
function normalizeBackupExcludePattern(pattern) {
|
|
2741
|
+
let normalized = pattern;
|
|
2742
|
+
while (normalized.startsWith("**/")) normalized = normalized.slice(3);
|
|
2743
|
+
while (normalized.includes("/**/")) normalized = normalized.replace(/\/\*\*\//g, "/");
|
|
2744
|
+
if (normalized.endsWith("/**")) normalized = normalized.slice(0, -3);
|
|
2745
|
+
if (!normalized || normalized === "**") return null;
|
|
2746
|
+
return normalized;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
//#endregion
|
|
2750
|
+
//#region src/container-connection.ts
|
|
2751
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
2752
|
+
/**
|
|
2753
|
+
* Manages a capnweb WebSocket RPC session to the container.
|
|
2754
|
+
*
|
|
2755
|
+
* The RPC stub is created eagerly in the constructor using a deferred
|
|
2756
|
+
* transport. Calls made before `connect()` completes are queued in the
|
|
2757
|
+
* transport and flushed once the WebSocket is established.
|
|
2758
|
+
*/
|
|
2759
|
+
var ContainerConnection = class {
|
|
2760
|
+
stub;
|
|
2761
|
+
session;
|
|
2762
|
+
transport;
|
|
2763
|
+
ws = null;
|
|
2764
|
+
connected = false;
|
|
2765
|
+
connectPromise = null;
|
|
2766
|
+
containerStub;
|
|
2767
|
+
port;
|
|
2768
|
+
logger;
|
|
2769
|
+
constructor(options) {
|
|
2770
|
+
this.containerStub = options.stub;
|
|
2771
|
+
this.port = options.port ?? 3e3;
|
|
2772
|
+
this.logger = options.logger ?? createNoOpLogger();
|
|
2773
|
+
this.transport = new DeferredTransport();
|
|
2774
|
+
this.session = new RpcSession(this.transport);
|
|
2775
|
+
this.stub = this.session.getRemoteMain();
|
|
2776
|
+
}
|
|
2777
|
+
/**
|
|
2778
|
+
* Get the typed RPC stub.
|
|
2779
|
+
*
|
|
2780
|
+
* The stub is available immediately — calls made before connect()
|
|
2781
|
+
* completes are queued in the deferred transport and flushed once
|
|
2782
|
+
* the WebSocket is established.
|
|
2783
|
+
*/
|
|
2784
|
+
rpc() {
|
|
2785
|
+
if (!this.connected && !this.connectPromise) this.connect().catch(() => {});
|
|
2786
|
+
return this.stub;
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Return capnweb session statistics. The `imports` and `exports` counts
|
|
2790
|
+
* reflect all in-flight RPC calls, streams, and peer-held references.
|
|
2791
|
+
* An idle session has imports <= 1 && exports <= 1 (the bootstrap stubs).
|
|
2792
|
+
*/
|
|
2793
|
+
getStats() {
|
|
2794
|
+
return this.session.getStats();
|
|
2795
|
+
}
|
|
2796
|
+
isConnected() {
|
|
2797
|
+
return this.connected;
|
|
2798
|
+
}
|
|
2799
|
+
async connect() {
|
|
2800
|
+
if (this.connected) return;
|
|
2801
|
+
if (this.connectPromise) return this.connectPromise;
|
|
2802
|
+
this.connectPromise = this.doConnect();
|
|
2803
|
+
try {
|
|
2804
|
+
await this.connectPromise;
|
|
2805
|
+
} finally {
|
|
2806
|
+
this.connectPromise = null;
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
disconnect() {
|
|
2810
|
+
try {
|
|
2811
|
+
this.stub[Symbol.dispose]?.();
|
|
2812
|
+
} catch {}
|
|
2813
|
+
if (this.ws) {
|
|
2814
|
+
try {
|
|
2815
|
+
this.ws.close();
|
|
2816
|
+
} catch {}
|
|
2817
|
+
this.ws = null;
|
|
2818
|
+
}
|
|
2819
|
+
this.connected = false;
|
|
2820
|
+
this.connectPromise = null;
|
|
2821
|
+
}
|
|
2822
|
+
async doConnect() {
|
|
2823
|
+
const controller = new AbortController();
|
|
2824
|
+
const timeout = setTimeout(() => controller.abort(), DEFAULT_CONNECT_TIMEOUT_MS);
|
|
2825
|
+
try {
|
|
2826
|
+
const url = `http://localhost:${this.port}/rpc`;
|
|
2827
|
+
const request = new Request(url, {
|
|
2828
|
+
headers: {
|
|
2829
|
+
Upgrade: "websocket",
|
|
2830
|
+
Connection: "Upgrade"
|
|
2831
|
+
},
|
|
2832
|
+
signal: controller.signal
|
|
2833
|
+
});
|
|
2834
|
+
const response = await this.containerStub.fetch(request);
|
|
2835
|
+
clearTimeout(timeout);
|
|
2836
|
+
if (response.status !== 101) throw new Error(`WebSocket upgrade failed: ${response.status} ${response.statusText}`);
|
|
2837
|
+
const ws = response.webSocket;
|
|
2838
|
+
if (!ws) throw new Error("No WebSocket in upgrade response");
|
|
2839
|
+
ws.accept();
|
|
2840
|
+
ws.addEventListener("close", () => {
|
|
2841
|
+
this.connected = false;
|
|
2842
|
+
this.ws = null;
|
|
2843
|
+
this.logger.debug("ContainerConnection WebSocket closed");
|
|
2844
|
+
});
|
|
2845
|
+
ws.addEventListener("error", () => {
|
|
2846
|
+
this.connected = false;
|
|
2847
|
+
this.ws = null;
|
|
2848
|
+
});
|
|
2849
|
+
this.ws = ws;
|
|
2850
|
+
this.transport.activate(ws);
|
|
2851
|
+
this.connected = true;
|
|
2852
|
+
this.logger.debug("ContainerConnection established", { port: this.port });
|
|
2853
|
+
} catch (error) {
|
|
2854
|
+
clearTimeout(timeout);
|
|
2855
|
+
this.connected = false;
|
|
2856
|
+
this.transport.abort(error);
|
|
2857
|
+
this.logger.error("ContainerConnection failed", error instanceof Error ? error : new Error(String(error)));
|
|
2858
|
+
throw error;
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
};
|
|
2862
|
+
/**
|
|
2863
|
+
* RPC transport that queues sends and blocks receives until a WebSocket
|
|
2864
|
+
* is provided via `activate()`. Allows the RPC stub to be created before
|
|
2865
|
+
* the connection is established — queued calls flush automatically.
|
|
2866
|
+
*/
|
|
2867
|
+
var DeferredTransport = class {
|
|
2868
|
+
#ws = null;
|
|
2869
|
+
#sendQueue = [];
|
|
2870
|
+
#receiveQueue = [];
|
|
2871
|
+
#receiveResolver;
|
|
2872
|
+
#receiveRejecter;
|
|
2873
|
+
#error;
|
|
2874
|
+
activate(ws) {
|
|
2875
|
+
this.#ws = ws;
|
|
2876
|
+
ws.addEventListener("message", (event) => {
|
|
2877
|
+
if (this.#error) return;
|
|
2878
|
+
if (typeof event.data === "string") if (this.#receiveResolver) {
|
|
2879
|
+
this.#receiveResolver(event.data);
|
|
2880
|
+
this.#receiveResolver = void 0;
|
|
2881
|
+
this.#receiveRejecter = void 0;
|
|
2882
|
+
} else this.#receiveQueue.push(event.data);
|
|
2883
|
+
else this.#fail(/* @__PURE__ */ new TypeError("Received non-string message from WebSocket."));
|
|
2884
|
+
});
|
|
2885
|
+
ws.addEventListener("close", (event) => {
|
|
2886
|
+
this.#fail(/* @__PURE__ */ new Error(`Peer closed WebSocket: ${event.code} ${event.reason}`));
|
|
2887
|
+
});
|
|
2888
|
+
ws.addEventListener("error", () => {
|
|
2889
|
+
this.#fail(/* @__PURE__ */ new Error("WebSocket connection failed."));
|
|
2890
|
+
});
|
|
2891
|
+
for (const msg of this.#sendQueue) ws.send(msg);
|
|
2892
|
+
this.#sendQueue = [];
|
|
2893
|
+
}
|
|
2894
|
+
async send(message) {
|
|
2895
|
+
if (this.#ws) this.#ws.send(message);
|
|
2896
|
+
else this.#sendQueue.push(message);
|
|
2897
|
+
}
|
|
2898
|
+
async receive() {
|
|
2899
|
+
if (this.#receiveQueue.length > 0) return this.#receiveQueue.shift();
|
|
2900
|
+
if (this.#error) throw this.#error;
|
|
2901
|
+
return new Promise((resolve, reject) => {
|
|
2902
|
+
this.#receiveResolver = resolve;
|
|
2903
|
+
this.#receiveRejecter = reject;
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
abort(reason) {
|
|
2907
|
+
this.#fail(reason instanceof Error ? reason : new Error(String(reason)));
|
|
2908
|
+
if (this.#ws) {
|
|
2909
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
2910
|
+
this.#ws.close(3e3, message);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
#fail(err) {
|
|
2914
|
+
if (this.#error) return;
|
|
2915
|
+
this.#error = err;
|
|
2916
|
+
this.#receiveRejecter?.(err);
|
|
2917
|
+
this.#receiveResolver = void 0;
|
|
2918
|
+
this.#receiveRejecter = void 0;
|
|
2919
|
+
}
|
|
2920
|
+
};
|
|
2921
|
+
|
|
2922
|
+
//#endregion
|
|
2923
|
+
//#region src/clients/rpc-sandbox-client.ts
|
|
2924
|
+
/** Close the idle capnweb WebSocket promptly so the DO can sleep. */
|
|
2925
|
+
const DEFAULT_IDLE_DISCONNECT_MS = 1e3;
|
|
2926
|
+
/**
|
|
2927
|
+
* How often the busy/idle poller samples `getStats()`.
|
|
2928
|
+
*
|
|
2929
|
+
* Sets two worst-case bounds:
|
|
2930
|
+
*
|
|
2931
|
+
* 1. **Idle-detection lag.** Time between the session going idle on
|
|
2932
|
+
* the wire and the DO observing it (and arming the disconnect).
|
|
2933
|
+
* Bounded by `pollInterval`.
|
|
2934
|
+
* 2. **Activity-renewal lag while busy.** While a stream is active we
|
|
2935
|
+
* renew the DO's activity timeout once per tick. The alarm could
|
|
2936
|
+
* fire as late as `sleepAfter` after the last renew, so the
|
|
2937
|
+
* effective margin against a mid-stream sleep is
|
|
2938
|
+
* `sleepAfter - pollInterval`.
|
|
2939
|
+
*
|
|
2940
|
+
* **Invariant: `pollInterval` must be comfortably less than the
|
|
2941
|
+
* smallest configurable `sleepAfter`.** Aim for at least 2-3× headroom.
|
|
2942
|
+
* The minimum `sleepAfter` exercised by the E2E suite is 3s, so 1s gives
|
|
2943
|
+
* 3× margin and at least two renewals during a 3s window. If a smaller
|
|
2944
|
+
* `sleepAfter` is ever supported, drop this proportionally.
|
|
2945
|
+
*/
|
|
2946
|
+
const BUSY_POLL_INTERVAL_MS = 1e3;
|
|
2947
|
+
/**
|
|
2948
|
+
* Baseline getStats() values for an idle session. The bootstrap stub on each
|
|
2949
|
+
* side accounts for 1 import and 1 export.
|
|
2950
|
+
*/
|
|
2951
|
+
const IDLE_IMPORT_THRESHOLD = 1;
|
|
2952
|
+
const IDLE_EXPORT_THRESHOLD = 1;
|
|
2953
|
+
/**
|
|
2954
|
+
* Translate a capnweb-propagated error into a typed SandboxError.
|
|
2955
|
+
*
|
|
2956
|
+
* capnweb only preserves `error.name` and `error.message` across the wire.
|
|
2957
|
+
* The container encodes the full error as a JSON object in the message
|
|
2958
|
+
* string: `{"code":"...","message":"...","context":{...}}`.
|
|
2959
|
+
*/
|
|
2960
|
+
function translateRPCError(error) {
|
|
2961
|
+
if (error instanceof Error) {
|
|
2962
|
+
let payload;
|
|
2963
|
+
try {
|
|
2964
|
+
payload = JSON.parse(error.message);
|
|
2965
|
+
} catch {}
|
|
2966
|
+
if (payload && typeof payload.code === "string" && typeof payload.message === "string") throw createErrorFromResponse({
|
|
2967
|
+
code: payload.code,
|
|
2968
|
+
message: payload.message,
|
|
2969
|
+
context: payload.context ?? {},
|
|
2970
|
+
httpStatus: getHttpStatus(payload.code),
|
|
2971
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2972
|
+
});
|
|
2973
|
+
throw createErrorFromResponse(buildTransportErrorResponse(error), { cause: error });
|
|
2974
|
+
}
|
|
2975
|
+
throw createErrorFromResponse(buildTransportErrorResponse(new Error(String(error))), { cause: error });
|
|
2976
|
+
}
|
|
2977
|
+
/**
|
|
2978
|
+
* Inspect a transport-level Error's message and produce the ErrorResponse
|
|
2979
|
+
* that becomes an RPCTransportError. Pattern strings are pinned to the exact
|
|
2980
|
+
* messages emitted by capnweb's WebSocketTransport (see capnweb's
|
|
2981
|
+
* src/websocket.ts) and our DeferredTransport in container-connection.ts —
|
|
2982
|
+
* notably the trailing period in `WebSocket connection failed.` matches
|
|
2983
|
+
* capnweb verbatim. The DeferredTransport tests in
|
|
2984
|
+
* tests/container-connection.test.ts pin the literal strings.
|
|
2985
|
+
*/
|
|
2986
|
+
function buildTransportErrorResponse(error) {
|
|
2987
|
+
const message = error.message;
|
|
2988
|
+
const errorName = error.name;
|
|
2989
|
+
let kind = "unknown";
|
|
2990
|
+
let closeCode;
|
|
2991
|
+
let closeReason;
|
|
2992
|
+
if (errorName === "TypeError") kind = "invalid_frame";
|
|
2993
|
+
else if (errorName === "SyntaxError") kind = "protocol_error";
|
|
2994
|
+
else {
|
|
2995
|
+
const peerCloseMatch = message.match(/^Peer closed WebSocket: (\d+) ?(.*)$/);
|
|
2996
|
+
if (peerCloseMatch) {
|
|
2997
|
+
kind = "peer_closed";
|
|
2998
|
+
closeCode = Number(peerCloseMatch[1]);
|
|
2999
|
+
closeReason = peerCloseMatch[2] || void 0;
|
|
3000
|
+
} else if (message === "WebSocket connection failed.") kind = "connection_failed";
|
|
3001
|
+
else if (message.startsWith("WebSocket upgrade failed")) kind = "upgrade_failed";
|
|
3002
|
+
else if (message === "No WebSocket in upgrade response") kind = "upgrade_failed";
|
|
3003
|
+
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";
|
|
3004
|
+
}
|
|
3005
|
+
const context = {
|
|
3006
|
+
kind,
|
|
3007
|
+
originalMessage: message,
|
|
3008
|
+
errorName,
|
|
3009
|
+
...closeCode !== void 0 ? { closeCode } : {},
|
|
3010
|
+
...closeReason !== void 0 ? { closeReason } : {}
|
|
3011
|
+
};
|
|
3012
|
+
return {
|
|
3013
|
+
code: ErrorCode.RPC_TRANSPORT_ERROR,
|
|
3014
|
+
message,
|
|
3015
|
+
context,
|
|
3016
|
+
httpStatus: getHttpStatus(ErrorCode.RPC_TRANSPORT_ERROR),
|
|
3017
|
+
suggestion: getSuggestion(ErrorCode.RPC_TRANSPORT_ERROR, context),
|
|
3018
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* Wrap a capnweb RPC stub so that every method call translates errors
|
|
3023
|
+
* from the JSON wire format into typed SandboxError instances and signals
|
|
3024
|
+
* activity at call start.
|
|
3025
|
+
*
|
|
3026
|
+
* `onCallStarted` fires synchronously when an RPC method is invoked. The
|
|
3027
|
+
* RPCSandboxClient uses this to renew the DO's activity timeout
|
|
3028
|
+
* immediately, so even a call that completes entirely between two
|
|
3029
|
+
* busy-poll ticks still pushes the sleepAfter deadline forward.
|
|
3030
|
+
*
|
|
3031
|
+
* Note: there is no `onCallSettled` hook. A method whose returned promise
|
|
3032
|
+
* resolves with a `ReadableStream` is *not* finished when the promise
|
|
3033
|
+
* settles — capnweb keeps the export alive until the stream ends. The
|
|
3034
|
+
* busy/idle poll on `getStats()` is the source of truth for that.
|
|
3035
|
+
*/
|
|
3036
|
+
function wrapStub(stub, onCallStarted) {
|
|
3037
|
+
return new Proxy(stub, { get(target, prop, receiver) {
|
|
3038
|
+
const value = Reflect.get(target, prop, receiver);
|
|
3039
|
+
if (typeof value !== "function") return value;
|
|
3040
|
+
return (...args) => {
|
|
3041
|
+
onCallStarted();
|
|
3042
|
+
try {
|
|
3043
|
+
const result = Reflect.apply(value, target, args);
|
|
3044
|
+
if (result != null && typeof result.then === "function") return result.catch(translateRPCError);
|
|
3045
|
+
return result;
|
|
3046
|
+
} catch (err) {
|
|
3047
|
+
translateRPCError(err);
|
|
3048
|
+
}
|
|
3049
|
+
};
|
|
3050
|
+
} });
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* SandboxClient backed by direct capnweb RPC.
|
|
3054
|
+
*
|
|
3055
|
+
* Drop-in replacement for SandboxClient when the capnweb transport is active.
|
|
3056
|
+
* All operations call the container's SandboxRPCAPI directly over capnweb,
|
|
3057
|
+
* bypassing the HTTP handler/router layer entirely.
|
|
3058
|
+
*
|
|
3059
|
+
* Manages its own WebSocket lifecycle: a fresh `ContainerConnection` is
|
|
3060
|
+
* created on demand and torn down after `idleDisconnectMs` of inactivity.
|
|
3061
|
+
* Busy/idle detection relies on `RpcSession.getStats()` which tracks all
|
|
3062
|
+
* in-flight RPC calls and stream exports — including long-lived streaming
|
|
3063
|
+
* RPCs that would be invisible to a simple per-call request counter (see
|
|
3064
|
+
* the file-level comment for the full rationale).
|
|
3065
|
+
*/
|
|
3066
|
+
var RPCSandboxClient = class {
|
|
3067
|
+
connOptions;
|
|
3068
|
+
idleDisconnectMs;
|
|
3069
|
+
busyPollIntervalMs;
|
|
3070
|
+
logger;
|
|
3071
|
+
onActivity;
|
|
3072
|
+
onSessionBusy;
|
|
3073
|
+
onSessionIdle;
|
|
3074
|
+
conn = null;
|
|
3075
|
+
idleTimer = null;
|
|
3076
|
+
busyPollTimer = null;
|
|
3077
|
+
/** Tracks whether we currently believe the session is busy. */
|
|
3078
|
+
busy = false;
|
|
3079
|
+
/**
|
|
3080
|
+
* Set the first time the poller observes `conn.isConnected() === true`,
|
|
3081
|
+
* cleared in `destroyConnection()`. Lets us distinguish "the WebSocket
|
|
3082
|
+
* upgrade is still in progress" (don't tear down) from "we were
|
|
3083
|
+
* connected and the peer went away" (do tear down).
|
|
3084
|
+
*/
|
|
3085
|
+
wasEverConnected = false;
|
|
3086
|
+
constructor(options) {
|
|
3087
|
+
this.connOptions = {
|
|
3088
|
+
stub: options.stub,
|
|
3089
|
+
port: options.port,
|
|
3090
|
+
logger: options.logger
|
|
3091
|
+
};
|
|
3092
|
+
this.idleDisconnectMs = options.idleDisconnectMs ?? DEFAULT_IDLE_DISCONNECT_MS;
|
|
3093
|
+
this.busyPollIntervalMs = options.busyPollIntervalMs ?? BUSY_POLL_INTERVAL_MS;
|
|
3094
|
+
this.logger = options.logger ?? createNoOpLogger();
|
|
3095
|
+
this.onActivity = options.onActivity;
|
|
3096
|
+
this.onSessionBusy = options.onSessionBusy;
|
|
3097
|
+
this.onSessionIdle = options.onSessionIdle;
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* Return the current connection, creating a new one if none exists or the
|
|
3101
|
+
* previous one was torn down by an idle disconnect. Starts the busy-poll
|
|
3102
|
+
* timer the first time a connection is materialized.
|
|
3103
|
+
*/
|
|
3104
|
+
getConnection() {
|
|
3105
|
+
if (!this.conn) {
|
|
3106
|
+
this.conn = new ContainerConnection(this.connOptions);
|
|
3107
|
+
this.startBusyPoll();
|
|
3108
|
+
}
|
|
3109
|
+
return this.conn;
|
|
3110
|
+
}
|
|
3111
|
+
/**
|
|
3112
|
+
* Called synchronously at the start of each RPC method invocation.
|
|
3113
|
+
* Renews the DO activity timeout so the sleepAfter alarm is pushed
|
|
3114
|
+
* forward before the container processes the call.
|
|
3115
|
+
*/
|
|
3116
|
+
renewActivity = () => {
|
|
3117
|
+
this.onActivity?.();
|
|
3118
|
+
};
|
|
3119
|
+
/**
|
|
3120
|
+
* Sample `getStats()` and update busy/idle state. While busy, renews the
|
|
3121
|
+
* activity timeout each tick so an in-flight stream keeps pushing the
|
|
3122
|
+
* sleepAfter deadline forward. On the busy → idle edge, fires
|
|
3123
|
+
* `onSessionIdle` and schedules the WebSocket disconnect.
|
|
3124
|
+
*
|
|
3125
|
+
* If the WebSocket has dropped underneath us (container crash, network
|
|
3126
|
+
* blip) we tear the connection down here. `destroyConnection()` fires
|
|
3127
|
+
* `onSessionIdle` if we were busy, so the DO's inflight counter doesn't
|
|
3128
|
+
* stay pinned forever waiting for a peer that's never going to reply.
|
|
3129
|
+
*/
|
|
3130
|
+
pollBusyState = () => {
|
|
3131
|
+
const conn = this.conn;
|
|
3132
|
+
if (!conn) return;
|
|
3133
|
+
if (!conn.isConnected()) {
|
|
3134
|
+
if (this.wasEverConnected) this.destroyConnection();
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
this.wasEverConnected = true;
|
|
3138
|
+
const { imports, exports } = conn.getStats();
|
|
3139
|
+
if (imports > IDLE_IMPORT_THRESHOLD || exports > IDLE_EXPORT_THRESHOLD) {
|
|
3140
|
+
if (!this.busy) {
|
|
3141
|
+
this.busy = true;
|
|
3142
|
+
this.onSessionBusy?.();
|
|
3143
|
+
}
|
|
3144
|
+
this.onActivity?.();
|
|
3145
|
+
this.clearIdleTimer();
|
|
3146
|
+
} else if (this.busy) {
|
|
3147
|
+
this.busy = false;
|
|
3148
|
+
this.onSessionIdle?.();
|
|
3149
|
+
this.scheduleIdleDisconnect();
|
|
3150
|
+
} else if (!this.idleTimer) this.scheduleIdleDisconnect();
|
|
3151
|
+
};
|
|
3152
|
+
startBusyPoll() {
|
|
3153
|
+
if (this.busyPollTimer) return;
|
|
3154
|
+
this.busyPollTimer = setInterval(this.pollBusyState, this.busyPollIntervalMs);
|
|
3155
|
+
}
|
|
3156
|
+
stopBusyPoll() {
|
|
3157
|
+
if (this.busyPollTimer) {
|
|
3158
|
+
clearInterval(this.busyPollTimer);
|
|
3159
|
+
this.busyPollTimer = null;
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
scheduleIdleDisconnect() {
|
|
3163
|
+
this.clearIdleTimer();
|
|
3164
|
+
this.idleTimer = setTimeout(() => {
|
|
3165
|
+
this.idleTimer = null;
|
|
3166
|
+
const conn = this.conn;
|
|
3167
|
+
if (!conn || !conn.isConnected()) return;
|
|
3168
|
+
const { imports, exports } = conn.getStats();
|
|
3169
|
+
if (imports <= IDLE_IMPORT_THRESHOLD && exports <= IDLE_EXPORT_THRESHOLD) {
|
|
3170
|
+
this.logger.debug("Disconnecting idle capnweb connection");
|
|
3171
|
+
this.destroyConnection();
|
|
3172
|
+
}
|
|
3173
|
+
}, this.idleDisconnectMs);
|
|
3174
|
+
}
|
|
3175
|
+
clearIdleTimer() {
|
|
3176
|
+
if (this.idleTimer) {
|
|
3177
|
+
clearTimeout(this.idleTimer);
|
|
3178
|
+
this.idleTimer = null;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
destroyConnection() {
|
|
3182
|
+
this.stopBusyPoll();
|
|
3183
|
+
this.clearIdleTimer();
|
|
3184
|
+
if (this.busy) {
|
|
3185
|
+
this.busy = false;
|
|
3186
|
+
this.onSessionIdle?.();
|
|
3187
|
+
}
|
|
3188
|
+
if (this.conn) {
|
|
3189
|
+
this.conn.disconnect();
|
|
3190
|
+
this.conn = null;
|
|
3191
|
+
}
|
|
3192
|
+
this.wasEverConnected = false;
|
|
3193
|
+
}
|
|
3194
|
+
get commands() {
|
|
3195
|
+
return wrapStub(this.getConnection().rpc().commands, this.renewActivity);
|
|
3196
|
+
}
|
|
3197
|
+
get files() {
|
|
3198
|
+
return wrapStub(this.getConnection().rpc().files, this.renewActivity);
|
|
3199
|
+
}
|
|
3200
|
+
get processes() {
|
|
3201
|
+
return wrapStub(this.getConnection().rpc().processes, this.renewActivity);
|
|
3202
|
+
}
|
|
3203
|
+
get ports() {
|
|
3204
|
+
return wrapStub(this.getConnection().rpc().ports, this.renewActivity);
|
|
3205
|
+
}
|
|
3206
|
+
get git() {
|
|
3207
|
+
return wrapStub(this.getConnection().rpc().git, this.renewActivity);
|
|
3208
|
+
}
|
|
3209
|
+
get utils() {
|
|
3210
|
+
return wrapStub(this.getConnection().rpc().utils, this.renewActivity);
|
|
3211
|
+
}
|
|
3212
|
+
get backup() {
|
|
3213
|
+
return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
|
|
3214
|
+
}
|
|
3215
|
+
get desktop() {
|
|
3216
|
+
return wrapStub(this.getConnection().rpc().desktop, this.renewActivity);
|
|
3217
|
+
}
|
|
3218
|
+
get watch() {
|
|
3219
|
+
return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
|
|
3220
|
+
}
|
|
3221
|
+
get interpreter() {
|
|
3222
|
+
return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
|
|
3223
|
+
}
|
|
3224
|
+
setRetryTimeoutMs(_ms) {}
|
|
3225
|
+
getTransportMode() {
|
|
3226
|
+
return "rpc";
|
|
3227
|
+
}
|
|
3228
|
+
isWebSocketConnected() {
|
|
3229
|
+
return this.conn?.isConnected() ?? false;
|
|
3230
|
+
}
|
|
3231
|
+
async connect() {
|
|
3232
|
+
await this.getConnection().connect();
|
|
3233
|
+
}
|
|
3234
|
+
disconnect() {
|
|
3235
|
+
this.destroyConnection();
|
|
3236
|
+
}
|
|
3237
|
+
async writeFileStream(path$1, stream, sessionId) {
|
|
3238
|
+
return this.files.writeFileStream(path$1, stream, sessionId);
|
|
3239
|
+
}
|
|
3240
|
+
};
|
|
2702
3241
|
|
|
2703
3242
|
//#endregion
|
|
2704
3243
|
//#region src/file-stream.ts
|
|
@@ -2834,11 +3373,11 @@ async function collectFile(stream) {
|
|
|
2834
3373
|
* - Host header injection
|
|
2835
3374
|
* - Open redirect vulnerabilities
|
|
2836
3375
|
*/
|
|
2837
|
-
var
|
|
3376
|
+
var SandboxSecurityError = class extends Error {
|
|
2838
3377
|
constructor(message, code) {
|
|
2839
3378
|
super(message);
|
|
2840
3379
|
this.code = code;
|
|
2841
|
-
this.name = "
|
|
3380
|
+
this.name = "SandboxSecurityError";
|
|
2842
3381
|
}
|
|
2843
3382
|
};
|
|
2844
3383
|
/**
|
|
@@ -2859,8 +3398,8 @@ function validatePort(port) {
|
|
|
2859
3398
|
* Only enforces critical requirements - allows maximum developer flexibility
|
|
2860
3399
|
*/
|
|
2861
3400
|
function sanitizeSandboxId(id) {
|
|
2862
|
-
if (!id || id.length > 63) throw new
|
|
2863
|
-
if (id.startsWith("-") || id.endsWith("-")) throw new
|
|
3401
|
+
if (!id || id.length > 63) throw new SandboxSecurityError("Sandbox ID must be 1-63 characters long.", "INVALID_SANDBOX_ID_LENGTH");
|
|
3402
|
+
if (id.startsWith("-") || id.endsWith("-")) throw new SandboxSecurityError("Sandbox ID cannot start or end with hyphens (DNS requirement).", "INVALID_SANDBOX_ID_HYPHENS");
|
|
2864
3403
|
const reservedNames = [
|
|
2865
3404
|
"www",
|
|
2866
3405
|
"api",
|
|
@@ -2871,7 +3410,7 @@ function sanitizeSandboxId(id) {
|
|
|
2871
3410
|
"workers"
|
|
2872
3411
|
];
|
|
2873
3412
|
const lowerCaseId = id.toLowerCase();
|
|
2874
|
-
if (reservedNames.includes(lowerCaseId)) throw new
|
|
3413
|
+
if (reservedNames.includes(lowerCaseId)) throw new SandboxSecurityError(`Reserved sandbox ID '${id}' is not allowed.`, "RESERVED_SANDBOX_ID");
|
|
2875
3414
|
return id;
|
|
2876
3415
|
}
|
|
2877
3416
|
/**
|
|
@@ -2890,23 +3429,23 @@ function validateLanguage(language) {
|
|
|
2890
3429
|
"ts"
|
|
2891
3430
|
];
|
|
2892
3431
|
const normalized = language.toLowerCase();
|
|
2893
|
-
if (!supportedLanguages.includes(normalized)) throw new
|
|
3432
|
+
if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
|
|
2894
3433
|
}
|
|
2895
3434
|
|
|
2896
3435
|
//#endregion
|
|
2897
3436
|
//#region src/interpreter.ts
|
|
2898
3437
|
var CodeInterpreter = class {
|
|
2899
|
-
|
|
3438
|
+
getInterpreterClient;
|
|
2900
3439
|
contexts = /* @__PURE__ */ new Map();
|
|
2901
|
-
constructor(
|
|
2902
|
-
this.
|
|
3440
|
+
constructor(interpreterClient) {
|
|
3441
|
+
this.getInterpreterClient = typeof interpreterClient === "function" ? interpreterClient : () => interpreterClient;
|
|
2903
3442
|
}
|
|
2904
3443
|
/**
|
|
2905
3444
|
* Create a new code execution context
|
|
2906
3445
|
*/
|
|
2907
3446
|
async createCodeContext(options = {}) {
|
|
2908
3447
|
validateLanguage(options.language);
|
|
2909
|
-
const context = await this.
|
|
3448
|
+
const context = await this.getInterpreterClient().createCodeContext(options);
|
|
2910
3449
|
this.contexts.set(context.id, context);
|
|
2911
3450
|
return context;
|
|
2912
3451
|
}
|
|
@@ -2920,7 +3459,7 @@ var CodeInterpreter = class {
|
|
|
2920
3459
|
context = await this.getOrCreateDefaultContext(language);
|
|
2921
3460
|
}
|
|
2922
3461
|
const execution = new Execution(code, context);
|
|
2923
|
-
await this.
|
|
3462
|
+
await this.getInterpreterClient().runCodeStream(context.id, code, options.language, {
|
|
2924
3463
|
onStdout: (output) => {
|
|
2925
3464
|
execution.logs.stdout.push(output.text);
|
|
2926
3465
|
if (options.onStdout) return options.onStdout(output);
|
|
@@ -2949,13 +3488,13 @@ var CodeInterpreter = class {
|
|
|
2949
3488
|
const language = options.language || "python";
|
|
2950
3489
|
context = await this.getOrCreateDefaultContext(language);
|
|
2951
3490
|
}
|
|
2952
|
-
return this.
|
|
3491
|
+
return this.getInterpreterClient().streamCode(context.id, code, options.language);
|
|
2953
3492
|
}
|
|
2954
3493
|
/**
|
|
2955
3494
|
* List all code contexts
|
|
2956
3495
|
*/
|
|
2957
3496
|
async listCodeContexts() {
|
|
2958
|
-
const contexts = await this.
|
|
3497
|
+
const contexts = await this.getInterpreterClient().listCodeContexts();
|
|
2959
3498
|
for (const context of contexts) this.contexts.set(context.id, context);
|
|
2960
3499
|
return contexts;
|
|
2961
3500
|
}
|
|
@@ -2963,7 +3502,7 @@ var CodeInterpreter = class {
|
|
|
2963
3502
|
* Delete a code context
|
|
2964
3503
|
*/
|
|
2965
3504
|
async deleteCodeContext(contextId) {
|
|
2966
|
-
await this.
|
|
3505
|
+
await this.getInterpreterClient().deleteCodeContext(contextId);
|
|
2967
3506
|
this.contexts.delete(contextId);
|
|
2968
3507
|
}
|
|
2969
3508
|
async getOrCreateDefaultContext(language) {
|
|
@@ -3652,7 +4191,7 @@ function isLocalhostPattern(hostname) {
|
|
|
3652
4191
|
* This file is auto-updated by .github/changeset-version.ts during releases
|
|
3653
4192
|
* DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
|
|
3654
4193
|
*/
|
|
3655
|
-
const SDK_VERSION = "0.9.
|
|
4194
|
+
const SDK_VERSION = "0.9.2";
|
|
3656
4195
|
|
|
3657
4196
|
//#endregion
|
|
3658
4197
|
//#region src/sandbox.ts
|
|
@@ -3760,7 +4299,7 @@ function enhanceSession(stub, rpcSession) {
|
|
|
3760
4299
|
}
|
|
3761
4300
|
function connect(stub) {
|
|
3762
4301
|
return async (request, port) => {
|
|
3763
|
-
if (!validatePort(port)) throw new
|
|
4302
|
+
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
|
|
3764
4303
|
const portSwitchedRequest = switchPort(request, port);
|
|
3765
4304
|
return await stub.fetch(portSwitchedRequest);
|
|
3766
4305
|
};
|
|
@@ -3913,6 +4452,30 @@ var Sandbox = class Sandbox extends Container {
|
|
|
3913
4452
|
}
|
|
3914
4453
|
});
|
|
3915
4454
|
}
|
|
4455
|
+
/**
|
|
4456
|
+
* Create the appropriate client for a given transport protocol.
|
|
4457
|
+
*/
|
|
4458
|
+
createClientForTransport(transport) {
|
|
4459
|
+
if (transport === "rpc") {
|
|
4460
|
+
const self = this;
|
|
4461
|
+
return new RPCSandboxClient({
|
|
4462
|
+
stub: this,
|
|
4463
|
+
port: 3e3,
|
|
4464
|
+
logger: this.logger,
|
|
4465
|
+
onActivity: () => {
|
|
4466
|
+
this.renewActivityTimeout();
|
|
4467
|
+
},
|
|
4468
|
+
onSessionBusy: () => {
|
|
4469
|
+
self.inflightRequests++;
|
|
4470
|
+
},
|
|
4471
|
+
onSessionIdle: () => {
|
|
4472
|
+
self.inflightRequests = Math.max(0, self.inflightRequests - 1);
|
|
4473
|
+
if (self.inflightRequests === 0) this.renewActivityTimeout();
|
|
4474
|
+
}
|
|
4475
|
+
});
|
|
4476
|
+
}
|
|
4477
|
+
return this.createSandboxClient();
|
|
4478
|
+
}
|
|
3916
4479
|
constructor(ctx, env) {
|
|
3917
4480
|
super(ctx, env);
|
|
3918
4481
|
const envObj = env;
|
|
@@ -3925,8 +4488,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
3925
4488
|
sandboxId: this.ctx.id.toString()
|
|
3926
4489
|
});
|
|
3927
4490
|
const transportEnv = envObj?.SANDBOX_TRANSPORT;
|
|
3928
|
-
if (transportEnv === "websocket") this.transport =
|
|
3929
|
-
else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http" or "
|
|
4491
|
+
if (transportEnv === "websocket" || transportEnv === "rpc") this.transport = transportEnv;
|
|
4492
|
+
else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "rpc". Defaulting to "http".`);
|
|
4493
|
+
this.logger.info(`Using ${this.transport} transport`);
|
|
3930
4494
|
const backupBucket = envObj?.BACKUP_BUCKET;
|
|
3931
4495
|
if (isR2Bucket(backupBucket)) this.backupBucket = backupBucket;
|
|
3932
4496
|
this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
|
|
@@ -3937,8 +4501,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
3937
4501
|
accessKeyId: this.r2AccessKeyId,
|
|
3938
4502
|
secretAccessKey: this.r2SecretAccessKey
|
|
3939
4503
|
});
|
|
3940
|
-
this.client = this.
|
|
3941
|
-
this.codeInterpreter = new CodeInterpreter(this);
|
|
4504
|
+
this.client = this.createClientForTransport(this.transport);
|
|
4505
|
+
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
3942
4506
|
this.ctx.blockConcurrencyWhile(async () => {
|
|
3943
4507
|
this.sandboxName = await this.ctx.storage.get("sandboxName") ?? null;
|
|
3944
4508
|
this.normalizeId = await this.ctx.storage.get("normalizeId") ?? false;
|
|
@@ -3962,8 +4526,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
3962
4526
|
if (storedTransport && storedTransport !== this.transport) {
|
|
3963
4527
|
this.transport = storedTransport;
|
|
3964
4528
|
const previousClient = this.client;
|
|
3965
|
-
this.client = this.
|
|
3966
|
-
this.codeInterpreter = new CodeInterpreter(this);
|
|
4529
|
+
this.client = this.createClientForTransport(storedTransport);
|
|
4530
|
+
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
3967
4531
|
previousClient.disconnect();
|
|
3968
4532
|
}
|
|
3969
4533
|
if (storedTransport) this.hasStoredTransport = true;
|
|
@@ -4044,8 +4608,8 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4044
4608
|
* Storage is written before the in-memory state and client are updated.
|
|
4045
4609
|
*/
|
|
4046
4610
|
async setTransport(transport) {
|
|
4047
|
-
if (transport !== "http" && transport !== "websocket") {
|
|
4048
|
-
this.logger.warn(`Invalid transport value: "${transport}". Must be "http" or "
|
|
4611
|
+
if (transport !== "http" && transport !== "websocket" && transport !== "rpc") {
|
|
4612
|
+
this.logger.warn(`Invalid transport value: "${transport}". Must be "http", "websocket", or "rpc". Ignoring.`);
|
|
4049
4613
|
return;
|
|
4050
4614
|
}
|
|
4051
4615
|
if (this.hasStoredTransport && this.transport === transport) return;
|
|
@@ -4053,9 +4617,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4053
4617
|
const previousClient = this.client;
|
|
4054
4618
|
this.transport = transport;
|
|
4055
4619
|
this.hasStoredTransport = true;
|
|
4056
|
-
this.client = this.
|
|
4057
|
-
this.codeInterpreter = new CodeInterpreter(this);
|
|
4620
|
+
this.client = this.createClientForTransport(transport);
|
|
4621
|
+
this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
|
|
4058
4622
|
previousClient.disconnect();
|
|
4623
|
+
this.renewActivityTimeout();
|
|
4059
4624
|
this.logger.debug("Transport updated", { transport });
|
|
4060
4625
|
}
|
|
4061
4626
|
/**
|
|
@@ -4344,9 +4909,46 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4344
4909
|
if (result.exitCode !== 0) throw new S3FSMountError(`S3FS mount failed: ${result.stderr || result.stdout || "Unknown error"}`);
|
|
4345
4910
|
}
|
|
4346
4911
|
/**
|
|
4347
|
-
*
|
|
4912
|
+
* In-flight `destroy()` promise. While set, concurrent callers coalesce
|
|
4913
|
+
* onto the same teardown instead of triggering a second one. Cleared when
|
|
4914
|
+
* the underlying work settles, so a later call that genuinely needs to
|
|
4915
|
+
* recreate a destroyed sandbox still runs.
|
|
4916
|
+
*
|
|
4917
|
+
* If the underlying teardown hangs (e.g. `super.destroy()` never resolves
|
|
4918
|
+
* because the Containers control plane is unresponsive), every coalesced
|
|
4919
|
+
* caller hangs on the same promise until the Durable Object is evicted.
|
|
4920
|
+
* This is deliberate: a second concurrent teardown would not make a stuck
|
|
4921
|
+
* control plane unstuck, and spawning one would defeat the point of
|
|
4922
|
+
* coalescing. Callers that need bounded waits must apply their own
|
|
4923
|
+
* timeout around `destroy()`.
|
|
4924
|
+
*/
|
|
4925
|
+
inflightDestroy = null;
|
|
4926
|
+
/**
|
|
4927
|
+
* Cleanup and destroy the sandbox container.
|
|
4928
|
+
*
|
|
4929
|
+
* Concurrent calls coalesce: if a previous `destroy()` is still in flight,
|
|
4930
|
+
* subsequent calls await the same underlying work instead of starting a
|
|
4931
|
+
* second teardown. A canonical `sandbox.destroy.coalesced` event is logged
|
|
4932
|
+
* per coalesced call so repeated destroy traffic is observable.
|
|
4348
4933
|
*/
|
|
4349
4934
|
async destroy() {
|
|
4935
|
+
if (this.inflightDestroy) {
|
|
4936
|
+
logCanonicalEvent(this.logger, {
|
|
4937
|
+
event: "sandbox.destroy.coalesced",
|
|
4938
|
+
outcome: "success",
|
|
4939
|
+
durationMs: 0
|
|
4940
|
+
});
|
|
4941
|
+
return this.inflightDestroy;
|
|
4942
|
+
}
|
|
4943
|
+
const work = this.doDestroy();
|
|
4944
|
+
this.inflightDestroy = work;
|
|
4945
|
+
try {
|
|
4946
|
+
await work;
|
|
4947
|
+
} finally {
|
|
4948
|
+
if (this.inflightDestroy === work) this.inflightDestroy = null;
|
|
4949
|
+
}
|
|
4950
|
+
}
|
|
4951
|
+
async doDestroy() {
|
|
4350
4952
|
const startTime = Date.now();
|
|
4351
4953
|
let mountsProcessed = 0;
|
|
4352
4954
|
let mountFailures = 0;
|
|
@@ -4356,7 +4958,6 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4356
4958
|
if (this.ctx.container?.running) try {
|
|
4357
4959
|
await this.client.desktop.stop();
|
|
4358
4960
|
} catch {}
|
|
4359
|
-
this.client.disconnect();
|
|
4360
4961
|
for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
|
|
4361
4962
|
mountsProcessed++;
|
|
4362
4963
|
if (mountInfo.mountType === "local-sync") try {
|
|
@@ -4381,6 +4982,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4381
4982
|
}
|
|
4382
4983
|
}
|
|
4383
4984
|
await this.ctx.storage.delete("portTokens");
|
|
4985
|
+
this.client.disconnect();
|
|
4384
4986
|
outcome = "success";
|
|
4385
4987
|
await super.destroy();
|
|
4386
4988
|
} catch (error) {
|
|
@@ -4508,6 +5110,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4508
5110
|
this.containerGeneration++;
|
|
4509
5111
|
this.defaultSession = null;
|
|
4510
5112
|
this.defaultSessionInit = null;
|
|
5113
|
+
this.client.disconnect();
|
|
4511
5114
|
for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
|
|
4512
5115
|
this.activeMounts.clear();
|
|
4513
5116
|
await this.ctx.storage.delete("defaultSession");
|
|
@@ -4786,22 +5389,43 @@ var Sandbox = class Sandbox extends Container {
|
|
|
4786
5389
|
}
|
|
4787
5390
|
}
|
|
4788
5391
|
async initializeDefaultSession(sessionId, generation) {
|
|
5392
|
+
let placementId;
|
|
4789
5393
|
try {
|
|
4790
|
-
await this.client.utils.createSession({
|
|
5394
|
+
placementId = (await this.client.utils.createSession({
|
|
4791
5395
|
id: sessionId,
|
|
4792
5396
|
env: this.envVars || {},
|
|
4793
5397
|
cwd: "/workspace"
|
|
4794
|
-
});
|
|
5398
|
+
})).containerPlacementId;
|
|
4795
5399
|
} catch (error) {
|
|
4796
5400
|
if (!(error instanceof SessionAlreadyExistsError)) throw error;
|
|
5401
|
+
placementId = error.containerPlacementId;
|
|
4797
5402
|
this.logger.debug("Session exists in container but not in DO state, syncing", { sessionId });
|
|
4798
5403
|
}
|
|
4799
5404
|
if (generation !== this.containerGeneration) throw new Error("Default session initialization was invalidated by a container stop");
|
|
4800
5405
|
await this.ctx.storage.put("defaultSession", sessionId);
|
|
5406
|
+
await this.capturePlacementId(placementId);
|
|
4801
5407
|
this.defaultSession = sessionId;
|
|
4802
5408
|
this.logger.debug("Default session initialized", { sessionId });
|
|
4803
5409
|
return sessionId;
|
|
4804
5410
|
}
|
|
5411
|
+
/**
|
|
5412
|
+
* Persist the container's placement ID in DO storage.
|
|
5413
|
+
*
|
|
5414
|
+
* Called from the session-create handshake so subsequent reads via
|
|
5415
|
+
* `getContainerPlacementId()` do not require a round-trip to the container. The value
|
|
5416
|
+
* is overwritten on every handshake so that container replacements (which
|
|
5417
|
+
* assign a new placement ID) are reflected on the next session-create.
|
|
5418
|
+
*
|
|
5419
|
+
* A value of `undefined` means the handshake response omitted the field
|
|
5420
|
+
* (older container, unexpected error shape) and the stored value is left
|
|
5421
|
+
* untouched. `null` means the env var is not set in the container and is
|
|
5422
|
+
* stored as-is so callers can distinguish "observed and absent" from "not
|
|
5423
|
+
* yet observed."
|
|
5424
|
+
*/
|
|
5425
|
+
async capturePlacementId(containerPlacementId) {
|
|
5426
|
+
if (containerPlacementId === void 0) return;
|
|
5427
|
+
await this.ctx.storage.put("containerPlacementId", containerPlacementId);
|
|
5428
|
+
}
|
|
4805
5429
|
async exec(command, options) {
|
|
4806
5430
|
const session = await this.ensureDefaultSession();
|
|
4807
5431
|
return this.execWithSession(command, session, options);
|
|
@@ -5306,6 +5930,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5306
5930
|
}
|
|
5307
5931
|
async writeFile(path$1, content, options = {}) {
|
|
5308
5932
|
const session = options.sessionId ?? await this.ensureDefaultSession();
|
|
5933
|
+
if (content instanceof ReadableStream) return this.client.writeFileStream(path$1, content, session);
|
|
5309
5934
|
return this.client.files.writeFile(path$1, content, session, { encoding: options.encoding });
|
|
5310
5935
|
}
|
|
5311
5936
|
async deleteFile(path$1, sessionId) {
|
|
@@ -5455,7 +6080,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5455
6080
|
let outcome = "error";
|
|
5456
6081
|
let caughtError;
|
|
5457
6082
|
try {
|
|
5458
|
-
if (!validatePort(port)) throw new
|
|
6083
|
+
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
|
|
5459
6084
|
if (options.hostname.endsWith(".workers.dev")) throw new CustomDomainRequiredError({
|
|
5460
6085
|
code: ErrorCode.CUSTOM_DOMAIN_REQUIRED,
|
|
5461
6086
|
message: `Port exposure requires a custom domain. .workers.dev domains do not support wildcard subdomains required for port proxying.`,
|
|
@@ -5471,7 +6096,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5471
6096
|
} else token = this.generatePortToken();
|
|
5472
6097
|
const tokens = await this.readPortTokens();
|
|
5473
6098
|
const existingPort = Object.entries(tokens).find(([p, entry]) => entry.token === token && p !== port.toString());
|
|
5474
|
-
if (existingPort) throw new
|
|
6099
|
+
if (existingPort) throw new SandboxSecurityError(`Token '${token}' is already in use by port ${existingPort[0]}. Please use a different token.`);
|
|
5475
6100
|
const sessionId = await this.ensureDefaultSession();
|
|
5476
6101
|
await this.client.ports.exposePort(port, sessionId, options?.name);
|
|
5477
6102
|
tokens[port.toString()] = {
|
|
@@ -5506,7 +6131,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5506
6131
|
let outcome = "error";
|
|
5507
6132
|
let caughtError;
|
|
5508
6133
|
try {
|
|
5509
|
-
if (!validatePort(port)) throw new
|
|
6134
|
+
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
|
|
5510
6135
|
const tokens = await this.readPortTokens();
|
|
5511
6136
|
if (tokens[port.toString()]) {
|
|
5512
6137
|
delete tokens[port.toString()];
|
|
@@ -5572,9 +6197,9 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5572
6197
|
}
|
|
5573
6198
|
}
|
|
5574
6199
|
validateCustomToken(token) {
|
|
5575
|
-
if (token.length === 0) throw new
|
|
5576
|
-
if (token.length > 16) throw new
|
|
5577
|
-
if (!/^[a-z0-9_]+$/.test(token)) throw new
|
|
6200
|
+
if (token.length === 0) throw new SandboxSecurityError(`Custom token cannot be empty.`);
|
|
6201
|
+
if (token.length > 16) throw new SandboxSecurityError(`Custom token too long. Maximum 16 characters allowed. Received: ${token.length} characters.`);
|
|
6202
|
+
if (!/^[a-z0-9_]+$/.test(token)) throw new SandboxSecurityError(`Custom token must contain only lowercase letters (a-z), numbers (0-9), and underscores (_). Invalid token provided.`);
|
|
5578
6203
|
}
|
|
5579
6204
|
generatePortToken() {
|
|
5580
6205
|
const array = new Uint8Array(12);
|
|
@@ -5582,10 +6207,10 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5582
6207
|
return btoa(String.fromCharCode(...array)).replace(/\+/g, "_").replace(/\//g, "_").replace(/=/g, "").toLowerCase();
|
|
5583
6208
|
}
|
|
5584
6209
|
constructPreviewUrl(port, sandboxId, hostname, token) {
|
|
5585
|
-
if (!validatePort(port)) throw new
|
|
6210
|
+
if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
|
|
5586
6211
|
const effectiveId = this.sandboxName || sandboxId;
|
|
5587
6212
|
const hasUppercase = /[A-Z]/.test(effectiveId);
|
|
5588
|
-
if (!this.normalizeId && hasUppercase) throw new
|
|
6213
|
+
if (!this.normalizeId && hasUppercase) throw new SandboxSecurityError(`Preview URLs require lowercase sandbox IDs. Your ID "${effectiveId}" contains uppercase letters.\n\nTo fix this:\n1. Create a new sandbox with: getSandbox(ns, "${effectiveId}", { normalizeId: true })\n2. This will create a sandbox with ID: "${effectiveId.toLowerCase()}"\n\nNote: Due to DNS case-insensitivity, IDs with uppercase letters cannot be used with preview URLs.`);
|
|
5589
6214
|
const sanitizedSandboxId = sanitizeSandboxId(sandboxId).toLowerCase();
|
|
5590
6215
|
if (isLocalhostPattern(hostname)) {
|
|
5591
6216
|
const [host, portStr] = hostname.split(":");
|
|
@@ -5595,7 +6220,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5595
6220
|
baseUrl.hostname = `${port}-${sanitizedSandboxId}-${token}.${host}`;
|
|
5596
6221
|
return baseUrl.toString();
|
|
5597
6222
|
} catch (error) {
|
|
5598
|
-
throw new
|
|
6223
|
+
throw new SandboxSecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
5599
6224
|
}
|
|
5600
6225
|
}
|
|
5601
6226
|
try {
|
|
@@ -5603,7 +6228,7 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5603
6228
|
baseUrl.hostname = `${port}-${sanitizedSandboxId}-${token}.${hostname}`;
|
|
5604
6229
|
return baseUrl.toString();
|
|
5605
6230
|
} catch (error) {
|
|
5606
|
-
throw new
|
|
6231
|
+
throw new SandboxSecurityError(`Failed to construct preview URL: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
5607
6232
|
}
|
|
5608
6233
|
}
|
|
5609
6234
|
/**
|
|
@@ -5617,12 +6242,13 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5617
6242
|
...options?.env ?? {}
|
|
5618
6243
|
});
|
|
5619
6244
|
const envPayload = Object.keys(filteredEnv).length > 0 ? filteredEnv : void 0;
|
|
5620
|
-
await this.client.utils.createSession({
|
|
6245
|
+
const response = await this.client.utils.createSession({
|
|
5621
6246
|
id: sessionId,
|
|
5622
6247
|
...envPayload && { env: envPayload },
|
|
5623
6248
|
...options?.cwd && { cwd: options.cwd },
|
|
5624
6249
|
...options?.commandTimeoutMs !== void 0 && { commandTimeoutMs: options.commandTimeoutMs }
|
|
5625
6250
|
});
|
|
6251
|
+
await this.capturePlacementId(response.containerPlacementId);
|
|
5626
6252
|
return this.getSessionWrapper(sessionId);
|
|
5627
6253
|
}
|
|
5628
6254
|
/**
|
|
@@ -5657,6 +6283,26 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5657
6283
|
timestamp: response.timestamp
|
|
5658
6284
|
};
|
|
5659
6285
|
}
|
|
6286
|
+
/**
|
|
6287
|
+
* Get the Cloudflare placement ID observed for the underlying container.
|
|
6288
|
+
*
|
|
6289
|
+
* The placement ID is captured during the first session-create handshake
|
|
6290
|
+
* after a container start and stored in Durable Object storage, so this
|
|
6291
|
+
* method returns the cached value without contacting the container. A new
|
|
6292
|
+
* placement ID is captured on each subsequent session-create handshake,
|
|
6293
|
+
* which occurs whenever the container has been replaced.
|
|
6294
|
+
*
|
|
6295
|
+
* Returns `null` when a handshake has completed but the container's
|
|
6296
|
+
* `CLOUDFLARE_PLACEMENT_ID` environment variable is not set (for example,
|
|
6297
|
+
* in local development).
|
|
6298
|
+
*
|
|
6299
|
+
* Returns `undefined` when no handshake has been observed yet on this
|
|
6300
|
+
* sandbox. Call any method that triggers session creation (such as
|
|
6301
|
+
* `exec()`) to populate the value.
|
|
6302
|
+
*/
|
|
6303
|
+
async getContainerPlacementId() {
|
|
6304
|
+
return this.ctx.storage.get("containerPlacementId");
|
|
6305
|
+
}
|
|
5660
6306
|
getSessionWrapper(sessionId) {
|
|
5661
6307
|
return {
|
|
5662
6308
|
id: sessionId,
|
|
@@ -5796,6 +6442,22 @@ var Sandbox = class Sandbox extends Container {
|
|
|
5796
6442
|
});
|
|
5797
6443
|
return this.backupBucket;
|
|
5798
6444
|
}
|
|
6445
|
+
normalizeBackupExcludes(excludes) {
|
|
6446
|
+
const normalizedExcludes = [];
|
|
6447
|
+
for (const pattern of excludes) {
|
|
6448
|
+
const normalized = normalizeBackupExcludePattern(pattern);
|
|
6449
|
+
if (normalized === null) {
|
|
6450
|
+
this.logger.warn("Exclude pattern reduced to empty after globstar normalization; skipping", { original: pattern });
|
|
6451
|
+
continue;
|
|
6452
|
+
}
|
|
6453
|
+
if (normalized !== pattern) this.logger.warn("Exclude pattern contained ** (globstar) which mksquashfs does not support; normalized automatically", {
|
|
6454
|
+
original: pattern,
|
|
6455
|
+
normalized
|
|
6456
|
+
});
|
|
6457
|
+
normalizedExcludes.push(normalized);
|
|
6458
|
+
}
|
|
6459
|
+
return normalizedExcludes;
|
|
6460
|
+
}
|
|
5799
6461
|
static PRESIGNED_URL_EXPIRY_SECONDS = 3600;
|
|
5800
6462
|
/**
|
|
5801
6463
|
* Create a unique, dedicated session for a single backup operation.
|
|
@@ -6026,10 +6688,14 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6026
6688
|
context: { reason: "excludes must be an array of strings" },
|
|
6027
6689
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6028
6690
|
});
|
|
6691
|
+
const normalizedExcludes = this.normalizeBackupExcludes(excludes);
|
|
6029
6692
|
backupSession = await this.ensureBackupSession();
|
|
6030
6693
|
backupId = crypto.randomUUID();
|
|
6031
6694
|
const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
|
|
6032
|
-
const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession,
|
|
6695
|
+
const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, {
|
|
6696
|
+
gitignore,
|
|
6697
|
+
excludes: normalizedExcludes
|
|
6698
|
+
});
|
|
6033
6699
|
if (!createResult.success) throw new BackupCreateError({
|
|
6034
6700
|
message: "Container failed to create backup archive",
|
|
6035
6701
|
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
@@ -6144,10 +6810,14 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6144
6810
|
context: { reason: "excludes must be an array of strings" },
|
|
6145
6811
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6146
6812
|
});
|
|
6813
|
+
const normalizedExcludes = this.normalizeBackupExcludes(excludes);
|
|
6147
6814
|
backupSession = await this.ensureBackupSession();
|
|
6148
6815
|
backupId = crypto.randomUUID();
|
|
6149
6816
|
const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
|
|
6150
|
-
const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession,
|
|
6817
|
+
const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, {
|
|
6818
|
+
gitignore,
|
|
6819
|
+
excludes: normalizedExcludes
|
|
6820
|
+
});
|
|
6151
6821
|
if (!createResult.success) throw new BackupCreateError({
|
|
6152
6822
|
message: "Container failed to create backup archive",
|
|
6153
6823
|
code: ErrorCode.BACKUP_CREATE_FAILED,
|
|
@@ -6498,5 +7168,5 @@ var Sandbox = class Sandbox extends Container {
|
|
|
6498
7168
|
};
|
|
6499
7169
|
|
|
6500
7170
|
//#endregion
|
|
6501
|
-
export { DesktopInvalidOptionsError as A, CommandClient as C, BackupNotFoundError as D, BackupExpiredError as E, InvalidBackupConfigError as F, ProcessExitedBeforeReadyError as I, ProcessReadyTimeoutError as L, DesktopProcessCrashedError as M, DesktopStartFailedError as N, BackupRestoreError as O, DesktopUnavailableError as P,
|
|
6502
|
-
//# sourceMappingURL=sandbox-
|
|
7171
|
+
export { DesktopInvalidOptionsError as A, CommandClient as C, BackupNotFoundError as D, BackupExpiredError as E, InvalidBackupConfigError as F, ProcessExitedBeforeReadyError as I, ProcessReadyTimeoutError as L, DesktopProcessCrashedError as M, DesktopStartFailedError as N, BackupRestoreError as O, DesktopUnavailableError as P, RPCTransportError as R, DesktopClient as S, BackupCreateError as T, UtilityClient as _, BucketMountError as a, GitClient as b, MissingCredentialsError as c, parseSSEStream as d, responseToAsyncIterable as f, SandboxClient as g, streamFile as h, proxyTerminal as i, DesktopNotStartedError as j, DesktopInvalidCoordinatesError as k, S3FSMountError as l, collectFile as m, getSandbox as n, BucketUnmountError as o, CodeInterpreter as p, proxyToSandbox as r, InvalidMountConfigError as s, Sandbox as t, asyncIterableToSSEStream as u, ProcessClient as v, BackupClient as w, FileClient as x, PortClient as y, SessionTerminatedError as z };
|
|
7172
|
+
//# sourceMappingURL=sandbox-CReFGUtF.js.map
|