@cloudflare/sandbox 0.10.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
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-8Hvune8K.js";
3
- import { Container, getContainer, switchPort } from "@cloudflare/containers";
2
+ import { n as getHttpStatus, r as ErrorCode, t as getSuggestion } from "./errors-aRUdk9K8.js";
3
+ import { Container, ContainerProxy, getContainer, switchPort } from "@cloudflare/containers";
4
4
  import { AwsClient } from "aws4fetch";
5
5
  import { RpcSession, RpcTarget } from "capnweb";
6
6
  import path from "node:path/posix";
@@ -231,7 +231,7 @@ var SessionTerminatedError = class extends SandboxError {
231
231
  }
232
232
  };
233
233
  /**
234
- * Error thrown when a port is already exposed
234
+ * Compatibility error for legacy port exposure registry responses.
235
235
  */
236
236
  var PortAlreadyExposedError = class extends SandboxError {
237
237
  constructor(errorResponse) {
@@ -246,7 +246,7 @@ var PortAlreadyExposedError = class extends SandboxError {
246
246
  }
247
247
  };
248
248
  /**
249
- * Error thrown when a port is not exposed
249
+ * Compatibility error for legacy port exposure registry responses.
250
250
  */
251
251
  var PortNotExposedError = class extends SandboxError {
252
252
  constructor(errorResponse) {
@@ -629,42 +629,6 @@ var BackupRestoreError = class extends SandboxError {
629
629
  return this.context.backupId;
630
630
  }
631
631
  };
632
- var DesktopNotStartedError = class extends SandboxError {
633
- constructor(errorResponse) {
634
- super(errorResponse);
635
- this.name = "DesktopNotStartedError";
636
- }
637
- };
638
- var DesktopStartFailedError = class extends SandboxError {
639
- constructor(errorResponse) {
640
- super(errorResponse);
641
- this.name = "DesktopStartFailedError";
642
- }
643
- };
644
- var DesktopUnavailableError = class extends SandboxError {
645
- constructor(errorResponse) {
646
- super(errorResponse);
647
- this.name = "DesktopUnavailableError";
648
- }
649
- };
650
- var DesktopProcessCrashedError = class extends SandboxError {
651
- constructor(errorResponse) {
652
- super(errorResponse);
653
- this.name = "DesktopProcessCrashedError";
654
- }
655
- };
656
- var DesktopInvalidOptionsError = class extends SandboxError {
657
- constructor(errorResponse) {
658
- super(errorResponse);
659
- this.name = "DesktopInvalidOptionsError";
660
- }
661
- };
662
- var DesktopInvalidCoordinatesError = class extends SandboxError {
663
- constructor(errorResponse) {
664
- super(errorResponse);
665
- this.name = "DesktopInvalidCoordinatesError";
666
- }
667
- };
668
632
  /**
669
633
  * Raised when the capnweb WebSocket session itself fails on the SDK side.
670
634
  * Unlike the rest of the SandboxError tree, the container never produces
@@ -746,12 +710,6 @@ function createErrorFromResponse(errorResponse, options) {
746
710
  case ErrorCode.INTERPRETER_NOT_READY: return new InterpreterNotReadyError(errorResponse);
747
711
  case ErrorCode.CONTEXT_NOT_FOUND: return new ContextNotFoundError(errorResponse);
748
712
  case ErrorCode.CODE_EXECUTION_ERROR: return new CodeExecutionError(errorResponse);
749
- case ErrorCode.DESKTOP_NOT_STARTED: return new DesktopNotStartedError(errorResponse);
750
- case ErrorCode.DESKTOP_START_FAILED: return new DesktopStartFailedError(errorResponse);
751
- case ErrorCode.DESKTOP_UNAVAILABLE: return new DesktopUnavailableError(errorResponse);
752
- case ErrorCode.DESKTOP_PROCESS_CRASHED: return new DesktopProcessCrashedError(errorResponse);
753
- case ErrorCode.DESKTOP_INVALID_OPTIONS: return new DesktopInvalidOptionsError(errorResponse);
754
- case ErrorCode.DESKTOP_INVALID_COORDINATES: return new DesktopInvalidCoordinatesError(errorResponse);
755
713
  case ErrorCode.RPC_TRANSPORT_ERROR: return new RPCTransportError(errorResponse, options);
756
714
  case ErrorCode.VALIDATION_FAILED: return new ValidationFailedError(errorResponse);
757
715
  case ErrorCode.INVALID_JSON_RESPONSE:
@@ -1590,21 +1548,21 @@ var BaseHttpClient = class {
1590
1548
  body: JSON.stringify(data),
1591
1549
  ...requestOptions
1592
1550
  });
1593
- return this.handleResponse(response, responseHandler);
1551
+ return await this.handleResponse(response, responseHandler);
1594
1552
  }
1595
1553
  /**
1596
1554
  * Make a GET request
1597
1555
  */
1598
1556
  async get(endpoint, responseHandler) {
1599
1557
  const response = await this.doFetch(endpoint, { method: "GET" });
1600
- return this.handleResponse(response, responseHandler);
1558
+ return await this.handleResponse(response, responseHandler);
1601
1559
  }
1602
1560
  /**
1603
1561
  * Make a DELETE request
1604
1562
  */
1605
1563
  async delete(endpoint, responseHandler) {
1606
1564
  const response = await this.doFetch(endpoint, { method: "DELETE" });
1607
- return this.handleResponse(response, responseHandler);
1565
+ return await this.handleResponse(response, responseHandler);
1608
1566
  }
1609
1567
  /**
1610
1568
  * Handle HTTP response with error checking and parsing
@@ -1673,7 +1631,7 @@ var BaseHttpClient = class {
1673
1631
  headers: { "Content-Type": "application/json" },
1674
1632
  body: body && method === "POST" ? JSON.stringify(body) : void 0
1675
1633
  });
1676
- return this.handleStreamResponse(response);
1634
+ return await this.handleStreamResponse(response);
1677
1635
  }
1678
1636
  };
1679
1637
 
@@ -1782,240 +1740,6 @@ var CommandClient = class extends BaseHttpClient {
1782
1740
  }
1783
1741
  };
1784
1742
 
1785
- //#endregion
1786
- //#region src/clients/desktop-client.ts
1787
- /**
1788
- * Decode a base64-encoded screenshot payload into a Uint8Array.
1789
- * Shared with the RPC client wrapper, which receives base64 over the
1790
- * wire and needs the same `format: 'bytes'` convenience.
1791
- */
1792
- function base64ToBytes(data) {
1793
- const binaryString = atob(data);
1794
- const bytes = new Uint8Array(binaryString.length);
1795
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1796
- return bytes;
1797
- }
1798
- /**
1799
- * Client for desktop environment lifecycle, input, and screen operations
1800
- */
1801
- var DesktopClient = class extends BaseHttpClient {
1802
- /**
1803
- * Start the desktop environment with optional resolution and DPI.
1804
- */
1805
- async start(options) {
1806
- try {
1807
- const data = {
1808
- ...options?.resolution !== void 0 && { resolution: options.resolution },
1809
- ...options?.dpi !== void 0 && { dpi: options.dpi }
1810
- };
1811
- return await this.post("/api/desktop/start", data);
1812
- } catch (error) {
1813
- this.options.onError?.(error instanceof Error ? error.message : String(error));
1814
- throw error;
1815
- }
1816
- }
1817
- /**
1818
- * Stop the desktop environment and all related processes.
1819
- */
1820
- async stop() {
1821
- try {
1822
- return await this.post("/api/desktop/stop", {});
1823
- } catch (error) {
1824
- this.options.onError?.(error instanceof Error ? error.message : String(error));
1825
- throw error;
1826
- }
1827
- }
1828
- /**
1829
- * Get desktop lifecycle and process health status.
1830
- */
1831
- async status() {
1832
- return await this.get("/api/desktop/status");
1833
- }
1834
- async screenshot(options) {
1835
- const wantsBytes = options?.format === "bytes";
1836
- const data = {
1837
- format: "base64",
1838
- ...options?.imageFormat !== void 0 && { imageFormat: options.imageFormat },
1839
- ...options?.quality !== void 0 && { quality: options.quality },
1840
- ...options?.showCursor !== void 0 && { showCursor: options.showCursor }
1841
- };
1842
- const response = await this.post("/api/desktop/screenshot", data);
1843
- if (wantsBytes) return {
1844
- ...response,
1845
- data: base64ToBytes(response.data)
1846
- };
1847
- return response;
1848
- }
1849
- async screenshotRegion(region, options) {
1850
- const wantsBytes = options?.format === "bytes";
1851
- const data = {
1852
- region,
1853
- format: "base64",
1854
- ...options?.imageFormat !== void 0 && { imageFormat: options.imageFormat },
1855
- ...options?.quality !== void 0 && { quality: options.quality },
1856
- ...options?.showCursor !== void 0 && { showCursor: options.showCursor }
1857
- };
1858
- const response = await this.post("/api/desktop/screenshot/region", data);
1859
- if (wantsBytes) return {
1860
- ...response,
1861
- data: base64ToBytes(response.data)
1862
- };
1863
- return response;
1864
- }
1865
- /**
1866
- * Single-click at the given coordinates.
1867
- */
1868
- async click(x, y, options) {
1869
- await this.post("/api/desktop/mouse/click", {
1870
- x,
1871
- y,
1872
- button: options?.button ?? "left",
1873
- clickCount: 1
1874
- });
1875
- }
1876
- /**
1877
- * Double-click at the given coordinates.
1878
- */
1879
- async doubleClick(x, y, options) {
1880
- await this.post("/api/desktop/mouse/click", {
1881
- x,
1882
- y,
1883
- button: options?.button ?? "left",
1884
- clickCount: 2
1885
- });
1886
- }
1887
- /**
1888
- * Triple-click at the given coordinates.
1889
- */
1890
- async tripleClick(x, y, options) {
1891
- await this.post("/api/desktop/mouse/click", {
1892
- x,
1893
- y,
1894
- button: options?.button ?? "left",
1895
- clickCount: 3
1896
- });
1897
- }
1898
- /**
1899
- * Right-click at the given coordinates.
1900
- */
1901
- async rightClick(x, y) {
1902
- await this.post("/api/desktop/mouse/click", {
1903
- x,
1904
- y,
1905
- button: "right",
1906
- clickCount: 1
1907
- });
1908
- }
1909
- /**
1910
- * Middle-click at the given coordinates.
1911
- */
1912
- async middleClick(x, y) {
1913
- await this.post("/api/desktop/mouse/click", {
1914
- x,
1915
- y,
1916
- button: "middle",
1917
- clickCount: 1
1918
- });
1919
- }
1920
- /**
1921
- * Press and hold a mouse button.
1922
- */
1923
- async mouseDown(x, y, options) {
1924
- await this.post("/api/desktop/mouse/down", {
1925
- ...x !== void 0 && { x },
1926
- ...y !== void 0 && { y },
1927
- button: options?.button ?? "left"
1928
- });
1929
- }
1930
- /**
1931
- * Release a held mouse button.
1932
- */
1933
- async mouseUp(x, y, options) {
1934
- await this.post("/api/desktop/mouse/up", {
1935
- ...x !== void 0 && { x },
1936
- ...y !== void 0 && { y },
1937
- button: options?.button ?? "left"
1938
- });
1939
- }
1940
- /**
1941
- * Move the mouse cursor to coordinates.
1942
- */
1943
- async moveMouse(x, y) {
1944
- await this.post("/api/desktop/mouse/move", {
1945
- x,
1946
- y
1947
- });
1948
- }
1949
- /**
1950
- * Drag from start coordinates to end coordinates.
1951
- */
1952
- async drag(startX, startY, endX, endY, options) {
1953
- await this.post("/api/desktop/mouse/drag", {
1954
- startX,
1955
- startY,
1956
- endX,
1957
- endY,
1958
- button: options?.button ?? "left"
1959
- });
1960
- }
1961
- /**
1962
- * Scroll at coordinates in the specified direction.
1963
- */
1964
- async scroll(x, y, direction, amount = 3) {
1965
- await this.post("/api/desktop/mouse/scroll", {
1966
- x,
1967
- y,
1968
- direction,
1969
- amount
1970
- });
1971
- }
1972
- /**
1973
- * Get the current cursor coordinates.
1974
- */
1975
- async getCursorPosition() {
1976
- return await this.get("/api/desktop/mouse/position");
1977
- }
1978
- /**
1979
- * Type text into the focused element.
1980
- */
1981
- async type(text, options) {
1982
- await this.post("/api/desktop/keyboard/type", {
1983
- text,
1984
- ...options?.delayMs !== void 0 && { delayMs: options.delayMs }
1985
- });
1986
- }
1987
- /**
1988
- * Press and release a key or key combination.
1989
- */
1990
- async press(key) {
1991
- await this.post("/api/desktop/keyboard/press", { key });
1992
- }
1993
- /**
1994
- * Press and hold a key.
1995
- */
1996
- async keyDown(key) {
1997
- await this.post("/api/desktop/keyboard/down", { key });
1998
- }
1999
- /**
2000
- * Release a held key.
2001
- */
2002
- async keyUp(key) {
2003
- await this.post("/api/desktop/keyboard/up", { key });
2004
- }
2005
- /**
2006
- * Get the active desktop screen size.
2007
- */
2008
- async getScreenSize() {
2009
- return await this.get("/api/desktop/screen/size");
2010
- }
2011
- /**
2012
- * Get health status for a specific desktop process.
2013
- */
2014
- async getProcessStatus(name) {
2015
- return this.get(`/api/desktop/process/${encodeURIComponent(name)}/status`);
2016
- }
2017
- };
2018
-
2019
1743
  //#endregion
2020
1744
  //#region src/clients/file-client.ts
2021
1745
  /**
@@ -2365,40 +2089,9 @@ var InterpreterClient = class extends BaseHttpClient {
2365
2089
  //#endregion
2366
2090
  //#region src/clients/port-client.ts
2367
2091
  /**
2368
- * Client for port management and preview URL operations
2092
+ * Client for port readiness operations.
2369
2093
  */
2370
2094
  var PortClient = class extends BaseHttpClient {
2371
- /**
2372
- * Expose a port and get a preview URL
2373
- * @param port - Port number to expose
2374
- * @param sessionId - The session ID for this operation
2375
- * @param name - Optional name for the port
2376
- */
2377
- async exposePort(port, sessionId, name) {
2378
- const data = {
2379
- port,
2380
- sessionId,
2381
- name
2382
- };
2383
- return await this.post("/api/expose-port", data);
2384
- }
2385
- /**
2386
- * Unexpose a port and remove its preview URL
2387
- * @param port - Port number to unexpose
2388
- * @param sessionId - The session ID for this operation
2389
- */
2390
- async unexposePort(port, sessionId) {
2391
- const url = `/api/exposed-ports/${port}?session=${encodeURIComponent(sessionId)}`;
2392
- return await this.delete(url);
2393
- }
2394
- /**
2395
- * Get all currently exposed ports
2396
- * @param sessionId - The session ID for this operation
2397
- */
2398
- async getExposedPorts(sessionId) {
2399
- const url = `/api/exposed-ports?session=${encodeURIComponent(sessionId)}`;
2400
- return await this.get(url);
2401
- }
2402
2095
  /**
2403
2096
  * Watch a port for readiness via SSE stream
2404
2097
  * @param request - Port watch configuration
@@ -2649,7 +2342,6 @@ var SandboxClient = class {
2649
2342
  git;
2650
2343
  interpreter;
2651
2344
  utils;
2652
- desktop;
2653
2345
  watch;
2654
2346
  /**
2655
2347
  * Tunnels are RPC-only — the route-based transport does not implement them.
@@ -2682,7 +2374,6 @@ var SandboxClient = class {
2682
2374
  this.git = new GitClient(clientOptions);
2683
2375
  this.interpreter = new InterpreterClient(clientOptions);
2684
2376
  this.utils = new UtilityClient(clientOptions);
2685
- this.desktop = new DesktopClient(clientOptions);
2686
2377
  this.watch = new WatchClient(clientOptions);
2687
2378
  }
2688
2379
  /**
@@ -2703,7 +2394,6 @@ var SandboxClient = class {
2703
2394
  this.git.setRetryTimeoutMs(ms);
2704
2395
  this.interpreter.setRetryTimeoutMs(ms);
2705
2396
  this.utils.setRetryTimeoutMs(ms);
2706
- this.desktop.setRetryTimeoutMs(ms);
2707
2397
  this.watch.setRetryTimeoutMs(ms);
2708
2398
  }
2709
2399
  }
@@ -3332,32 +3022,6 @@ var ContainerControlClient = class {
3332
3022
  get backup() {
3333
3023
  return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
3334
3024
  }
3335
- get desktop() {
3336
- const stub = wrapStub(this.getConnection().rpc().desktop, this.renewActivity);
3337
- const wire = stub;
3338
- return new Proxy(stub, { get(target, prop, receiver) {
3339
- if (prop === "screenshot") return async (options) => {
3340
- const { format, ...rest } = options ?? {};
3341
- const result = await wire.screenshot(rest);
3342
- return format === "bytes" ? {
3343
- ...result,
3344
- data: base64ToBytes(result.data)
3345
- } : result;
3346
- };
3347
- if (prop === "screenshotRegion") return async (region, options) => {
3348
- const { format, ...rest } = options ?? {};
3349
- const result = await wire.screenshotRegion({
3350
- region,
3351
- ...rest
3352
- });
3353
- return format === "bytes" ? {
3354
- ...result,
3355
- data: base64ToBytes(result.data)
3356
- } : result;
3357
- };
3358
- return Reflect.get(target, prop, receiver);
3359
- } });
3360
- }
3361
3025
  get watch() {
3362
3026
  return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
3363
3027
  }
@@ -3390,6 +3054,89 @@ var ContainerControlClient = class {
3390
3054
  }
3391
3055
  };
3392
3056
 
3057
+ //#endregion
3058
+ //#region src/current-runtime-identity.ts
3059
+ const CURRENT_RUNTIME_IDENTITY_STORAGE_KEY = "currentRuntimeIdentity";
3060
+ var RuntimeIdentityInactiveError = class extends Error {
3061
+ constructor() {
3062
+ super("Runtime identity is no longer active");
3063
+ this.name = "RuntimeIdentityInactiveError";
3064
+ }
3065
+ };
3066
+ var RuntimeIdentity = class {
3067
+ id;
3068
+ constructor(record) {
3069
+ this.id = record.id;
3070
+ }
3071
+ owns(record) {
3072
+ return record.runtimeIdentityID === this.id;
3073
+ }
3074
+ scope(value) {
3075
+ return {
3076
+ ...value,
3077
+ runtimeIdentityID: this.id
3078
+ };
3079
+ }
3080
+ };
3081
+ var CurrentRuntimeIdentity = class {
3082
+ /**
3083
+ * Runtime identity is stored in Durable Object storage so a reconstructed DO
3084
+ * can still recognize the live container runtime it owns. In-memory state is
3085
+ * only a cache and cannot define runtime-scoped correctness.
3086
+ */
3087
+ constructor(storage, getContainerState, isContainerRunning) {
3088
+ this.storage = storage;
3089
+ this.getContainerState = getContainerState;
3090
+ this.isContainerRunning = isContainerRunning;
3091
+ }
3092
+ async get() {
3093
+ const status = await this.getStatus();
3094
+ return status.status === "active" ? status.runtime : null;
3095
+ }
3096
+ async getStatus() {
3097
+ const state = await this.getContainerState();
3098
+ if (state.status !== "healthy") return {
3099
+ status: "inactive",
3100
+ reason: "runtime-not-healthy",
3101
+ containerStatus: state.status
3102
+ };
3103
+ if (!this.isContainerRunning()) return {
3104
+ status: "inactive",
3105
+ reason: "runtime-not-running",
3106
+ containerStatus: state.status
3107
+ };
3108
+ const runtime = await this.getStored();
3109
+ if (!runtime) return {
3110
+ status: "inactive",
3111
+ reason: "missing-runtime-id",
3112
+ containerStatus: state.status
3113
+ };
3114
+ return {
3115
+ status: "active",
3116
+ runtime,
3117
+ containerStatus: state.status
3118
+ };
3119
+ }
3120
+ async getStored(storage = this.storage) {
3121
+ const record = await storage.get(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY) ?? null;
3122
+ return record ? new RuntimeIdentity(record) : null;
3123
+ }
3124
+ async markStarted() {
3125
+ const record = { id: crypto.randomUUID() };
3126
+ await this.storage.put(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY, record);
3127
+ return new RuntimeIdentity(record);
3128
+ }
3129
+ async clear() {
3130
+ await this.storage.delete(CURRENT_RUNTIME_IDENTITY_STORAGE_KEY);
3131
+ }
3132
+ async isActive(runtime) {
3133
+ return (await this.get())?.id === runtime.id;
3134
+ }
3135
+ async assertActive(runtime) {
3136
+ if (!await this.isActive(runtime)) throw new RuntimeIdentityInactiveError();
3137
+ }
3138
+ };
3139
+
3393
3140
  //#endregion
3394
3141
  //#region src/file-stream.ts
3395
3142
  /**
@@ -3582,6 +3329,32 @@ function validateLanguage(language) {
3582
3329
  const normalized = language.toLowerCase();
3583
3330
  if (!supportedLanguages.includes(normalized)) throw new SandboxSecurityError(`Unsupported language '${language}'. Supported languages: python, javascript, typescript`, "INVALID_LANGUAGE");
3584
3331
  }
3332
+ /**
3333
+ * Validates a single DNS label for use as a Cloudflare Tunnel hostname.
3334
+ *
3335
+ * Used by `sandbox.tunnels.get(port, { name })` to reject obviously-bad
3336
+ * input client-side before any network call. Whether the chosen label is
3337
+ * actually available under the configured zone is left to the Cloudflare
3338
+ * API (returned as a typed error).
3339
+ *
3340
+ * Rules:
3341
+ * - 1–63 characters
3342
+ * - Lowercase letters, digits, and internal hyphens only
3343
+ * - No leading or trailing hyphen
3344
+ * - No dots — multi-label hostnames need a delegated subdomain zone or
3345
+ * Advanced Certificate Manager, which are out of scope for this
3346
+ * feature. Universal SSL only covers `<label>.<zone>`.
3347
+ *
3348
+ * Throws `SandboxSecurityError` on any violation. Designed to be called
3349
+ * before any other tunnel work so callers see a fast, deterministic
3350
+ * failure.
3351
+ */
3352
+ const TUNNEL_NAME_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
3353
+ function validateTunnelName(name) {
3354
+ if (typeof name !== "string") throw new SandboxSecurityError(`Tunnel name must be a string. Received: ${typeof name}`, "INVALID_TUNNEL_NAME");
3355
+ if (name.length === 0 || name.length > 63) throw new SandboxSecurityError(`Tunnel name '${name}' must be 1–63 characters long.`, "INVALID_TUNNEL_NAME_LENGTH");
3356
+ 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");
3357
+ }
3585
3358
 
3586
3359
  //#endregion
3587
3360
  //#region src/interpreter.ts
@@ -4237,118 +4010,138 @@ function base64ToUint8Array(base64) {
4237
4010
  }
4238
4011
 
4239
4012
  //#endregion
4240
- //#region src/pty/proxy.ts
4241
- async function proxyTerminal(stub, sessionId, request, options) {
4242
- if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId is required for terminal access");
4243
- if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") throw new Error("terminal() requires a WebSocket upgrade request");
4244
- const params = new URLSearchParams({ sessionId });
4245
- if (options?.cols) params.set("cols", String(options.cols));
4246
- if (options?.rows) params.set("rows", String(options.rows));
4247
- if (options?.shell) params.set("shell", options.shell);
4248
- const ptyUrl = `http://localhost/ws/pty?${params}`;
4249
- const ptyRequest = new Request(ptyUrl, request);
4250
- return stub.fetch(switchPort(ptyRequest, 3e3));
4251
- }
4252
-
4253
- //#endregion
4254
- //#region src/request-handler.ts
4255
- async function proxyToSandbox(request, env$1) {
4256
- const logger = createLogger({
4257
- component: "sandbox-do",
4258
- traceId: TraceContext.fromHeaders(request.headers) || TraceContext.generate(),
4259
- operation: "proxy"
4260
- });
4013
+ //#region src/preview-forwarding.ts
4014
+ async function forwardPreviewRequest(tcpPort, request, lifecycle) {
4015
+ const containerURL = request.url.replace("https:", "http:");
4016
+ const settleForward = lifecycle.beginForward();
4261
4017
  try {
4262
- const url = new URL(request.url);
4263
- const routeInfo = extractSandboxRoute(url);
4264
- if (!routeInfo) return null;
4265
- const { sandboxId, port, path: path$1, token } = routeInfo;
4266
- const sandbox = getSandbox(env$1.Sandbox, sandboxId, { normalizeId: true });
4267
- if (port !== 3e3) {
4268
- if (!await sandbox.validatePortToken(port, token)) {
4269
- logger.warn("Invalid token access blocked", {
4270
- port,
4271
- sandboxId,
4272
- path: path$1,
4273
- hostname: url.hostname,
4274
- url: request.url,
4275
- method: request.method,
4276
- userAgent: request.headers.get("User-Agent") || "unknown"
4277
- });
4278
- return new Response(JSON.stringify({
4279
- error: `Access denied: Invalid token or port not exposed`,
4280
- code: "INVALID_TOKEN"
4281
- }), {
4282
- status: 404,
4283
- headers: { "Content-Type": "application/json" }
4284
- });
4285
- }
4018
+ const response = await tcpPort.fetch(containerURL, request);
4019
+ if (response.webSocket !== null) return {
4020
+ status: "response",
4021
+ response: bridgePreviewWebSocket(response, lifecycle, settleForward)
4022
+ };
4023
+ if (response.body !== null) {
4024
+ const { readable, writable } = new TransformStream();
4025
+ response.body.pipeTo(writable).finally(settleForward).catch(() => {});
4026
+ return {
4027
+ status: "response",
4028
+ response: new Response(readable, response)
4029
+ };
4286
4030
  }
4287
- if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") return await sandbox.fetch(switchPort(request, port));
4288
- let proxyUrl;
4289
- if (port !== 3e3) proxyUrl = `http://localhost:${port}${path$1}${url.search}`;
4290
- else proxyUrl = `http://localhost:3000${path$1}${url.search}`;
4291
- const headers = {
4292
- "X-Original-URL": request.url,
4293
- "X-Forwarded-Host": url.hostname,
4294
- "X-Forwarded-Proto": url.protocol.replace(":", ""),
4295
- "X-Sandbox-Name": sandboxId
4031
+ settleForward();
4032
+ return {
4033
+ status: "response",
4034
+ response
4296
4035
  };
4297
- request.headers.forEach((value, key) => {
4298
- headers[key] = value;
4299
- });
4300
- const proxyRequest = new Request(proxyUrl, {
4301
- method: request.method,
4302
- headers,
4303
- body: request.body,
4304
- duplex: "half",
4305
- redirect: "manual"
4306
- });
4307
- return await sandbox.containerFetch(proxyRequest, port);
4308
4036
  } catch (error) {
4309
- logger.error("Proxy routing error", error instanceof Error ? error : new Error(String(error)));
4310
- return new Response("Proxy routing error", { status: 500 });
4311
- }
4312
- }
4313
- function extractSandboxRoute(url) {
4314
- const dotIndex = url.hostname.indexOf(".");
4315
- if (dotIndex === -1) return null;
4316
- const subdomain = url.hostname.slice(0, dotIndex);
4317
- url.hostname.slice(dotIndex + 1);
4318
- const firstHyphen = subdomain.indexOf("-");
4319
- if (firstHyphen === -1) return null;
4320
- const portStr = subdomain.slice(0, firstHyphen);
4321
- if (!/^\d{4,5}$/.test(portStr)) return null;
4322
- const port = parseInt(portStr, 10);
4323
- if (!validatePort(port)) return null;
4324
- const rest = subdomain.slice(firstHyphen + 1);
4325
- const lastHyphen = rest.lastIndexOf("-");
4326
- if (lastHyphen === -1) return null;
4327
- const sandboxId = rest.slice(0, lastHyphen);
4328
- const token = rest.slice(lastHyphen + 1);
4329
- if (!/^[a-z0-9_]+$/.test(token) || token.length === 0 || token.length > 63) return null;
4330
- if (sandboxId.length === 0 || sandboxId.length > 63) return null;
4331
- let sanitizedSandboxId;
4332
- try {
4333
- sanitizedSandboxId = sanitizeSandboxId(sandboxId);
4334
- } catch {
4335
- return null;
4037
+ settleForward();
4038
+ if (error instanceof Error && error.message.includes("Network connection lost.")) return { status: "network-lost" };
4039
+ throw error;
4336
4040
  }
4337
- return {
4338
- port,
4339
- sandboxId: sanitizedSandboxId,
4340
- path: url.pathname || "/",
4341
- token
4041
+ }
4042
+ function bridgePreviewWebSocket(response, lifecycle, settleForward) {
4043
+ const containerWebSocket = response.webSocket;
4044
+ if (containerWebSocket === null) {
4045
+ settleForward();
4046
+ return response;
4047
+ }
4048
+ const [client, server] = Object.values(new WebSocketPair());
4049
+ let settled = false;
4050
+ const settle = () => {
4051
+ if (!settled) {
4052
+ settled = true;
4053
+ settleForward();
4054
+ }
4342
4055
  };
4056
+ containerWebSocket.accept();
4057
+ server.accept();
4058
+ server.addEventListener("message", async (event) => {
4059
+ lifecycle.renewActivity();
4060
+ try {
4061
+ const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
4062
+ containerWebSocket.send(data);
4063
+ } catch {
4064
+ server.close(1011, "Failed to forward message to container");
4065
+ }
4066
+ });
4067
+ containerWebSocket.addEventListener("message", async (event) => {
4068
+ lifecycle.renewActivity();
4069
+ try {
4070
+ const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
4071
+ server.send(data);
4072
+ } catch {
4073
+ containerWebSocket.close(1011, "Failed to forward message to client");
4074
+ }
4075
+ });
4076
+ server.addEventListener("close", (event) => {
4077
+ settle();
4078
+ const code = event.code === 1005 || event.code === 1006 ? 1e3 : event.code;
4079
+ containerWebSocket.close(code, event.reason);
4080
+ });
4081
+ containerWebSocket.addEventListener("close", (event) => {
4082
+ settle();
4083
+ const code = event.code === 1005 || event.code === 1006 ? 1e3 : event.code;
4084
+ server.close(code, event.reason);
4085
+ });
4086
+ server.addEventListener("error", () => {
4087
+ settle();
4088
+ containerWebSocket.close(1011, "Client WebSocket error");
4089
+ });
4090
+ containerWebSocket.addEventListener("error", () => {
4091
+ settle();
4092
+ server.close(1011, "Container WebSocket error");
4093
+ });
4094
+ return new Response(null, {
4095
+ status: response.status,
4096
+ webSocket: client,
4097
+ headers: response.headers
4098
+ });
4343
4099
  }
4100
+
4101
+ //#endregion
4102
+ //#region src/preview-proxy-protocol.ts
4103
+ /** @internal */
4104
+ const PREVIEW_PROXY_HEADER = "x-sandbox-preview-proxy";
4105
+ /** @internal */
4106
+ const PREVIEW_PROXY_PORT_HEADER = "x-sandbox-preview-port";
4107
+ /** @internal */
4108
+ const PREVIEW_PROXY_TOKEN_HEADER = "x-sandbox-preview-token";
4109
+ /** @internal */
4110
+ const PREVIEW_PROXY_SANDBOX_ID_HEADER = "x-sandbox-preview-sandbox-id";
4111
+ /** @internal */
4112
+ const PREVIEW_PROXY_HEADERS = [
4113
+ PREVIEW_PROXY_HEADER,
4114
+ PREVIEW_PROXY_PORT_HEADER,
4115
+ PREVIEW_PROXY_TOKEN_HEADER,
4116
+ PREVIEW_PROXY_SANDBOX_ID_HEADER
4117
+ ];
4118
+
4119
+ //#endregion
4120
+ //#region src/preview-url.ts
4344
4121
  function isLocalhostPattern(hostname) {
4345
- if (hostname.startsWith("[")) if (hostname.includes("]:")) return hostname.substring(0, hostname.indexOf("]:") + 1) === "[::1]";
4346
- else return hostname === "[::1]";
4122
+ if (hostname.startsWith("[")) {
4123
+ if (hostname.includes("]:")) return hostname.substring(0, hostname.indexOf("]:") + 1) === "[::1]";
4124
+ return hostname === "[::1]";
4125
+ }
4347
4126
  if (hostname === "::1") return true;
4348
4127
  const hostPart = hostname.split(":")[0];
4349
4128
  return hostPart === "localhost" || hostPart === "127.0.0.1" || hostPart === "0.0.0.0";
4350
4129
  }
4351
4130
 
4131
+ //#endregion
4132
+ //#region src/pty/proxy.ts
4133
+ async function proxyTerminal(stub, sessionId, request, options) {
4134
+ if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId is required for terminal access");
4135
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") throw new Error("terminal() requires a WebSocket upgrade request");
4136
+ const params = new URLSearchParams({ sessionId });
4137
+ if (options?.cols) params.set("cols", String(options.cols));
4138
+ if (options?.rows) params.set("rows", String(options.rows));
4139
+ if (options?.shell) params.set("shell", options.shell);
4140
+ const ptyUrl = `http://localhost/ws/pty?${params}`;
4141
+ const ptyRequest = new Request(ptyUrl, request);
4142
+ return stub.fetch(switchPort(ptyRequest, 3e3));
4143
+ }
4144
+
4352
4145
  //#endregion
4353
4146
  //#region src/storage-mount/r2-egress-handler.ts
4354
4147
  const XML_NS = "xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"";
@@ -4678,11 +4471,590 @@ const r2EgressHandler = async (request, env$1, ctx) => {
4678
4471
  };
4679
4472
 
4680
4473
  //#endregion
4681
- //#region src/tunnels/sandbox-control-callback.ts
4682
- var SandboxControlCallbackImpl = class extends RpcTarget {
4683
- constructor(getHandler, logger) {
4684
- super();
4685
- this.getHandler = getHandler;
4474
+ //#region src/storage-mount/s3-credential-proxy-handler.ts
4475
+ const PER_MOUNT_SUFFIX = ".s3-credential-proxy.internal";
4476
+ const SELF_TEST_PATH = "/__sandbox_credential_proxy_self_test__";
4477
+ const DIAGNOSTICS_PATH = "/__sandbox_credential_proxy_diagnostics__";
4478
+ const DEFAULT_SLOW_REQUEST_MS = 1e3;
4479
+ const ERROR_RESPONSE_BODY_LIMIT = 2048;
4480
+ const MAX_DIAGNOSTIC_EVENTS = 500;
4481
+ const DUMMY_AUTH_HEADERS = new Set([
4482
+ "authorization",
4483
+ "x-amz-date",
4484
+ "x-amz-content-sha256",
4485
+ "x-amz-security-token",
4486
+ "x-goog-date",
4487
+ "x-goog-content-sha256"
4488
+ ]);
4489
+ const sigV4ClientCache = /* @__PURE__ */ new Map();
4490
+ const directoryMarkerCache = /* @__PURE__ */ new Map();
4491
+ const credentialProxyDiagnosticEvents = [];
4492
+ let credentialProxyDiagnosticEventCount = 0;
4493
+ function evictSigV4ClientCacheEntry(mountId) {
4494
+ sigV4ClientCache.delete(mountId);
4495
+ }
4496
+ function evictDirectoryMarkerCacheForMount(mountId) {
4497
+ const prefix = `${mountId}:`;
4498
+ for (const key of directoryMarkerCache.keys()) if (key.startsWith(prefix)) directoryMarkerCache.delete(key);
4499
+ }
4500
+ function toHex(buffer) {
4501
+ return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
4502
+ }
4503
+ async function sha256Hex(data) {
4504
+ return toHex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data)));
4505
+ }
4506
+ async function hmacSHA256(key, data) {
4507
+ const cryptoKey = await crypto.subtle.importKey("raw", key, {
4508
+ name: "HMAC",
4509
+ hash: "SHA-256"
4510
+ }, false, ["sign"]);
4511
+ return crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
4512
+ }
4513
+ function detectS3Region(provider, endpoint) {
4514
+ if (provider === "r2") return "auto";
4515
+ try {
4516
+ const host = new URL(endpoint).hostname;
4517
+ const m = host.match(/s3[.-]([a-z0-9-]+)\.amazonaws\.com/);
4518
+ if (m && m[1] !== "amazonaws") return m[1];
4519
+ if (host === "s3.amazonaws.com") return "us-east-1";
4520
+ } catch {}
4521
+ return "auto";
4522
+ }
4523
+ function buildCleanHeaders(original) {
4524
+ const clean = new Headers();
4525
+ for (const [k, v] of original) {
4526
+ const lower = k.toLowerCase();
4527
+ if (!DUMMY_AUTH_HEADERS.has(lower) && lower !== "host") clean.set(k, v);
4528
+ }
4529
+ const contentSHA256 = original.get("x-amz-content-sha256");
4530
+ if (contentSHA256 && isValidContentSHA256(contentSHA256)) clean.set("x-amz-content-sha256", contentSHA256);
4531
+ return clean;
4532
+ }
4533
+ function isValidContentSHA256(value) {
4534
+ return value === "UNSIGNED-PAYLOAD" || /^[a-fA-F0-9]{64}$/.test(value);
4535
+ }
4536
+ function getCredentialProxyDebugConfig(env$1) {
4537
+ const envRecord = env$1;
4538
+ const enabled = envRecord.SANDBOX_CREDENTIAL_PROXY_DEBUG === "true";
4539
+ const diagnosticsEndpointEnabled = envRecord.SANDBOX_CREDENTIAL_PROXY_DIAGNOSTICS_ENDPOINT === "true";
4540
+ const configuredSlowRequestMs = Number(envRecord.SANDBOX_CREDENTIAL_PROXY_SLOW_REQUEST_MS);
4541
+ return {
4542
+ diagnosticsEndpointEnabled,
4543
+ enabled,
4544
+ slowRequestMs: Number.isFinite(configuredSlowRequestMs) && configuredSlowRequestMs >= 0 ? configuredSlowRequestMs : DEFAULT_SLOW_REQUEST_MS
4545
+ };
4546
+ }
4547
+ function recordCredentialProxyDiagnosticEvent(event) {
4548
+ credentialProxyDiagnosticEvents.push(event);
4549
+ credentialProxyDiagnosticEventCount++;
4550
+ while (credentialProxyDiagnosticEvents.length > MAX_DIAGNOSTIC_EVENTS) credentialProxyDiagnosticEvents.shift();
4551
+ }
4552
+ function getCredentialProxyDiagnosticsResponse(url, containerId) {
4553
+ const since = Number(url.searchParams.get("since") ?? "0");
4554
+ const bufferStartCount = credentialProxyDiagnosticEventCount - credentialProxyDiagnosticEvents.length;
4555
+ const events = credentialProxyDiagnosticEvents.filter((event, index) => {
4556
+ if (event.containerId !== containerId) return false;
4557
+ return !Number.isFinite(since) || bufferStartCount + index >= since;
4558
+ });
4559
+ return Response.json({
4560
+ nextCursor: credentialProxyDiagnosticEventCount,
4561
+ events
4562
+ });
4563
+ }
4564
+ async function withCredentialProxyDiagnostics(requestInfo, debugConfig, containerId, path$1, operation) {
4565
+ const started = Date.now();
4566
+ try {
4567
+ const response = await operation();
4568
+ const durationMs = Date.now() - started;
4569
+ if (debugConfig.enabled) recordCredentialProxyDiagnosticEvent({
4570
+ ...requestInfo,
4571
+ containerId,
4572
+ durationMs,
4573
+ ok: response.ok,
4574
+ path: path$1,
4575
+ status: response.status,
4576
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4577
+ });
4578
+ if (debugConfig.enabled || durationMs >= debugConfig.slowRequestMs) console.info("sandbox.s3_credential_proxy.request", {
4579
+ ...requestInfo,
4580
+ durationMs,
4581
+ ok: response.ok,
4582
+ status: response.status,
4583
+ responseContentLength: response.headers.get("content-length")
4584
+ });
4585
+ if (!response.ok) {
4586
+ const responseForLog = response.clone();
4587
+ const requestInfoSnapshot = { ...requestInfo };
4588
+ responseForLog.text().then((body) => {
4589
+ console.warn("sandbox.s3_credential_proxy.upstream_error", {
4590
+ ...requestInfoSnapshot,
4591
+ durationMs,
4592
+ status: response.status,
4593
+ statusText: response.statusText,
4594
+ errorBody: body.slice(0, ERROR_RESPONSE_BODY_LIMIT)
4595
+ });
4596
+ }).catch(() => {});
4597
+ }
4598
+ return response;
4599
+ } catch (error) {
4600
+ const durationMs = Date.now() - started;
4601
+ console.warn("sandbox.s3_credential_proxy.request_error", {
4602
+ ...requestInfo,
4603
+ durationMs,
4604
+ error: error instanceof Error ? error.message : String(error)
4605
+ });
4606
+ throw error;
4607
+ }
4608
+ }
4609
+ function getSigV4Client(mountId, endpoint, provider, credentials, region) {
4610
+ const cached = sigV4ClientCache.get(mountId);
4611
+ if (cached && cached.accessKeyId === credentials.accessKeyId && cached.secretAccessKey === credentials.secretAccessKey && cached.endpoint === endpoint && cached.provider === provider && cached.region === region) return cached.client;
4612
+ const client = new AwsClient({
4613
+ accessKeyId: credentials.accessKeyId,
4614
+ secretAccessKey: credentials.secretAccessKey,
4615
+ service: "s3",
4616
+ region,
4617
+ retries: 0
4618
+ });
4619
+ sigV4ClientCache.set(mountId, {
4620
+ client,
4621
+ accessKeyId: credentials.accessKeyId,
4622
+ secretAccessKey: credentials.secretAccessKey,
4623
+ endpoint,
4624
+ provider,
4625
+ region
4626
+ });
4627
+ return client;
4628
+ }
4629
+ function encodeCanonicalQueryPart(value) {
4630
+ return encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
4631
+ }
4632
+ function getCanonicalURI(url) {
4633
+ return url.pathname.split("/").map((segment) => segment.split(/(%[0-9A-Fa-f]{2})/g).map((part) => /^%[0-9A-Fa-f]{2}$/.test(part) ? part.toUpperCase() : encodeCanonicalQueryPart(part)).join("")).join("/");
4634
+ }
4635
+ function getCanonicalQueryString(url) {
4636
+ const query = url.search.startsWith("?") ? url.search.slice(1) : url.search;
4637
+ if (!query) return "";
4638
+ return query.split("&").map((part) => {
4639
+ const separatorIndex = part.indexOf("=");
4640
+ if (separatorIndex === -1) return [part, ""];
4641
+ return [part.slice(0, separatorIndex), part.slice(separatorIndex + 1)];
4642
+ }).map(([key, value]) => [encodeCanonicalQueryPart(decodeURIEncodedQueryPart(key)), encodeCanonicalQueryPart(decodeURIEncodedQueryPart(value))]).sort(([leftKey, leftValue], [rightKey, rightValue]) => {
4643
+ if (leftKey < rightKey) return -1;
4644
+ if (leftKey > rightKey) return 1;
4645
+ if (leftValue < rightValue) return -1;
4646
+ if (leftValue > rightValue) return 1;
4647
+ return 0;
4648
+ }).map(([key, value]) => `${key}=${value}`).join("&");
4649
+ }
4650
+ function decodeURIEncodedQueryPart(value) {
4651
+ try {
4652
+ return decodeURIComponent(value);
4653
+ } catch {
4654
+ return value;
4655
+ }
4656
+ }
4657
+ function getSigV4PayloadHash(headers) {
4658
+ const existingHash = headers.get("x-amz-content-sha256");
4659
+ if (existingHash && existingHash !== "UNSIGNED-PAYLOAD") return {
4660
+ hash: existingHash,
4661
+ mode: "signed"
4662
+ };
4663
+ return {
4664
+ hash: "UNSIGNED-PAYLOAD",
4665
+ mode: "unsigned"
4666
+ };
4667
+ }
4668
+ function isZeroLengthDirectoryMarkerPUT(request, realPath) {
4669
+ return request.method.toUpperCase() === "PUT" && request.headers.get("content-length") === "0" && realPath.endsWith("/");
4670
+ }
4671
+ function isDirectoryMarkerHEAD(request) {
4672
+ return request.method.toUpperCase() === "HEAD";
4673
+ }
4674
+ function getDirectoryMarkerCacheKey(mountId, realPath) {
4675
+ return `${mountId}:${realPath.replace(/\/+$/, "")}`;
4676
+ }
4677
+ function getDirectoryMarkerResponseHeaders(request) {
4678
+ const headers = [
4679
+ ["Accept-Ranges", "bytes"],
4680
+ ["Content-Length", "0"],
4681
+ ["ETag", "\"d41d8cd98f00b204e9800998ecf8427e\""],
4682
+ ["Last-Modified", (/* @__PURE__ */ new Date()).toUTCString()]
4683
+ ];
4684
+ const contentType = request.headers.get("content-type");
4685
+ if (contentType) headers.push(["Content-Type", contentType]);
4686
+ for (const [name, value] of request.headers) if (name.toLowerCase().startsWith("x-amz-meta-")) headers.push([name, value]);
4687
+ return headers;
4688
+ }
4689
+ function normalizePrefix(prefix) {
4690
+ if (!prefix) return void 0;
4691
+ return prefix.replace(/^\/+/, "").replace(/\/+$/, "");
4692
+ }
4693
+ function getObjectKeyForPath(realPath, bucket) {
4694
+ const pathSegments = realPath.split("/").filter(Boolean);
4695
+ if (pathSegments[0] !== bucket) return null;
4696
+ return pathSegments.slice(1).join("/");
4697
+ }
4698
+ function isObjectKeyWithinPrefix(objectKey, prefix) {
4699
+ const normalizedPrefix = normalizePrefix(prefix);
4700
+ if (!normalizedPrefix) return true;
4701
+ return objectKey === normalizedPrefix || objectKey.startsWith(`${normalizedPrefix}/`);
4702
+ }
4703
+ function isRequestWithinMountScope(realPath, url, bucket, prefix) {
4704
+ const objectKey = getObjectKeyForPath(realPath, bucket);
4705
+ if (objectKey === null) return false;
4706
+ const requestedPrefix = url.searchParams.get("prefix");
4707
+ if (objectKey !== "" && !isObjectKeyWithinPrefix(objectKey, prefix)) return false;
4708
+ if (objectKey === "" && normalizePrefix(prefix) !== void 0 && url.search !== "" && requestedPrefix === null) return false;
4709
+ if (requestedPrefix !== null) return isObjectKeyWithinPrefix(requestedPrefix, prefix);
4710
+ return true;
4711
+ }
4712
+ function isBucketRootProbe(request, realPath, url, bucket) {
4713
+ const method = request.method.toUpperCase();
4714
+ return (method === "GET" || method === "HEAD") && url.search === "" && getObjectKeyForPath(realPath, bucket) === "";
4715
+ }
4716
+ function deleteDirectoryMarkerCacheEntry(mountId, realPath) {
4717
+ directoryMarkerCache.delete(getDirectoryMarkerCacheKey(mountId, realPath));
4718
+ }
4719
+ function getContentLength(request) {
4720
+ const contentLength = request.headers.get("content-length");
4721
+ if (contentLength === null) return null;
4722
+ const parsed = Number(contentLength);
4723
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
4724
+ return parsed;
4725
+ }
4726
+ function getSigV4ForwardInit(request) {
4727
+ const contentLength = getContentLength(request);
4728
+ if (contentLength === 0) return { body: new Uint8Array(0) };
4729
+ if (contentLength === null || request.body === null) return {};
4730
+ const { readable, writable } = new FixedLengthStream(contentLength);
4731
+ request.body.pipeTo(writable).catch((error) => {
4732
+ writable.abort(error).catch(() => {});
4733
+ });
4734
+ return { body: readable };
4735
+ }
4736
+ function getGCSHeaders(request) {
4737
+ const headers = new Headers();
4738
+ for (const [k, v] of request.headers) {
4739
+ const lower = k.toLowerCase();
4740
+ if (DUMMY_AUTH_HEADERS.has(lower) || lower === "host" || lower === "content-length" || lower === "expect") continue;
4741
+ if (lower.startsWith("x-amz-meta-")) {
4742
+ headers.set(`x-goog-meta-${lower.slice(11)}`, v);
4743
+ continue;
4744
+ }
4745
+ if (lower.startsWith("x-amz-")) continue;
4746
+ headers.set(k, v);
4747
+ }
4748
+ return headers;
4749
+ }
4750
+ async function signAndForwardSigV4(request, mountId, endpoint, provider, credentials, requestInfo) {
4751
+ const signingStarted = Date.now();
4752
+ const region = detectS3Region(provider, endpoint);
4753
+ const payload = getSigV4PayloadHash(request.headers);
4754
+ const client = getSigV4Client(mountId, endpoint, provider, credentials, region);
4755
+ requestInfo.payloadHashMode = payload.mode;
4756
+ requestInfo.clientSetupMs = Date.now() - signingStarted;
4757
+ const upstreamStarted = Date.now();
4758
+ const forwardInit = getSigV4ForwardInit(request);
4759
+ requestInfo.bodyPresent = forwardInit?.body !== void 0 || request.body !== null;
4760
+ const response = await client.fetch(request, forwardInit);
4761
+ requestInfo.upstreamMs = Date.now() - upstreamStarted;
4762
+ return response;
4763
+ }
4764
+ async function signAndForwardGCS(request, credentials, requestInfo) {
4765
+ const url = new URL(request.url);
4766
+ const gcsHeaders = getGCSHeaders(request);
4767
+ const dateStr = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:-]/g, "").replace(/\.\d+Z$/, "")}Z`;
4768
+ const dateOnly = dateStr.slice(0, 8);
4769
+ const location = "auto";
4770
+ const service = "storage";
4771
+ const credentialScope = `${dateOnly}/${location}/${service}/goog4_request`;
4772
+ const bodyHash = "UNSIGNED-PAYLOAD";
4773
+ const headerEntries = [
4774
+ ["host", url.host],
4775
+ ["x-goog-content-sha256", bodyHash],
4776
+ ["x-goog-date", dateStr]
4777
+ ];
4778
+ for (const [k, v] of gcsHeaders) headerEntries.push([k.toLowerCase(), v.trim()]);
4779
+ headerEntries.sort((a, b) => a[0].localeCompare(b[0]));
4780
+ const signedHeaders = headerEntries.map(([k]) => k).join(";");
4781
+ const canonicalHeaders = headerEntries.map(([k, v]) => `${k}:${v}\n`).join("");
4782
+ const stringToSign = [
4783
+ "GOOG4-HMAC-SHA256",
4784
+ dateStr,
4785
+ credentialScope,
4786
+ await sha256Hex([
4787
+ request.method,
4788
+ getCanonicalURI(url),
4789
+ getCanonicalQueryString(url),
4790
+ canonicalHeaders,
4791
+ signedHeaders,
4792
+ bodyHash
4793
+ ].join("\n"))
4794
+ ].join("\n");
4795
+ const signature = toHex(await hmacSHA256(await hmacSHA256(await hmacSHA256(await hmacSHA256(await hmacSHA256(new TextEncoder().encode(`GOOG4${credentials.secretAccessKey}`), dateOnly), location), service), "goog4_request"), stringToSign));
4796
+ const authorization = `GOOG4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
4797
+ const newHeaders = new Headers(gcsHeaders);
4798
+ newHeaders.set("x-goog-date", dateStr);
4799
+ newHeaders.set("x-goog-content-sha256", bodyHash);
4800
+ newHeaders.set("Authorization", authorization);
4801
+ const gcsBody = getContentLength(request) === 0 ? new Uint8Array(0) : request.body;
4802
+ const upstreamStarted = Date.now();
4803
+ const response = await fetch(new Request(request.url, {
4804
+ method: request.method,
4805
+ headers: newHeaders,
4806
+ body: gcsBody
4807
+ }));
4808
+ requestInfo.upstreamMs = Date.now() - upstreamStarted;
4809
+ return response;
4810
+ }
4811
+ const s3CredentialProxyHandler = async (request, env$1, ctx) => {
4812
+ const url = new URL(request.url);
4813
+ if (url.pathname === SELF_TEST_PATH) return new Response("OK", { status: 200 });
4814
+ const debugConfig = getCredentialProxyDebugConfig(env$1);
4815
+ if (url.pathname === DIAGNOSTICS_PATH) {
4816
+ if (!debugConfig.enabled || !debugConfig.diagnosticsEndpointEnabled) return new Response("Not Found", { status: 404 });
4817
+ return getCredentialProxyDiagnosticsResponse(url, ctx.containerId);
4818
+ }
4819
+ const segments = url.pathname.split("/").filter(Boolean);
4820
+ const hostname = url.hostname;
4821
+ let mountId;
4822
+ let realPath;
4823
+ if (hostname.endsWith(PER_MOUNT_SUFFIX)) {
4824
+ mountId = hostname.slice(0, -29);
4825
+ realPath = url.pathname;
4826
+ } else {
4827
+ mountId = segments[0] ?? null;
4828
+ realPath = mountId ? url.pathname.slice(`/${mountId}`.length) || "/" : "/";
4829
+ }
4830
+ if (!mountId) return new Response("Bad Request: missing mount ID", { status: 400 });
4831
+ const mount = ctx.params?.mounts[mountId];
4832
+ if (!mount) return new Response(`Forbidden: unknown mount ID "${mountId}"`, { status: 403 });
4833
+ if (mount.readOnly) {
4834
+ const method = request.method.toUpperCase();
4835
+ if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") return new Response("Forbidden: bucket mount is read-only", { status: 403 });
4836
+ }
4837
+ const realUrl = new URL(realPath + (url.search || ""), mount.endpoint);
4838
+ if (isBucketRootProbe(request, realPath, url, mount.bucket)) return new Response(null, { status: 200 });
4839
+ if (!isRequestWithinMountScope(realPath, url, mount.bucket, mount.prefix)) return new Response("Forbidden: request is outside mounted bucket scope", { status: 403 });
4840
+ const cleanHeaders = buildCleanHeaders(request.headers);
4841
+ const cleanRequest = new Request(realUrl.toString(), {
4842
+ method: request.method,
4843
+ headers: cleanHeaders,
4844
+ body: request.body
4845
+ });
4846
+ const requestInfo = {
4847
+ authStrategy: mount.authStrategy,
4848
+ bucket: mount.bucket,
4849
+ contentLength: request.headers.get("content-length"),
4850
+ method: request.method,
4851
+ mountId,
4852
+ query: [...url.searchParams.keys()].sort()
4853
+ };
4854
+ if (isZeroLengthDirectoryMarkerPUT(cleanRequest, realPath)) {
4855
+ const responseHeaders = getDirectoryMarkerResponseHeaders(cleanRequest);
4856
+ requestInfo.bodyPresent = request.body !== null;
4857
+ if (mount.authStrategy === "s3-sigv4") requestInfo.payloadHashMode = getSigV4PayloadHash(cleanRequest.headers).mode;
4858
+ return withCredentialProxyDiagnostics(requestInfo, debugConfig, ctx.containerId, realPath, async () => {
4859
+ const response = mount.authStrategy === "gcs" ? await signAndForwardGCS(cleanRequest, mount.credentials, requestInfo) : await signAndForwardSigV4(cleanRequest, mountId, mount.endpoint, mount.provider, mount.credentials, requestInfo);
4860
+ if (response.ok) directoryMarkerCache.set(getDirectoryMarkerCacheKey(mountId, realPath), responseHeaders);
4861
+ return response;
4862
+ });
4863
+ }
4864
+ if (isDirectoryMarkerHEAD(cleanRequest)) {
4865
+ const responseHeaders = directoryMarkerCache.get(getDirectoryMarkerCacheKey(mountId, realPath));
4866
+ if (responseHeaders) {
4867
+ requestInfo.bodyPresent = false;
4868
+ if (mount.authStrategy === "s3-sigv4") requestInfo.payloadHashMode = getSigV4PayloadHash(cleanRequest.headers).mode;
4869
+ return withCredentialProxyDiagnostics(requestInfo, debugConfig, ctx.containerId, realPath, () => Promise.resolve(new Response(null, {
4870
+ status: 200,
4871
+ headers: responseHeaders
4872
+ })));
4873
+ }
4874
+ }
4875
+ if (cleanRequest.method.toUpperCase() !== "HEAD") deleteDirectoryMarkerCacheEntry(mountId, realPath);
4876
+ if (mount.authStrategy === "gcs") return withCredentialProxyDiagnostics(requestInfo, debugConfig, ctx.containerId, realPath, () => signAndForwardGCS(cleanRequest, mount.credentials, requestInfo));
4877
+ return withCredentialProxyDiagnostics(requestInfo, debugConfig, ctx.containerId, realPath, () => signAndForwardSigV4(cleanRequest, mountId, mount.endpoint, mount.provider, mount.credentials, requestInfo));
4878
+ };
4879
+
4880
+ //#endregion
4881
+ //#region src/tunnels/credentials.ts
4882
+ /**
4883
+ * Resolve a Cloudflare account id from environment with documented
4884
+ * precedence. Used by features that need to address a specific account
4885
+ * (Cloudflare Tunnel, R2 backup) to find their account id without
4886
+ * forcing every caller to set the same env var.
4887
+ *
4888
+ * Precedence (first non-empty wins):
4889
+ * 1. The feature-specific override env var (e.g. `CLOUDFLARE_TUNNEL_ACCOUNT_ID`).
4890
+ * 2. `CLOUDFLARE_ACCOUNT_ID`.
4891
+ * 3. The single account `CLOUDFLARE_API_TOKEN` is scoped to, via
4892
+ * `GET /user/tokens/verify`. Multi-account tokens are rejected.
4893
+ *
4894
+ * The resolver is feature-agnostic; only the `overrideKey` differs per
4895
+ * caller. Throws on any failure with a message that names the env vars
4896
+ * the caller can set to fix it.
4897
+ */
4898
+ const TOKEN_VERIFY_URL = "https://api.cloudflare.com/client/v4/user/tokens/verify";
4899
+ const ACCOUNTS_LIST_URL = "https://api.cloudflare.com/client/v4/accounts";
4900
+ /**
4901
+ * Per-request timeout for the credential introspection calls below.
4902
+ * Without one a hung Cloudflare control-plane call wedges every
4903
+ * first-time named-tunnel `get()` on the DO (the resolver promises are
4904
+ * memoised on `Sandbox`, so the first caller's hang is everyone's hang).
4905
+ */
4906
+ const CREDENTIALS_TIMEOUT_MS = 1e4;
4907
+ /**
4908
+ * Fetch wrapper that adds an `AbortSignal.timeout` and surfaces a
4909
+ * timeout as a labelled `Error` so the caller can blame the right URL.
4910
+ */
4911
+ async function fetchWithTimeout(fetcher, url, init, timeoutMs = CREDENTIALS_TIMEOUT_MS) {
4912
+ try {
4913
+ return await fetcher(url, {
4914
+ ...init,
4915
+ signal: AbortSignal.timeout(timeoutMs)
4916
+ });
4917
+ } catch (err) {
4918
+ if (err instanceof Error && err.name === "TimeoutError") throw new Error(`Cloudflare API request to ${url} timed out after ${timeoutMs}ms`);
4919
+ throw err;
4920
+ }
4921
+ }
4922
+ /**
4923
+ * Cloudflare error code returned by `GET /user/tokens/verify` when the
4924
+ * presented token is an account-owned (`cfat-`) token rather than a
4925
+ * user-owned one. Matches the heuristic wrangler uses in
4926
+ * `src/user/whoami.ts` (`getTokenType`).
4927
+ */
4928
+ const ACCOUNT_OWNED_TOKEN_CODE = 1e3;
4929
+ async function resolveAccountId(env$1, options) {
4930
+ const override = getEnvString(env$1, options.overrideKey);
4931
+ if (override) return override;
4932
+ const generic = getEnvString(env$1, "CLOUDFLARE_ACCOUNT_ID");
4933
+ if (generic) return generic;
4934
+ const token = getEnvString(env$1, "CLOUDFLARE_API_TOKEN");
4935
+ if (!token) throw new Error(`Cloudflare account id could not be resolved. Set one of: ${options.overrideKey}, CLOUDFLARE_ACCOUNT_ID, or CLOUDFLARE_API_TOKEN (a token scoped to a single account).`);
4936
+ const fetcher = options.fetcher ?? fetch;
4937
+ const response = await fetchWithTimeout(fetcher, TOKEN_VERIFY_URL, {
4938
+ method: "GET",
4939
+ headers: {
4940
+ authorization: `Bearer ${token}`,
4941
+ "content-type": "application/json"
4942
+ }
4943
+ });
4944
+ let body;
4945
+ try {
4946
+ body = await response.json();
4947
+ } catch (err) {
4948
+ const message = err instanceof Error ? err.message : String(err);
4949
+ throw new Error(`Cloudflare token verification returned malformed JSON: ${message}`);
4950
+ }
4951
+ if (response.ok && body?.success) {
4952
+ const derived = body.result_info?.account?.id;
4953
+ if (!derived) throw new Error(`Cloudflare token is not scoped to a single account (ambiguous). Set ${options.overrideKey} or CLOUDFLARE_ACCOUNT_ID explicitly.`);
4954
+ return derived;
4955
+ }
4956
+ if (body?.errors?.some((e) => e.code === ACCOUNT_OWNED_TOKEN_CODE)) return await deriveAccountIdViaAccountToken(token, fetcher, options);
4957
+ throw new Error(`Cloudflare token verification failed with status ${response.status}. Check that CLOUDFLARE_API_TOKEN is valid or set ${options.overrideKey} / CLOUDFLARE_ACCOUNT_ID explicitly.`);
4958
+ }
4959
+ /**
4960
+ * Account-owned token (cfat-) fallback: list the accounts the token can
4961
+ * see, and — if there's exactly one — confirm with the account-scoped
4962
+ * verify endpoint before returning the id.
4963
+ *
4964
+ * Common failure modes get specific, actionable error messages:
4965
+ * - `/accounts` 403 (token lacks `account:read`): tell the caller to
4966
+ * set `CLOUDFLARE_ACCOUNT_ID` explicitly.
4967
+ * - multiple accounts: same.
4968
+ * - zero accounts: same.
4969
+ * - confirm step fails: surface the API error code verbatim.
4970
+ */
4971
+ async function deriveAccountIdViaAccountToken(token, fetcher, options) {
4972
+ const listResponse = await fetchWithTimeout(fetcher, `${ACCOUNTS_LIST_URL}?per_page=2`, {
4973
+ method: "GET",
4974
+ headers: {
4975
+ authorization: `Bearer ${token}`,
4976
+ "content-type": "application/json"
4977
+ }
4978
+ });
4979
+ let listBody;
4980
+ try {
4981
+ listBody = await listResponse.json();
4982
+ } catch (err) {
4983
+ const message = err instanceof Error ? err.message : String(err);
4984
+ throw new Error(`Cloudflare account-owned token: /accounts returned malformed JSON: ${message}`);
4985
+ }
4986
+ if (!listResponse.ok || !listBody?.success) throw new Error(`Cloudflare account-owned token (cfat-...) detected, but /accounts returned status ${listResponse.status}. The token may lack account:read scope. Set CLOUDFLARE_ACCOUNT_ID explicitly to skip introspection.`);
4987
+ const accounts = listBody.result ?? [];
4988
+ if (accounts.length === 0) throw new Error("Cloudflare account-owned token has access to no accounts. Set CLOUDFLARE_ACCOUNT_ID explicitly.");
4989
+ if (accounts.length > 1) throw new Error("Cloudflare account-owned token has access to multiple accounts (ambiguous). Set CLOUDFLARE_ACCOUNT_ID explicitly to disambiguate.");
4990
+ const accountId = accounts[0]?.id;
4991
+ if (!accountId) throw new Error("Cloudflare /accounts returned a result without an id field.");
4992
+ const verifyResponse = await fetchWithTimeout(fetcher, `${ACCOUNTS_LIST_URL}/${encodeURIComponent(accountId)}/tokens/verify`, {
4993
+ method: "GET",
4994
+ headers: {
4995
+ authorization: `Bearer ${token}`,
4996
+ "content-type": "application/json"
4997
+ }
4998
+ });
4999
+ let verifyBody;
5000
+ try {
5001
+ verifyBody = await verifyResponse.json();
5002
+ } catch (err) {
5003
+ const message = err instanceof Error ? err.message : String(err);
5004
+ throw new Error(`Cloudflare account token verify returned malformed JSON: ${message}`);
5005
+ }
5006
+ if (!verifyResponse.ok || !verifyBody?.success) {
5007
+ const detail = verifyBody?.errors?.map((e) => `${e.code}: ${e.message}`).join("; ") ?? `HTTP ${verifyResponse.status}`;
5008
+ throw new Error(`Cloudflare account token verify failed for account ${accountId}: ${detail}`);
5009
+ }
5010
+ return accountId;
5011
+ }
5012
+ const ZONES_LIST_URL = "https://api.cloudflare.com/client/v4/zones";
5013
+ /**
5014
+ * Resolve a Cloudflare zone id.
5015
+ *
5016
+ * Precedence:
5017
+ * 1. `CLOUDFLARE_ZONE_ID` env var.
5018
+ * 2. The single zone the token can see under `accountId`, via
5019
+ * `GET /zones?account.id=<accountId>&per_page=2`.
5020
+ *
5021
+ * Step 2 deliberately fetches at most two results: one is the happy path,
5022
+ * two (or more) means the token is ambiguous and we refuse to guess.
5023
+ * Multi-zone tokens must set `CLOUDFLARE_ZONE_ID` explicitly so the
5024
+ * caller's intent is unambiguous.
5025
+ */
5026
+ async function resolveZoneId(env$1, options) {
5027
+ const envZone = getEnvString(env$1, "CLOUDFLARE_ZONE_ID");
5028
+ if (envZone) return envZone;
5029
+ const response = await fetchWithTimeout(options.fetcher ?? fetch, `${ZONES_LIST_URL}?account.id=${encodeURIComponent(options.accountId)}&per_page=2`, {
5030
+ method: "GET",
5031
+ headers: {
5032
+ authorization: `Bearer ${options.token}`,
5033
+ "content-type": "application/json"
5034
+ }
5035
+ });
5036
+ if (!response.ok) throw new Error(`Cloudflare zones lookup failed with status ${response.status}. Set CLOUDFLARE_ZONE_ID explicitly or grant the API token Zone:Read.`);
5037
+ let body;
5038
+ try {
5039
+ body = await response.json();
5040
+ } catch (err) {
5041
+ const message = err instanceof Error ? err.message : String(err);
5042
+ throw new Error(`Cloudflare zones lookup returned malformed JSON: ${message}`);
5043
+ }
5044
+ const zones = body.result ?? [];
5045
+ if (zones.length === 0) throw new Error(`Cloudflare API token has access to no zones in account ${options.accountId}. Set CLOUDFLARE_ZONE_ID explicitly or grant the token Zone:Read on the intended zone.`);
5046
+ if (zones.length > 1) throw new Error(`Cloudflare API token has access to multiple zones in account ${options.accountId} (ambiguous). Set CLOUDFLARE_ZONE_ID explicitly to disambiguate.`);
5047
+ const zoneId = zones[0]?.id;
5048
+ if (!zoneId) throw new Error("Cloudflare zones lookup returned a result without an id field.");
5049
+ return zoneId;
5050
+ }
5051
+
5052
+ //#endregion
5053
+ //#region src/tunnels/sandbox-control-callback.ts
5054
+ var SandboxControlCallbackImpl = class extends RpcTarget {
5055
+ constructor(getHandler, logger) {
5056
+ super();
5057
+ this.getHandler = getHandler;
4686
5058
  this.logger = logger;
4687
5059
  }
4688
5060
  async onTunnelExit(id, port, exitCode) {
@@ -4699,6 +5071,229 @@ var SandboxControlCallbackImpl = class extends RpcTarget {
4699
5071
  }
4700
5072
  };
4701
5073
 
5074
+ //#endregion
5075
+ //#region src/tunnels/cloudflare-api.ts
5076
+ /**
5077
+ * Cloudflare API client for named-tunnel orchestration.
5078
+ *
5079
+ * Design notes:
5080
+ *
5081
+ * - The Cloudflare API envelope is `{ success, result, errors }`. We
5082
+ * unwrap `result` on success and surface a thrown `Error` with the
5083
+ * API error code/message on failure. Transport-level errors
5084
+ * propagate unchanged.
5085
+ * - Delete endpoints are idempotent from the caller's perspective:
5086
+ * a 404 (already gone) resolves successfully so destroy() can run
5087
+ * without special-casing.
5088
+ * - `upsertCNAME` is the most subtle wrapper: it lists existing
5089
+ * records, reuses a matching one, and refuses to mutate a record
5090
+ * whose content differs from what we want. This is the fence that
5091
+ * stops two sandboxes from racing on the same hostname.
5092
+ */
5093
+ const API_BASE = "https://api.cloudflare.com/client/v4";
5094
+ /**
5095
+ * Default request timeout. Cloudflare API P99 latency is well under
5096
+ * this; values much smaller risk false positives on cold control-plane
5097
+ * paths (e.g. first `cfd_tunnel` POST in a new account).
5098
+ */
5099
+ const DEFAULT_TIMEOUT_MS = 1e4;
5100
+ /**
5101
+ * Internal request helper. Centralises auth header, JSON encoding,
5102
+ * timeout enforcement, and envelope unwrapping so each wrapper above
5103
+ * stays declarative.
5104
+ */
5105
+ async function cfRequest(url, token, fetcher, options = {}) {
5106
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
5107
+ const init = {
5108
+ method: options.method ?? "GET",
5109
+ headers: {
5110
+ authorization: `Bearer ${token}`,
5111
+ "content-type": "application/json"
5112
+ },
5113
+ signal: AbortSignal.timeout(timeoutMs)
5114
+ };
5115
+ if (options.body !== void 0) init.body = JSON.stringify(options.body);
5116
+ let response;
5117
+ try {
5118
+ response = await fetcher(url, init);
5119
+ } catch (err) {
5120
+ if (err instanceof Error && err.name === "TimeoutError") throw new Error(`Cloudflare API request to ${url} timed out after ${timeoutMs}ms`);
5121
+ throw err;
5122
+ }
5123
+ if (options.acceptStatuses?.includes(response.status)) return;
5124
+ let envelope;
5125
+ try {
5126
+ envelope = await response.json();
5127
+ } catch (err) {
5128
+ const message = err instanceof Error ? err.message : String(err);
5129
+ throw new Error(`Cloudflare API returned non-JSON response (status ${response.status}): ${message}`);
5130
+ }
5131
+ if (!response.ok || envelope.success === false) {
5132
+ const errs = envelope.errors ?? [];
5133
+ const summary = errs.length ? errs.map((e) => `${e.code ?? "???"}: ${e.message ?? "unknown"}`).join(", ") : `HTTP ${response.status}`;
5134
+ throw new Error(`Cloudflare API error: ${summary}`);
5135
+ }
5136
+ return envelope.result;
5137
+ }
5138
+ /**
5139
+ * Heuristic for the "tags are an Enterprise-only feature" error class.
5140
+ * Empirically grounded against a non-Enterprise account:
5141
+ *
5142
+ * - DNS create with `tags: [...]` on a non-Enterprise zone rejects with
5143
+ * Cloudflare error code 9300 and the message "DNS record has N tags,
5144
+ * exceeding the quota of 0.". The error string `cfRequest` constructs
5145
+ * embeds both the code and the message, so we match on either signal.
5146
+ * - Tunnel create with `tags: [...]` silently succeeds and drops the
5147
+ * field on the floor (no error to retry on). The fallback wrapper
5148
+ * therefore costs nothing on tunnel writes.
5149
+ *
5150
+ * Generic "requires Enterprise" phrasing is also matched as a forward-
5151
+ * compatibility hedge in case Cloudflare changes the response shape on
5152
+ * future endpoints.
5153
+ */
5154
+ function isEnterpriseOnlyTagError(error) {
5155
+ if (!(error instanceof Error)) return false;
5156
+ const msg = error.message.toLowerCase();
5157
+ if (msg.includes("9300") && msg.includes("tag")) return true;
5158
+ if (!msg.includes("tag")) return false;
5159
+ return msg.includes("quota") || msg.includes("enterprise") || msg.includes("not allowed") || msg.includes("not entitled") || msg.includes("not available") || msg.includes("not supported");
5160
+ }
5161
+ /**
5162
+ * Build the `tags` field attached to created Cloudflare resources. The
5163
+ * tag is `sandboxId:<id>`, the same key used in DNS comments / tunnel
5164
+ * metadata; together they let an operator find every resource a given
5165
+ * sandbox owns from the Cloudflare dashboard.
5166
+ *
5167
+ * Tags are an Enterprise-only feature. The wrapper `createWithTagFallback`
5168
+ * automatically retries the request without tags on the documented
5169
+ * "requires Enterprise" error so non-enterprise accounts succeed without
5170
+ * any configuration.
5171
+ */
5172
+ function buildSandboxTags(sandboxId) {
5173
+ if (!sandboxId) return void 0;
5174
+ return [`sandboxId:${sandboxId}`];
5175
+ }
5176
+ /**
5177
+ * Wrap a tagged-create request with an automatic tag-strip retry. The
5178
+ * callback receives `tags`: pass it through to the request body as-is on
5179
+ * the first call (`undefined` on the retry). The retry only fires for
5180
+ * the Enterprise-only tag error class; any other failure surfaces
5181
+ * verbatim.
5182
+ */
5183
+ async function createWithTagFallback(sandboxId, send) {
5184
+ const tags = buildSandboxTags(sandboxId);
5185
+ if (!tags) return send(void 0);
5186
+ try {
5187
+ return await send(tags);
5188
+ } catch (err) {
5189
+ if (!isEnterpriseOnlyTagError(err)) throw err;
5190
+ return send(void 0);
5191
+ }
5192
+ }
5193
+ async function createTunnel(args) {
5194
+ const fetcher = args.fetcher ?? fetch;
5195
+ const result = await createWithTagFallback(args.metadata.sandboxId, (tags) => cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel`, args.token, fetcher, {
5196
+ method: "POST",
5197
+ body: {
5198
+ name: args.tunnelName,
5199
+ config_src: "cloudflare",
5200
+ metadata: args.metadata,
5201
+ ...tags ? { tags } : {}
5202
+ }
5203
+ }));
5204
+ if (!result) throw new Error("Cloudflare tunnel create returned no result body");
5205
+ return {
5206
+ id: result.id,
5207
+ token: result.token
5208
+ };
5209
+ }
5210
+ /**
5211
+ * Look up an existing tunnel by exact name match. Filters out tunnels
5212
+ * marked `deleted_at != null` defensively in case the API ignores the
5213
+ * `is_deleted=false` query parameter.
5214
+ *
5215
+ * When `expectedSandboxId` is provided, also verify that the tunnel's
5216
+ * `metadata.sandboxId` tag matches — this is the authoritative "this
5217
+ * resource was created by this sandbox" check, and the tag is set by
5218
+ * `createTunnel`. Mismatches are treated as "not found" so the caller
5219
+ * falls through to creating a fresh tunnel.
5220
+ */
5221
+ async function findTunnelByName(args) {
5222
+ const fetcher = args.fetcher ?? fetch;
5223
+ const result = await cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel?name=${encodeURIComponent(args.tunnelName)}&is_deleted=false`, args.token, fetcher);
5224
+ if (!result) return null;
5225
+ const live = result.find((t) => !t.deleted_at);
5226
+ if (!live) return null;
5227
+ if (args.expectedSandboxId !== void 0) {
5228
+ if (live.metadata?.sandboxId !== args.expectedSandboxId) return null;
5229
+ }
5230
+ return {
5231
+ id: live.id,
5232
+ name: live.name
5233
+ };
5234
+ }
5235
+ async function deleteTunnel(args) {
5236
+ const fetcher = args.fetcher ?? fetch;
5237
+ await cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel/${encodeURIComponent(args.tunnelId)}`, args.token, fetcher, {
5238
+ method: "DELETE",
5239
+ acceptStatuses: [404]
5240
+ });
5241
+ }
5242
+ /**
5243
+ * Fetch the opaque `--token` for an existing tunnel. Used on the retry
5244
+ * path: when `findTunnelByName` discovers a tunnel left behind from a
5245
+ * previous failed attempt, we need its token to run `cloudflared` again.
5246
+ *
5247
+ * The Cloudflare API returns the token as a bare quoted string in the
5248
+ * `result` envelope (e.g. `"<base64-token>"`).
5249
+ */
5250
+ async function getTunnelToken(args) {
5251
+ const fetcher = args.fetcher ?? fetch;
5252
+ const result = await cfRequest(`${API_BASE}/accounts/${encodeURIComponent(args.accountId)}/cfd_tunnel/${encodeURIComponent(args.tunnelId)}/token`, args.token, fetcher);
5253
+ if (typeof result !== "string" || result.length === 0) throw new Error(`Cloudflare did not return a token for tunnel ${args.tunnelId}`);
5254
+ return result;
5255
+ }
5256
+ async function getZoneName(args) {
5257
+ const fetcher = args.fetcher ?? fetch;
5258
+ const result = await cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}`, args.token, fetcher);
5259
+ if (!result?.name) throw new Error(`Cloudflare zone ${args.zoneId} did not return a name`);
5260
+ return result.name;
5261
+ }
5262
+ async function upsertCNAME(args) {
5263
+ const fetcher = args.fetcher ?? fetch;
5264
+ const existing = (await cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}/dns_records?type=CNAME&name=${encodeURIComponent(args.hostname)}`, args.token, fetcher) ?? []).find((r) => r.type === "CNAME" && r.name === args.hostname);
5265
+ if (existing) {
5266
+ if (existing.content === args.cnameTarget) return {
5267
+ recordId: existing.id,
5268
+ reused: true
5269
+ };
5270
+ throw new Error(`DNS record for ${args.hostname} already exists with different content (owned by you, not us): existing content="${existing.content}", existing comment="${existing.comment ?? ""}". Delete the record manually to allow the sandbox to manage it.`);
5271
+ }
5272
+ const createResult = await createWithTagFallback(args.sandboxId, (tags) => cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}/dns_records`, args.token, fetcher, {
5273
+ method: "POST",
5274
+ body: {
5275
+ type: "CNAME",
5276
+ name: args.hostname,
5277
+ content: args.cnameTarget,
5278
+ proxied: true,
5279
+ comment: args.comment,
5280
+ ...tags ? { tags } : {}
5281
+ }
5282
+ }));
5283
+ if (!createResult) throw new Error("Cloudflare DNS create returned no result body");
5284
+ return {
5285
+ recordId: createResult.id,
5286
+ reused: false
5287
+ };
5288
+ }
5289
+ async function deleteDNSRecord(args) {
5290
+ const fetcher = args.fetcher ?? fetch;
5291
+ await cfRequest(`${API_BASE}/zones/${encodeURIComponent(args.zoneId)}/dns_records/${encodeURIComponent(args.recordId)}`, args.token, fetcher, {
5292
+ method: "DELETE",
5293
+ acceptStatuses: [404]
5294
+ });
5295
+ }
5296
+
4702
5297
  //#endregion
