@cloudflare/sandbox 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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-COsTRno_.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";
@@ -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:
@@ -761,6 +719,53 @@ function createErrorFromResponse(errorResponse, options) {
761
719
  }
762
720
  }
763
721
 
722
+ //#endregion
723
+ //#region src/response-retry.ts
724
+ const DEFAULT_INITIAL_RETRY_DELAY_MS = 3e3;
725
+ const DEFAULT_MAX_RETRY_DELAY_MS = 3e4;
726
+ const RETRYABLE_WEBSOCKET_UPGRADE_STATUSES = new Set([
727
+ 500,
728
+ 502,
729
+ 503,
730
+ 504
731
+ ]);
732
+ function isRetryableWebSocketUpgradeResponse(response) {
733
+ return RETRYABLE_WEBSOCKET_UPGRADE_STATUSES.has(response.status);
734
+ }
735
+ /**
736
+ * Retry Response-returning operations while their response remains retryable.
737
+ * The retry budget covers the whole operation; each attempt owns any
738
+ * per-request timeout inside the caller-provided `fetchResponse` function.
739
+ */
740
+ async function fetchWithResponseRetry(fetchResponse, options) {
741
+ const startTime = Date.now();
742
+ let attempt = 0;
743
+ while (true) {
744
+ const response = await fetchResponse();
745
+ if (!options.shouldRetry(response)) return response;
746
+ const elapsed = Date.now() - startTime;
747
+ const remaining = options.retryTimeoutMs - elapsed;
748
+ if (remaining <= options.minTimeForRetryMs) {
749
+ options.onRetryExhausted?.({
750
+ attempts: attempt + 1,
751
+ elapsedMs: elapsed,
752
+ response
753
+ });
754
+ return response;
755
+ }
756
+ const delay = Math.min(DEFAULT_INITIAL_RETRY_DELAY_MS * 2 ** attempt, DEFAULT_MAX_RETRY_DELAY_MS);
757
+ options.logger.info(options.retryLogMessage, {
758
+ status: response.status,
759
+ attempt: attempt + 1,
760
+ delayMs: delay,
761
+ remainingSec: Math.floor(remaining / 1e3),
762
+ ...options.getRetryLogContext?.(response)
763
+ });
764
+ await new Promise((resolve) => setTimeout(resolve, delay));
765
+ attempt++;
766
+ }
767
+ }
768
+
764
769
  //#endregion
765
770
  //#region src/clients/transport/base-transport.ts
766
771
  /**
@@ -796,30 +801,17 @@ var BaseTransport = class {
796
801
  * transport-specific doFetch() with retry logic for container startup.
797
802
  */
798
803
  async fetch(path$1, options) {
799
- const startTime = Date.now();
800
- let attempt = 0;
801
- while (true) {
802
- const response = await this.doFetch(path$1, options);
803
- if (response.status === 503) {
804
- const elapsed = Date.now() - startTime;
805
- const remaining = this.retryTimeoutMs - elapsed;
806
- if (remaining > MIN_TIME_FOR_RETRY_MS$1) {
807
- const delay = Math.min(3e3 * 2 ** attempt, 3e4);
808
- this.logger.info("Container not ready, retrying", {
809
- status: response.status,
810
- attempt: attempt + 1,
811
- delayMs: delay,
812
- remainingSec: Math.floor(remaining / 1e3),
813
- mode: this.getMode()
814
- });
815
- await this.sleep(delay);
816
- attempt++;
817
- continue;
818
- }
819
- this.logger.error("Container failed to become ready", /* @__PURE__ */ new Error(`Failed after ${attempt + 1} attempts over ${Math.floor(elapsed / 1e3)}s`));
804
+ return fetchWithResponseRetry(() => this.doFetch(path$1, options), {
805
+ retryTimeoutMs: this.retryTimeoutMs,
806
+ minTimeForRetryMs: MIN_TIME_FOR_RETRY_MS$1,
807
+ logger: this.logger,
808
+ retryLogMessage: "Container not ready, retrying",
809
+ shouldRetry: (response) => response.status === 503,
810
+ getRetryLogContext: () => ({ mode: this.getMode() }),
811
+ onRetryExhausted: ({ attempts, elapsedMs }) => {
812
+ this.logger.error("Container failed to become ready", /* @__PURE__ */ new Error(`Failed after ${attempts} attempts over ${Math.floor(elapsedMs / 1e3)}s`));
820
813
  }
821
- return response;
822
- }
814
+ });
823
815
  }
824
816
  /**
825
817
  * Build a URL targeting the container's HTTP server.
@@ -859,12 +851,6 @@ var BaseTransport = class {
859
851
  if (!response.body) throw new Error("No response body for streaming");
860
852
  return response.body;
861
853
  }
862
- /**
863
- * Sleep utility for retry delays
864
- */
865
- sleep(ms) {
866
- return new Promise((resolve) => setTimeout(resolve, ms));
867
- }
868
854
  };
869
855
 
870
856
  //#endregion
@@ -1033,24 +1019,13 @@ var WebSocketTransport = class extends BaseTransport {
1033
1019
  else await this.connectViaWebSocket();
1034
1020
  }
1035
1021
  async fetchUpgradeWithRetry(attemptUpgrade) {
1036
- const retryTimeoutMs = this.getRetryTimeoutMs();
1037
- const startTime = Date.now();
1038
- let attempt = 0;
1039
- while (true) {
1040
- const response = await attemptUpgrade();
1041
- if (response.status !== 503) return response;
1042
- const remaining = retryTimeoutMs - (Date.now() - startTime);
1043
- if (remaining <= MIN_TIME_FOR_CONNECT_RETRY_MS) return response;
1044
- const delay = Math.min(3e3 * 2 ** attempt, 3e4);
1045
- this.logger.info("WebSocket container not ready, retrying", {
1046
- status: response.status,
1047
- attempt: attempt + 1,
1048
- delayMs: delay,
1049
- remainingSec: Math.floor(remaining / 1e3)
1050
- });
1051
- await this.sleep(delay);
1052
- attempt++;
1053
- }
1022
+ return fetchWithResponseRetry(attemptUpgrade, {
1023
+ retryTimeoutMs: this.getRetryTimeoutMs(),
1024
+ minTimeForRetryMs: MIN_TIME_FOR_CONNECT_RETRY_MS,
1025
+ logger: this.logger,
1026
+ retryLogMessage: "WebSocket upgrade returned retryable status, retrying",
1027
+ shouldRetry: isRetryableWebSocketUpgradeResponse
1028
+ });
1054
1029
  }
1055
1030
  /**
1056
1031
  * Connect using fetch-based WebSocket (Cloudflare Workers style)
@@ -1590,21 +1565,21 @@ var BaseHttpClient = class {
1590
1565
  body: JSON.stringify(data),
1591
1566
  ...requestOptions
1592
1567
  });
1593
- return this.handleResponse(response, responseHandler);
1568
+ return await this.handleResponse(response, responseHandler);
1594
1569
  }
1595
1570
  /**
1596
1571
  * Make a GET request
1597
1572
  */
1598
1573
  async get(endpoint, responseHandler) {
1599
1574
  const response = await this.doFetch(endpoint, { method: "GET" });
1600
- return this.handleResponse(response, responseHandler);
1575
+ return await this.handleResponse(response, responseHandler);
1601
1576
  }
1602
1577
  /**
1603
1578
  * Make a DELETE request
1604
1579
  */
1605
1580
  async delete(endpoint, responseHandler) {
1606
1581
  const response = await this.doFetch(endpoint, { method: "DELETE" });
1607
- return this.handleResponse(response, responseHandler);
1582
+ return await this.handleResponse(response, responseHandler);
1608
1583
  }
1609
1584
  /**
1610
1585
  * Handle HTTP response with error checking and parsing
@@ -1673,7 +1648,7 @@ var BaseHttpClient = class {
1673
1648
  headers: { "Content-Type": "application/json" },
1674
1649
  body: body && method === "POST" ? JSON.stringify(body) : void 0
1675
1650
  });
1676
- return this.handleStreamResponse(response);
1651
+ return await this.handleStreamResponse(response);
1677
1652
  }
1678
1653
  };
1679
1654
 
@@ -1782,240 +1757,6 @@ var CommandClient = class extends BaseHttpClient {
1782
1757
  }
1783
1758
  };
1784
1759
 
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
1760
  //#endregion
2020
1761
  //#region src/clients/file-client.ts
2021
1762
  /**
@@ -2618,7 +2359,6 @@ var SandboxClient = class {
2618
2359
  git;
2619
2360
  interpreter;
2620
2361
  utils;
2621
- desktop;
2622
2362
  watch;
2623
2363
  /**
2624
2364
  * Tunnels are RPC-only — the route-based transport does not implement them.
@@ -2651,11 +2391,10 @@ var SandboxClient = class {
2651
2391
  this.git = new GitClient(clientOptions);
2652
2392
  this.interpreter = new InterpreterClient(clientOptions);
2653
2393
  this.utils = new UtilityClient(clientOptions);
2654
- this.desktop = new DesktopClient(clientOptions);
2655
2394
  this.watch = new WatchClient(clientOptions);
2656
2395
  }
2657
2396
  /**
2658
- * Update the 503 retry budget on all transports without recreating the client.
2397
+ * Update the transport retry budget without recreating the client.
2659
2398
  *
2660
2399
  * In WebSocket mode a single shared transport is used, so one update covers
2661
2400
  * every sub-client. In HTTP mode each sub-client owns its own transport, so
@@ -2672,7 +2411,6 @@ var SandboxClient = class {
2672
2411
  this.git.setRetryTimeoutMs(ms);
2673
2412
  this.interpreter.setRetryTimeoutMs(ms);
2674
2413
  this.utils.setRetryTimeoutMs(ms);
2675
- this.desktop.setRetryTimeoutMs(ms);
2676
2414
  this.watch.setRetryTimeoutMs(ms);
2677
2415
  }
2678
2416
  }
@@ -2743,7 +2481,6 @@ const DISABLE_SESSION_TOKEN = "__DISABLE_SESSION__";
2743
2481
  const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
2744
2482
  const DEFAULT_RETRY_TIMEOUT_MS = 12e4;
2745
2483
  const MIN_TIME_FOR_RETRY_MS = 15e3;
2746
- const MAX_RETRY_BACKOFF_MS = 3e4;
2747
2484
  /**
2748
2485
  * Manages a capnweb WebSocket RPC session to the container.
2749
2486
  *
@@ -2821,7 +2558,7 @@ var ContainerControlConnection = class {
2821
2558
  this.connectPromise = null;
2822
2559
  }
2823
2560
  /**
2824
- * Update the 503 retry budget without recreating the connection. Takes
2561
+ * Update the upgrade retry budget without recreating the connection. Takes
2825
2562
  * effect on the next `connect()`; an in-flight connect uses the value
2826
2563
  * captured at start. Mirrors `WebSocketTransport.setRetryTimeoutMs`.
2827
2564
  */
