@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.
- package/Dockerfile +0 -55
- package/dist/bridge/index.js +194 -3
- package/dist/bridge/index.js.map +1 -1
- package/dist/{contexts-D_shbnJs.d.ts → contexts-B0_bcx9f.d.ts} +2 -33
- package/dist/contexts-B0_bcx9f.d.ts.map +1 -0
- package/dist/{errors-8Hvune8K.js → errors-aRUdk9K8.js} +3 -21
- package/dist/errors-aRUdk9K8.js.map +1 -0
- package/dist/index.d.ts +3 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +63 -5
- package/dist/index.js.map +1 -0
- package/dist/openai/index.d.ts +1 -1
- package/dist/opencode/index.d.ts +2 -2
- package/dist/opencode/index.d.ts.map +1 -1
- package/dist/opencode/index.js +1 -1
- package/dist/{sandbox-CwcSm_60.d.ts → sandbox-D3N9M5EI.d.ts} +179 -465
- package/dist/sandbox-D3N9M5EI.d.ts.map +1 -0
- package/dist/{sandbox-B-MUmsli.js → sandbox-Duj2gvUC.js} +2207 -763
- package/dist/sandbox-Duj2gvUC.js.map +1 -0
- package/package.json +2 -2
- package/dist/contexts-D_shbnJs.d.ts.map +0 -1
- package/dist/errors-8Hvune8K.js.map +0 -1
- package/dist/sandbox-B-MUmsli.js.map +0 -1
- package/dist/sandbox-CwcSm_60.d.ts.map +0 -1
|
@@ -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-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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/
|
|
4241
|
-
async function
|
|
4242
|
-
|
|
4243
|
-
|
|
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
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
if (
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
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
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
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
|
-
|
|
4310
|
-
|
|
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
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
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("["))
|
|
4346
|
-
|
|
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/
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
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
|
|
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
|
-
*
|
|
4733
|
-
*
|
|
4734
|
-
*
|
|
4735
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
4759
|
-
|
|
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 (
|
|
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
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
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
|
|
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.
|
|
5836
|
+
const SDK_VERSION = "0.12.0";
|
|
4867
5837
|
|
|
4868
5838
|
//#endregion
|
|
4869
5839
|
//#region src/sandbox.ts
|
|
4870
|
-
const
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
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
|
-
|
|
6589
|
+
validateProtectedS3fsOptions(options, mountLabel, extraProtected = []) {
|
|
5552
6590
|
if (!options) return;
|
|
5553
|
-
const protectedOptions = new Set([
|
|
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
|
|
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.
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
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:
|
|
6862
|
+
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
|
5748
6863
|
});
|
|
5749
6864
|
}
|
|
5750
|
-
|
|
5751
|
-
|
|
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
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
5960
|
-
|
|
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
|
|
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
|
|
7088
|
-
*
|
|
7089
|
-
*
|
|
7090
|
-
*
|
|
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
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
const
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
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
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
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
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
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(
|
|
7267
|
-
const b = encoder.encode(
|
|
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
|
-
|
|
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
|
|
7625
|
-
const { client, accountId, bucketName } = this.
|
|
7626
|
-
const
|
|
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
|
|
7637
|
-
const { client, accountId, bucketName } = this.
|
|
7638
|
-
const
|
|
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
|
|
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(
|
|
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
|
|
7695
|
-
const { client, accountId, bucketName } = this.
|
|
7696
|
-
const
|
|
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.
|
|
7714
|
-
const
|
|
7715
|
-
const createResp = await client.fetch(`${
|
|
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(`${
|
|
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.
|
|
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(`${
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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 {
|
|
8543
|
-
//# sourceMappingURL=sandbox-
|
|
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
|