4703
5298
  //#region src/tunnels/tunnels-handler.ts
4704
5299
  /**
@@ -4713,6 +5308,14 @@ var SandboxControlCallbackImpl = class extends RpcTarget {
4713
5308
  */
4714
5309
  /** DO storage key for the `port → TunnelInfo` map. */
4715
5310
  const STORAGE_KEY = "tunnels";
5311
+ /**
5312
+ * Sidecar storage key for per-port metadata the handler needs but the
5313
+ * public `TunnelInfo` shape does not carry: the options hash used to
5314
+ * detect divergent retries, and (for named tunnels) the DNS record id
5315
+ * needed for cleanup. Kept under a separate key so the existing
5316
+ * `tunnels` shape remains a clean `Record<port, TunnelInfo>`.
5317
+ */
5318
+ const META_STORAGE_KEY = "tunnels:meta";
4716
5319
  function validateTunnelPort(port) {
4717
5320
  if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding reserved ports.`);
4718
5321
  }
@@ -4722,47 +5325,142 @@ function shortId() {
4722
5325
  crypto.getRandomValues(buf);
4723
5326
  return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
4724
5327
  }
5328
+ /**
5329
+ * Match a structured SandboxError code anywhere on the error — translated
5330
+ * SandboxErrors expose the code both as a top-level `code` field and on
5331
+ * the nested `errorResponse.code`. Used for the few error codes the SDK
5332
+ * recognises and recovers from (TUNNEL_NOT_FOUND, TUNNEL_ALREADY_RUNNING).
5333
+ *
5334
+ * Previous versions matched by substring on `error.message`, which
5335
+ * false-positived on any error whose message merely quoted the literal
5336
+ * code token.
5337
+ */
5338
+ function hasErrorCode(error, code) {
5339
+ if (!error || typeof error !== "object") return false;
5340
+ const e = error;
5341
+ if (e.code === code) return true;
5342
+ if (e.errorResponse?.code === code) return true;
5343
+ return false;
5344
+ }
4725
5345
  function isTunnelNotFoundError(error) {
4726
- return (error instanceof Error ? error.message : String(error)).includes("TUNNEL_NOT_FOUND");
5346
+ return hasErrorCode(error, "TUNNEL_NOT_FOUND");
5347
+ }
5348
+ function isTunnelAlreadyRunningError(error) {
5349
+ return hasErrorCode(error, "TUNNEL_ALREADY_RUNNING");
4727
5350
  }
4728
5351
  async function readMap(storage) {
4729
5352
  return await storage.get(STORAGE_KEY) ?? {};
4730
5353
  }
5354
+ async function readMetaMap(storage) {
5355
+ return await storage.get(META_STORAGE_KEY) ?? {};
5356
+ }
4731
5357
  /**
4732
- * Concrete `TunnelsHandler` implementation. Extends `RpcTarget` so it
4733
- * can cross the Workers RPC boundary: the Sandbox DO is reachable from
4734
- * Workers via Workers RPC (`stub.tunnels.get(port)`), and only
4735
- * `RpcTarget` instances are passed by reference across that boundary.
5358
+ * Stable hash of `options`. Empty/undefined options collapse to the same
5359
+ * hash so `get(port)`, `get(port, {})`, and `get(port, { name: undefined })`
5360
+ * all hit the same cache entry. Named tunnels hash on `name` alone (the
5361
+ * only option today).
5362
+ *
5363
+ * The `v1:` prefix exists so a future addition of a second option (e.g.
5364
+ * `subdomain`) can change the canonical form without colliding with an
5365
+ * older record's hash. Comparison goes through `optionsHashesEqual`, which
5366
+ * normalises legacy unversioned hashes (`quick`, `named:foo`) to their v1
5367
+ * form before equality, so upgrading does not invalidate cached records.
5368
+ */
5369
+ function computeOptionsHash(options) {
5370
+ if (!options || !options.name) return "v1:quick";
5371
+ return `v1:named:${options.name}`;
5372
+ }
5373
+ /** Strip the optional `v1:` prefix so legacy hashes compare equal. */
5374
+ function normaliseHash(hash) {
5375
+ return hash.startsWith("v1:") ? hash.slice(3) : hash;
5376
+ }
5377
+ function optionsHashesEqual(a, b) {
5378
+ return normaliseHash(a) === normaliseHash(b);
5379
+ }
5380
+ /**
5381
+ * Concrete `TunnelsHandler` implementation.
5382
+ *
5383
+ * Extends `RpcTarget` for forward compatibility with direct Workers RPC
5384
+ * pipelining (`stub.tunnels.get(port)`): only `RpcTarget` instances may
5385
+ * be passed by reference across the Workers RPC boundary. Today the
5386
+ * public `sandbox.tunnels` proxy in `getSandbox()` dispatches through
5387
+ * `stub.callTunnels(method, args)` instead — pipelining through
5388
+ * property getters is broken under the vite-plugin runtime — so the
5389
+ * `RpcTarget` base is not on the hot call path. It is retained so the
5390
+ * pipelining shape works once that constraint lifts.
4736
5391
  */
4737
5392
  var TunnelsRpcTarget = class extends RpcTarget$1 {
4738
5393
  #host;
4739
5394
  #withPortLock;
5395
+ /**
5396
+ * Memoised zone name (e.g. `'example.com'`) for the configured
5397
+ * `CLOUDFLARE_ZONE_ID`. Filled in lazily on the first named-tunnel
5398
+ * `get()` so quick-tunnel callers never hit the zone-lookup endpoint.
5399
+ *
5400
+ * Only successful resolutions are cached: a rejected lookup clears
5401
+ * the slot so the next caller retries, instead of permanently
5402
+ * poisoning every subsequent named-tunnel `get()` on the DO with the
5403
+ * same transient error.
5404
+ */
5405
+ #zoneNamePromise = null;
4740
5406
  constructor(host, withPortLock) {
4741
5407
  super();
4742
5408
  this.#host = host;
4743
5409
  this.#withPortLock = withPortLock;
4744
5410
  }
4745
- async get(port) {
5411
+ /**
5412
+ * Resolve the zone name for the configured zone id. Memoised for the
5413
+ * lifetime of this handler; the zone name doesn't change while a DO
5414
+ * is alive, and one extra GET on first use is cheaper than threading
5415
+ * the value through the host.
5416
+ *
5417
+ * On failure the cached promise is cleared so the next caller retries.
5418
+ * Without that, a transient 5xx on the first call would permanently
5419
+ * poison every subsequent named-tunnel `get()` until the DO restarts.
5420
+ */
5421
+ async #getZoneName(config) {
5422
+ if (!this.#zoneNamePromise) {
5423
+ const pending = getZoneName({
5424
+ token: config.token,
5425
+ zoneId: config.zoneId,
5426
+ fetcher: this.#host.fetcher
5427
+ });
5428
+ this.#zoneNamePromise = pending;
5429
+ pending.catch(() => {
5430
+ if (this.#zoneNamePromise === pending) this.#zoneNamePromise = null;
5431
+ });
5432
+ }
5433
+ return this.#zoneNamePromise;
5434
+ }
5435
+ async get(port, options) {
4746
5436
  const startTime = Date.now();
4747
5437
  let outcome = "error";
4748
5438
  let cacheState = "miss";
4749
5439
  let caughtError;
4750
5440
  try {
4751
5441
  validateTunnelPort(port);
5442
+ if (options?.name !== void 0) validateTunnelName(options.name);
5443
+ const requestedHash = computeOptionsHash(options);
4752
5444
  const info = await this.#withPortLock(port, async () => {
4753
5445
  const existing = (await readMap(this.#host.storage))[port.toString()];
4754
5446
  if (existing) {
5447
+ const metaEntry = (await readMetaMap(this.#host.storage))[port.toString()];
5448
+ if (!optionsHashesEqual(metaEntry?.optionsHash ?? (existing.name ? `v1:named:${existing.name}` : "v1:quick"), requestedHash)) throw new Error(`Tunnel on port ${port} was created with different options. Call destroy(${port}) before changing tunnel options.`);
5449
+ if (metaEntry?.needsRespawn && existing.name) return await this.#provisionNamedTunnel(port, existing.name);
5450
+ if (existing.name && this.#host.getNamedTunnelConfig) {
5451
+ const currentConfig = await this.#host.getNamedTunnelConfig();
5452
+ const storedAccountId = metaEntry?.accountId;
5453
+ const storedZoneId = metaEntry?.zoneId;
5454
+ if (storedAccountId !== void 0 && storedAccountId !== currentConfig.accountId || storedZoneId !== void 0 && storedZoneId !== currentConfig.zoneId) {
5455
+ this.#zoneNamePromise = null;
5456
+ return await this.#provisionNamedTunnel(port, existing.name);
5457
+ }
5458
+ }
4755
5459
  cacheState = "hit";
4756
5460
  return existing;
4757
5461
  }
4758
- const id = `quick-${shortId()}`;
4759
- const spawned = await this.#host.client.tunnels.runQuickTunnel(id, port);
4760
- await this.#host.storage.transaction(async (txn) => {
4761
- const nextMap = await readMap(txn);
4762
- nextMap[port.toString()] = spawned;
4763
- await txn.put(STORAGE_KEY, nextMap);
4764
- });
4765
- return spawned;
5462
+ if (options?.name) return await this.#provisionNamedTunnel(port, options.name);
5463
+ return await this.#provisionQuickTunnel(port);
4766
5464
  });
4767
5465
  outcome = "success";
4768
5466
  return info;
@@ -4780,6 +5478,129 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
4780
5478
  });
4781
5479
  }
4782
5480
  }
5481
+ /**
5482
+ * Provision a fresh quick tunnel and persist it. Caller holds the
5483
+ * per-port lock.
5484
+ *
5485
+ * Quick-tunnel ids are minted from a 32-bit random source. Collisions
5486
+ * are astronomically unlikely, but if the container happens to already
5487
+ * have one running under the freshly-minted id it rejects with
5488
+ * TUNNEL_ALREADY_RUNNING. Mint a fresh id and try again rather than
5489
+ * surfacing the confusing error — the retry budget caps the loop so a
5490
+ * persistent failure still surfaces.
5491
+ */
5492
+ async #provisionQuickTunnel(port) {
5493
+ const MAX_ID_RETRIES = 3;
5494
+ let lastError;
5495
+ for (let attempt = 0; attempt < MAX_ID_RETRIES; attempt += 1) {
5496
+ const id = `quick-${shortId()}`;
5497
+ try {
5498
+ const spawned = await this.#host.client.tunnels.runQuickTunnel(id, port);
5499
+ await this.#host.storage.transaction(async (txn) => {
5500
+ const nextMap = await readMap(txn);
5501
+ nextMap[port.toString()] = spawned;
5502
+ await txn.put(STORAGE_KEY, nextMap);
5503
+ const nextMeta = await readMetaMap(txn);
5504
+ nextMeta[port.toString()] = { optionsHash: "v1:quick" };
5505
+ await txn.put(META_STORAGE_KEY, nextMeta);
5506
+ });
5507
+ return spawned;
5508
+ } catch (err) {
5509
+ if (!isTunnelAlreadyRunningError(err)) throw err;
5510
+ lastError = err;
5511
+ }
5512
+ }
5513
+ throw lastError ?? /* @__PURE__ */ new Error("Failed to mint a unique quick-tunnel id");
5514
+ }
5515
+ /**
5516
+ * Provision a named tunnel end-to-end:
5517
+ * 1. resolve credentials + zone name
5518
+ * 2. reuse or create the Cloudflare tunnel resource
5519
+ * 3. upsert the proxied CNAME (or reuse a matching one)
5520
+ * 4. spawn cloudflared inside the container
5521
+ * 5. persist the record + meta
5522
+ *
5523
+ * Failure between (2) and (5) intentionally leaves the Cloudflare-side
5524
+ * resources in place so a retry can re-discover them via
5525
+ * `findTunnelByName` and the DNS reuse path. See
5526
+ * `.plans/09-named-tunnel-api.md § Retry-friendly failure model`.
5527
+ */
5528
+ async #provisionNamedTunnel(port, name) {
5529
+ if (!this.#host.sandboxId) throw new Error("Named tunnels require host.sandboxId on the tunnels handler.");
5530
+ if (!this.#host.getNamedTunnelConfig) throw new Error("Named tunnels require host.getNamedTunnelConfig on the tunnels handler.");
5531
+ const config = await this.#host.getNamedTunnelConfig();
5532
+ const hostname = `${name}.${await this.#getZoneName({
5533
+ token: config.token,
5534
+ zoneId: config.zoneId
5535
+ })}`;
5536
+ const sandboxId = this.#host.sandboxId;
5537
+ const tunnelName = `sandbox-${sandboxId}-${name}`;
5538
+ let tunnelId;
5539
+ let tunnelToken;
5540
+ const existingTunnel = await findTunnelByName({
5541
+ token: config.token,
5542
+ accountId: config.accountId,
5543
+ tunnelName,
5544
+ expectedSandboxId: sandboxId,
5545
+ fetcher: this.#host.fetcher
5546
+ });
5547
+ if (existingTunnel) {
5548
+ tunnelId = existingTunnel.id;
5549
+ tunnelToken = await getTunnelToken({
5550
+ token: config.token,
5551
+ accountId: config.accountId,
5552
+ tunnelId,
5553
+ fetcher: this.#host.fetcher
5554
+ });
5555
+ } else {
5556
+ const created = await createTunnel({
5557
+ token: config.token,
5558
+ accountId: config.accountId,
5559
+ tunnelName,
5560
+ metadata: {
5561
+ sandboxId,
5562
+ createdBy: "sandbox-sdk",
5563
+ name,
5564
+ port
5565
+ },
5566
+ fetcher: this.#host.fetcher
5567
+ });
5568
+ tunnelId = created.id;
5569
+ tunnelToken = created.token;
5570
+ }
5571
+ const dnsResult = await upsertCNAME({
5572
+ token: config.token,
5573
+ zoneId: config.zoneId,
5574
+ hostname,
5575
+ cnameTarget: `${tunnelId}.cfargotunnel.com`,
5576
+ comment: `sandbox-${sandboxId}`,
5577
+ sandboxId,
5578
+ fetcher: this.#host.fetcher
5579
+ });
5580
+ await this.#host.client.tunnels.runNamedTunnel(tunnelId, tunnelToken, port);
5581
+ const info = {
5582
+ id: tunnelId,
5583
+ port,
5584
+ name,
5585
+ hostname,
5586
+ url: `https://${hostname}`,
5587
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
5588
+ };
5589
+ await this.#host.storage.transaction(async (txn) => {
5590
+ const nextMap = await readMap(txn);
5591
+ nextMap[port.toString()] = info;
5592
+ await txn.put(STORAGE_KEY, nextMap);
5593
+ const nextMeta = await readMetaMap(txn);
5594
+ nextMeta[port.toString()] = {
5595
+ optionsHash: computeOptionsHash({ name }),
5596
+ dnsRecordId: dnsResult.recordId,
5597
+ accountId: config.accountId,
5598
+ zoneId: config.zoneId
5599
+ };
5600
+ await txn.put(META_STORAGE_KEY, nextMeta);
5601
+ });
5602
+ return info;
5603
+ }
4783
5604
  async destroy(portOrInfo) {
4784
5605
  const port = typeof portOrInfo === "number" ? portOrInfo : portOrInfo.port;
4785
5606
  const startTime = Date.now();
@@ -4791,16 +5612,68 @@ var TunnelsRpcTarget = class extends RpcTarget$1 {
4791
5612
  const existing = (await readMap(this.#host.storage))[port.toString()];
4792
5613
  if (!existing) return;
4793
5614
  tunnelId = existing.id;
5615
+ const metaBefore = (await readMetaMap(this.#host.storage))[port.toString()];
4794
5616
  await this.#host.storage.transaction(async (txn) => {
4795
5617
  const current = await readMap(txn);
4796
5618
  delete current[port.toString()];
4797
5619
  await txn.put(STORAGE_KEY, current);
5620
+ const currentMeta = await readMetaMap(txn);
5621
+ delete currentMeta[port.toString()];
5622
+ await txn.put(META_STORAGE_KEY, currentMeta);
4798
5623
  });
4799
5624
  try {
4800
5625
  await this.#host.client.tunnels.destroyTunnel(existing.id);
4801
5626
  } catch (error) {
4802
- if (!isTunnelNotFoundError(error)) throw error;
5627
+ if (isTunnelNotFoundError(error)) {} else if (metaBefore?.dnsRecordId) this.#host.logger.warn("tunnel.destroy: container tunnel cleanup failed", {
5628
+ port,
5629
+ tunnelId,
5630
+ error: error instanceof Error ? error.message : String(error)
5631
+ });
5632
+ else throw error;
5633
+ }
5634
+ if (!metaBefore?.dnsRecordId) return;
5635
+ if (!this.#host.getNamedTunnelConfig) return;
5636
+ let config;
5637
+ try {
5638
+ config = await this.#host.getNamedTunnelConfig();
5639
+ } catch (err) {
5640
+ this.#host.logger.warn("tunnel.destroy: skipping CF cleanup, credentials unavailable", {
5641
+ port,
5642
+ tunnelId,
5643
+ dnsRecordId: metaBefore.dnsRecordId,
5644
+ error: err instanceof Error ? err.message : String(err)
5645
+ });
5646
+ return;
4803
5647
  }
5648
+ const fetcher = this.#host.fetcher;
5649
+ const accountId = metaBefore.accountId ?? config.accountId;
5650
+ const zoneId = metaBefore.zoneId ?? config.zoneId;
5651
+ await Promise.allSettled([metaBefore.dnsRecordId ? deleteDNSRecord({
5652
+ token: config.token,
5653
+ zoneId,
5654
+ recordId: metaBefore.dnsRecordId,
5655
+ fetcher
5656
+ }).catch((err) => {
5657
+ this.#host.logger.warn("tunnel.destroy: dns delete failed", {
5658
+ port,
5659
+ tunnelId,
5660
+ recordId: metaBefore.dnsRecordId,
5661
+ zoneId,
5662
+ error: err instanceof Error ? err.message : String(err)
5663
+ });
5664
+ }) : Promise.resolve(), deleteTunnel({
5665
+ token: config.token,
5666
+ accountId,
5667
+ tunnelId: existing.id,
5668
+ fetcher
5669
+ }).catch((err) => {
5670
+ this.#host.logger.warn("tunnel.destroy: tunnel delete failed", {
5671
+ port,
5672
+ tunnelId,
5673
+ accountId,
5674
+ error: err instanceof Error ? err.message : String(err)
5675
+ });
5676
+ })]);
4804
5677
  });