@@ -2885,30 +2622,18 @@ var ContainerControlConnection = class {
2885
2622
  }
2886
2623
  }
2887
2624
  /**
2888
- * Issue WebSocket upgrade fetches, retrying transient 503 responses with
2889
- * exponential backoff (3s 6s 12s → … capped at 30s) until either
2890
- * the upgrade succeeds, a non-503 status is returned, or the retry budget
2891
- * runs out. Mirrors `WebSocketTransport.fetchUpgradeWithRetry` so both
2892
- * transports behave the same way during container startup.
2625
+ * Issue WebSocket upgrade fetches, retrying transient control-plane
2626
+ * unavailability responses until either the upgrade succeeds, a
2627
+ * non-retryable status is returned, or the retry budget runs out.
2893
2628
  */
2894
2629
  async fetchUpgradeWithRetry() {
2895
- const retryTimeoutMs = this.retryTimeoutMs;
2896
- const startTime = Date.now();
2897
- let attempt = 0;
2898
- while (true) {
2899
- const response = await this.fetchUpgradeAttempt();
2900
- if (response.status !== 503) return response;
2901
- const remaining = retryTimeoutMs - (Date.now() - startTime);
2902
- if (remaining <= MIN_TIME_FOR_RETRY_MS) return response;
2903
- const delay = Math.min(3e3 * 2 ** attempt, MAX_RETRY_BACKOFF_MS);
2904
- this.logger.info("ContainerControlConnection upgrade returned 503, retrying", {
2905
- attempt: attempt + 1,
2906
- delayMs: delay,
2907
- remainingSec: Math.floor(remaining / 1e3)
2908
- });
2909
- await this.sleep(delay);
2910
- attempt++;
2911
- }
2630
+ return fetchWithResponseRetry(() => this.fetchUpgradeAttempt(), {
2631
+ retryTimeoutMs: this.retryTimeoutMs,
2632
+ minTimeForRetryMs: MIN_TIME_FOR_RETRY_MS,
2633
+ logger: this.logger,
2634
+ retryLogMessage: "ContainerControlConnection upgrade returned retryable status, retrying",
2635
+ shouldRetry: isRetryableWebSocketUpgradeResponse
2636
+ });
2912
2637
  }
2913
2638
  /**
2914
2639
  * Single WebSocket-upgrade fetch attempt. Owns its own AbortController so
@@ -2932,9 +2657,6 @@ var ContainerControlConnection = class {
2932
2657
  clearTimeout(timeout);
2933
2658
  }
2934
2659
  }
2935
- sleep(ms) {
2936
- return new Promise((resolve) => setTimeout(resolve, ms));
2937
- }
2938
2660
  };
2939
2661
  /**
2940
2662
  * RPC transport that queues sends and blocks receives until a WebSocket
@@ -3301,32 +3023,6 @@ var ContainerControlClient = class {
3301
3023
  get backup() {
3302
3024
  return wrapStub(this.getConnection().rpc().backup, this.renewActivity);
3303
3025
  }
3304
- get desktop() {
3305
- const stub = wrapStub(this.getConnection().rpc().desktop, this.renewActivity);
3306
- const wire = stub;
3307
- return new Proxy(stub, { get(target, prop, receiver) {
3308
- if (prop === "screenshot") return async (options) => {
3309
- const { format, ...rest } = options ?? {};
3310
- const result = await wire.screenshot(rest);
3311
- return format === "bytes" ? {
3312
- ...result,
3313
- data: base64ToBytes(result.data)
3314
- } : result;
3315
- };
3316
- if (prop === "screenshotRegion") return async (region, options) => {
3317
- const { format, ...rest } = options ?? {};
3318
- const result = await wire.screenshotRegion({
3319
- region,
3320
- ...rest
3321
- });
3322
- return format === "bytes" ? {
3323
- ...result,
3324
- data: base64ToBytes(result.data)
3325
- } : result;
3326
- };
3327
- return Reflect.get(target, prop, receiver);
3328
- } });
3329
- }
3330
3026
  get watch() {
3331
3027
  return wrapStub(this.getConnection().rpc().watch, this.renewActivity);
3332
3028
  }
@@ -3337,7 +3033,7 @@ var ContainerControlClient = class {
3337
3033
  return wrapStub(this.getConnection().rpc().interpreter, this.renewActivity);
3338
3034
  }
3339
3035
  /**
3340
- * Update the 503 upgrade-retry budget. Applies to the current connection
3036
+ * Update the upgrade retry budget. Applies to the current connection
3341
3037
  * (if any) and is remembered for any future connections created after the
3342
3038
  * client is torn down and reconnected.
3343
3039
  */
@@ -4775,6 +4471,413 @@ const r2EgressHandler = async (request, env$1, ctx) => {
4775
4471
  }
4776
4472
  };
4777
4473
 