4805
5678
  outcome = "success";
4806
5679
  } catch (error) {
@@ -4832,29 +5705,126 @@ function createTunnelsHandler(host) {
4832
5705
  const tunnels = new TunnelsRpcTarget(host, withPortLock);
4833
5706
  const handleTunnelExit = async (id, port, exitCode) => {
4834
5707
  const startTime = Date.now();
4835
- await withPortLock(port, async () => {
4836
- await host.storage.transaction(async (txn) => {
4837
- const map = await readMap(txn);
4838
- if (map[port.toString()]?.id === id) {
5708
+ let outcome = "error";
5709
+ let caughtError;
5710
+ try {
5711
+ await withPortLock(port, async () => {
5712
+ await host.storage.transaction(async (txn) => {
5713
+ const map = await readMap(txn);
5714
+ const existing = map[port.toString()];
5715
+ if (existing?.id !== id) return;
5716
+ if (existing.name) {
5717
+ const meta$1 = await readMetaMap(txn);
5718
+ meta$1[port.toString()] = {
5719
+ ...meta$1[port.toString()],
5720
+ optionsHash: meta$1[port.toString()]?.optionsHash ?? `v1:named:${existing.name}`,
5721
+ needsRespawn: true
5722
+ };
5723
+ await txn.put(META_STORAGE_KEY, meta$1);
5724
+ return;
5725
+ }
4839
5726
  delete map[port.toString()];
4840
5727
  await txn.put(STORAGE_KEY, map);
4841
- }
5728
+ const meta = await readMetaMap(txn);
5729
+ delete meta[port.toString()];
5730
+ await txn.put(META_STORAGE_KEY, meta);
5731
+ });
4842
5732
  });
5733
+ outcome = "success";
5734
+ } catch (error) {
5735
+ caughtError = error instanceof Error ? error : new Error(String(error));
5736
+ throw error;
5737
+ } finally {
4843
5738
  logCanonicalEvent(host.logger, {
4844
5739
  event: "tunnel.exit",
4845
- outcome: "success",
5740
+ outcome,
4846
5741
  port,
4847
5742
  tunnelId: id,
4848
5743
  exitCode: exitCode ?? void 0,
4849
- durationMs: Date.now() - startTime
5744
+ durationMs: Date.now() - startTime,
5745
+ error: caughtError
4850
5746
  });
4851
- });
5747
+ }
5748
+ };
5749
+ /**
5750
+ * Iterate every stored tunnel and call `tunnels.destroy(port)` on it,
5751
+ * sequentially. Each `destroy()` already swallows container-side
5752
+ * TUNNEL_NOT_FOUND and best-effort-logs Cloudflare-side failures; we
5753
+ * wrap the call in catch-and-log here too so a transport-level error
5754
+ * on one port can't poison the rest of the teardown.
5755
+ *
5756
+ * Each port is processed sequentially: this caps the *number of
5757
+ * concurrent ports* in flight at one. Note that an individual
5758
+ * destroy() still fans the DNS-delete and tunnel-delete out via
5759
+ * `Promise.allSettled` internally — so "sequential" here means
5760
+ * "one port at a time", not "one Cloudflare API call at a time".
5761
+ * The handful of ports we expect in the common case makes the
5762
+ * trade-off cheap.
5763
+ */
5764
+ const destroyAll = async () => {
5765
+ const map = await readMap(host.storage);
5766
+ const ports = Object.keys(map).map((p) => Number(p));
5767
+ for (const port of ports) try {
5768
+ await tunnels.destroy(port);
5769
+ } catch (err) {
5770
+ host.logger.warn("tunnels.destroyAll: destroy(port) failed", {
5771
+ port,
5772
+ error: err instanceof Error ? err.message : String(err)
5773
+ });
5774
+ }
4852
5775
  };
4853
5776
  return {
4854
5777
  tunnels,
4855
- handleTunnelExit
5778
+ handleTunnelExit,
5779
+ destroyAll
4856
5780
  };
4857
5781
  }
5782
+ /**
5783
+ * Reconcile storage with a fresh container.
5784
+ *
5785
+ * Called from `Sandbox.onStart()` after every container restart. The
5786
+ * `cloudflared` processes the container was running all died with it, so
5787
+ * any stored record is *not* currently backed by a running tunnel.
5788
+ *
5789
+ * Two tunnel flavours, two recovery stories:
5790
+ *
5791
+ * - Quick tunnels: the `*.trycloudflare.com` URL is bound to the dead
5792
+ * `cloudflared` process. Nothing on Cloudflare's side outlives the
5793
+ * container, and the URL is unrecoverable. Drop the record from both
5794
+ * maps so the next `get(port)` takes the miss branch and mints a new
5795
+ * URL.
5796
+ * - Named tunnels: the Cloudflare-side tunnel + DNS record survive.
5797
+ * The hostname is stable, the DNS still resolves to
5798
+ * `<tunnelId>.cfargotunnel.com`, and the next caller can reuse both
5799
+ * by walking the same `findTunnelByName` / `upsertCNAME` path the
5800
+ * SDK uses for retries. Keep the record in storage and mark the
5801
+ * meta entry `needsRespawn: true`; the next `get(port, { name })`
5802
+ * cache hit falls through to `#provisionNamedTunnel` to respawn
5803
+ * `cloudflared`.
5804
+ *
5805
+ * Crucially, named-tunnel metadata (including `dnsRecordId`) is
5806
+ * preserved so `destroy(port)` and `sandbox.destroy()` can still clean
5807
+ * up the Cloudflare-side resources after a restart. Wiping meta
5808
+ * unconditionally — the previous behaviour — silently leaked the tunnel
5809
+ * and DNS record on every restart.
5810
+ */
5811
+ async function pruneTunnelsForRestart(storage) {
5812
+ await storage.transaction(async (txn) => {
5813
+ const map = await readMap(txn);
5814
+ const meta = await readMetaMap(txn);
5815
+ const nextMap = {};
5816
+ const nextMeta = {};
5817
+ for (const [portKey, info] of Object.entries(map)) if (info.name) {
5818
+ nextMap[portKey] = info;
5819
+ nextMeta[portKey] = {
5820
+ ...meta[portKey] ?? { optionsHash: `v1:named:${info.name}` },
5821
+ needsRespawn: true
5822
+ };
5823
+ }
5824
+ await txn.put(STORAGE_KEY, nextMap);
5825
+ await txn.put(META_STORAGE_KEY, nextMeta);
5826
+ });
5827
+ }
4858
5828
 
4859
5829
  //#endregion
4860
5830
  //#region src/version.ts
@@ -4863,14 +5833,47 @@ function createTunnelsHandler(host) {
4863
5833
  * This file is auto-updated by .github/changeset-version.ts during releases
4864
5834
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
4865
5835
  */
4866
- const SDK_VERSION = "0.10.3";
5836
+ const SDK_VERSION = "0.12.0";
4867
5837
 
4868
5838
  //#endregion
4869
5839
  //#region src/sandbox.ts
4870
- const R2_EGRESS_PROXY_TARGET_CLASS_NAME = "CloudflareSandboxR2EgressProxyTarget";
4871
- var R2EgressProxyTarget = class extends Container {};
4872
- Object.defineProperty(R2EgressProxyTarget, "name", { value: R2_EGRESS_PROXY_TARGET_CLASS_NAME });
4873
- R2EgressProxyTarget.outboundHandlers = { r2EgressMount: r2EgressHandler };
5840
+ const PORT_TOKENS_STORAGE_KEY = "portTokens";
5841
+ const ACTIVE_PREVIEW_PORTS_STORAGE_KEY = "activePreviewPorts";
5842
+ const CONTAINER_PROXY_CLASS_NAME = "ContainerProxy";
5843
+ const S3_CREDENTIAL_PROXY_HOST = "s3-credential-proxy.internal";
5844
+ const S3_CREDENTIAL_PROXY_DIAGNOSTIC_HOST = "s3-credential-proxy.sandbox.test";
5845
+ var ContainerProxyOutboundTarget = class extends Container {};
5846
+ Object.defineProperty(ContainerProxyOutboundTarget, "name", { value: CONTAINER_PROXY_CLASS_NAME });
5847
+ ContainerProxyOutboundTarget.outboundHandlers = {
5848
+ r2EgressMount: r2EgressHandler,
5849
+ s3CredentialProxyMount: s3CredentialProxyHandler
5850
+ };
5851
+ /**
5852
+ * SDK-level ContainerProxy that directly dispatches SDK-internal mount hosts
5853
+ * (r2.internal, s3-credential-proxy.internal) without relying on
5854
+ * outboundHandlersRegistry lookups, which are NOT shared between the Durable
5855
+ * Object's execution context and the ContainerProxy WorkerEntrypoint context.
5856
+ *
5857
+ * Users must export this class from their Worker entrypoint so the Sandbox DO
5858
+ * can create outbound-interception fetchers that reference it.
5859
+ */
5860
+ var ContainerProxy$1 = class extends ContainerProxy {
5861
+ async fetch(request) {
5862
+ const hostname = new URL(request.url).hostname;
5863
+ const props = this.ctx.props;
5864
+ const override = props.outboundByHostOverrides?.[hostname];
5865
+ if (override) {
5866
+ const handlerCtx = {
5867
+ containerId: props.containerId ?? "",
5868
+ className: props.className ?? "",
5869
+ params: override.params
5870
+ };
5871
+ if (override.method === "r2EgressMount") return r2EgressHandler(request, this.env, handlerCtx);
5872
+ if (override.method === "s3CredentialProxyMount") return s3CredentialProxyHandler(request, this.env, handlerCtx);
5873
+ }
5874
+ return super.fetch(request);
5875
+ }
5876
+ };
4874
5877
  function isFetcher(value) {
4875
5878
  return typeof value === "object" && value !== null && "fetch" in value && typeof value.fetch === "function";
4876
5879
  }
@@ -4880,6 +5883,8 @@ const R2_DEFAULT_S3FS_OPTIONS = {
4880
5883
  enable_noobj_cache: true,
4881
5884
  multipart_size: "5"
4882
5885
  };
5886
+ const R2_DEFAULT_S3FS_OPTION_ENTRIES = Object.entries(R2_DEFAULT_S3FS_OPTIONS).map(([key, value]) => value === true ? key : `${key}=${value}`);
5887
+ const S3FS_DISABLE_EXPECT_HEADER_CONFIG = " Expect:\n";
4883
5888
  const BACKUP_DEFAULT_TTL_SECONDS = 259200;
4884
5889
  const BACKUP_MAX_NAME_LENGTH = 256;
4885
5890
  const BACKUP_CONTAINER_DIR = "/var/backups";
@@ -5071,10 +6076,6 @@ function getSandbox(ns, id, options) {
5071
6076
  }),
5072
6077
  terminal: (request, opts) => proxyTerminal(stub, defaultSessionId, request, opts),
5073
6078
  wsConnect: connect(stub),
5074
- desktop: new Proxy({}, { get(_, method) {
5075
- if (typeof method !== "string" || method === "then") return void 0;
5076
- return (...args) => stub.callDesktop(method, args);
5077
- } }),
5078
6079
  tunnels: new Proxy({}, { get: (_, method) => {
5079
6080
  if (typeof method !== "string" || method === "then") return void 0;
5080
6081
  return (...args) => stub.callTunnels(method, args);
@@ -5106,6 +6107,7 @@ var Sandbox = class Sandbox extends Container {
5106
6107
  sandboxName = null;
5107
6108
  tunnelsHandler = null;
5108
6109
  tunnelExitHandler = null;
6110
+ destroyAllTunnels = null;
5109
6111
  controlCallback;
5110
6112
  normalizeId = false;
5111
6113
  defaultSession = null;
@@ -5115,6 +6117,8 @@ var Sandbox = class Sandbox extends Container {
5115
6117
  logger;
5116
6118
  keepAliveEnabled = false;
5117
6119
  activeMounts = /* @__PURE__ */ new Map();
6120
+ mountOperationQueue = Promise.resolve();
6121
+ currentRuntime;
5118
6122
  transport = "http";
5119
6123
  /**
5120
6124
  * True once transport has been written to storage at least once (either
@@ -5140,8 +6144,23 @@ var Sandbox = class Sandbox extends Container {
5140
6144
  r2SecretAccessKey = null;
5141
6145
  r2AccountId = null;
5142
6146
  backupBucketName = null;
6147
+ backupBucketEndpoint = null;
5143
6148
  r2Client = null;
5144
6149
  /**
6150
+ * Lazily-resolved Cloudflare account id for named-tunnel provisioning.
6151
+ * Resolved on first access via `tunnels/credentials.ts` and cached for
6152
+ * the lifetime of this DO instance. See the credentials helper for
6153
+ * the precedence chain.
6154
+ */
6155
+ tunnelAccountIdPromise = null;
6156
+ /**
6157
+ * Lazily-resolved Cloudflare zone id for named-tunnel provisioning.
6158
+ * Falls back to the single zone the token can see under the resolved
6159
+ * account id when `CLOUDFLARE_ZONE_ID` is not set. Cached for the
6160
+ * lifetime of this DO instance.
6161
+ */
6162
+ tunnelZoneIdPromise = null;
6163
+ /**
5145
6164
  * Default container startup timeouts (conservative for production)
5146
6165
  * Based on Cloudflare docs: "Containers take several minutes to provision"
5147
6166
  */
@@ -5165,56 +6184,6 @@ var Sandbox = class Sandbox extends Container {
5165
6184
  */
5166
6185
  hasStoredContainerTimeouts = false;
5167
6186
  /**
5168
- * Desktop environment operations.
5169
- * Within the DO, this getter provides direct access to DesktopClient.
5170
- * Over RPC, the getSandbox() proxy intercepts this property and routes
5171
- * calls through callDesktop() instead.
5172
- */
5173
- get desktop() {
5174
- return this.client.desktop;
5175
- }
5176
- /**
5177
- * Allowed desktop methods — derived from the Desktop interface.
5178
- * Restricts callDesktop() to a known set of operations.
5179
- */
5180
- static DESKTOP_METHODS = new Set([
5181
- "start",
5182
- "stop",
5183
- "status",
5184
- "screenshot",
5185
- "screenshotRegion",
5186
- "click",
5187
- "doubleClick",
5188
- "tripleClick",
5189
- "rightClick",
5190
- "middleClick",
5191
- "mouseDown",
5192
- "mouseUp",
5193
- "moveMouse",
5194
- "drag",
5195
- "scroll",
5196
- "getCursorPosition",
5197
- "type",
5198
- "press",
5199
- "keyDown",
5200
- "keyUp",
5201
- "getScreenSize",
5202
- "getProcessStatus"
5203
- ]);
5204
- /**
5205
- * Dispatch method for desktop operations.
5206
- * Called by the client-side proxy created in getSandbox() to provide
5207
- * the `sandbox.desktop.status()` API without relying on RPC pipelining
5208
- * through property getters which is broken when using vite-plugin.
5209
- */
5210
- async callDesktop(method, args) {
5211
- if (!Sandbox.DESKTOP_METHODS.has(method)) throw new Error(`Unknown desktop method: ${method}`);
5212
- const client = this.client.desktop;
5213
- const fn = client[method];
5214
- if (typeof fn !== "function") throw new Error(`sandbox.desktop missing method: ${method}`);
5215
- return fn.apply(client, args);
5216
- }
5217
- /**
5218
6187
  * Dispatch method for tunnel operations.
5219
6188
  * Called by the client-side proxy created in getSandbox() to provide
5220
6189
  * the `sandbox.tunnels` API without relying on RPC pipelining
@@ -5300,16 +6269,64 @@ var Sandbox = class Sandbox extends Container {
5300
6269
  component: "sandbox-do",
5301
6270
  sandboxId: this.ctx.id.toString()
5302
6271
  });
6272
+ this.currentRuntime = new CurrentRuntimeIdentity(this.ctx.storage, () => this.getState(), () => this.ctx.container?.running === true);
5303
6273
  const transportEnv = envObj?.SANDBOX_TRANSPORT;
5304
6274
  if (transportEnv === "websocket" || transportEnv === "rpc") this.transport = transportEnv;
5305
6275
  else if (transportEnv != null && transportEnv !== "http") this.logger.warn(`Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "rpc". Defaulting to "http".`);
5306
6276
  this.logger.info(`Using ${this.transport} transport`);
5307
6277
  const backupBucket = envObj?.BACKUP_BUCKET;
5308
6278
  if (isR2Bucket(backupBucket)) this.backupBucket = backupBucket;
5309
- this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
6279
+ this.r2AccountId = getEnvString(envObj, "CLOUDFLARE_R2_ACCOUNT_ID") ?? getEnvString(envObj, "CLOUDFLARE_ACCOUNT_ID") ?? null;
5310
6280
  this.r2AccessKeyId = getEnvString(envObj, "R2_ACCESS_KEY_ID") ?? null;
5311
6281
  this.r2SecretAccessKey = getEnvString(envObj, "R2_SECRET_ACCESS_KEY") ?? null;
5312
6282
  this.backupBucketName = getEnvString(envObj, "BACKUP_BUCKET_NAME") ?? null;
6283
+ const rawEndpoint = getEnvString(envObj, "BACKUP_BUCKET_ENDPOINT") ?? null;
6284
+ if (rawEndpoint !== null) {
6285
+ let parsed;
6286
+ try {
6287
+ parsed = new URL(rawEndpoint);
6288
+ } catch {
6289
+ const msg = `BACKUP_BUCKET_ENDPOINT is not a valid URL: "${rawEndpoint}". Expected format: https://<account_id>.eu.r2.cloudflarestorage.com`;
6290
+ throw new InvalidBackupConfigError({
6291
+ message: msg,
6292
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6293
+ httpStatus: 400,
6294
+ context: { reason: msg },
6295
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6296
+ });
6297
+ }
6298
+ if (parsed.protocol !== "https:") {
6299
+ const msg = `BACKUP_BUCKET_ENDPOINT must use https://, got "${parsed.protocol.slice(0, -1)}://"`;
6300
+ throw new InvalidBackupConfigError({
6301
+ message: msg,
6302
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6303
+ httpStatus: 400,
6304
+ context: { reason: msg },
6305
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6306
+ });
6307
+ }
6308
+ if (parsed.pathname !== "/") {
6309
+ const msg = `BACKUP_BUCKET_ENDPOINT must not include a path (got "${parsed.pathname}"). Provide only the origin, e.g. https://<account_id>.eu.r2.cloudflarestorage.com`;
6310
+ throw new InvalidBackupConfigError({
6311
+ message: msg,
6312
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6313
+ httpStatus: 400,
6314
+ context: { reason: msg },
6315
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6316
+ });
6317
+ }
6318
+ if (parsed.search !== "" || parsed.hash !== "") {
6319
+ const msg = "BACKUP_BUCKET_ENDPOINT must not include query parameters or fragments. Provide only the origin, e.g. https://<account_id>.eu.r2.cloudflarestorage.com";
6320
+ throw new InvalidBackupConfigError({
6321
+ message: msg,
6322
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6323
+ httpStatus: 400,
6324
+ context: { reason: msg },
6325
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6326
+ });
6327
+ }
6328
+ this.backupBucketEndpoint = parsed.origin;
6329
+ } else this.backupBucketEndpoint = null;
5313
6330
  if (this.r2AccessKeyId && this.r2SecretAccessKey) this.r2Client = new AwsClient({
5314
6331
  accessKeyId: this.r2AccessKeyId,
5315
6332
  secretAccessKey: this.r2SecretAccessKey
@@ -5344,6 +6361,7 @@ var Sandbox = class Sandbox extends Container {
5344
6361
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
5345
6362
  this.tunnelsHandler = null;
5346
6363
  this.tunnelExitHandler = null;
6364
+ this.destroyAllTunnels = null;
5347
6365
  previousClient.disconnect();
5348
6366
  }
5349
6367
  if (storedTransport) this.hasStoredTransport = true;
@@ -5425,6 +6443,7 @@ var Sandbox = class Sandbox extends Container {
5425
6443
  this.codeInterpreter = new CodeInterpreter(() => this.client.interpreter);
5426
6444
  this.tunnelsHandler = null;
5427
6445
  this.tunnelExitHandler = null;
6446
+ this.destroyAllTunnels = null;
5428
6447
  previousClient.disconnect();
5429
6448
  this.renewActivityTimeout();
5430
6449
  this.logger.debug("Transport updated", { transport });
@@ -5469,6 +6488,24 @@ var Sandbox = class Sandbox extends Container {
5469
6488
  * @throws InvalidMountConfigError if bucket name, mount path, or endpoint is invalid
5470
6489
  */
5471
6490
  async mountBucket(bucket, mountPath, options) {
6491
+ return this.runMountOperation(async () => {
6492
+ await this.mountBucketUnlocked(bucket, mountPath, options);
6493
+ });
6494
+ }
6495
+ async runMountOperation(operation) {
6496
+ const previous = this.mountOperationQueue;
6497
+ let release;
6498
+ this.mountOperationQueue = new Promise((resolve) => {
6499
+ release = resolve;
6500
+ });
6501
+ await previous.catch(() => {});
6502
+ try {
6503
+ await operation();
6504
+ } finally {
6505
+ release();
6506
+ }
6507
+ }
6508
+ async mountBucketUnlocked(bucket, mountPath, options) {
5472
6509
  if (options.prefix !== void 0) validatePrefix(options.prefix);
5473
6510
  if ("localBucket" in options && options.localBucket) {
5474
6511
  await this.mountBucketLocal(bucket, mountPath, options);
@@ -5508,6 +6545,7 @@ var Sandbox = class Sandbox extends Container {
5508
6545
  logger: this.logger
5509
6546
  });
5510
6547
  const mountInfo = {
6548
+ mountId: crypto.randomUUID(),
5511
6549
  mountType: "local-sync",
5512
6550
  bucket,
5513
6551
  mountPath,
@@ -5548,14 +6586,37 @@ var Sandbox = class Sandbox extends Container {
5548
6586
  };
5549
6587
  return { buckets };
5550
6588
  }
5551
- validateR2EgressS3fsOptions(options) {
6589
+ validateProtectedS3fsOptions(options, mountLabel, extraProtected = []) {
5552
6590
  if (!options) return;
5553
- const protectedOptions = new Set(["passwd_file", "url"]);
6591
+ const protectedOptions = new Set([
6592
+ "passwd_file",
6593
+ "url",
6594
+ ...extraProtected
6595
+ ]);
5554
6596
  for (const option of options) {
5555
6597
  const [key] = option.split("=");
5556
- if (protectedOptions.has(key)) throw new InvalidMountConfigError(`s3fs option "${key}" cannot be overridden for R2 binding mounts`);
6598
+ if (protectedOptions.has(key)) throw new InvalidMountConfigError(`s3fs option "${key}" cannot be overridden for ${mountLabel} mounts`);
5557
6599
  }
5558
6600
  }
6601
+ getS3CredentialProxyParams(options) {
6602
+ const mounts = {};
6603
+ for (const [, m] of this.activeMounts) if (m.mountType === "fuse" && m.credentialProxy) {
6604
+ if (m.mountId === options?.excludeMountId) continue;
6605
+ mounts[m.mountId] = {
6606
+ endpoint: m.credentialProxy.endpoint,
6607
+ bucket: m.credentialProxy.bucket,
6608
+ ...m.credentialProxy.prefix !== void 0 ? { prefix: m.credentialProxy.prefix } : {},
6609
+ credentials: m.credentialProxy.credentials,
6610
+ readOnly: m.credentialProxy.readOnly,
6611
+ provider: m.credentialProxy.provider,
6612
+ authStrategy: m.credentialProxy.authStrategy
6613
+ };
6614
+ }
6615
+ return { mounts };
6616
+ }
6617
+ resolveCredentialProxyAuthStrategy(provider) {
6618
+ return provider === "gcs" ? "gcs" : "s3-sigv4";
6619
+ }
5559
6620
  /**
5560
6621
  * Credential-less R2 mount: egress interception routes s3fs requests to the
5561
6622
  * R2 binding. No S3 credentials are needed in the container or Worker env.
@@ -5565,24 +6626,30 @@ var Sandbox = class Sandbox extends Container {
5565
6626
  const prefix = options.prefix;
5566
6627
  let mountOutcome = "error";
5567
6628
  let mountError;
6629
+ let passwordFilePath;
6630
+ let additionalHeaderFilePath;
5568
6631
  try {
5569
6632
  validateBucketBindingName(bucket, mountPath);
5570
6633
  this.validateMountPath(mountPath);
5571
- this.validateR2EgressS3fsOptions(options.s3fsOptions);
6634
+ this.validateProtectedS3fsOptions(options.s3fsOptions, "R2 binding");
5572
6635
  for (const [existingMountPath, mountInfo$1] of this.activeMounts) {
5573
6636
  if (mountInfo$1.mountType === "r2-egress" && mountInfo$1.bucket === bucket && mountInfo$1.prefix !== prefix) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted at ${existingMountPath} with a different prefix. Mount the same binding only once, or use the same prefix for additional mounts.`);
5574
6637
  if (mountInfo$1.mountType === "r2-egress" && mountInfo$1.bucket === bucket && mountInfo$1.readOnly !== (options.readOnly ?? false)) throw new InvalidMountConfigError(`R2 binding "${bucket}" is already mounted at ${existingMountPath} with a different readOnly setting. Mount the same binding only once, or use the same readOnly value for additional mounts.`);
5575
6638
  }
5576
- const passwordFilePath = this.generatePasswordFilePath();
6639
+ passwordFilePath = this.generatePasswordFilePath();
6640
+ additionalHeaderFilePath = this.generateS3FSAdditionalHeaderFilePath();
5577
6641
  await this.createPasswordFile(passwordFilePath, bucket, {
5578
6642
  accessKeyId: "x",
5579
6643
  secretAccessKey: "x"
5580
6644
  });
6645
+ await this.createDisableExpectHeaderFile(additionalHeaderFilePath);
5581
6646
  const mountInfo = {
6647
+ mountId: crypto.randomUUID(),
5582
6648
  mountType: "r2-egress",
5583
6649
  bucket,
5584
6650
  mountPath,
5585
6651
  passwordFilePath,
6652
+ additionalHeaderFilePath,
5586
6653
  mounted: false,
5587
6654
  prefix,
5588
6655
  readOnly: options.readOnly ?? false
@@ -5597,6 +6664,7 @@ var Sandbox = class Sandbox extends Container {
5597
6664
  ...parseS3fsOptions(resolveS3fsOptions("r2", options.s3fsOptions)),
5598
6665
  use_path_request_style: true,
5599
6666
  url: "http://r2.internal",
6667
+ ahbe_conf: additionalHeaderFilePath,
5600
6668
  ...options.readOnly ? { ro: true } : {}
5601
6669
  }));
5602
6670
  const mountCmd = `s3fs ${shellEscape(s3fsSource)} ${shellEscape(mountPath)} -o ${optionsStr}`;
@@ -5620,7 +6688,13 @@ var Sandbox = class Sandbox extends Container {
5620
6688
  mountError = error instanceof Error ? error : new Error(String(error));
5621
6689
  const failedMount = this.activeMounts.get(mountPath);
5622
6690
  this.activeMounts.delete(mountPath);
5623
- if (failedMount?.mountType === "r2-egress") await this.deletePasswordFile(failedMount.passwordFilePath).catch(() => {});
6691
+ if (failedMount?.mountType === "r2-egress") {
6692
+ await this.deletePasswordFile(failedMount.passwordFilePath).catch(() => {});
6693
+ if (failedMount.additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(failedMount.additionalHeaderFilePath).catch(() => {});
6694
+ } else {
6695
+ if (passwordFilePath) await this.deletePasswordFile(passwordFilePath).catch(() => {});
6696
+ if (additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(additionalHeaderFilePath).catch(() => {});
6697
+ }
5624
6698
  const remainingParams = this.getR2EgressParams();
5625
6699
  await this.configureR2EgressOutbound(remainingParams).catch(() => {});
5626
6700
  throw error;
@@ -5646,6 +6720,7 @@ var Sandbox = class Sandbox extends Container {
5646
6720
  let mountOutcome = "error";
5647
6721
  let mountError;
5648
6722
  let passwordFilePath;
6723
+ let additionalHeaderFilePath;
5649
6724
  let provider = null;
5650
6725
  let dirExisted = true;
5651
6726
  try {
@@ -5667,33 +6742,81 @@ var Sandbox = class Sandbox extends Container {
5667
6742
  R2_SECRET_ACCESS_KEY: this.r2SecretAccessKey || void 0,
5668
6743
  ...this.envVars
5669
6744
  });
6745
+ const credentialProxyEnabled = options.credentialProxy === true;
6746
+ if (credentialProxyEnabled) this.validateProtectedS3fsOptions(options.s3fsOptions, "credential proxy", ["ahbe_conf", "use_path_request_style"]);
5670
6747
  passwordFilePath = this.generatePasswordFilePath();
6748
+ if (credentialProxyEnabled) additionalHeaderFilePath = this.generateS3FSAdditionalHeaderFilePath();
6749
+ const mountId = crypto.randomUUID();
5671
6750
  const mountInfo = {
6751
+ mountId,
5672
6752
  mountType: "fuse",
5673
6753
  bucket: s3fsSource,
5674
6754
  mountPath,
5675
6755
  endpoint: options.endpoint,
5676
6756
  provider,
5677
6757
  passwordFilePath,
5678
- mounted: false
6758
+ ...additionalHeaderFilePath ? { additionalHeaderFilePath } : {},
6759
+ mounted: false,
6760
+ ...credentialProxyEnabled ? { credentialProxy: {
6761
+ endpoint: options.endpoint,
6762
+ bucket,
6763
+ ...prefix !== void 0 ? { prefix } : {},
6764
+ credentials,
6765
+ readOnly: options.readOnly ?? false,
6766
+ provider,
6767
+ authStrategy: this.resolveCredentialProxyAuthStrategy(provider)
6768
+ } } : {}
5679
6769
  };
5680
6770
  this.activeMounts.set(mountPath, mountInfo);
5681
- await this.createPasswordFile(passwordFilePath, bucket, credentials);
6771
+ await this.createPasswordFile(passwordFilePath, bucket, credentialProxyEnabled ? {
6772
+ accessKeyId: "x",
6773
+ secretAccessKey: "x"
6774
+ } : credentials);
6775
+ if (credentialProxyEnabled) {
6776
+ if (additionalHeaderFilePath) await this.createDisableExpectHeaderFile(additionalHeaderFilePath);
6777
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams());
6778
+ }
5682
6779
  dirExisted = (await this.execInternal(`test -d ${shellEscape(mountPath)}`)).exitCode === 0;
5683
6780
  await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
5684
- await this.executeS3FSMount(s3fsSource, mountPath, options, provider, passwordFilePath);
6781
+ const effectiveOptions = credentialProxyEnabled ? {
6782
+ ...options,
6783
+ endpoint: `http://${S3_CREDENTIAL_PROXY_HOST}/${mountId}`,
6784
+ s3fsOptions: [
6785
+ ...provider === "r2" ? R2_DEFAULT_S3FS_OPTION_ENTRIES : [],
6786
+ ...options.s3fsOptions ?? [],
6787
+ ...additionalHeaderFilePath ? [`ahbe_conf=${additionalHeaderFilePath}`] : [],
6788
+ "use_path_request_style"
6789
+ ]
6790
+ } : options;
6791
+ await this.executeS3FSMount(s3fsSource, mountPath, effectiveOptions, provider, passwordFilePath);
5685
6792
  mountInfo.mounted = true;
5686
6793
  mountOutcome = "success";
5687
6794
  } catch (error) {
5688
6795
  mountError = error instanceof Error ? error : new Error(String(error));
5689
- if (passwordFilePath) await this.deletePasswordFile(passwordFilePath);
5690
6796
  try {
5691
6797
  await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && fusermount -u ${shellEscape(mountPath)}`);
5692
6798
  } catch {}
6799
+ if (passwordFilePath) await this.deletePasswordFile(passwordFilePath);
6800
+ if (additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(additionalHeaderFilePath);
5693
6801
  if (!dirExisted) try {
5694
6802
  await this.execInternal(`rmdir ${shellEscape(mountPath)} 2>/dev/null`);
5695
6803
  } catch {}
5696
- this.activeMounts.delete(mountPath);
6804
+ const failedMount = this.activeMounts.get(mountPath);
6805
+ if (failedMount?.mountType === "fuse" && failedMount.credentialProxy) try {
6806
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams({ excludeMountId: failedMount.mountId }));
6807
+ this.activeMounts.delete(mountPath);
6808
+ evictSigV4ClientCacheEntry(failedMount.mountId);
6809
+ evictDirectoryMarkerCacheForMount(failedMount.mountId);
6810
+ } catch (cleanupError) {
6811
+ this.logger.warn("credential proxy cleanup failed", {
6812
+ mountPath,
6813
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
6814
+ });
6815
+ this.activeMounts.delete(mountPath);
6816
+ evictSigV4ClientCacheEntry(failedMount.mountId);
6817
+ evictDirectoryMarkerCacheForMount(failedMount.mountId);
6818
+ }
6819
+ else this.activeMounts.delete(mountPath);
5697
6820
  throw error;
5698
6821
  } finally {
5699
6822
  logCanonicalEvent(this.logger, {
@@ -5715,6 +6838,11 @@ var Sandbox = class Sandbox extends Container {
5715
6838
  * @throws InvalidMountConfigError if mount path doesn't exist or isn't mounted
5716
6839
  */
5717
6840
  async unmountBucket(mountPath) {
6841
+ return this.runMountOperation(async () => {
6842
+ await this.unmountBucketUnlocked(mountPath);
6843
+ });
6844
+ }
6845
+ async unmountBucketUnlocked(mountPath) {
5718
6846
  const unmountStartTime = Date.now();
5719
6847
  let unmountOutcome = "error";
5720
6848
  let unmountError;
@@ -5725,30 +6853,75 @@ var Sandbox = class Sandbox extends Container {
5725
6853
  await mountInfo.syncManager.stop();
5726
6854
  mountInfo.mounted = false;
5727
6855
  this.activeMounts.delete(mountPath);
5728
- } else try {
5729
- const result = await this.execInternal(`fusermount -u ${shellEscape(mountPath)}`);
5730
- if (result.exitCode !== 0) {
5731
- const stderr = result.stderr || "unknown error";
5732
- throw new BucketUnmountError(`fusermount -u failed (exit ${result.exitCode}): ${stderr}`);
5733
- }
5734
- mountInfo.mounted = false;
5735
- this.activeMounts.delete(mountPath);
5736
- if (mountInfo.mountType === "r2-egress") await this.configureR2EgressOutbound(this.getR2EgressParams());
6856
+ } else if (mountInfo.mountType === "fuse" && mountInfo.credentialProxy && !mountInfo.mounted) {
5737
6857
  try {
5738
- const cleanup = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} || rmdir ${shellEscape(mountPath)}`);
5739
- if (cleanup.exitCode !== 0) this.logger.warn("mount directory removal failed", {
5740
- mountPath,
5741
- exitCode: cleanup.exitCode,
5742
- stderr: cleanup.stderr
5743
- });
5744
- } catch (err) {
5745
- this.logger.warn("mount directory removal failed", {
6858
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams({ excludeMountId: mountInfo.mountId }));
6859
+ } catch (cleanupError) {
6860
+ this.logger.warn("credential proxy outbound reconfiguration failed on unmount", {
5746
6861
  mountPath,
5747
- error: err instanceof Error ? err.message : String(err)
6862
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
5748
6863
  });
5749
6864
  }
5750
- } finally {
5751
- await this.deletePasswordFile(mountInfo.passwordFilePath);
6865
+ this.activeMounts.delete(mountPath);
6866
+ evictSigV4ClientCacheEntry(mountInfo.mountId);
6867
+ evictDirectoryMarkerCacheForMount(mountInfo.mountId);
6868
+ } else {
6869
+ let unmounted = false;
6870
+ try {
6871
+ const result = await this.execInternal(`fusermount -u ${shellEscape(mountPath)}`);
6872
+ if (result.exitCode !== 0) {
6873
+ const stderr = result.stderr || "unknown error";
6874
+ throw new BucketUnmountError(`fusermount -u failed (exit ${result.exitCode}): ${stderr}`);
6875
+ }
6876
+ unmounted = true;
6877
+ mountInfo.mounted = false;
6878
+ if (mountInfo.mountType === "r2-egress") {
6879
+ const remainingBuckets = {};
6880
+ for (const [, activeMount] of this.activeMounts) if (activeMount.mountType === "r2-egress" && activeMount.mountId !== mountInfo.mountId) remainingBuckets[activeMount.bucket] = {
6881
+ prefix: activeMount.prefix,
6882
+ readOnly: activeMount.readOnly
6883
+ };
6884
+ try {
6885
+ await this.configureR2EgressOutbound({ buckets: remainingBuckets });
6886
+ } catch (cleanupError) {
6887
+ this.logger.warn("r2 egress outbound reconfiguration failed on unmount", {
6888
+ mountPath,
6889
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
6890
+ });
6891
+ }
6892
+ this.activeMounts.delete(mountPath);
6893
+ } else if (mountInfo.mountType === "fuse" && mountInfo.credentialProxy) {
6894
+ try {
6895
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams({ excludeMountId: mountInfo.mountId }));
6896
+ } catch (cleanupError) {
6897
+ this.logger.warn("credential proxy outbound reconfiguration failed on unmount", {
6898
+ mountPath,
6899
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
6900
+ });
6901
+ }
6902
+ this.activeMounts.delete(mountPath);
6903
+ evictSigV4ClientCacheEntry(mountInfo.mountId);
6904
+ evictDirectoryMarkerCacheForMount(mountInfo.mountId);
6905
+ } else this.activeMounts.delete(mountPath);
6906
+ try {
6907
+ const cleanup = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} || rmdir ${shellEscape(mountPath)}`);
6908
+ if (cleanup.exitCode !== 0) this.logger.warn("mount directory removal failed", {
6909
+ mountPath,
6910
+ exitCode: cleanup.exitCode,
6911
+ stderr: cleanup.stderr
6912
+ });
6913
+ } catch (err) {
6914
+ this.logger.warn("mount directory removal failed", {
6915
+ mountPath,
6916
+ error: err instanceof Error ? err.message : String(err)
6917
+ });
6918
+ }
6919
+ } finally {
6920
+ if (unmounted) {
6921
+ await this.deletePasswordFile(mountInfo.passwordFilePath);
6922
+ if (mountInfo.additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(mountInfo.additionalHeaderFilePath);
6923
+ }
6924
+ }
5752
6925
  }
5753
6926
  unmountOutcome = "success";
5754
6927
  } catch (error) {
@@ -5791,6 +6964,20 @@ var Sandbox = class Sandbox extends Container {
5791
6964
  return `/tmp/.passwd-s3fs-${crypto.randomUUID()}`;
5792
6965
  }
5793
6966
  /**
6967
+ * Generate unique ahbe_conf file path for s3fs additional header config
6968
+ */
6969
+ generateS3FSAdditionalHeaderFilePath() {
6970
+ return `/tmp/.s3fs-ahbe-${crypto.randomUUID()}.conf`;
6971
+ }
6972
+ /**
6973
+ * Create s3fs ahbe_conf file that suppresses the Expect: 100-continue header.
6974
+ * Restricted to 0600 so s3fs will accept it (same requirement as passwd files).
6975
+ */
6976
+ async createDisableExpectHeaderFile(headerFilePath) {
6977
+ await this.client.files.writeFile(headerFilePath, S3FS_DISABLE_EXPECT_HEADER_CONFIG, DISABLE_SESSION_TOKEN);
6978
+ await this.execInternal(`chmod 0600 ${shellEscape(headerFilePath)}`);
6979
+ }
6980
+ /**
5794
6981
  * Create password file with s3fs credentials
5795
6982
  * Format: bucket:accessKeyId:secretAccessKey
5796
6983
  */
@@ -5812,6 +6999,16 @@ var Sandbox = class Sandbox extends Container {
5812
6999
  });
5813
7000
  }
5814
7001
  }
7002
+ async deleteAdditionalHeaderFile(headerFilePath) {
7003
+ try {
7004
+ await this.execInternal(`rm -f ${shellEscape(headerFilePath)}`);
7005
+ } catch (error) {
7006
+ this.logger.warn("s3fs additional header file cleanup failed", {
7007
+ headerFilePath,
7008
+ error: error instanceof Error ? error.message : String(error)
7009
+ });
7010
+ }
7011
+ }
5815
7012
  /**
5816
7013
  * Execute S3FS mount command
5817
7014
  */
@@ -5896,9 +7093,9 @@ var Sandbox = class Sandbox extends Container {
5896
7093
  let outcome = "error";
5897
7094
  let caughtError;
5898
7095
  try {
5899
- if (this.ctx.container?.running) try {
5900
- await this.client.desktop.stop();
5901
- } catch {}
7096
+ await this.ctx.storage.delete(PORT_TOKENS_STORAGE_KEY);
7097
+ await this.clearActivePreviewPorts();
7098
+ await this.currentRuntime.clear();
5902
7099
  for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
5903
7100
  mountsProcessed++;
5904
7101
  if (mountInfo.mountType === "local-sync") try {
@@ -5918,10 +7115,17 @@ var Sandbox = class Sandbox extends Container {
5918
7115
  this.logger.warn(`Failed to unmount bucket ${mountInfo.bucket} from ${mountPath}: ${errorMsg}`);
5919
7116
  }
5920
7117
  await this.deletePasswordFile(mountInfo.passwordFilePath);
7118
+ if (mountInfo.additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(mountInfo.additionalHeaderFilePath);
5921
7119
  }
5922
7120
  }
5923
- await this.ctx.storage.delete("portTokens");
7121
+ try {
7122
+ this.ensureTunnelsBuilt();
7123
+ await this.destroyAllTunnels?.();
7124
+ } catch (error) {
7125
+ this.logger.warn("Failed to tear down tunnels during destroy()", { error: error instanceof Error ? error.message : String(error) });
7126
+ }
5924
7127
  await this.ctx.storage.delete("tunnels");
7128
+ await this.ctx.storage.delete("tunnels:meta");
5925
7129
  this.client.disconnect();
5926
7130
  outcome = "success";
5927
7131
  await super.destroy();
@@ -5941,74 +7145,20 @@ var Sandbox = class Sandbox extends Container {
5941
7145
  }
5942
7146
  async onStart() {
5943
7147
  this.logger.debug("Sandbox started");
7148
+ await this.currentRuntime.markStarted();
5944
7149
  this.checkVersionCompatibility().catch((error) => {
5945
7150
  this.logger.error("Version compatibility check failed", error instanceof Error ? error : new Error(String(error)));
5946
7151
  });
5947
7152
  try {
5948
- await this.restoreExposedPorts();
5949
- } catch (error) {
5950
- this.logger.error("Failed to restore exposed ports after container start", error instanceof Error ? error : new Error(String(error)));
5951
- }
5952
- try {
5953
- await this.ctx.storage.delete("tunnels");
7153
+ await pruneTunnelsForRestart(this.ctx.storage);
5954
7154
  } catch (error) {
5955
- this.logger.error("Failed to clear tunnel storage after container start", error instanceof Error ? error : new Error(String(error)));
7155
+ this.logger.error("Failed to reconcile tunnel storage after container start", error instanceof Error ? error : new Error(String(error)));
5956
7156
  }
5957
7157
  }
5958
- /**
5959
- * Re-expose ports on the container runtime using tokens persisted in DO
5960
- * storage. Called from onStart() after a container (re)start.
5961
- *
5962
- * The DO storage holds the source of truth for which ports should be
5963
- * exposed, which tokens authorize them, and the friendly name (if any)
5964
- * that the caller set when first exposing the port. If a port is already
5965
- * exposed on the container this is a no-op for that port. Individual port
5966
- * failures are logged but do not abort the overall restore — a transient
5967
- * failure for one port must not prevent the others from being restored.
5968
- */
5969
- async restoreExposedPorts() {
5970
- const savedTokens = await this.readPortTokens();
5971
- const portEntries = Object.entries(savedTokens);
5972
- if (portEntries.length === 0) return;
5973
- const startTime = Date.now();
5974
- let restored = 0;
5975
- let skipped = 0;
5976
- let failed = 0;
5977
- const exposedSet = await this.client.ports.getExposedPorts(DISABLE_SESSION_TOKEN).then((response) => new Set(response.ports.map((p) => p.port))).catch((error) => {
5978
- this.logger.warn("Failed to fetch exposed ports for restore; assuming none exposed", { error: error instanceof Error ? error.message : String(error) });
5979
- return /* @__PURE__ */ new Set();
5980
- });
5981
- for (const [portStr, entry] of portEntries) {
5982
- const port = Number.parseInt(portStr, 10);
5983
- if (!Number.isFinite(port) || !validatePort(port)) {
5984
- this.logger.warn("Skipping restore of invalid port in storage", { port: portStr });
5985
- failed++;
5986
- continue;
5987
- }
5988
- if (exposedSet.has(port)) {
5989
- skipped++;
5990
- continue;
5991
- }
5992
- try {
5993
- await this.client.ports.exposePort(port, DISABLE_SESSION_TOKEN, entry.name);
5994
- restored++;
5995
- } catch (error) {
5996
- failed++;
5997
- this.logger.warn("Failed to re-expose port on container restart", {
5998
- port,
5999
- error: error instanceof Error ? error.message : String(error)
6000
- });
6001
- }
6002
- }
6003
- logCanonicalEvent(this.logger, {
6004
- event: "port.restore",
6005
- outcome: failed === 0 ? "success" : "error",
6006
- durationMs: Date.now() - startTime,
6007
- restored,
6008
- skipped,
6009
- failed,
6010
- total: portEntries.length
6011
- });
7158
+ async stop(signal) {
7159
+ await this.currentRuntime.clear();
7160
+ await this.clearActivePreviewPorts();
7161
+ await super.stop(signal);
6012
7162
  }
6013
7163
  /**
6014
7164
  * Read the `portTokens` map from DO storage, normalizing the legacy
@@ -6016,12 +7166,32 @@ var Sandbox = class Sandbox extends Container {
6016
7166
  * ({ token, name? }). The legacy format predates port-name persistence and
6017
7167
  * can appear on any DO whose storage was written before that change.
6018
7168
  */
6019
- async readPortTokens() {
6020
- const raw = await this.ctx.storage.get("portTokens") ?? {};
7169
+ async readPortTokens(storage = this.ctx.storage) {
7170
+ const raw = await storage.get(PORT_TOKENS_STORAGE_KEY) ?? {};
6021
7171
  const normalized = {};
6022
7172
  for (const [port, value] of Object.entries(raw)) normalized[port] = typeof value === "string" ? { token: value } : value;
6023
7173
  return normalized;
6024
7174
  }
7175
+ async readActivePreviewPorts(storage = this.ctx.storage) {
7176
+ return await storage.get(ACTIVE_PREVIEW_PORTS_STORAGE_KEY) ?? {};
7177
+ }
7178
+ async writeActivePreviewPorts(activations, storage = this.ctx.storage) {
7179
+ if (Object.keys(activations).length === 0) {
7180
+ await storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY);
7181
+ return;
7182
+ }
7183
+ await storage.put(ACTIVE_PREVIEW_PORTS_STORAGE_KEY, activations);
7184
+ }
7185
+ async readPreviewState(storage = this.ctx.storage) {
7186
+ const [tokens, activations] = await Promise.all([this.readPortTokens(storage), this.readActivePreviewPorts(storage)]);
7187
+ return {
7188
+ tokens,
7189
+ activations
7190
+ };
7191
+ }
7192
+ async clearActivePreviewPorts() {
7193
+ await this.ctx.storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY);
7194
+ }
6025
7195
  /**
6026
7196
  * Check if the container version matches the SDK version
6027
7197
  * Logs a warning if there's a mismatch
@@ -6054,11 +7224,25 @@ var Sandbox = class Sandbox extends Container {
6054
7224
  this.containerGeneration++;
6055
7225
  this.defaultSession = null;
6056
7226
  this.defaultSessionInit = null;
7227
+ await this.currentRuntime.clear();
7228
+ await this.clearActivePreviewPorts();
7229
+ try {
7230
+ await pruneTunnelsForRestart(this.ctx.storage);
7231
+ } catch (error) {
7232
+ this.logger.error("Failed to reconcile tunnel storage after container stop", error instanceof Error ? error : new Error(String(error)));
7233
+ }
6057
7234
  this.client.disconnect();
6058
7235
  let hadR2EgressMount = false;
7236
+ let hadCredentialProxyMount = false;
6059
7237
  for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
6060
7238
  else if (m.mountType === "r2-egress") hadR2EgressMount = true;
7239
+ else if (m.mountType === "fuse" && m.credentialProxy) {
7240
+ hadCredentialProxyMount = true;
7241
+ evictSigV4ClientCacheEntry(m.mountId);
7242
+ evictDirectoryMarkerCacheForMount(m.mountId);
7243
+ }
6061
7244
  if (hadR2EgressMount) await this.configureR2EgressOutbound({ buckets: {} }).catch(() => {});
7245
+ if (hadCredentialProxyMount) await this.configureS3CredentialProxyOutbound({ mounts: {} }).catch(() => {});
6062
7246
  this.activeMounts.clear();
6063
7247
  await this.ctx.storage.delete("defaultSession");
6064
7248
  }
@@ -6275,6 +7459,99 @@ var Sandbox = class Sandbox extends Container {
6275
7459
  await super.onActivityExpired();
6276
7460
  }
6277
7461
  }
7462
+ isPreviewProxyRequest(request) {
7463
+ return request.headers.get(PREVIEW_PROXY_HEADER) === "1";
7464
+ }
7465
+ invalidPreviewTokenResponse() {
7466
+ return new Response(JSON.stringify({
7467
+ error: "Access denied: Invalid token or port not exposed",
7468
+ code: "INVALID_TOKEN"
7469
+ }), {
7470
+ status: 404,
7471
+ headers: { "Content-Type": "application/json" }
7472
+ });
7473
+ }
7474
+ stalePreviewURLResponse() {
7475
+ return new Response(JSON.stringify({
7476
+ error: "Preview URL is stale because the sandbox runtime is not active",
7477
+ code: "STALE_PREVIEW_URL"
7478
+ }), {
7479
+ status: 410,
7480
+ headers: { "Content-Type": "application/json" }
7481
+ });
7482
+ }
7483
+ getPreviewForwardingContainer() {
7484
+ return this.ctx.container;
7485
+ }
7486
+ beginPreviewForward() {
7487
+ const lifecycle = this;
7488
+ lifecycle.inflightRequests = (lifecycle.inflightRequests ?? 0) + 1;
7489
+ this.renewActivityTimeout();
7490
+ let settled = false;
7491
+ return () => {
7492
+ if (settled) return;
7493
+ settled = true;
7494
+ lifecycle.inflightRequests = Math.max(0, (lifecycle.inflightRequests ?? 0) - 1);
7495
+ if (lifecycle.inflightRequests === 0) this.renewActivityTimeout();
7496
+ };
7497
+ }
7498
+ async fetchPreviewIfRunning(request, port, runtime) {
7499
+ const container = this.getPreviewForwardingContainer();
7500
+ const state = await this.getState();
7501
+ if (!container?.running || state.status !== "healthy") return this.stalePreviewURLResponse();
7502
+ if (!await this.currentRuntime.isActive(runtime)) return this.stalePreviewURLResponse();
7503
+ const result = await forwardPreviewRequest(container.getTcpPort(port), request, {
7504
+ beginForward: () => this.beginPreviewForward(),
7505
+ renewActivity: () => this.renewActivityTimeout()
7506
+ });
7507
+ if (result.status === "network-lost") {
7508
+ if (!await this.currentRuntime.isActive(runtime)) return this.stalePreviewURLResponse();
7509
+ return new Response("Container suddenly disconnected, try again", { status: 500 });
7510
+ }
7511
+ return result.response;
7512
+ }
7513
+ buildPreviewProxyRequest(request, port, sandboxId) {
7514
+ const url = new URL(request.url);
7515
+ const proxyUrl = `http://localhost:${port}${url.pathname}${url.search}`;
7516
+ const headers = new Headers(request.headers);
7517
+ for (const header of PREVIEW_PROXY_HEADERS) headers.delete(header);
7518
+ headers.set("X-Original-URL", request.url);
7519
+ headers.set("X-Forwarded-Host", url.hostname);
7520
+ headers.set("X-Forwarded-Proto", url.protocol.replace(":", ""));
7521
+ headers.set("X-Sandbox-Name", this.sandboxName ?? sandboxId);
7522
+ if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") return new Request(request, {
7523
+ headers,
7524
+ redirect: "manual"
7525
+ });
7526
+ return new Request(proxyUrl, {
7527
+ method: request.method,
7528
+ headers,
7529
+ body: request.body,
7530
+ duplex: "half",
7531
+ redirect: "manual"
7532
+ });
7533
+ }
7534
+ async proxyPreviewRequest(request) {
7535
+ const portValue = request.headers.get(PREVIEW_PROXY_PORT_HEADER);
7536
+ const token = request.headers.get(PREVIEW_PROXY_TOKEN_HEADER);
7537
+ const sandboxId = request.headers.get(PREVIEW_PROXY_SANDBOX_ID_HEADER);
7538
+ const port = portValue === null ? NaN : Number.parseInt(portValue, 10);
7539
+ if (!Number.isFinite(port) || !validatePort(port) || !token || !sandboxId) return this.invalidPreviewTokenResponse();
7540
+ const proxyRequest = this.buildPreviewProxyRequest(request, port, sandboxId);
7541
+ const validation = await this.validatePreviewURLForRuntime(port, token);
7542
+ if (validation.status === "invalid") return this.invalidPreviewTokenResponse();
7543
+ if (validation.status === "stale") {
7544
+ this.logger.warn("Stale preview URL blocked", {
7545
+ port,
7546
+ sandboxId,
7547
+ containerStatus: validation.containerStatus,
7548
+ reason: validation.reason,
7549
+ method: request.method
7550
+ });
7551
+ return this.stalePreviewURLResponse();
7552
+ }
7553
+ return await this.fetchPreviewIfRunning(proxyRequest, port, validation.runtime);
7554
+ }
6278
7555
  async fetch(request) {
6279
7556
  const traceId = TraceContext.fromHeaders(request.headers) || TraceContext.generate();
6280
7557
  const requestLogger = this.logger.child({
@@ -6282,6 +7559,7 @@ var Sandbox = class Sandbox extends Container {
6282
7559
  operation: "fetch"
6283
7560
  });
6284
7561
  const url = new URL(request.url);
7562
+ if (this.isPreviewProxyRequest(request)) return await this.proxyPreviewRequest(request);
6285
7563
  if (!this.sandboxName && request.headers.has("X-Sandbox-Name")) {
6286
7564
  const name = request.headers.get("X-Sandbox-Name");
6287
7565
  this.sandboxName = name;
@@ -7002,40 +8280,6 @@ var Sandbox = class Sandbox extends Container {
7002
8280
  return this.client.files.exists(path$1, session);
7003
8281
  }
7004
8282
  /**
7005
- * Get the noVNC preview URL for browser-based desktop viewing.
7006
- * Confirms desktop is active, then uses exposePort() to generate
7007
- * a token-authenticated preview URL for the noVNC port (6080).
7008
- *
7009
- * @param hostname - The custom domain hostname for preview URLs
7010
- * (e.g., 'preview.example.com'). Required because preview URLs
7011
- * use subdomain patterns that .workers.dev doesn't support.
7012
- * @param options - Optional settings
7013
- * @param options.token - Reuse an existing token instead of generating a new one
7014
- * @returns The authenticated noVNC preview URL
7015
- */
7016
- async getDesktopStreamUrl(hostname, options) {
7017
- if ((await this.client.desktop.status()).status === "inactive") throw new Error("Desktop is not running. Call sandbox.desktop.start() first.");
7018
- let url;
7019
- try {
7020
- url = (await this.exposePort(6080, {
7021
- hostname,
7022
- token: options?.token
7023
- })).url;
7024
- } catch {
7025
- const existingEntry = (await this.readPortTokens())["6080"];
7026
- if (existingEntry && this.sandboxName) url = this.constructPreviewUrl(6080, this.sandboxName, hostname, existingEntry.token);
7027
- else throw new Error("Failed to get desktop stream URL: port 6080 could not be exposed and no existing token found.");
7028
- }
7029
- try {
7030
- await this.waitForPort({
7031
- portToCheck: 6080,
7032
- retries: 30,
7033
- waitInterval: 500
7034
- });
7035
- } catch {}
7036
- return { url };
7037
- }
7038
- /**
7039
8283
  * Watch a directory for file system changes using native inotify.
7040
8284
  *
7041
8285
  * The returned promise resolves only after the watcher is established on the
@@ -7084,11 +8328,10 @@ var Sandbox = class Sandbox extends Container {
7084
8328
  /**
7085
8329
  * Expose a port and get a preview URL for accessing services running in the sandbox
7086
8330
  *
7087
- * Preview URLs survive transient container restarts: the token and any
7088
- * friendly name are persisted in Durable Object storage, and the port is
7089
- * automatically re-exposed on the container when it comes back up. Tokens
7090
- * are cleared only on explicit `unexposePort()` or full sandbox
7091
- * `destroy()`.
8331
+ * Preview URL authorization survives transient container restarts, but
8332
+ * forwarding is active only for the runtime where `exposePort()` was last
8333
+ * called. Call `exposePort()` again after a restart to reactivate an
8334
+ * existing URL for the current runtime.
7092
8335
  *
7093
8336
  * @param port - Port number to expose (1024-65535)
7094
8337
  * @param options - Configuration options
@@ -7125,27 +8368,33 @@ var Sandbox = class Sandbox extends Container {
7125
8368
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
7126
8369
  });
7127
8370
  if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
7128
- let token;
7129
- if (options.token !== void 0) {
7130
- this.validateCustomToken(options.token);
7131
- token = options.token;
7132
- } else token = this.generatePortToken();
7133
- const tokens = await this.readPortTokens();
7134
- const existingPort = Object.entries(tokens).find(([p, entry]) => entry.token === token && p !== port.toString());
7135
- if (existingPort) throw new SandboxSecurityError(`Token '${token}' is already in use by port ${existingPort[0]}. Please use a different token.`);
7136
- const sessionId = this.serializeExecutionContext(await this.resolveExecution());
7137
- await this.client.ports.exposePort(port, sessionId, options?.name);
7138
- tokens[port.toString()] = {
7139
- token,
7140
- name: options?.name
7141
- };
7142
- await this.ctx.storage.put("portTokens", tokens);
8371
+ if (options.token !== void 0) this.validateCustomToken(options.token);
8372
+ await this.ensureDefaultSession();
8373
+ let runtime = await this.currentRuntime.get();
8374
+ runtime = runtime ?? await this.currentRuntime.markStarted();
8375
+ await this.currentRuntime.assertActive(runtime);
8376
+ const token = await this.ctx.storage.transaction(async (txn) => {
8377
+ const tokens = await this.readPortTokens(txn);
8378
+ const existingEntry = tokens[port.toString()];
8379
+ const nextToken = options.token ?? existingEntry?.token ?? this.generatePortToken();
8380
+ const existingPort = Object.entries(tokens).find(([p, entry]) => entry.token === nextToken && p !== port.toString());
8381
+ if (existingPort) throw new SandboxSecurityError(`Token '${nextToken}' is already in use by port ${existingPort[0]}. Please use a different token.`);
8382
+ const activations = await this.readActivePreviewPorts(txn);
8383
+ tokens[port.toString()] = {
8384
+ token: nextToken,
8385
+ name: options.name
8386
+ };
8387
+ activations[port.toString()] = runtime.scope({ token: nextToken });
8388
+ await Promise.all([txn.put(PORT_TOKENS_STORAGE_KEY, tokens), this.writeActivePreviewPorts(activations, txn)]);
8389
+ return nextToken;
8390
+ });
8391
+ await this.currentRuntime.assertActive(runtime);
7143
8392
  const url = this.constructPreviewUrl(port, this.sandboxName, options.hostname, token);
7144
8393
  outcome = "success";
7145
8394
  return {
7146
8395
  url,
7147
8396
  port,
7148
- name: options?.name
8397
+ name: options.name
7149
8398
  };
7150
8399
  } catch (error) {
7151
8400
  caughtError = error instanceof Error ? error : new Error(String(error));
@@ -7156,29 +8405,37 @@ var Sandbox = class Sandbox extends Container {
7156
8405
  outcome,
7157
8406
  port,
7158
8407
  durationMs: Date.now() - exposeStartTime,
7159
- name: options?.name,
8408
+ name: options.name,
7160
8409
  hostname: options.hostname,
7161
8410
  error: caughtError
7162
8411
  });
7163
8412
  }
7164
8413
  }
8414
+ /**
8415
+ * Revoke preview URL authorization and current-runtime activation for a port.
8416
+ *
8417
+ * Revocation is idempotent: calling this for a port with no preview state is
8418
+ * still successful. The operation clears Durable Object-owned preview state
8419
+ * only and does not contact, probe, wake, or clean up the container runtime.
8420
+ */
7165
8421
  async unexposePort(port) {
7166
8422
  const unexposeStartTime = Date.now();
7167
8423
  let outcome = "error";
7168
8424
  let caughtError;
7169
8425
  try {
7170
8426
  if (!validatePort(port)) throw new SandboxSecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
7171
- const tokens = await this.readPortTokens();
7172
- if (tokens[port.toString()]) {
7173
- delete tokens[port.toString()];
7174
- await this.ctx.storage.put("portTokens", tokens);
7175
- }
7176
- const sessionId = this.serializeExecutionContext(await this.resolveExecution());
7177
- try {
7178
- await this.client.ports.unexposePort(port, sessionId);
7179
- } catch (error) {
7180
- if (!(error instanceof PortNotExposedError)) throw error;
7181
- }
8427
+ await this.ctx.storage.transaction(async (txn) => {
8428
+ const tokens = await this.readPortTokens(txn);
8429
+ if (tokens[port.toString()]) {
8430
+ delete tokens[port.toString()];
8431
+ await txn.put(PORT_TOKENS_STORAGE_KEY, tokens);
8432
+ }
8433
+ const activations = await this.readActivePreviewPorts(txn);
8434
+ if (activations[port.toString()]) {
8435
+ delete activations[port.toString()];
8436
+ await this.writeActivePreviewPorts(activations, txn);
8437
+ }
8438
+ });
7182
8439
  outcome = "success";
7183
8440
  } catch (error) {
7184
8441
  caughtError = error instanceof Error ? error : new Error(String(error));
@@ -7193,23 +8450,17 @@ var Sandbox = class Sandbox extends Container {
7193
8450
  });
7194
8451
  }
7195
8452
  }
8453
+ /**
8454
+ * Returns preview URLs that are currently forwardable in the active runtime.
8455
+ * Durable authorization without current-runtime activation is omitted.
8456
+ */
7196
8457
  async getExposedPorts(hostname) {
7197
- const sessionId = this.serializeExecutionContext(await this.resolveExecution());
7198
- const response = await this.client.ports.getExposedPorts(sessionId);
7199
8458
  if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
7200
- const tokens = await this.readPortTokens();
7201
- return response.ports.flatMap((port) => {
7202
- const entry = tokens[port.port.toString()];
7203
- if (!entry) {
7204
- this.logger.warn("Port exposed on container but no token in storage; omitting from preview URL list", { port: port.port });
7205
- return [];
7206
- }
7207
- return [{
7208
- url: this.constructPreviewUrl(port.port, this.sandboxName, hostname, entry.token),
7209
- port: port.port,
7210
- status: port.status
7211
- }];
7212
- });
8459
+ return (await this.getCurrentPreviewPorts()).map(({ port, entry }) => ({
8460
+ url: this.constructPreviewUrl(port, this.sandboxName, hostname, entry.token),
8461
+ port,
8462
+ status: "active"
8463
+ }));
7213
8464
  }
7214
8465
  /**
7215
8466
  * Namespaced tunnel API. Quick tunnels are zero-config preview URLs
@@ -7245,26 +8496,172 @@ var Sandbox = class Sandbox extends Container {
7245
8496
  const built = createTunnelsHandler({
7246
8497
  client: this.client,
7247
8498
  storage: this.ctx.storage,
7248
- logger: this.logger
8499
+ logger: this.logger,
8500
+ sandboxId: this.ctx.id.toString(),
8501
+ getNamedTunnelConfig: async () => {
8502
+ const envObj = this.env;
8503
+ const token = getEnvString(envObj, "CLOUDFLARE_API_TOKEN");
8504
+ if (!token) throw new Error("Named tunnels require CLOUDFLARE_API_TOKEN. Set it as a secret in your wrangler.jsonc.");
8505
+ const accountId = await this.getTunnelAccountId();
8506
+ return {
8507
+ token,
8508
+ accountId,
8509
+ zoneId: await this.getTunnelZoneId(token, accountId)
8510
+ };
8511
+ }
7249
8512
  });
7250
8513
  this.tunnelsHandler = built.tunnels;
7251
8514
  this.tunnelExitHandler = built.handleTunnelExit;
8515
+ this.destroyAllTunnels = built.destroyAll;
7252
8516
  }
7253
- async isPortExposed(port) {
7254
- try {
7255
- const sessionId = this.serializeExecutionContext(await this.resolveExecution());
7256
- return (await this.client.ports.getExposedPorts(sessionId)).ports.some((exposedPort) => exposedPort.port === port);
7257
- } catch (error) {
7258
- this.logger.error("Error checking if port is exposed", error instanceof Error ? error : new Error(String(error)), { port });
7259
- return false;
8517
+ /**
8518
+ * Resolve the Cloudflare account id used for named-tunnel provisioning.
8519
+ *
8520
+ * Memoised for the lifetime of this DO instance. The first call may hit
8521
+ * `GET /user/tokens/verify` to derive the account id from the configured
8522
+ * `CLOUDFLARE_API_TOKEN`; subsequent calls return the cached promise.
8523
+ *
8524
+ * Only successful resolutions are cached: a rejected lookup clears the
8525
+ * slot so the next caller retries. Otherwise a transient failure on
8526
+ * first use would permanently poison every later named-tunnel `get()`
8527
+ * on this DO instance.
8528
+ */
8529
+ getTunnelAccountId() {
8530
+ if (!this.tunnelAccountIdPromise) {
8531
+ const pending = resolveAccountId(this.env, { overrideKey: "CLOUDFLARE_TUNNEL_ACCOUNT_ID" });
8532
+ this.tunnelAccountIdPromise = pending;
8533
+ pending.catch(() => {
8534
+ if (this.tunnelAccountIdPromise === pending) this.tunnelAccountIdPromise = null;
8535
+ });
8536
+ }
8537
+ return this.tunnelAccountIdPromise;
8538
+ }
8539
+ /**
8540
+ * Resolve the Cloudflare zone id used for named-tunnel provisioning.
8541
+ *
8542
+ * Memoised for the lifetime of this DO instance. Falls back to the
8543
+ * single zone the token can see under `accountId` via `GET /zones`
8544
+ * when `CLOUDFLARE_ZONE_ID` is not set. Failed lookups clear the cache
8545
+ * so the next caller retries — see `getTunnelAccountId` for the
8546
+ * rationale.
8547
+ */
8548
+ getTunnelZoneId(token, accountId) {
8549
+ if (!this.tunnelZoneIdPromise) {
8550
+ const pending = resolveZoneId(this.env, {
8551
+ token,
8552
+ accountId
8553
+ });
8554
+ this.tunnelZoneIdPromise = pending;
8555
+ pending.catch(() => {
8556
+ if (this.tunnelZoneIdPromise === pending) this.tunnelZoneIdPromise = null;
8557
+ });
7260
8558
  }
8559
+ return this.tunnelZoneIdPromise;
7261
8560
  }
8561
+ /**
8562
+ * Returns whether a port is currently preview-forwardable.
8563
+ * This checks Durable Object-owned auth and runtime activation without
8564
+ * contacting or waking the container.
8565
+ */
8566
+ async isPortExposed(port) {
8567
+ if (!validatePort(port)) return false;
8568
+ return (await this.getCurrentPreviewPorts()).some((activePort) => activePort.port === port);
8569
+ }
8570
+ /**
8571
+ * Checks durable preview URL authorization for a port/token pair.
8572
+ *
8573
+ * This does not check whether the port is activated for the current runtime
8574
+ * and is not sufficient to decide whether preview traffic may forward.
8575
+ */
7262
8576
  async validatePortToken(port, token) {
7263
8577
  const entry = (await this.readPortTokens())[port.toString()];
7264
8578
  if (!entry) return false;
8579
+ return this.previewTokensMatch(entry.token, token);
8580
+ }
8581
+ async validatePreviewURLForRuntime(port, token) {
8582
+ const containerState = await this.getState();
8583
+ const containerRunning = this.ctx.container?.running === true;
8584
+ const { tokens, activations, runtime } = await this.ctx.storage.transaction(async (txn) => {
8585
+ const [previewState, runtime$1] = await Promise.all([this.readPreviewState(txn), this.currentRuntime.getStored(txn)]);
8586
+ return {
8587
+ ...previewState,
8588
+ runtime: runtime$1
8589
+ };
8590
+ });
8591
+ const entry = tokens[port.toString()];
8592
+ if (!entry) return { status: "invalid" };
8593
+ if (!this.previewTokensMatch(entry.token, token)) return { status: "invalid" };
8594
+ if (containerState.status !== "healthy") return {
8595
+ status: "stale",
8596
+ reason: "runtime-not-healthy",
8597
+ containerStatus: containerState.status
8598
+ };
8599
+ if (!containerRunning) return {
8600
+ status: "stale",
8601
+ reason: "runtime-not-running",
8602
+ containerStatus: containerState.status
8603
+ };
8604
+ if (!runtime) return {
8605
+ status: "stale",
8606
+ reason: "missing-runtime-id",
8607
+ containerStatus: containerState.status
8608
+ };
8609
+ const activation = activations[port.toString()];
8610
+ if (!activation) return {
8611
+ status: "stale",
8612
+ reason: "missing-activation",
8613
+ containerStatus: containerState.status
8614
+ };
8615
+ if (!runtime.owns(activation)) return {
8616
+ status: "stale",
8617
+ reason: "runtime-mismatch",
8618
+ containerStatus: containerState.status
8619
+ };
8620
+ if (!this.previewTokensMatch(activation.token, token)) {
8621
+ this.logger.warn("Preview URL activation token mismatch", {
8622
+ port,
8623
+ runtimeIdentityID: runtime.id
8624
+ });
8625
+ return {
8626
+ status: "stale",
8627
+ reason: "token-mismatch",
8628
+ containerStatus: containerState.status
8629
+ };
8630
+ }
8631
+ return {
8632
+ status: "active",
8633
+ runtime
8634
+ };
8635
+ }
8636
+ async getCurrentPreviewPorts() {
8637
+ const containerState = await this.getState();
8638
+ const containerRunning = this.ctx.container?.running === true;
8639
+ const { tokens, activations, runtime } = await this.ctx.storage.transaction(async (txn) => {
8640
+ const [previewState, runtime$1] = await Promise.all([this.readPreviewState(txn), this.currentRuntime.getStored(txn)]);
8641
+ return {
8642
+ ...previewState,
8643
+ runtime: runtime$1
8644
+ };
8645
+ });
8646
+ if (containerState.status !== "healthy" || !containerRunning || !runtime) return [];
8647
+ const activePorts = [];
8648
+ for (const [portKey, activation] of Object.entries(activations)) {
8649
+ const port = Number.parseInt(portKey, 10);
8650
+ const entry = tokens[portKey];
8651
+ if (!entry || !Number.isInteger(port) || !validatePort(port)) continue;
8652
+ if (!runtime.owns(activation)) continue;
8653
+ if (!this.previewTokensMatch(entry.token, activation.token)) continue;
8654
+ activePorts.push({
8655
+ port,
8656
+ entry
8657
+ });
8658
+ }
8659
+ return activePorts.sort((a, b) => a.port - b.port);
8660
+ }
8661
+ previewTokensMatch(expected, actual) {
7265
8662
  const encoder = new TextEncoder();
7266
- const a = encoder.encode(entry.token);
7267
- const b = encoder.encode(token);
8663
+ const a = encoder.encode(expected);
8664
+ const b = encoder.encode(actual);
7268
8665
  try {
7269
8666
  return crypto.subtle.timingSafeEqual(a, b);
7270
8667
  } catch {
@@ -7596,10 +8993,10 @@ var Sandbox = class Sandbox extends Container {
7596
8993
  * Returns validated presigned URL configuration or throws if not configured.
7597
8994
  * All credential fields plus the R2 binding are required for backup to work.
7598
8995
  */
7599
- requirePresignedUrlSupport() {
8996
+ requirePresignedURLSupport() {
7600
8997
  if (!this.r2Client || !this.r2AccountId || !this.backupBucketName) {
7601
8998
  const missing = [];
7602
- if (!this.r2AccountId) missing.push("CLOUDFLARE_ACCOUNT_ID");
8999
+ if (!this.r2AccountId) missing.push("CLOUDFLARE_R2_ACCOUNT_ID or CLOUDFLARE_ACCOUNT_ID");
7603
9000
  if (!this.r2AccessKeyId) missing.push("R2_ACCESS_KEY_ID");
7604
9001
  if (!this.r2SecretAccessKey) missing.push("R2_SECRET_ACCESS_KEY");
7605
9002
  if (!this.backupBucketName) missing.push("BACKUP_BUCKET_NAME");
@@ -7617,15 +9014,21 @@ var Sandbox = class Sandbox extends Container {
7617
9014
  bucketName: this.backupBucketName
7618
9015
  };
7619
9016
  }
9017
+ getBackupBucketEndpoint(accountId) {
9018
+ return this.backupBucketEndpoint ?? `https://${accountId}.r2.cloudflarestorage.com`;
9019
+ }
9020
+ getBackupObjectURL(accountId, bucketName, r2Key) {
9021
+ const encodedBucket = encodeURIComponent(bucketName);
9022
+ const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
9023
+ return new URL(`${this.getBackupBucketEndpoint(accountId)}/${encodedBucket}/${encodedKey}`);
9024
+ }
7620
9025
  /**
7621
9026
  * Generate a presigned GET URL for downloading an object from R2.
7622
9027
  * The container can curl this URL directly without credentials.
7623
9028
  */
7624
- async generatePresignedGetUrl(r2Key) {
7625
- const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
7626
- const encodedBucket = encodeURIComponent(bucketName);
7627
- const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
7628
- const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
9029
+ async generatePresignedGetURL(r2Key) {
9030
+ const { client, accountId, bucketName } = this.requirePresignedURLSupport();
9031
+ const url = this.getBackupObjectURL(accountId, bucketName, r2Key);
7629
9032
  url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
7630
9033
  return (await client.sign(new Request(url), { aws: { signQuery: true } })).url;
7631
9034
  }
@@ -7633,11 +9036,9 @@ var Sandbox = class Sandbox extends Container {
7633
9036
  * Generate a presigned PUT URL for uploading an object to R2.
7634
9037
  * The container can curl PUT to this URL directly without credentials.
7635
9038
  */
7636
- async generatePresignedPutUrl(r2Key) {
7637
- const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
7638
- const encodedBucket = encodeURIComponent(bucketName);
7639
- const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
7640
- const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
9039
+ async generatePresignedPutURL(r2Key) {
9040
+ const { client, accountId, bucketName } = this.requirePresignedURLSupport();
9041
+ const url = this.getBackupObjectURL(accountId, bucketName, r2Key);
7641
9042
  url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
7642
9043
  return (await client.sign(new Request(url, { method: "PUT" }), { aws: { signQuery: true } })).url;
7643
9044
  }
@@ -7647,7 +9048,7 @@ var Sandbox = class Sandbox extends Container {
7647
9048
  * ~24 MB/s throughput vs ~0.6 MB/s for base64 readFile.
7648
9049
  */
7649
9050
  async uploadBackupPresigned(archivePath, r2Key, archiveSize, backupId, dir, backupSession) {
7650
- const presignedUrl = await this.generatePresignedPutUrl(r2Key);
9051
+ const presignedURL = await this.generatePresignedPutURL(r2Key);
7651
9052
  const curlCmd = [
7652
9053
  "curl -sSf",
7653
9054
  "-X PUT",
@@ -7657,7 +9058,7 @@ var Sandbox = class Sandbox extends Container {
7657
9058
  "--retry 2",
7658
9059
  "--retry-max-time 60",
7659
9060
  `-T ${shellEscape(archivePath)}`,
7660
- shellEscape(presignedUrl)
9061
+ shellEscape(presignedURL)
7661
9062
  ].join(" ");
7662
9063
  const result = await this.execWithSession(curlCmd, backupSession, {
7663
9064
  timeout: 181e4,
@@ -7691,11 +9092,9 @@ var Sandbox = class Sandbox extends Container {
7691
9092
  /**
7692
9093
  * Generate a presigned PUT URL for a single part in a multipart upload.
7693
9094
  */
7694
- async generatePresignedPartUrl(r2Key, uploadId, partNumber) {
7695
- const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
7696
- const encodedBucket = encodeURIComponent(bucketName);
7697
- const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
7698
- const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
9095
+ async generatePresignedPartURL(r2Key, uploadId, partNumber) {
9096
+ const { client, accountId, bucketName } = this.requirePresignedURLSupport();
9097
+ const url = this.getBackupObjectURL(accountId, bucketName, r2Key);
7699
9098
  url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
7700
9099
  url.searchParams.set("partNumber", String(partNumber));
7701
9100
  url.searchParams.set("uploadId", uploadId);
@@ -7710,9 +9109,9 @@ var Sandbox = class Sandbox extends Container {
7710
9109
  const targetParts = calculatePartCount(sizeBytes, BACKUP_MULTIPART_TARGET_PARTS, BACKUP_MULTIPART_MAX_PARTS);
7711
9110
  const numParts = Math.min(targetParts, Math.floor(sizeBytes / BACKUP_MULTIPART_MIN_PART_SIZE));
7712
9111
  if (numParts <= 1) return this.uploadBackupPresigned(archivePath, r2Key, sizeBytes, backupId, dir, backupSession);
7713
- const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
7714
- const objectUrl = `https://${accountId}.r2.cloudflarestorage.com/${encodeURIComponent(bucketName)}/${r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/")}`;
7715
- const createResp = await client.fetch(`${objectUrl}?uploads`, { method: "POST" });
9112
+ const { client, accountId, bucketName } = this.requirePresignedURLSupport();
9113
+ const objectURL = this.getBackupObjectURL(accountId, bucketName, r2Key).toString();
9114
+ const createResp = await client.fetch(`${objectURL}?uploads`, { method: "POST" });
7716
9115
  if (!createResp.ok) throw new BackupCreateError({
7717
9116
  message: `Failed to initiate multipart upload: HTTP ${createResp.status}`,
7718
9117
  code: ErrorCode.BACKUP_CREATE_FAILED,
@@ -7735,7 +9134,7 @@ var Sandbox = class Sandbox extends Container {
7735
9134
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
7736
9135
  });
7737
9136
  const abortMultipart = async () => {
7738
- await client.fetch(`${objectUrl}?uploadId=${encodeURIComponent(uploadId)}`, { method: "DELETE" }).catch(() => {});
9137
+ await client.fetch(`${objectURL}?uploadId=${encodeURIComponent(uploadId)}`, { method: "DELETE" }).catch(() => {});
7739
9138
  };
7740
9139
  try {
7741
9140
  const partSize = Math.ceil(sizeBytes / numParts);
@@ -7746,7 +9145,7 @@ var Sandbox = class Sandbox extends Container {
7746
9145
  size: i === numParts - 1 ? sizeBytes - i * partSize : partSize
7747
9146
  })).map(async (part) => ({
7748
9147
  ...part,
7749
- url: await this.generatePresignedPartUrl(r2Key, uploadId, part.partNumber)
9148
+ url: await this.generatePresignedPartURL(r2Key, uploadId, part.partNumber)
7750
9149
  })));
7751
9150
  let uploadResult;
7752
9151
  try {
@@ -7777,7 +9176,7 @@ var Sandbox = class Sandbox extends Container {
7777
9176
  ...uploadResult.parts.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${p.etag}</ETag></Part>`),
7778
9177
  "</CompleteMultipartUpload>"
7779
9178
  ].join("");
7780
- const completeResp = await client.fetch(`${objectUrl}?uploadId=${encodeURIComponent(uploadId)}`, {
9179
+ const completeResp = await client.fetch(`${objectURL}?uploadId=${encodeURIComponent(uploadId)}`, {
7781
9180
  method: "POST",
7782
9181
  headers: { "Content-Type": "application/xml" },
7783
9182
  body: completeXml
@@ -7819,7 +9218,7 @@ var Sandbox = class Sandbox extends Container {
7819
9218
  * with dd using byte offsets, then atomically moved to the final path.
7820
9219
  */
7821
9220
  async downloadBackupParallel(archivePath, r2Key, expectedSize, backupId, dir, backupSession) {
7822
- const presignedUrl = await this.generatePresignedGetUrl(r2Key);
9221
+ const presignedURL = await this.generatePresignedGetURL(r2Key);
7823
9222
  await this.execWithSession(`mkdir -p ${BACKUP_CONTAINER_DIR}`, backupSession, { origin: "internal" });
7824
9223
  const tmpPath = `${archivePath}.tmp`;
7825
9224
  if (expectedSize < BACKUP_DOWNLOAD_PARALLEL_MIN_SIZE) {
@@ -7830,7 +9229,7 @@ var Sandbox = class Sandbox extends Container {
7830
9229
  "--retry 2",
7831
9230
  "--retry-max-time 60",
7832
9231
  `-o ${shellEscape(tmpPath)}`,
7833
- shellEscape(presignedUrl)
9232
+ shellEscape(presignedURL)
7834
9233
  ].join(" ");
7835
9234
  const result = await this.execWithSession(curlCmd, backupSession, {
7836
9235
  timeout: 181e4,
@@ -7863,7 +9262,7 @@ var Sandbox = class Sandbox extends Container {
7863
9262
  "--connect-timeout 10",
7864
9263
  "--max-time 1800",
7865
9264
  `-H ${shellEscape(`Range: bytes=${range}`)}`,
7866
- shellEscape(presignedUrl),
9265
+ shellEscape(presignedURL),
7867
9266
  "|",
7868
9267
  "dd",
7869
9268
  `of=${shellEscape(tmpPath)}`,
@@ -7936,10 +9335,13 @@ var Sandbox = class Sandbox extends Container {
7936
9335
  * create-archive → read → upload (or mount → extract) flow
7937
9336
  * is not interleaved with another backup operation on the same directory.
7938
9337
  */
7939
- enqueueBackupOp(fn) {
7940
- const next = this.backupInProgress.then(fn, () => fn());
9338
+ async enqueueBackupOp(fn) {
9339
+ try {
9340
+ await this.backupInProgress;
9341
+ } catch {}
9342
+ const next = fn();
7941
9343
  this.backupInProgress = next.catch(() => {});
7942
- return next;
9344
+ return await next;
7943
9345
  }
7944
9346
  /**
7945
9347
  * Create a backup of a directory and upload it to R2.
@@ -7963,13 +9365,13 @@ var Sandbox = class Sandbox extends Container {
7963
9365
  * under the `backups/` prefix after the desired retention period.
7964
9366
  */
7965
9367
  async createBackup(options) {
7966
- if (options.localBucket) return this.enqueueBackupOp(() => this.doCreateBackupLocal(options));
9368
+ if (options.localBucket) return await this.enqueueBackupOp(() => this.doCreateBackupLocal(options));
7967
9369
  this.requireBackupBucket();
7968
- return this.enqueueBackupOp(() => this.doCreateBackup(options));
9370
+ return await this.enqueueBackupOp(() => this.doCreateBackup(options));
7969
9371
  }
7970
9372
  async doCreateBackup(options) {
7971
9373
  const bucket = this.requireBackupBucket();
7972
- this.requirePresignedUrlSupport();
9374
+ this.requirePresignedURLSupport();
7973
9375
  const { dir, name, ttl = BACKUP_DEFAULT_TTL_SECONDS, gitignore = false, excludes = [], compression, multipart = true } = options;
7974
9376
  const backupStartTime = Date.now();
7975
9377
  let backupId;
@@ -8262,14 +9664,14 @@ var Sandbox = class Sandbox extends Container {
8262
9664
  * Concurrent backup/restore calls on the same sandbox are serialized.
8263
9665
  */
8264
9666
  async restoreBackup(backup) {
8265
- if (backup.localBucket) return this.enqueueBackupOp(() => this.doRestoreBackupLocal(backup));
9667
+ if (backup.localBucket) return await this.enqueueBackupOp(() => this.doRestoreBackupLocal(backup));
8266
9668
  this.requireBackupBucket();
8267
- return this.enqueueBackupOp(() => this.doRestoreBackup(backup));
9669
+ return await this.enqueueBackupOp(() => this.doRestoreBackup(backup));
8268
9670
  }
8269
9671
  async doRestoreBackup(backup) {
8270
9672
  const restoreStartTime = Date.now();
8271
9673
  const bucket = this.requireBackupBucket();
8272
- this.requirePresignedUrlSupport();
9674
+ this.requirePresignedURLSupport();
8273
9675
  const { id, dir } = backup;
8274
9676
  let outcome = "error";
8275
9677
  let caughtError;
@@ -8524,10 +9926,18 @@ var Sandbox = class Sandbox extends Container {
8524
9926
  const ctx = this.ctx;
8525
9927
  if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("R2 binding mounts require container outbound interception support");
8526
9928
  if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("R2 binding mounts require exporting ContainerProxy from the Worker entrypoint");
9929
+ this.constructor.outboundHandlers = { r2EgressMount: r2EgressHandler };
9930
+ if (Object.keys(params.buckets).length > 0) await this.setOutboundByHost("r2.internal", "r2EgressMount", params);
9931
+ else await this.removeOutboundByHost("r2.internal");
9932
+ this.logger.debug("r2 egress: registering host interception", {
9933
+ host: "r2.internal",
9934
+ method: "r2EgressMount",
9935
+ targetClassName: CONTAINER_PROXY_CLASS_NAME
9936
+ });
8527
9937
  const fetcher = ctx.exports.ContainerProxy({ props: {
8528
9938
  enableInternet: this.enableInternet,
8529
9939
  containerId: this.ctx.id.toString(),
8530
- className: R2_EGRESS_PROXY_TARGET_CLASS_NAME,
9940
+ className: CONTAINER_PROXY_CLASS_NAME,
8531
9941
  outboundByHostOverrides: { "r2.internal": {
8532
9942
  method: "r2EgressMount",
8533
9943
  params
@@ -8536,8 +9946,42 @@ var Sandbox = class Sandbox extends Container {
8536
9946
  if (!isFetcher(fetcher)) throw new InvalidMountConfigError("R2 binding mounts require ContainerProxy to return a valid Fetcher");
8537
9947
  await ctx.container.interceptOutboundHttp("r2.internal", fetcher);
8538
9948
  }
9949
+ async configureS3CredentialProxyOutbound(params) {
9950
+ const ctx = this.ctx;
9951
+ if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("Credential proxy bucket mounts require container outbound interception support");
9952
+ if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("Credential proxy bucket mounts require exporting ContainerProxy from the Worker entrypoint");
9953
+ const hosts = [S3_CREDENTIAL_PROXY_HOST, S3_CREDENTIAL_PROXY_DIAGNOSTIC_HOST];
9954
+ this.constructor.outboundHandlers = { s3CredentialProxyMount: s3CredentialProxyHandler };
9955
+ if (Object.keys(params.mounts).length > 0) for (const host of hosts) await this.setOutboundByHost(host, "s3CredentialProxyMount", params);
9956
+ else for (const host of hosts) await this.removeOutboundByHost(host);
9957
+ const hostOverrides = {};
9958
+ for (const host of hosts) hostOverrides[host] = {
9959
+ method: "s3CredentialProxyMount",
9960
+ params
9961
+ };
9962
+ this.logger.debug("s3 credential proxy: registering host interception", {
9963
+ hosts,
9964
+ method: "s3CredentialProxyMount",
9965
+ targetClassName: CONTAINER_PROXY_CLASS_NAME
9966
+ });
9967
+ const fetcher = ctx.exports.ContainerProxy({ props: {
9968
+ enableInternet: this.enableInternet,
9969
+ containerId: this.ctx.id.toString(),
9970
+ className: CONTAINER_PROXY_CLASS_NAME,
9971
+ outboundByHostOverrides: hostOverrides
9972
+ } });
9973
+ if (!isFetcher(fetcher)) throw new InvalidMountConfigError("Credential proxy bucket mounts require ContainerProxy to return a valid Fetcher");
9974
+ try {
9975
+ const selfTest = await fetcher.fetch(new Request(`http://${S3_CREDENTIAL_PROXY_HOST}${SELF_TEST_PATH}`));
9976
+ await selfTest.text();
9977
+ this.logger.debug("s3 credential proxy: fetcher self-test complete", { status: selfTest.status });
9978
+ } catch (error) {
9979
+ this.logger.warn("s3 credential proxy: fetcher self-test failed", { error: error instanceof Error ? error.message : String(error) });
9980
+ }
9981
+ for (const host of hosts) await ctx.container.interceptOutboundHttp(host, fetcher);
9982
+ }
8539
9983
  };
8540
9984
 
8541
9985
  //#endregion
8542
- 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 };
8543
- //# sourceMappingURL=sandbox-B-MUmsli.js.map
9986
+ 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 };
9987
+ //# sourceMappingURL=sandbox-Duj2gvUC.js.map