4474
+ //#endregion
4475
+ //#region src/storage-mount/s3-credential-proxy-handler.ts
4476
+ const PER_MOUNT_SUFFIX = ".s3-credential-proxy.internal";
4477
+ const SELF_TEST_PATH = "/__sandbox_credential_proxy_self_test__";
4478
+ const DIAGNOSTICS_PATH = "/__sandbox_credential_proxy_diagnostics__";
4479
+ const DEFAULT_SLOW_REQUEST_MS = 1e3;
4480
+ const ERROR_RESPONSE_BODY_LIMIT = 2048;
4481
+ const MAX_DIAGNOSTIC_EVENTS = 500;
4482
+ const DUMMY_AUTH_HEADERS = new Set([
4483
+ "authorization",
4484
+ "x-amz-date",
4485
+ "x-amz-content-sha256",
4486
+ "x-amz-security-token",
4487
+ "x-goog-date",
4488
+ "x-goog-content-sha256"
4489
+ ]);
4490
+ const sigV4ClientCache = /* @__PURE__ */ new Map();
4491
+ const directoryMarkerCache = /* @__PURE__ */ new Map();
4492
+ const credentialProxyDiagnosticEvents = [];
4493
+ let credentialProxyDiagnosticEventCount = 0;
4494
+ function evictSigV4ClientCacheEntry(mountId) {
4495
+ sigV4ClientCache.delete(mountId);
4496
+ }
4497
+ function evictDirectoryMarkerCacheForMount(mountId) {
4498
+ const prefix = `${mountId}:`;
4499
+ for (const key of directoryMarkerCache.keys()) if (key.startsWith(prefix)) directoryMarkerCache.delete(key);
4500
+ }
4501
+ function toHex(buffer) {
4502
+ return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
4503
+ }
4504
+ async function sha256Hex(data) {
4505
+ return toHex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data)));
4506
+ }
4507
+ async function hmacSHA256(key, data) {
4508
+ const cryptoKey = await crypto.subtle.importKey("raw", key, {
4509
+ name: "HMAC",
4510
+ hash: "SHA-256"
4511
+ }, false, ["sign"]);
4512
+ return crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
4513
+ }
4514
+ function detectS3Region(provider, endpoint) {
4515
+ if (provider === "r2") return "auto";
4516
+ try {
4517
+ const host = new URL(endpoint).hostname;
4518
+ const m = host.match(/s3[.-]([a-z0-9-]+)\.amazonaws\.com/);
4519
+ if (m && m[1] !== "amazonaws") return m[1];
4520
+ if (host === "s3.amazonaws.com") return "us-east-1";
4521
+ } catch {}
4522
+ return "auto";
4523
+ }
4524
+ function buildCleanHeaders(original) {
4525
+ const clean = new Headers();
4526
+ for (const [k, v] of original) {
4527
+ const lower = k.toLowerCase();
4528
+ if (!DUMMY_AUTH_HEADERS.has(lower) && lower !== "host") clean.set(k, v);
4529
+ }
4530
+ const contentSHA256 = original.get("x-amz-content-sha256");
4531
+ if (contentSHA256 && isValidContentSHA256(contentSHA256)) clean.set("x-amz-content-sha256", contentSHA256);
4532
+ return clean;
4533
+ }
4534
+ function isValidContentSHA256(value) {
4535
+ return value === "UNSIGNED-PAYLOAD" || /^[a-fA-F0-9]{64}$/.test(value);
4536
+ }
4537
+ function getCredentialProxyDebugConfig(env$1) {
4538
+ const envRecord = env$1;
4539
+ const enabled = envRecord.SANDBOX_CREDENTIAL_PROXY_DEBUG === "true";
4540
+ const diagnosticsEndpointEnabled = envRecord.SANDBOX_CREDENTIAL_PROXY_DIAGNOSTICS_ENDPOINT === "true";
4541
+ const configuredSlowRequestMs = Number(envRecord.SANDBOX_CREDENTIAL_PROXY_SLOW_REQUEST_MS);
4542
+ return {
4543
+ diagnosticsEndpointEnabled,
4544
+ enabled,
4545
+ slowRequestMs: Number.isFinite(configuredSlowRequestMs) && configuredSlowRequestMs >= 0 ? configuredSlowRequestMs : DEFAULT_SLOW_REQUEST_MS
4546
+ };
4547
+ }
4548
+ function recordCredentialProxyDiagnosticEvent(event) {
4549
+ credentialProxyDiagnosticEvents.push(event);
4550
+ credentialProxyDiagnosticEventCount++;
4551
+ while (credentialProxyDiagnosticEvents.length > MAX_DIAGNOSTIC_EVENTS) credentialProxyDiagnosticEvents.shift();
4552
+ }
4553
+ function getCredentialProxyDiagnosticsResponse(url, containerId) {
4554
+ const since = Number(url.searchParams.get("since") ?? "0");
4555
+ const bufferStartCount = credentialProxyDiagnosticEventCount - credentialProxyDiagnosticEvents.length;
4556
+ const events = credentialProxyDiagnosticEvents.filter((event, index) => {
4557
+ if (event.containerId !== containerId) return false;
4558
+ return !Number.isFinite(since) || bufferStartCount + index >= since;
4559
+ });
4560
+ return Response.json({
4561
+ nextCursor: credentialProxyDiagnosticEventCount,
4562
+ events
4563
+ });
4564
+ }
4565
+ async function withCredentialProxyDiagnostics(requestInfo, debugConfig, containerId, path$1, operation) {
4566
+ const started = Date.now();
4567
+ try {
4568
+ const response = await operation();
4569
+ const durationMs = Date.now() - started;
4570
+ if (debugConfig.enabled) recordCredentialProxyDiagnosticEvent({
4571
+ ...requestInfo,
4572
+ containerId,
4573
+ durationMs,
4574
+ ok: response.ok,
4575
+ path: path$1,
4576
+ status: response.status,
4577
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4578
+ });
4579
+ if (debugConfig.enabled || durationMs >= debugConfig.slowRequestMs) console.info("sandbox.s3_credential_proxy.request", {
4580
+ ...requestInfo,
4581
+ durationMs,
4582
+ ok: response.ok,
4583
+ status: response.status,
4584
+ responseContentLength: response.headers.get("content-length")
4585
+ });
4586
+ if (!response.ok) {
4587
+ const responseForLog = response.clone();
4588
+ const requestInfoSnapshot = { ...requestInfo };
4589
+ responseForLog.text().then((body) => {
4590
+ console.warn("sandbox.s3_credential_proxy.upstream_error", {
4591
+ ...requestInfoSnapshot,
4592
+ durationMs,
4593
+ status: response.status,
4594
+ statusText: response.statusText,
4595
+ errorBody: body.slice(0, ERROR_RESPONSE_BODY_LIMIT)
4596
+ });
4597
+ }).catch(() => {});
4598
+ }
4599
+ return response;
4600
+ } catch (error) {
4601
+ const durationMs = Date.now() - started;
4602
+ console.warn("sandbox.s3_credential_proxy.request_error", {
4603
+ ...requestInfo,
4604
+ durationMs,
4605
+ error: error instanceof Error ? error.message : String(error)
4606
+ });
4607
+ throw error;
4608
+ }
4609
+ }
4610
+ function getSigV4Client(mountId, endpoint, provider, credentials, region) {
4611
+ const cached = sigV4ClientCache.get(mountId);
4612
+ if (cached && cached.accessKeyId === credentials.accessKeyId && cached.secretAccessKey === credentials.secretAccessKey && cached.endpoint === endpoint && cached.provider === provider && cached.region === region) return cached.client;
4613
+ const client = new AwsClient({
4614
+ accessKeyId: credentials.accessKeyId,
4615
+ secretAccessKey: credentials.secretAccessKey,
4616
+ service: "s3",
4617
+ region,
4618
+ retries: 0
4619
+ });
4620
+ sigV4ClientCache.set(mountId, {
4621
+ client,
4622
+ accessKeyId: credentials.accessKeyId,
4623
+ secretAccessKey: credentials.secretAccessKey,
4624
+ endpoint,
4625
+ provider,
4626
+ region
4627
+ });
4628
+ return client;
4629
+ }
4630
+ function encodeCanonicalQueryPart(value) {
4631
+ return encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
4632
+ }
4633
+ function getCanonicalURI(url) {
4634
+ 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("/");
4635
+ }
4636
+ function getCanonicalQueryString(url) {
4637
+ const query = url.search.startsWith("?") ? url.search.slice(1) : url.search;
4638
+ if (!query) return "";
4639
+ return query.split("&").map((part) => {
4640
+ const separatorIndex = part.indexOf("=");
4641
+ if (separatorIndex === -1) return [part, ""];
4642
+ return [part.slice(0, separatorIndex), part.slice(separatorIndex + 1)];
4643
+ }).map(([key, value]) => [encodeCanonicalQueryPart(decodeURIEncodedQueryPart(key)), encodeCanonicalQueryPart(decodeURIEncodedQueryPart(value))]).sort(([leftKey, leftValue], [rightKey, rightValue]) => {
4644
+ if (leftKey < rightKey) return -1;
4645
+ if (leftKey > rightKey) return 1;
4646
+ if (leftValue < rightValue) return -1;
4647
+ if (leftValue > rightValue) return 1;
4648
+ return 0;
4649
+ }).map(([key, value]) => `${key}=${value}`).join("&");
4650
+ }
4651
+ function decodeURIEncodedQueryPart(value) {
4652
+ try {
4653
+ return decodeURIComponent(value);
4654
+ } catch {
4655
+ return value;
4656
+ }
4657
+ }
4658
+ function getSigV4PayloadHash(headers) {
4659
+ const existingHash = headers.get("x-amz-content-sha256");
4660
+ if (existingHash && existingHash !== "UNSIGNED-PAYLOAD") return {
4661
+ hash: existingHash,
4662
+ mode: "signed"
4663
+ };
4664
+ return {
4665
+ hash: "UNSIGNED-PAYLOAD",
4666
+ mode: "unsigned"
4667
+ };
4668
+ }
4669
+ function isZeroLengthDirectoryMarkerPUT(request, realPath) {
4670
+ return request.method.toUpperCase() === "PUT" && request.headers.get("content-length") === "0" && realPath.endsWith("/");
4671
+ }
4672
+ function isDirectoryMarkerHEAD(request) {
4673
+ return request.method.toUpperCase() === "HEAD";
4674
+ }
4675
+ function getDirectoryMarkerCacheKey(mountId, realPath) {
4676
+ return `${mountId}:${realPath.replace(/\/+$/, "")}`;
4677
+ }
4678
+ function getDirectoryMarkerResponseHeaders(request) {
4679
+ const headers = [
4680
+ ["Accept-Ranges", "bytes"],
4681
+ ["Content-Length", "0"],
4682
+ ["ETag", "\"d41d8cd98f00b204e9800998ecf8427e\""],
4683
+ ["Last-Modified", (/* @__PURE__ */ new Date()).toUTCString()]
4684
+ ];
4685
+ const contentType = request.headers.get("content-type");
4686
+ if (contentType) headers.push(["Content-Type", contentType]);
4687
+ for (const [name, value] of request.headers) if (name.toLowerCase().startsWith("x-amz-meta-")) headers.push([name, value]);
4688
+ return headers;
4689
+ }
4690
+ function normalizePrefix(prefix) {
4691
+ if (!prefix) return void 0;
4692
+ return prefix.replace(/^\/+/, "").replace(/\/+$/, "");
4693
+ }
4694
+ function getObjectKeyForPath(realPath, bucket) {
4695
+ const pathSegments = realPath.split("/").filter(Boolean);
4696
+ if (pathSegments[0] !== bucket) return null;
4697
+ return pathSegments.slice(1).join("/");
4698
+ }
4699
+ function isObjectKeyWithinPrefix(objectKey, prefix) {
4700
+ const normalizedPrefix = normalizePrefix(prefix);
4701
+ if (!normalizedPrefix) return true;
4702
+ return objectKey === normalizedPrefix || objectKey.startsWith(`${normalizedPrefix}/`);
4703
+ }
4704
+ function isRequestWithinMountScope(realPath, url, bucket, prefix) {
4705
+ const objectKey = getObjectKeyForPath(realPath, bucket);
4706
+ if (objectKey === null) return false;
4707
+ const requestedPrefix = url.searchParams.get("prefix");
4708
+ if (objectKey !== "" && !isObjectKeyWithinPrefix(objectKey, prefix)) return false;
4709
+ if (objectKey === "" && normalizePrefix(prefix) !== void 0 && url.search !== "" && requestedPrefix === null) return false;
4710
+ if (requestedPrefix !== null) return isObjectKeyWithinPrefix(requestedPrefix, prefix);
4711
+ return true;
4712
+ }
4713
+ function isBucketRootProbe(request, realPath, url, bucket) {
4714
+ const method = request.method.toUpperCase();
4715
+ return (method === "GET" || method === "HEAD") && url.search === "" && getObjectKeyForPath(realPath, bucket) === "";
4716
+ }
4717
+ function deleteDirectoryMarkerCacheEntry(mountId, realPath) {
4718
+ directoryMarkerCache.delete(getDirectoryMarkerCacheKey(mountId, realPath));
4719
+ }
4720
+ function getContentLength(request) {
4721
+ const contentLength = request.headers.get("content-length");
4722
+ if (contentLength === null) return null;
4723
+ const parsed = Number(contentLength);
4724
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
4725
+ return parsed;
4726
+ }
4727
+ function getSigV4ForwardInit(request) {
4728
+ const contentLength = getContentLength(request);
4729
+ if (contentLength === 0) return { body: new Uint8Array(0) };
4730
+ if (contentLength === null || request.body === null) return {};
4731
+ const { readable, writable } = new FixedLengthStream(contentLength);
4732
+ request.body.pipeTo(writable).catch((error) => {
4733
+ writable.abort(error).catch(() => {});
4734
+ });
4735
+ return { body: readable };
4736
+ }
4737
+ function getGCSHeaders(request) {
4738
+ const headers = new Headers();
4739
+ for (const [k, v] of request.headers) {
4740
+ const lower = k.toLowerCase();
4741
+ if (DUMMY_AUTH_HEADERS.has(lower) || lower === "host" || lower === "content-length" || lower === "expect") continue;
4742
+ if (lower.startsWith("x-amz-meta-")) {
4743
+ headers.set(`x-goog-meta-${lower.slice(11)}`, v);
4744
+ continue;
4745
+ }
4746
+ if (lower.startsWith("x-amz-")) continue;
4747
+ headers.set(k, v);
4748
+ }
4749
+ return headers;
4750
+ }
4751
+ async function signAndForwardSigV4(request, mountId, endpoint, provider, credentials, requestInfo) {
4752
+ const signingStarted = Date.now();
4753
+ const region = detectS3Region(provider, endpoint);
4754
+ const payload = getSigV4PayloadHash(request.headers);
4755
+ const client = getSigV4Client(mountId, endpoint, provider, credentials, region);
4756
+ requestInfo.payloadHashMode = payload.mode;
4757
+ requestInfo.clientSetupMs = Date.now() - signingStarted;
4758
+ const upstreamStarted = Date.now();
4759
+ const forwardInit = getSigV4ForwardInit(request);
4760
+ requestInfo.bodyPresent = forwardInit?.body !== void 0 || request.body !== null;
4761
+ const response = await client.fetch(request, forwardInit);
4762
+ requestInfo.upstreamMs = Date.now() - upstreamStarted;
4763
+ return response;
4764
+ }
4765
+ async function signAndForwardGCS(request, credentials, requestInfo) {
4766
+ const url = new URL(request.url);
4767
+ const gcsHeaders = getGCSHeaders(request);
4768
+ const dateStr = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:-]/g, "").replace(/\.\d+Z$/, "")}Z`;
4769
+ const dateOnly = dateStr.slice(0, 8);
4770
+ const location = "auto";
4771
+ const service = "storage";
4772
+ const credentialScope = `${dateOnly}/${location}/${service}/goog4_request`;
4773
+ const bodyHash = "UNSIGNED-PAYLOAD";
4774
+ const headerEntries = [
4775
+ ["host", url.host],
4776
+ ["x-goog-content-sha256", bodyHash],
4777
+ ["x-goog-date", dateStr]
4778
+ ];
4779
+ for (const [k, v] of gcsHeaders) headerEntries.push([k.toLowerCase(), v.trim()]);
4780
+ headerEntries.sort((a, b) => a[0].localeCompare(b[0]));
4781
+ const signedHeaders = headerEntries.map(([k]) => k).join(";");
4782
+ const canonicalHeaders = headerEntries.map(([k, v]) => `${k}:${v}\n`).join("");
4783
+ const stringToSign = [
4784
+ "GOOG4-HMAC-SHA256",
4785
+ dateStr,
4786
+ credentialScope,
4787
+ await sha256Hex([
4788
+ request.method,
4789
+ getCanonicalURI(url),
4790
+ getCanonicalQueryString(url),
4791
+ canonicalHeaders,
4792
+ signedHeaders,
4793
+ bodyHash
4794
+ ].join("\n"))
4795
+ ].join("\n");
4796
+ 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));
4797
+ const authorization = `GOOG4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
4798
+ const newHeaders = new Headers(gcsHeaders);
4799
+ newHeaders.set("x-goog-date", dateStr);
4800
+ newHeaders.set("x-goog-content-sha256", bodyHash);
4801
+ newHeaders.set("Authorization", authorization);
4802
+ const gcsBody = getContentLength(request) === 0 ? new Uint8Array(0) : request.body;
4803
+ const upstreamStarted = Date.now();
4804
+ const response = await fetch(new Request(request.url, {
4805
+ method: request.method,
4806
+ headers: newHeaders,
4807
+ body: gcsBody
4808
+ }));
4809
+ requestInfo.upstreamMs = Date.now() - upstreamStarted;
4810
+ return response;
4811
+ }
4812
+ const s3CredentialProxyHandler = async (request, env$1, ctx) => {
4813
+ const url = new URL(request.url);
4814
+ if (url.pathname === SELF_TEST_PATH) return new Response("OK", { status: 200 });
4815
+ const debugConfig = getCredentialProxyDebugConfig(env$1);
4816
+ if (url.pathname === DIAGNOSTICS_PATH) {
4817
+ if (!debugConfig.enabled || !debugConfig.diagnosticsEndpointEnabled) return new Response("Not Found", { status: 404 });
4818
+ return getCredentialProxyDiagnosticsResponse(url, ctx.containerId);
4819
+ }
4820
+ const segments = url.pathname.split("/").filter(Boolean);
4821
+ const hostname = url.hostname;
4822
+ let mountId;
4823
+ let realPath;
4824
+ if (hostname.endsWith(PER_MOUNT_SUFFIX)) {
4825
+ mountId = hostname.slice(0, -29);
4826
+ realPath = url.pathname;
4827
+ } else {
4828
+ mountId = segments[0] ?? null;
4829
+ realPath = mountId ? url.pathname.slice(`/${mountId}`.length) || "/" : "/";
4830
+ }
4831
+ if (!mountId) return new Response("Bad Request: missing mount ID", { status: 400 });
4832
+ const mount = ctx.params?.mounts[mountId];
4833
+ if (!mount) return new Response(`Forbidden: unknown mount ID "${mountId}"`, { status: 403 });
4834
+ if (mount.readOnly) {
4835
+ const method = request.method.toUpperCase();
4836
+ if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") return new Response("Forbidden: bucket mount is read-only", { status: 403 });
4837
+ }
4838
+ const realUrl = new URL(realPath + (url.search || ""), mount.endpoint);
4839
+ if (isBucketRootProbe(request, realPath, url, mount.bucket)) return new Response(null, { status: 200 });
4840
+ if (!isRequestWithinMountScope(realPath, url, mount.bucket, mount.prefix)) return new Response("Forbidden: request is outside mounted bucket scope", { status: 403 });
4841
+ const cleanHeaders = buildCleanHeaders(request.headers);
4842
+ const cleanRequest = new Request(realUrl.toString(), {
4843
+ method: request.method,
4844
+ headers: cleanHeaders,
4845
+ body: request.body
4846
+ });
4847
+ const requestInfo = {
4848
+ authStrategy: mount.authStrategy,
4849
+ bucket: mount.bucket,
4850
+ contentLength: request.headers.get("content-length"),
4851
+ method: request.method,
4852
+ mountId,
4853
+ query: [...url.searchParams.keys()].sort()
4854
+ };
4855
+ if (isZeroLengthDirectoryMarkerPUT(cleanRequest, realPath)) {
4856
+ const responseHeaders = getDirectoryMarkerResponseHeaders(cleanRequest);
4857
+ requestInfo.bodyPresent = request.body !== null;
4858
+ if (mount.authStrategy === "s3-sigv4") requestInfo.payloadHashMode = getSigV4PayloadHash(cleanRequest.headers).mode;
4859
+ return withCredentialProxyDiagnostics(requestInfo, debugConfig, ctx.containerId, realPath, async () => {
4860
+ const response = mount.authStrategy === "gcs" ? await signAndForwardGCS(cleanRequest, mount.credentials, requestInfo) : await signAndForwardSigV4(cleanRequest, mountId, mount.endpoint, mount.provider, mount.credentials, requestInfo);
4861
+ if (response.ok) directoryMarkerCache.set(getDirectoryMarkerCacheKey(mountId, realPath), responseHeaders);
4862
+ return response;
4863
+ });
4864
+ }
4865
+ if (isDirectoryMarkerHEAD(cleanRequest)) {
4866
+ const responseHeaders = directoryMarkerCache.get(getDirectoryMarkerCacheKey(mountId, realPath));
4867
+ if (responseHeaders) {
4868
+ requestInfo.bodyPresent = false;
4869
+ if (mount.authStrategy === "s3-sigv4") requestInfo.payloadHashMode = getSigV4PayloadHash(cleanRequest.headers).mode;
4870
+ return withCredentialProxyDiagnostics(requestInfo, debugConfig, ctx.containerId, realPath, () => Promise.resolve(new Response(null, {
4871
+ status: 200,
4872
+ headers: responseHeaders
4873
+ })));
4874
+ }
4875
+ }
4876
+ if (cleanRequest.method.toUpperCase() !== "HEAD") deleteDirectoryMarkerCacheEntry(mountId, realPath);
4877
+ if (mount.authStrategy === "gcs") return withCredentialProxyDiagnostics(requestInfo, debugConfig, ctx.containerId, realPath, () => signAndForwardGCS(cleanRequest, mount.credentials, requestInfo));
4878
+ return withCredentialProxyDiagnostics(requestInfo, debugConfig, ctx.containerId, realPath, () => signAndForwardSigV4(cleanRequest, mountId, mount.endpoint, mount.provider, mount.credentials, requestInfo));
4879
+ };
4880
+
4778
4881
  //#endregion
4779
4882
  //#region src/tunnels/credentials.ts
4780
4883
  /**
@@ -5731,16 +5834,47 @@ async function pruneTunnelsForRestart(storage) {
5731
5834
  * This file is auto-updated by .github/changeset-version.ts during releases
5732
5835
  * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
5733
5836
  */
5734
- const SDK_VERSION = "0.11.0";
5837
+ const SDK_VERSION = "0.12.1";
5735
5838
 
5736
5839
  //#endregion
5737
5840
  //#region src/sandbox.ts
5738
5841
  const PORT_TOKENS_STORAGE_KEY = "portTokens";
5739
5842
  const ACTIVE_PREVIEW_PORTS_STORAGE_KEY = "activePreviewPorts";
5740
- const R2_EGRESS_PROXY_TARGET_CLASS_NAME = "CloudflareSandboxR2EgressProxyTarget";
5741
- var R2EgressProxyTarget = class extends Container {};
5742
- Object.defineProperty(R2EgressProxyTarget, "name", { value: R2_EGRESS_PROXY_TARGET_CLASS_NAME });
5743
- R2EgressProxyTarget.outboundHandlers = { r2EgressMount: r2EgressHandler };
5843
+ const CONTAINER_PROXY_CLASS_NAME = "ContainerProxy";
5844
+ const S3_CREDENTIAL_PROXY_HOST = "s3-credential-proxy.internal";
5845
+ const S3_CREDENTIAL_PROXY_DIAGNOSTIC_HOST = "s3-credential-proxy.sandbox.test";
5846
+ var ContainerProxyOutboundTarget = class extends Container {};
5847
+ Object.defineProperty(ContainerProxyOutboundTarget, "name", { value: CONTAINER_PROXY_CLASS_NAME });
5848
+ ContainerProxyOutboundTarget.outboundHandlers = {
5849
+ r2EgressMount: r2EgressHandler,
5850
+ s3CredentialProxyMount: s3CredentialProxyHandler
5851
+ };
5852
+ /**
5853
+ * SDK-level ContainerProxy that directly dispatches SDK-internal mount hosts
5854
+ * (r2.internal, s3-credential-proxy.internal) without relying on
5855
+ * outboundHandlersRegistry lookups, which are NOT shared between the Durable
5856
+ * Object's execution context and the ContainerProxy WorkerEntrypoint context.
5857
+ *
5858
+ * Users must export this class from their Worker entrypoint so the Sandbox DO
5859
+ * can create outbound-interception fetchers that reference it.
5860
+ */
5861
+ var ContainerProxy$1 = class extends ContainerProxy {
5862
+ async fetch(request) {
5863
+ const hostname = new URL(request.url).hostname;
5864
+ const props = this.ctx.props;
5865
+ const override = props.outboundByHostOverrides?.[hostname];
5866
+ if (override) {
5867
+ const handlerCtx = {
5868
+ containerId: props.containerId ?? "",
5869
+ className: props.className ?? "",
5870
+ params: override.params
5871
+ };
5872
+ if (override.method === "r2EgressMount") return r2EgressHandler(request, this.env, handlerCtx);
5873
+ if (override.method === "s3CredentialProxyMount") return s3CredentialProxyHandler(request, this.env, handlerCtx);
5874
+ }
5875
+ return super.fetch(request);
5876
+ }
5877
+ };
5744
5878
  function isFetcher(value) {
5745
5879
  return typeof value === "object" && value !== null && "fetch" in value && typeof value.fetch === "function";
5746
5880
  }
@@ -5750,6 +5884,8 @@ const R2_DEFAULT_S3FS_OPTIONS = {
5750
5884
  enable_noobj_cache: true,
5751
5885
  multipart_size: "5"
5752
5886
  };
5887
+ const R2_DEFAULT_S3FS_OPTION_ENTRIES = Object.entries(R2_DEFAULT_S3FS_OPTIONS).map(([key, value]) => value === true ? key : `${key}=${value}`);
5888
+ const S3FS_DISABLE_EXPECT_HEADER_CONFIG = " Expect:\n";
5753
5889
  const BACKUP_DEFAULT_TTL_SECONDS = 259200;
5754
5890
  const BACKUP_MAX_NAME_LENGTH = 256;
5755
5891
  const BACKUP_CONTAINER_DIR = "/var/backups";
@@ -5941,10 +6077,6 @@ function getSandbox(ns, id, options) {
5941
6077
  }),
5942
6078
  terminal: (request, opts) => proxyTerminal(stub, defaultSessionId, request, opts),
5943
6079
  wsConnect: connect(stub),
5944
- desktop: new Proxy({}, { get(_, method) {
5945
- if (typeof method !== "string" || method === "then") return void 0;
5946
- return (...args) => stub.callDesktop(method, args);
5947
- } }),
5948
6080
  tunnels: new Proxy({}, { get: (_, method) => {
5949
6081
  if (typeof method !== "string" || method === "then") return void 0;
5950
6082
  return (...args) => stub.callTunnels(method, args);
@@ -5986,6 +6118,7 @@ var Sandbox = class Sandbox extends Container {
5986
6118
  logger;
5987
6119
  keepAliveEnabled = false;
5988
6120
  activeMounts = /* @__PURE__ */ new Map();
6121
+ mountOperationQueue = Promise.resolve();
5989
6122
  currentRuntime;
5990
6123
  transport = "http";
5991
6124
  /**
@@ -6052,56 +6185,6 @@ var Sandbox = class Sandbox extends Container {
6052
6185
  */
6053
6186
  hasStoredContainerTimeouts = false;
6054
6187
  /**
6055
- * Desktop environment operations.
6056
- * Within the DO, this getter provides direct access to DesktopClient.
6057
- * Over RPC, the getSandbox() proxy intercepts this property and routes
6058
- * calls through callDesktop() instead.
6059
- */
6060
- get desktop() {
6061
- return this.client.desktop;
6062
- }
6063
- /**
6064
- * Allowed desktop methods — derived from the Desktop interface.
6065
- * Restricts callDesktop() to a known set of operations.
6066
- */
6067
- static DESKTOP_METHODS = new Set([
6068
- "start",
6069
- "stop",
6070
- "status",
6071
- "screenshot",
6072
- "screenshotRegion",
6073
- "click",
6074
- "doubleClick",
6075
- "tripleClick",
6076
- "rightClick",
6077
- "middleClick",
6078
- "mouseDown",
6079
- "mouseUp",
6080
- "moveMouse",
6081
- "drag",
6082
- "scroll",
6083
- "getCursorPosition",
6084
- "type",
6085
- "press",
6086
- "keyDown",
6087
- "keyUp",
6088
- "getScreenSize",
6089
- "getProcessStatus"
6090
- ]);
6091
- /**
6092
- * Dispatch method for desktop operations.
6093
- * Called by the client-side proxy created in getSandbox() to provide
6094
- * the `sandbox.desktop.status()` API without relying on RPC pipelining
6095
- * through property getters which is broken when using vite-plugin.
6096
- */
6097
- async callDesktop(method, args) {
6098
- if (!Sandbox.DESKTOP_METHODS.has(method)) throw new Error(`Unknown desktop method: ${method}`);
6099
- const client = this.client.desktop;
6100
- const fn = client[method];
6101
- if (typeof fn !== "function") throw new Error(`sandbox.desktop missing method: ${method}`);
6102
- return fn.apply(client, args);
6103
- }
6104
- /**
6105
6188
  * Dispatch method for tunnel operations.
6106
6189
  * Called by the client-side proxy created in getSandbox() to provide
6107
6190
  * the `sandbox.tunnels` API without relying on RPC pipelining
@@ -6406,6 +6489,24 @@ var Sandbox = class Sandbox extends Container {
6406
6489
  * @throws InvalidMountConfigError if bucket name, mount path, or endpoint is invalid
6407
6490
  */
6408
6491
  async mountBucket(bucket, mountPath, options) {
6492
+ return this.runMountOperation(async () => {
6493
+ await this.mountBucketUnlocked(bucket, mountPath, options);
6494
+ });
6495
+ }
6496
+ async runMountOperation(operation) {
6497
+ const previous = this.mountOperationQueue;
6498
+ let release;
6499
+ this.mountOperationQueue = new Promise((resolve) => {
6500
+ release = resolve;
6501
+ });
6502
+ await previous.catch(() => {});
6503
+ try {
6504
+ await operation();
6505
+ } finally {
6506
+ release();
6507
+ }
6508
+ }
6509
+ async mountBucketUnlocked(bucket, mountPath, options) {
6409
6510
  if (options.prefix !== void 0) validatePrefix(options.prefix);
6410
6511
  if ("localBucket" in options && options.localBucket) {
6411
6512
  await this.mountBucketLocal(bucket, mountPath, options);
@@ -6445,6 +6546,7 @@ var Sandbox = class Sandbox extends Container {
6445
6546
  logger: this.logger
6446
6547
  });
6447
6548
  const mountInfo = {
6549
+ mountId: crypto.randomUUID(),
6448
6550
  mountType: "local-sync",
6449
6551
  bucket,
6450
6552
  mountPath,
@@ -6485,13 +6587,36 @@ var Sandbox = class Sandbox extends Container {
6485
6587
  };
6486
6588
  return { buckets };
6487
6589
  }
6488
- validateR2EgressS3fsOptions(options) {
6590
+ validateProtectedS3fsOptions(options, mountLabel, extraProtected = []) {
6489
6591
  if (!options) return;
6490
- const protectedOptions = new Set(["passwd_file", "url"]);
6592
+ const protectedOptions = new Set([
6593
+ "passwd_file",
6594
+ "url",
6595
+ ...extraProtected
6596
+ ]);
6491
6597
  for (const option of options) {
6492
6598
  const [key] = option.split("=");
6493
- if (protectedOptions.has(key)) throw new InvalidMountConfigError(`s3fs option "${key}" cannot be overridden for R2 binding mounts`);
6599
+ if (protectedOptions.has(key)) throw new InvalidMountConfigError(`s3fs option "${key}" cannot be overridden for ${mountLabel} mounts`);
6600
+ }
6601
+ }
6602
+ getS3CredentialProxyParams(options) {
6603
+ const mounts = {};
6604
+ for (const [, m] of this.activeMounts) if (m.mountType === "fuse" && m.credentialProxy) {
6605
+ if (m.mountId === options?.excludeMountId) continue;
6606
+ mounts[m.mountId] = {
6607
+ endpoint: m.credentialProxy.endpoint,
6608
+ bucket: m.credentialProxy.bucket,
6609
+ ...m.credentialProxy.prefix !== void 0 ? { prefix: m.credentialProxy.prefix } : {},
6610
+ credentials: m.credentialProxy.credentials,
6611
+ readOnly: m.credentialProxy.readOnly,
6612
+ provider: m.credentialProxy.provider,
6613
+ authStrategy: m.credentialProxy.authStrategy
6614
+ };
6494
6615
  }
6616
+ return { mounts };
6617
+ }
6618
+ resolveCredentialProxyAuthStrategy(provider) {
6619
+ return provider === "gcs" ? "gcs" : "s3-sigv4";
6495
6620
  }
6496
6621
  /**
6497
6622
  * Credential-less R2 mount: egress interception routes s3fs requests to the
@@ -6502,24 +6627,30 @@ var Sandbox = class Sandbox extends Container {
6502
6627
  const prefix = options.prefix;
6503
6628
  let mountOutcome = "error";
6504
6629
  let mountError;
6630
+ let passwordFilePath;
6631
+ let additionalHeaderFilePath;
6505
6632
  try {
6506
6633
  validateBucketBindingName(bucket, mountPath);
6507
6634
  this.validateMountPath(mountPath);
6508
- this.validateR2EgressS3fsOptions(options.s3fsOptions);
6635
+ this.validateProtectedS3fsOptions(options.s3fsOptions, "R2 binding");
6509
6636
  for (const [existingMountPath, mountInfo$1] of this.activeMounts) {
6510
6637
  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.`);
6511
6638
  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.`);
6512
6639
  }
6513
- const passwordFilePath = this.generatePasswordFilePath();
6640
+ passwordFilePath = this.generatePasswordFilePath();
6641
+ additionalHeaderFilePath = this.generateS3FSAdditionalHeaderFilePath();
6514
6642
  await this.createPasswordFile(passwordFilePath, bucket, {
6515
6643
  accessKeyId: "x",
6516
6644
  secretAccessKey: "x"
6517
6645
  });
6646
+ await this.createDisableExpectHeaderFile(additionalHeaderFilePath);
6518
6647
  const mountInfo = {
6648
+ mountId: crypto.randomUUID(),
6519
6649
  mountType: "r2-egress",
6520
6650
  bucket,
6521
6651
  mountPath,
6522
6652
  passwordFilePath,
6653
+ additionalHeaderFilePath,
6523
6654
  mounted: false,
6524
6655
  prefix,
6525
6656
  readOnly: options.readOnly ?? false
@@ -6534,6 +6665,7 @@ var Sandbox = class Sandbox extends Container {
6534
6665
  ...parseS3fsOptions(resolveS3fsOptions("r2", options.s3fsOptions)),
6535
6666
  use_path_request_style: true,
6536
6667
  url: "http://r2.internal",
6668
+ ahbe_conf: additionalHeaderFilePath,
6537
6669
  ...options.readOnly ? { ro: true } : {}
6538
6670
  }));
6539
6671
  const mountCmd = `s3fs ${shellEscape(s3fsSource)} ${shellEscape(mountPath)} -o ${optionsStr}`;
@@ -6557,7 +6689,13 @@ var Sandbox = class Sandbox extends Container {
6557
6689
  mountError = error instanceof Error ? error : new Error(String(error));
6558
6690
  const failedMount = this.activeMounts.get(mountPath);
6559
6691
  this.activeMounts.delete(mountPath);
6560
- if (failedMount?.mountType === "r2-egress") await this.deletePasswordFile(failedMount.passwordFilePath).catch(() => {});
6692
+ if (failedMount?.mountType === "r2-egress") {
6693
+ await this.deletePasswordFile(failedMount.passwordFilePath).catch(() => {});
6694
+ if (failedMount.additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(failedMount.additionalHeaderFilePath).catch(() => {});
6695
+ } else {
6696
+ if (passwordFilePath) await this.deletePasswordFile(passwordFilePath).catch(() => {});
6697
+ if (additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(additionalHeaderFilePath).catch(() => {});
6698
+ }
6561
6699
  const remainingParams = this.getR2EgressParams();
6562
6700
  await this.configureR2EgressOutbound(remainingParams).catch(() => {});
6563
6701
  throw error;
@@ -6583,6 +6721,7 @@ var Sandbox = class Sandbox extends Container {
6583
6721
  let mountOutcome = "error";
6584
6722
  let mountError;
6585
6723
  let passwordFilePath;
6724
+ let additionalHeaderFilePath;
6586
6725
  let provider = null;
6587
6726
  let dirExisted = true;
6588
6727
  try {
@@ -6604,33 +6743,81 @@ var Sandbox = class Sandbox extends Container {
6604
6743
  R2_SECRET_ACCESS_KEY: this.r2SecretAccessKey || void 0,
6605
6744
  ...this.envVars
6606
6745
  });
6746
+ const credentialProxyEnabled = options.credentialProxy === true;
6747
+ if (credentialProxyEnabled) this.validateProtectedS3fsOptions(options.s3fsOptions, "credential proxy", ["ahbe_conf", "use_path_request_style"]);
6607
6748
  passwordFilePath = this.generatePasswordFilePath();
6749
+ if (credentialProxyEnabled) additionalHeaderFilePath = this.generateS3FSAdditionalHeaderFilePath();
6750
+ const mountId = crypto.randomUUID();
6608
6751
  const mountInfo = {
6752
+ mountId,
6609
6753
  mountType: "fuse",
6610
6754
  bucket: s3fsSource,
6611
6755
  mountPath,
6612
6756
  endpoint: options.endpoint,
6613
6757
  provider,
6614
6758
  passwordFilePath,
6615
- mounted: false
6759
+ ...additionalHeaderFilePath ? { additionalHeaderFilePath } : {},
6760
+ mounted: false,
6761
+ ...credentialProxyEnabled ? { credentialProxy: {
6762
+ endpoint: options.endpoint,
6763
+ bucket,
6764
+ ...prefix !== void 0 ? { prefix } : {},
6765
+ credentials,
6766
+ readOnly: options.readOnly ?? false,
6767
+ provider,
6768
+ authStrategy: this.resolveCredentialProxyAuthStrategy(provider)
6769
+ } } : {}
6616
6770
  };
6617
6771
  this.activeMounts.set(mountPath, mountInfo);
6618
- await this.createPasswordFile(passwordFilePath, bucket, credentials);
6772
+ await this.createPasswordFile(passwordFilePath, bucket, credentialProxyEnabled ? {
6773
+ accessKeyId: "x",
6774
+ secretAccessKey: "x"
6775
+ } : credentials);
6776
+ if (credentialProxyEnabled) {
6777
+ if (additionalHeaderFilePath) await this.createDisableExpectHeaderFile(additionalHeaderFilePath);
6778
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams());
6779
+ }
6619
6780
  dirExisted = (await this.execInternal(`test -d ${shellEscape(mountPath)}`)).exitCode === 0;
6620
6781
  await this.execInternal(`mkdir -p ${shellEscape(mountPath)}`);
6621
- await this.executeS3FSMount(s3fsSource, mountPath, options, provider, passwordFilePath);
6782
+ const effectiveOptions = credentialProxyEnabled ? {
6783
+ ...options,
6784
+ endpoint: `http://${S3_CREDENTIAL_PROXY_HOST}/${mountId}`,
6785
+ s3fsOptions: [
6786
+ ...provider === "r2" ? R2_DEFAULT_S3FS_OPTION_ENTRIES : [],
6787
+ ...options.s3fsOptions ?? [],
6788
+ ...additionalHeaderFilePath ? [`ahbe_conf=${additionalHeaderFilePath}`] : [],
6789
+ "use_path_request_style"
6790
+ ]
6791
+ } : options;
6792
+ await this.executeS3FSMount(s3fsSource, mountPath, effectiveOptions, provider, passwordFilePath);
6622
6793
  mountInfo.mounted = true;
6623
6794
  mountOutcome = "success";
6624
6795
  } catch (error) {
6625
6796
  mountError = error instanceof Error ? error : new Error(String(error));
6626
- if (passwordFilePath) await this.deletePasswordFile(passwordFilePath);
6627
6797
  try {
6628
6798
  await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} && fusermount -u ${shellEscape(mountPath)}`);
6629
6799
  } catch {}
6800
+ if (passwordFilePath) await this.deletePasswordFile(passwordFilePath);
6801
+ if (additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(additionalHeaderFilePath);
6630
6802
  if (!dirExisted) try {
6631
6803
  await this.execInternal(`rmdir ${shellEscape(mountPath)} 2>/dev/null`);
6632
6804
  } catch {}
6633
- this.activeMounts.delete(mountPath);
6805
+ const failedMount = this.activeMounts.get(mountPath);
6806
+ if (failedMount?.mountType === "fuse" && failedMount.credentialProxy) try {
6807
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams({ excludeMountId: failedMount.mountId }));
6808
+ this.activeMounts.delete(mountPath);
6809
+ evictSigV4ClientCacheEntry(failedMount.mountId);
6810
+ evictDirectoryMarkerCacheForMount(failedMount.mountId);
6811
+ } catch (cleanupError) {
6812
+ this.logger.warn("credential proxy cleanup failed", {
6813
+ mountPath,
6814
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
6815
+ });
6816
+ this.activeMounts.delete(mountPath);
6817
+ evictSigV4ClientCacheEntry(failedMount.mountId);
6818
+ evictDirectoryMarkerCacheForMount(failedMount.mountId);
6819
+ }
6820
+ else this.activeMounts.delete(mountPath);
6634
6821
  throw error;
6635
6822
  } finally {
6636
6823
  logCanonicalEvent(this.logger, {
@@ -6652,6 +6839,11 @@ var Sandbox = class Sandbox extends Container {
6652
6839
  * @throws InvalidMountConfigError if mount path doesn't exist or isn't mounted
6653
6840
  */
6654
6841
  async unmountBucket(mountPath) {
6842
+ return this.runMountOperation(async () => {
6843
+ await this.unmountBucketUnlocked(mountPath);
6844
+ });
6845
+ }
6846
+ async unmountBucketUnlocked(mountPath) {
6655
6847
  const unmountStartTime = Date.now();
6656
6848
  let unmountOutcome = "error";
6657
6849
  let unmountError;
@@ -6662,30 +6854,75 @@ var Sandbox = class Sandbox extends Container {
6662
6854
  await mountInfo.syncManager.stop();
6663
6855
  mountInfo.mounted = false;
6664
6856
  this.activeMounts.delete(mountPath);
6665
- } else try {
6666
- const result = await this.execInternal(`fusermount -u ${shellEscape(mountPath)}`);
6667
- if (result.exitCode !== 0) {
6668
- const stderr = result.stderr || "unknown error";
6669
- throw new BucketUnmountError(`fusermount -u failed (exit ${result.exitCode}): ${stderr}`);
6670
- }
6671
- mountInfo.mounted = false;
6672
- this.activeMounts.delete(mountPath);
6673
- if (mountInfo.mountType === "r2-egress") await this.configureR2EgressOutbound(this.getR2EgressParams());
6857
+ } else if (mountInfo.mountType === "fuse" && mountInfo.credentialProxy && !mountInfo.mounted) {
6674
6858
  try {
6675
- const cleanup = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} || rmdir ${shellEscape(mountPath)}`);
6676
- if (cleanup.exitCode !== 0) this.logger.warn("mount directory removal failed", {
6859
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams({ excludeMountId: mountInfo.mountId }));
6860
+ } catch (cleanupError) {
6861
+ this.logger.warn("credential proxy outbound reconfiguration failed on unmount", {
6677
6862
  mountPath,
6678
- exitCode: cleanup.exitCode,
6679
- stderr: cleanup.stderr
6680
- });
6681
- } catch (err) {
6682
- this.logger.warn("mount directory removal failed", {
6683
- mountPath,
6684
- error: err instanceof Error ? err.message : String(err)
6863
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
6685
6864
  });
6686
6865
  }
6687
- } finally {
6688
- await this.deletePasswordFile(mountInfo.passwordFilePath);
6866
+ this.activeMounts.delete(mountPath);
6867
+ evictSigV4ClientCacheEntry(mountInfo.mountId);
6868
+ evictDirectoryMarkerCacheForMount(mountInfo.mountId);
6869
+ } else {
6870
+ let unmounted = false;
6871
+ try {
6872
+ const result = await this.execInternal(`fusermount -u ${shellEscape(mountPath)}`);
6873
+ if (result.exitCode !== 0) {
6874
+ const stderr = result.stderr || "unknown error";
6875
+ throw new BucketUnmountError(`fusermount -u failed (exit ${result.exitCode}): ${stderr}`);
6876
+ }
6877
+ unmounted = true;
6878
+ mountInfo.mounted = false;
6879
+ if (mountInfo.mountType === "r2-egress") {
6880
+ const remainingBuckets = {};
6881
+ for (const [, activeMount] of this.activeMounts) if (activeMount.mountType === "r2-egress" && activeMount.mountId !== mountInfo.mountId) remainingBuckets[activeMount.bucket] = {
6882
+ prefix: activeMount.prefix,
6883
+ readOnly: activeMount.readOnly
6884
+ };
6885
+ try {
6886
+ await this.configureR2EgressOutbound({ buckets: remainingBuckets });
6887
+ } catch (cleanupError) {
6888
+ this.logger.warn("r2 egress outbound reconfiguration failed on unmount", {
6889
+ mountPath,
6890
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
6891
+ });
6892
+ }
6893
+ this.activeMounts.delete(mountPath);
6894
+ } else if (mountInfo.mountType === "fuse" && mountInfo.credentialProxy) {
6895
+ try {
6896
+ await this.configureS3CredentialProxyOutbound(this.getS3CredentialProxyParams({ excludeMountId: mountInfo.mountId }));
6897
+ } catch (cleanupError) {
6898
+ this.logger.warn("credential proxy outbound reconfiguration failed on unmount", {
6899
+ mountPath,
6900
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
6901
+ });
6902
+ }
6903
+ this.activeMounts.delete(mountPath);
6904
+ evictSigV4ClientCacheEntry(mountInfo.mountId);
6905
+ evictDirectoryMarkerCacheForMount(mountInfo.mountId);
6906
+ } else this.activeMounts.delete(mountPath);
6907
+ try {
6908
+ const cleanup = await this.execInternal(`mountpoint -q ${shellEscape(mountPath)} || rmdir ${shellEscape(mountPath)}`);
6909
+ if (cleanup.exitCode !== 0) this.logger.warn("mount directory removal failed", {
6910
+ mountPath,
6911
+ exitCode: cleanup.exitCode,
6912
+ stderr: cleanup.stderr
6913
+ });
6914
+ } catch (err) {
6915
+ this.logger.warn("mount directory removal failed", {
6916
+ mountPath,
6917
+ error: err instanceof Error ? err.message : String(err)
6918
+ });
6919
+ }
6920
+ } finally {
6921
+ if (unmounted) {
6922
+ await this.deletePasswordFile(mountInfo.passwordFilePath);
6923
+ if (mountInfo.additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(mountInfo.additionalHeaderFilePath);
6924
+ }
6925
+ }
6689
6926
  }
6690
6927
  unmountOutcome = "success";
6691
6928
  } catch (error) {
@@ -6728,6 +6965,20 @@ var Sandbox = class Sandbox extends Container {
6728
6965
  return `/tmp/.passwd-s3fs-${crypto.randomUUID()}`;
6729
6966
  }
6730
6967
  /**
6968
+ * Generate unique ahbe_conf file path for s3fs additional header config
6969
+ */
6970
+ generateS3FSAdditionalHeaderFilePath() {
6971
+ return `/tmp/.s3fs-ahbe-${crypto.randomUUID()}.conf`;
6972
+ }
6973
+ /**
6974
+ * Create s3fs ahbe_conf file that suppresses the Expect: 100-continue header.
6975
+ * Restricted to 0600 so s3fs will accept it (same requirement as passwd files).
6976
+ */
6977
+ async createDisableExpectHeaderFile(headerFilePath) {
6978
+ await this.client.files.writeFile(headerFilePath, S3FS_DISABLE_EXPECT_HEADER_CONFIG, DISABLE_SESSION_TOKEN);
6979
+ await this.execInternal(`chmod 0600 ${shellEscape(headerFilePath)}`);
6980
+ }
6981
+ /**
6731
6982
  * Create password file with s3fs credentials
6732
6983
  * Format: bucket:accessKeyId:secretAccessKey
6733
6984
  */
@@ -6749,6 +7000,16 @@ var Sandbox = class Sandbox extends Container {
6749
7000
  });
6750
7001
  }
6751
7002
  }
7003
+ async deleteAdditionalHeaderFile(headerFilePath) {
7004
+ try {
7005
+ await this.execInternal(`rm -f ${shellEscape(headerFilePath)}`);
7006
+ } catch (error) {
7007
+ this.logger.warn("s3fs additional header file cleanup failed", {
7008
+ headerFilePath,
7009
+ error: error instanceof Error ? error.message : String(error)
7010
+ });
7011
+ }
7012
+ }
6752
7013
  /**
6753
7014
  * Execute S3FS mount command
6754
7015
  */
@@ -6836,9 +7097,6 @@ var Sandbox = class Sandbox extends Container {
6836
7097
  await this.ctx.storage.delete(PORT_TOKENS_STORAGE_KEY);
6837
7098
  await this.clearActivePreviewPorts();
6838
7099
  await this.currentRuntime.clear();
6839
- if (this.ctx.container?.running) try {
6840
- await this.client.desktop.stop();
6841
- } catch {}
6842
7100
  for (const [mountPath, mountInfo] of this.activeMounts.entries()) {
6843
7101
  mountsProcessed++;
6844
7102
  if (mountInfo.mountType === "local-sync") try {
@@ -6858,6 +7116,7 @@ var Sandbox = class Sandbox extends Container {
6858
7116
  this.logger.warn(`Failed to unmount bucket ${mountInfo.bucket} from ${mountPath}: ${errorMsg}`);
6859
7117
  }
6860
7118
  await this.deletePasswordFile(mountInfo.passwordFilePath);
7119
+ if (mountInfo.additionalHeaderFilePath) await this.deleteAdditionalHeaderFile(mountInfo.additionalHeaderFilePath);
6861
7120
  }
6862
7121
  }
6863
7122
  try {
@@ -6975,9 +7234,16 @@ var Sandbox = class Sandbox extends Container {
6975
7234
  }
6976
7235
  this.client.disconnect();
6977
7236
  let hadR2EgressMount = false;
7237
+ let hadCredentialProxyMount = false;
6978
7238
  for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
6979
7239
  else if (m.mountType === "r2-egress") hadR2EgressMount = true;
7240
+ else if (m.mountType === "fuse" && m.credentialProxy) {
7241
+ hadCredentialProxyMount = true;
7242
+ evictSigV4ClientCacheEntry(m.mountId);
7243
+ evictDirectoryMarkerCacheForMount(m.mountId);
7244
+ }
6980
7245
  if (hadR2EgressMount) await this.configureR2EgressOutbound({ buckets: {} }).catch(() => {});
7246
+ if (hadCredentialProxyMount) await this.configureS3CredentialProxyOutbound({ mounts: {} }).catch(() => {});
6981
7247
  this.activeMounts.clear();
6982
7248
  await this.ctx.storage.delete("defaultSession");
6983
7249
  }
@@ -8015,33 +8281,6 @@ var Sandbox = class Sandbox extends Container {
8015
8281
  return this.client.files.exists(path$1, session);
8016
8282
  }
8017
8283
  /**
8018
- * Get the noVNC preview URL for browser-based desktop viewing.
8019
- * Confirms desktop is active, then uses exposePort() to generate
8020
- * a token-authenticated preview URL for the noVNC port (6080).
8021
- *
8022
- * @param hostname - The custom domain hostname for preview URLs
8023
- * (e.g., 'preview.example.com'). Required because preview URLs
8024
- * use subdomain patterns that .workers.dev doesn't support.
8025
- * @param options - Optional settings
8026
- * @param options.token - Reuse an existing token instead of generating a new one
8027
- * @returns The authenticated noVNC preview URL
8028
- */
8029
- async getDesktopStreamUrl(hostname, options) {
8030
- if ((await this.client.desktop.status()).status === "inactive") throw new Error("Desktop is not running. Call sandbox.desktop.start() first.");
8031
- const url = (await this.exposePort(6080, {
8032
- hostname,
8033
- token: options?.token
8034
- })).url;
8035
- try {
8036
- await this.waitForPort({
8037
- portToCheck: 6080,
8038
- retries: 30,
8039
- waitInterval: 500
8040
- });
8041
- } catch {}
8042
- return { url };
8043
- }
8044
- /**
8045
8284
  * Watch a directory for file system changes using native inotify.
8046
8285
  *
8047
8286
  * The returned promise resolves only after the watcher is established on the
@@ -9097,10 +9336,13 @@ var Sandbox = class Sandbox extends Container {
9097
9336
  * create-archive → read → upload (or mount → extract) flow
9098
9337
  * is not interleaved with another backup operation on the same directory.
9099
9338
  */
9100
- enqueueBackupOp(fn) {
9101
- const next = this.backupInProgress.then(fn, () => fn());
9339
+ async enqueueBackupOp(fn) {
9340
+ try {
9341
+ await this.backupInProgress;
9342
+ } catch {}
9343
+ const next = fn();
9102
9344
  this.backupInProgress = next.catch(() => {});
9103
- return next;
9345
+ return await next;
9104
9346
  }
9105
9347
  /**
9106
9348
  * Create a backup of a directory and upload it to R2.
@@ -9124,9 +9366,9 @@ var Sandbox = class Sandbox extends Container {
9124
9366
  * under the `backups/` prefix after the desired retention period.
9125
9367
  */
9126
9368
  async createBackup(options) {
9127
- if (options.localBucket) return this.enqueueBackupOp(() => this.doCreateBackupLocal(options));
9369
+ if (options.localBucket) return await this.enqueueBackupOp(() => this.doCreateBackupLocal(options));
9128
9370
  this.requireBackupBucket();
9129
- return this.enqueueBackupOp(() => this.doCreateBackup(options));
9371
+ return await this.enqueueBackupOp(() => this.doCreateBackup(options));
9130
9372
  }
9131
9373
  async doCreateBackup(options) {
9132
9374
  const bucket = this.requireBackupBucket();
@@ -9423,9 +9665,9 @@ var Sandbox = class Sandbox extends Container {
9423
9665
  * Concurrent backup/restore calls on the same sandbox are serialized.
9424
9666
  */
9425
9667
  async restoreBackup(backup) {
9426
- if (backup.localBucket) return this.enqueueBackupOp(() => this.doRestoreBackupLocal(backup));
9668
+ if (backup.localBucket) return await this.enqueueBackupOp(() => this.doRestoreBackupLocal(backup));
9427
9669
  this.requireBackupBucket();
9428
- return this.enqueueBackupOp(() => this.doRestoreBackup(backup));
9670
+ return await this.enqueueBackupOp(() => this.doRestoreBackup(backup));
9429
9671
  }
9430
9672
  async doRestoreBackup(backup) {
9431
9673
  const restoreStartTime = Date.now();
@@ -9685,10 +9927,18 @@ var Sandbox = class Sandbox extends Container {
9685
9927
  const ctx = this.ctx;
9686
9928
  if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("R2 binding mounts require container outbound interception support");
9687
9929
  if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("R2 binding mounts require exporting ContainerProxy from the Worker entrypoint");
9930
+ this.constructor.outboundHandlers = { r2EgressMount: r2EgressHandler };
9931
+ if (Object.keys(params.buckets).length > 0) await this.setOutboundByHost("r2.internal", "r2EgressMount", params);
9932
+ else await this.removeOutboundByHost("r2.internal");
9933
+ this.logger.debug("r2 egress: registering host interception", {
9934
+ host: "r2.internal",
9935
+ method: "r2EgressMount",
9936
+ targetClassName: CONTAINER_PROXY_CLASS_NAME
9937
+ });
9688
9938
  const fetcher = ctx.exports.ContainerProxy({ props: {
9689
9939
  enableInternet: this.enableInternet,
9690
9940
  containerId: this.ctx.id.toString(),
9691
- className: R2_EGRESS_PROXY_TARGET_CLASS_NAME,
9941
+ className: CONTAINER_PROXY_CLASS_NAME,
9692
9942
  outboundByHostOverrides: { "r2.internal": {
9693
9943
  method: "r2EgressMount",
9694
9944
  params
@@ -9697,8 +9947,42 @@ var Sandbox = class Sandbox extends Container {
9697
9947
  if (!isFetcher(fetcher)) throw new InvalidMountConfigError("R2 binding mounts require ContainerProxy to return a valid Fetcher");
9698
9948
  await ctx.container.interceptOutboundHttp("r2.internal", fetcher);
9699
9949
  }
9950
+ async configureS3CredentialProxyOutbound(params) {
9951
+ const ctx = this.ctx;
9952
+ if (!ctx.container?.interceptOutboundHttp) throw new InvalidMountConfigError("Credential proxy bucket mounts require container outbound interception support");
9953
+ if (!ctx.exports?.ContainerProxy) throw new InvalidMountConfigError("Credential proxy bucket mounts require exporting ContainerProxy from the Worker entrypoint");
9954
+ const hosts = [S3_CREDENTIAL_PROXY_HOST, S3_CREDENTIAL_PROXY_DIAGNOSTIC_HOST];
9955
+ this.constructor.outboundHandlers = { s3CredentialProxyMount: s3CredentialProxyHandler };
9956
+ if (Object.keys(params.mounts).length > 0) for (const host of hosts) await this.setOutboundByHost(host, "s3CredentialProxyMount", params);
9957
+ else for (const host of hosts) await this.removeOutboundByHost(host);
9958
+ const hostOverrides = {};
9959
+ for (const host of hosts) hostOverrides[host] = {
9960
+ method: "s3CredentialProxyMount",
9961
+ params
9962
+ };
9963
+ this.logger.debug("s3 credential proxy: registering host interception", {
9964
+ hosts,
9965
+ method: "s3CredentialProxyMount",
9966
+ targetClassName: CONTAINER_PROXY_CLASS_NAME
9967
+ });
9968
+ const fetcher = ctx.exports.ContainerProxy({ props: {
9969
+ enableInternet: this.enableInternet,
9970
+ containerId: this.ctx.id.toString(),
9971
+ className: CONTAINER_PROXY_CLASS_NAME,
9972
+ outboundByHostOverrides: hostOverrides
9973
+ } });
9974
+ if (!isFetcher(fetcher)) throw new InvalidMountConfigError("Credential proxy bucket mounts require ContainerProxy to return a valid Fetcher");
9975
+ try {
9976
+ const selfTest = await fetcher.fetch(new Request(`http://${S3_CREDENTIAL_PROXY_HOST}${SELF_TEST_PATH}`));
9977
+ await selfTest.text();
9978
+ this.logger.debug("s3 credential proxy: fetcher self-test complete", { status: selfTest.status });
9979
+ } catch (error) {
9980
+ this.logger.warn("s3 credential proxy: fetcher self-test failed", { error: error instanceof Error ? error.message : String(error) });
9981
+ }
9982
+ for (const host of hosts) await ctx.container.interceptOutboundHttp(host, fetcher);
9983
+ }
9700
9984
  };
9701
9985
 
9702
9986
  //#endregion
9703
- export { DesktopClient as A, DesktopProcessCrashedError as B, streamFile as C, PortClient as D, ProcessClient as E, BackupNotFoundError as F, ProcessReadyTimeoutError as G, DesktopUnavailableError as H, BackupRestoreError as I, RPCTransportError as K, DesktopInvalidCoordinatesError as L, BackupClient as M, BackupCreateError as N, GitClient as O, BackupExpiredError as P, DesktopInvalidOptionsError as R, collectFile as S, UtilityClient as T, InvalidBackupConfigError as U, DesktopStartFailedError as V, ProcessExitedBeforeReadyError as W, CodeInterpreter as _, PREVIEW_PROXY_HEADERS as a, validatePort as b, PREVIEW_PROXY_TOKEN_HEADER as c, InvalidMountConfigError as d, MissingCredentialsError as f, responseToAsyncIterable as g, parseSSEStream as h, PREVIEW_PROXY_HEADER as i, CommandClient as j, FileClient as k, BucketMountError as l, asyncIterableToSSEStream as m, getSandbox as n, PREVIEW_PROXY_PORT_HEADER as o, S3FSMountError as p, SessionTerminatedError as q, proxyTerminal as r, PREVIEW_PROXY_SANDBOX_ID_HEADER as s, Sandbox as t, BucketUnmountError as u, SandboxSecurityError as v, SandboxClient as w, validateTunnelName as x, sanitizeSandboxId as y, DesktopNotStartedError as z };
9704
- //# sourceMappingURL=sandbox-DQxTkLyY.js.map
9987
+ export { FileClient as A, RPCTransportError as B, collectFile as C, ProcessClient as D, UtilityClient as E, BackupNotFoundError as F, BackupRestoreError as I, InvalidBackupConfigError as L, BackupClient as M, BackupCreateError as N, PortClient as O, BackupExpiredError as P, ProcessExitedBeforeReadyError as R, validateTunnelName as S, SandboxClient as T, SessionTerminatedError as V, responseToAsyncIterable as _, PREVIEW_PROXY_HEADER as a, sanitizeSandboxId as b, PREVIEW_PROXY_SANDBOX_ID_HEADER as c, BucketUnmountError as d, InvalidMountConfigError as f, parseSSEStream as g, asyncIterableToSSEStream as h, proxyTerminal as i, CommandClient as j, GitClient as k, PREVIEW_PROXY_TOKEN_HEADER as l, S3FSMountError as m, Sandbox as n, PREVIEW_PROXY_HEADERS as o, MissingCredentialsError as p, getSandbox as r, PREVIEW_PROXY_PORT_HEADER as s, ContainerProxy$1 as t, BucketMountError as u, CodeInterpreter as v, streamFile as w, validatePort as x, SandboxSecurityError as y, ProcessReadyTimeoutError as z };
9988
+ //# sourceMappingURL=sandbox-DKG3H156.js.map