@cloudflare/sandbox 0.8.11 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
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-Ilf8VjmX.js";
2
- import { t as ErrorCode } from "./errors-Bz21XTBJ.js";
2
+ import { t as ErrorCode } from "./errors-Dk2rApYI.js";
3
3
  import { Container, getContainer, switchPort } from "@cloudflare/containers";
4
4
  import { AwsClient } from "aws4fetch";
5
5
  import path from "node:path/posix";
@@ -208,6 +208,24 @@ var SessionDestroyedError = class extends SandboxError {
208
208
  }
209
209
  };
210
210
  /**
211
+ * Error thrown when a session's underlying shell exited without an explicit
212
+ * `destroy()` call (user ran `exit`, the shell crashed, or a child process
213
+ * took the shell down). The session-local state is gone, but the next call
214
+ * with the same sessionId will transparently start a fresh session.
215
+ */
216
+ var SessionTerminatedError = class extends SandboxError {
217
+ constructor(errorResponse) {
218
+ super(errorResponse);
219
+ this.name = "SessionTerminatedError";
220
+ }
221
+ get sessionId() {
222
+ return this.context.sessionId;
223
+ }
224
+ get exitCode() {
225
+ return this.context.exitCode;
226
+ }
227
+ };
228
+ /**
211
229
  * Error thrown when a port is already exposed
212
230
  */
213
231
  var PortAlreadyExposedError = class extends SandboxError {
@@ -674,6 +692,7 @@ function createErrorFromResponse(errorResponse) {
674
692
  case ErrorCode.PROCESS_ERROR: return new ProcessError(errorResponse);
675
693
  case ErrorCode.SESSION_ALREADY_EXISTS: return new SessionAlreadyExistsError(errorResponse);
676
694
  case ErrorCode.SESSION_DESTROYED: return new SessionDestroyedError(errorResponse);
695
+ case ErrorCode.SESSION_TERMINATED: return new SessionTerminatedError(errorResponse);
677
696
  case ErrorCode.PORT_ALREADY_EXPOSED: return new PortAlreadyExposedError(errorResponse);
678
697
  case ErrorCode.PORT_NOT_EXPOSED: return new PortNotExposedError(errorResponse);
679
698
  case ErrorCode.INVALID_PORT_NUMBER:
@@ -1645,18 +1664,14 @@ var BackupClient = class extends BaseHttpClient {
1645
1664
  * @param sessionId - Session context
1646
1665
  */
1647
1666
  async createArchive(dir, archivePath, sessionId, gitignore = false, excludes = []) {
1648
- try {
1649
- const data = {
1650
- dir,
1651
- archivePath,
1652
- gitignore,
1653
- excludes,
1654
- sessionId
1655
- };
1656
- return await this.post("/api/backup/create", data);
1657
- } catch (error) {
1658
- throw error;
1659
- }
1667
+ const data = {
1668
+ dir,
1669
+ archivePath,
1670
+ gitignore,
1671
+ excludes,
1672
+ sessionId
1673
+ };
1674
+ return await this.post("/api/backup/create", data);
1660
1675
  }
1661
1676
  /**
1662
1677
  * Tell the container to restore a squashfs archive into a directory.
@@ -1665,16 +1680,12 @@ var BackupClient = class extends BaseHttpClient {
1665
1680
  * @param sessionId - Session context
1666
1681
  */
1667
1682
  async restoreArchive(dir, archivePath, sessionId) {
1668
- try {
1669
- const data = {
1670
- dir,
1671
- archivePath,
1672
- sessionId
1673
- };
1674
- return await this.post("/api/backup/restore", data);
1675
- } catch (error) {
1676
- throw error;
1677
- }
1683
+ const data = {
1684
+ dir,
1685
+ archivePath,
1686
+ sessionId
1687
+ };
1688
+ return await this.post("/api/backup/restore", data);
1678
1689
  }
1679
1690
  };
1680
1691
 
@@ -1770,280 +1781,200 @@ var DesktopClient = class extends BaseHttpClient {
1770
1781
  * Get desktop lifecycle and process health status.
1771
1782
  */
1772
1783
  async status() {
1773
- try {
1774
- return await this.get("/api/desktop/status");
1775
- } catch (error) {
1776
- throw error;
1777
- }
1784
+ return await this.get("/api/desktop/status");
1778
1785
  }
1779
1786
  async screenshot(options) {
1780
- try {
1781
- const wantsBytes = options?.format === "bytes";
1782
- const data = {
1783
- format: "base64",
1784
- ...options?.imageFormat !== void 0 && { imageFormat: options.imageFormat },
1785
- ...options?.quality !== void 0 && { quality: options.quality },
1786
- ...options?.showCursor !== void 0 && { showCursor: options.showCursor }
1787
+ const wantsBytes = options?.format === "bytes";
1788
+ const data = {
1789
+ format: "base64",
1790
+ ...options?.imageFormat !== void 0 && { imageFormat: options.imageFormat },
1791
+ ...options?.quality !== void 0 && { quality: options.quality },
1792
+ ...options?.showCursor !== void 0 && { showCursor: options.showCursor }
1793
+ };
1794
+ const response = await this.post("/api/desktop/screenshot", data);
1795
+ if (wantsBytes) {
1796
+ const binaryString = atob(response.data);
1797
+ const bytes = new Uint8Array(binaryString.length);
1798
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1799
+ return {
1800
+ ...response,
1801
+ data: bytes
1787
1802
  };
1788
- const response = await this.post("/api/desktop/screenshot", data);
1789
- if (wantsBytes) {
1790
- const binaryString = atob(response.data);
1791
- const bytes = new Uint8Array(binaryString.length);
1792
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1793
- return {
1794
- ...response,
1795
- data: bytes
1796
- };
1797
- }
1798
- return response;
1799
- } catch (error) {
1800
- throw error;
1801
1803
  }
1804
+ return response;
1802
1805
  }
1803
1806
  async screenshotRegion(region, options) {
1804
- try {
1805
- const wantsBytes = options?.format === "bytes";
1806
- const data = {
1807
- region,
1808
- format: "base64",
1809
- ...options?.imageFormat !== void 0 && { imageFormat: options.imageFormat },
1810
- ...options?.quality !== void 0 && { quality: options.quality },
1811
- ...options?.showCursor !== void 0 && { showCursor: options.showCursor }
1807
+ const wantsBytes = options?.format === "bytes";
1808
+ const data = {
1809
+ region,
1810
+ format: "base64",
1811
+ ...options?.imageFormat !== void 0 && { imageFormat: options.imageFormat },
1812
+ ...options?.quality !== void 0 && { quality: options.quality },
1813
+ ...options?.showCursor !== void 0 && { showCursor: options.showCursor }
1814
+ };
1815
+ const response = await this.post("/api/desktop/screenshot/region", data);
1816
+ if (wantsBytes) {
1817
+ const binaryString = atob(response.data);
1818
+ const bytes = new Uint8Array(binaryString.length);
1819
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1820
+ return {
1821
+ ...response,
1822
+ data: bytes
1812
1823
  };
1813
- const response = await this.post("/api/desktop/screenshot/region", data);
1814
- if (wantsBytes) {
1815
- const binaryString = atob(response.data);
1816
- const bytes = new Uint8Array(binaryString.length);
1817
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1818
- return {
1819
- ...response,
1820
- data: bytes
1821
- };
1822
- }
1823
- return response;
1824
- } catch (error) {
1825
- throw error;
1826
1824
  }
1825
+ return response;
1827
1826
  }
1828
1827
  /**
1829
1828
  * Single-click at the given coordinates.
1830
1829
  */
1831
1830
  async click(x, y, options) {
1832
- try {
1833
- await this.post("/api/desktop/mouse/click", {
1834
- x,
1835
- y,
1836
- button: options?.button ?? "left",
1837
- clickCount: 1
1838
- });
1839
- } catch (error) {
1840
- throw error;
1841
- }
1831
+ await this.post("/api/desktop/mouse/click", {
1832
+ x,
1833
+ y,
1834
+ button: options?.button ?? "left",
1835
+ clickCount: 1
1836
+ });
1842
1837
  }
1843
1838
  /**
1844
1839
  * Double-click at the given coordinates.
1845
1840
  */
1846
1841
  async doubleClick(x, y, options) {
1847
- try {
1848
- await this.post("/api/desktop/mouse/click", {
1849
- x,
1850
- y,
1851
- button: options?.button ?? "left",
1852
- clickCount: 2
1853
- });
1854
- } catch (error) {
1855
- throw error;
1856
- }
1842
+ await this.post("/api/desktop/mouse/click", {
1843
+ x,
1844
+ y,
1845
+ button: options?.button ?? "left",
1846
+ clickCount: 2
1847
+ });
1857
1848
  }
1858
1849
  /**
1859
1850
  * Triple-click at the given coordinates.
1860
1851
  */
1861
1852
  async tripleClick(x, y, options) {
1862
- try {
1863
- await this.post("/api/desktop/mouse/click", {
1864
- x,
1865
- y,
1866
- button: options?.button ?? "left",
1867
- clickCount: 3
1868
- });
1869
- } catch (error) {
1870
- throw error;
1871
- }
1853
+ await this.post("/api/desktop/mouse/click", {
1854
+ x,
1855
+ y,
1856
+ button: options?.button ?? "left",
1857
+ clickCount: 3
1858
+ });
1872
1859
  }
1873
1860
  /**
1874
1861
  * Right-click at the given coordinates.
1875
1862
  */
1876
1863
  async rightClick(x, y) {
1877
- try {
1878
- await this.post("/api/desktop/mouse/click", {
1879
- x,
1880
- y,
1881
- button: "right",
1882
- clickCount: 1
1883
- });
1884
- } catch (error) {
1885
- throw error;
1886
- }
1864
+ await this.post("/api/desktop/mouse/click", {
1865
+ x,
1866
+ y,
1867
+ button: "right",
1868
+ clickCount: 1
1869
+ });
1887
1870
  }
1888
1871
  /**
1889
1872
  * Middle-click at the given coordinates.
1890
1873
  */
1891
1874
  async middleClick(x, y) {
1892
- try {
1893
- await this.post("/api/desktop/mouse/click", {
1894
- x,
1895
- y,
1896
- button: "middle",
1897
- clickCount: 1
1898
- });
1899
- } catch (error) {
1900
- throw error;
1901
- }
1875
+ await this.post("/api/desktop/mouse/click", {
1876
+ x,
1877
+ y,
1878
+ button: "middle",
1879
+ clickCount: 1
1880
+ });
1902
1881
  }
1903
1882
  /**
1904
1883
  * Press and hold a mouse button.
1905
1884
  */
1906
1885
  async mouseDown(x, y, options) {
1907
- try {
1908
- await this.post("/api/desktop/mouse/down", {
1909
- ...x !== void 0 && { x },
1910
- ...y !== void 0 && { y },
1911
- button: options?.button ?? "left"
1912
- });
1913
- } catch (error) {
1914
- throw error;
1915
- }
1886
+ await this.post("/api/desktop/mouse/down", {
1887
+ ...x !== void 0 && { x },
1888
+ ...y !== void 0 && { y },
1889
+ button: options?.button ?? "left"
1890
+ });
1916
1891
  }
1917
1892
  /**
1918
1893
  * Release a held mouse button.
1919
1894
  */
1920
1895
  async mouseUp(x, y, options) {
1921
- try {
1922
- await this.post("/api/desktop/mouse/up", {
1923
- ...x !== void 0 && { x },
1924
- ...y !== void 0 && { y },
1925
- button: options?.button ?? "left"
1926
- });
1927
- } catch (error) {
1928
- throw error;
1929
- }
1896
+ await this.post("/api/desktop/mouse/up", {
1897
+ ...x !== void 0 && { x },
1898
+ ...y !== void 0 && { y },
1899
+ button: options?.button ?? "left"
1900
+ });
1930
1901
  }
1931
1902
  /**
1932
1903
  * Move the mouse cursor to coordinates.
1933
1904
  */
1934
1905
  async moveMouse(x, y) {
1935
- try {
1936
- await this.post("/api/desktop/mouse/move", {
1937
- x,
1938
- y
1939
- });
1940
- } catch (error) {
1941
- throw error;
1942
- }
1906
+ await this.post("/api/desktop/mouse/move", {
1907
+ x,
1908
+ y
1909
+ });
1943
1910
  }
1944
1911
  /**
1945
1912
  * Drag from start coordinates to end coordinates.
1946
1913
  */
1947
1914
  async drag(startX, startY, endX, endY, options) {
1948
- try {
1949
- await this.post("/api/desktop/mouse/drag", {
1950
- startX,
1951
- startY,
1952
- endX,
1953
- endY,
1954
- button: options?.button ?? "left"
1955
- });
1956
- } catch (error) {
1957
- throw error;
1958
- }
1915
+ await this.post("/api/desktop/mouse/drag", {
1916
+ startX,
1917
+ startY,
1918
+ endX,
1919
+ endY,
1920
+ button: options?.button ?? "left"
1921
+ });
1959
1922
  }
1960
1923
  /**
1961
1924
  * Scroll at coordinates in the specified direction.
1962
1925
  */
1963
1926
  async scroll(x, y, direction, amount = 3) {
1964
- try {
1965
- await this.post("/api/desktop/mouse/scroll", {
1966
- x,
1967
- y,
1968
- direction,
1969
- amount
1970
- });
1971
- } catch (error) {
1972
- throw error;
1973
- }
1927
+ await this.post("/api/desktop/mouse/scroll", {
1928
+ x,
1929
+ y,
1930
+ direction,
1931
+ amount
1932
+ });
1974
1933
  }
1975
1934
  /**
1976
1935
  * Get the current cursor coordinates.
1977
1936
  */
1978
1937
  async getCursorPosition() {
1979
- try {
1980
- return await this.get("/api/desktop/mouse/position");
1981
- } catch (error) {
1982
- throw error;
1983
- }
1938
+ return await this.get("/api/desktop/mouse/position");
1984
1939
  }
1985
1940
  /**
1986
1941
  * Type text into the focused element.
1987
1942
  */
1988
1943
  async type(text, options) {
1989
- try {
1990
- await this.post("/api/desktop/keyboard/type", {
1991
- text,
1992
- ...options?.delayMs !== void 0 && { delayMs: options.delayMs }
1993
- });
1994
- } catch (error) {
1995
- throw error;
1996
- }
1944
+ await this.post("/api/desktop/keyboard/type", {
1945
+ text,
1946
+ ...options?.delayMs !== void 0 && { delayMs: options.delayMs }
1947
+ });
1997
1948
  }
1998
1949
  /**
1999
1950
  * Press and release a key or key combination.
2000
1951
  */
2001
1952
  async press(key) {
2002
- try {
2003
- await this.post("/api/desktop/keyboard/press", { key });
2004
- } catch (error) {
2005
- throw error;
2006
- }
1953
+ await this.post("/api/desktop/keyboard/press", { key });
2007
1954
  }
2008
1955
  /**
2009
1956
  * Press and hold a key.
2010
1957
  */
2011
1958
  async keyDown(key) {
2012
- try {
2013
- await this.post("/api/desktop/keyboard/down", { key });
2014
- } catch (error) {
2015
- throw error;
2016
- }
1959
+ await this.post("/api/desktop/keyboard/down", { key });
2017
1960
  }
2018
1961
  /**
2019
1962
  * Release a held key.
2020
1963
  */
2021
1964
  async keyUp(key) {
2022
- try {
2023
- await this.post("/api/desktop/keyboard/up", { key });
2024
- } catch (error) {
2025
- throw error;
2026
- }
1965
+ await this.post("/api/desktop/keyboard/up", { key });
2027
1966
  }
2028
1967
  /**
2029
1968
  * Get the active desktop screen size.
2030
1969
  */
2031
1970
  async getScreenSize() {
2032
- try {
2033
- return await this.get("/api/desktop/screen/size");
2034
- } catch (error) {
2035
- throw error;
2036
- }
1971
+ return await this.get("/api/desktop/screen/size");
2037
1972
  }
2038
1973
  /**
2039
1974
  * Get health status for a specific desktop process.
2040
1975
  */
2041
1976
  async getProcessStatus(name) {
2042
- try {
2043
- return await this.get(`/api/desktop/process/${encodeURIComponent(name)}/status`);
2044
- } catch (error) {
2045
- throw error;
2046
- }
1977
+ return await this.get(`/api/desktop/process/${encodeURIComponent(name)}/status`);
2047
1978
  }
2048
1979
  };
2049
1980
 
@@ -2060,16 +1991,12 @@ var FileClient = class extends BaseHttpClient {
2060
1991
  * @param options - Optional settings (recursive)
2061
1992
  */
2062
1993
  async mkdir(path$1, sessionId, options) {
2063
- try {
2064
- const data = {
2065
- path: path$1,
2066
- sessionId,
2067
- recursive: options?.recursive ?? false
2068
- };
2069
- return await this.post("/api/mkdir", data);
2070
- } catch (error) {
2071
- throw error;
2072
- }
1994
+ const data = {
1995
+ path: path$1,
1996
+ sessionId,
1997
+ recursive: options?.recursive ?? false
1998
+ };
1999
+ return await this.post("/api/mkdir", data);
2073
2000
  }
2074
2001
  /**
2075
2002
  * Write content to a file
@@ -2079,17 +2006,13 @@ var FileClient = class extends BaseHttpClient {
2079
2006
  * @param options - Optional settings (encoding)
2080
2007
  */
2081
2008
  async writeFile(path$1, content, sessionId, options) {
2082
- try {
2083
- const data = {
2084
- path: path$1,
2085
- content,
2086
- sessionId,
2087
- encoding: options?.encoding
2088
- };
2089
- return await this.post("/api/write", data);
2090
- } catch (error) {
2091
- throw error;
2092
- }
2009
+ const data = {
2010
+ path: path$1,
2011
+ content,
2012
+ sessionId,
2013
+ encoding: options?.encoding
2014
+ };
2015
+ return await this.post("/api/write", data);
2093
2016
  }
2094
2017
  /**
2095
2018
  * Read content from a file
@@ -2098,16 +2021,12 @@ var FileClient = class extends BaseHttpClient {
2098
2021
  * @param options - Optional settings (encoding)
2099
2022
  */
2100
2023
  async readFile(path$1, sessionId, options) {
2101
- try {
2102
- const data = {
2103
- path: path$1,
2104
- sessionId,
2105
- encoding: options?.encoding
2106
- };
2107
- return await this.post("/api/read", data);
2108
- } catch (error) {
2109
- throw error;
2110
- }
2024
+ const data = {
2025
+ path: path$1,
2026
+ sessionId,
2027
+ encoding: options?.encoding
2028
+ };
2029
+ return await this.post("/api/read", data);
2111
2030
  }
2112
2031
  /**
2113
2032
  * Stream a file using Server-Sent Events
@@ -2116,15 +2035,11 @@ var FileClient = class extends BaseHttpClient {
2116
2035
  * @param sessionId - The session ID for this operation
2117
2036
  */
2118
2037
  async readFileStream(path$1, sessionId) {
2119
- try {
2120
- const data = {
2121
- path: path$1,
2122
- sessionId
2123
- };
2124
- return await this.doStreamFetch("/api/read/stream", data);
2125
- } catch (error) {
2126
- throw error;
2127
- }
2038
+ const data = {
2039
+ path: path$1,
2040
+ sessionId
2041
+ };
2042
+ return await this.doStreamFetch("/api/read/stream", data);
2128
2043
  }
2129
2044
  /**
2130
2045
  * Delete a file
@@ -2132,15 +2047,11 @@ var FileClient = class extends BaseHttpClient {
2132
2047
  * @param sessionId - The session ID for this operation
2133
2048
  */
2134
2049
  async deleteFile(path$1, sessionId) {
2135
- try {
2136
- const data = {
2137
- path: path$1,
2138
- sessionId
2139
- };
2140
- return await this.post("/api/delete", data);
2141
- } catch (error) {
2142
- throw error;
2143
- }
2050
+ const data = {
2051
+ path: path$1,
2052
+ sessionId
2053
+ };
2054
+ return await this.post("/api/delete", data);
2144
2055
  }
2145
2056
  /**
2146
2057
  * Rename a file
@@ -2149,16 +2060,12 @@ var FileClient = class extends BaseHttpClient {
2149
2060
  * @param sessionId - The session ID for this operation
2150
2061
  */
2151
2062
  async renameFile(path$1, newPath, sessionId) {
2152
- try {
2153
- const data = {
2154
- oldPath: path$1,
2155
- newPath,
2156
- sessionId
2157
- };
2158
- return await this.post("/api/rename", data);
2159
- } catch (error) {
2160
- throw error;
2161
- }
2063
+ const data = {
2064
+ oldPath: path$1,
2065
+ newPath,
2066
+ sessionId
2067
+ };
2068
+ return await this.post("/api/rename", data);
2162
2069
  }
2163
2070
  /**
2164
2071
  * Move a file
@@ -2167,16 +2074,12 @@ var FileClient = class extends BaseHttpClient {
2167
2074
  * @param sessionId - The session ID for this operation
2168
2075
  */
2169
2076
  async moveFile(path$1, newPath, sessionId) {
2170
- try {
2171
- const data = {
2172
- sourcePath: path$1,
2173
- destinationPath: newPath,
2174
- sessionId
2175
- };
2176
- return await this.post("/api/move", data);
2177
- } catch (error) {
2178
- throw error;
2179
- }
2077
+ const data = {
2078
+ sourcePath: path$1,
2079
+ destinationPath: newPath,
2080
+ sessionId
2081
+ };
2082
+ return await this.post("/api/move", data);
2180
2083
  }
2181
2084
  /**
2182
2085
  * List files in a directory
@@ -2185,16 +2088,12 @@ var FileClient = class extends BaseHttpClient {
2185
2088
  * @param options - Optional settings (recursive, includeHidden)
2186
2089
  */
2187
2090
  async listFiles(path$1, sessionId, options) {
2188
- try {
2189
- const data = {
2190
- path: path$1,
2191
- sessionId,
2192
- options: options || {}
2193
- };
2194
- return await this.post("/api/list-files", data);
2195
- } catch (error) {
2196
- throw error;
2197
- }
2091
+ const data = {
2092
+ path: path$1,
2093
+ sessionId,
2094
+ options: options || {}
2095
+ };
2096
+ return await this.post("/api/list-files", data);
2198
2097
  }
2199
2098
  /**
2200
2099
  * Check if a file or directory exists
@@ -2202,15 +2101,11 @@ var FileClient = class extends BaseHttpClient {
2202
2101
  * @param sessionId - The session ID for this operation
2203
2102
  */
2204
2103
  async exists(path$1, sessionId) {
2205
- try {
2206
- const data = {
2207
- path: path$1,
2208
- sessionId
2209
- };
2210
- return await this.post("/api/exists", data);
2211
- } catch (error) {
2212
- throw error;
2213
- }
2104
+ const data = {
2105
+ path: path$1,
2106
+ sessionId
2107
+ };
2108
+ return await this.post("/api/exists", data);
2214
2109
  }
2215
2110
  };
2216
2111
 
@@ -2232,26 +2127,22 @@ var GitClient = class GitClient extends BaseHttpClient {
2232
2127
  * @param options - Optional settings (branch, targetDir, depth, timeoutMs)
2233
2128
  */
2234
2129
  async checkout(repoUrl, sessionId, options) {
2235
- try {
2236
- const timeoutMs = options?.timeoutMs ?? DEFAULT_GIT_CLONE_TIMEOUT_MS;
2237
- let targetDir = options?.targetDir;
2238
- if (!targetDir) targetDir = `/workspace/${extractRepoName(repoUrl)}`;
2239
- const data = {
2240
- repoUrl,
2241
- sessionId,
2242
- targetDir
2243
- };
2244
- if (options?.branch) data.branch = options.branch;
2245
- if (options?.depth !== void 0) {
2246
- if (!Number.isInteger(options.depth) || options.depth <= 0) throw new Error(`Invalid depth value: ${options.depth}. Must be a positive integer (e.g., 1, 5, 10).`);
2247
- data.depth = options.depth;
2248
- }
2249
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new Error(`Invalid timeout value: ${timeoutMs}. Must be a positive integer number of milliseconds.`);
2250
- data.timeoutMs = timeoutMs;
2251
- return await this.post("/api/git/checkout", data, void 0, { requestTimeoutMs: timeoutMs + GitClient.REQUEST_TIMEOUT_BUFFER_MS });
2252
- } catch (error) {
2253
- throw error;
2130
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_GIT_CLONE_TIMEOUT_MS;
2131
+ let targetDir = options?.targetDir;
2132
+ if (!targetDir) targetDir = `/workspace/${extractRepoName(repoUrl)}`;
2133
+ const data = {
2134
+ repoUrl,
2135
+ sessionId,
2136
+ targetDir
2137
+ };
2138
+ if (options?.branch) data.branch = options.branch;
2139
+ if (options?.depth !== void 0) {
2140
+ if (!Number.isInteger(options.depth) || options.depth <= 0) throw new Error(`Invalid depth value: ${options.depth}. Must be a positive integer (e.g., 1, 5, 10).`);
2141
+ data.depth = options.depth;
2254
2142
  }
2143
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new Error(`Invalid timeout value: ${timeoutMs}. Must be a positive integer number of milliseconds.`);
2144
+ data.timeoutMs = timeoutMs;
2145
+ return await this.post("/api/git/checkout", data, void 0, { requestTimeoutMs: timeoutMs + GitClient.REQUEST_TIMEOUT_BUFFER_MS });
2255
2146
  }
2256
2147
  };
2257
2148
 
@@ -2444,41 +2335,29 @@ var PortClient = class extends BaseHttpClient {
2444
2335
  * @param name - Optional name for the port
2445
2336
  */
2446
2337
  async exposePort(port, sessionId, name) {
2447
- try {
2448
- const data = {
2449
- port,
2450
- sessionId,
2451
- name
2452
- };
2453
- return await this.post("/api/expose-port", data);
2454
- } catch (error) {
2455
- throw error;
2456
- }
2457
- }
2338
+ const data = {
2339
+ port,
2340
+ sessionId,
2341
+ name
2342
+ };
2343
+ return await this.post("/api/expose-port", data);
2344
+ }
2458
2345
  /**
2459
2346
  * Unexpose a port and remove its preview URL
2460
2347
  * @param port - Port number to unexpose
2461
2348
  * @param sessionId - The session ID for this operation
2462
2349
  */
2463
2350
  async unexposePort(port, sessionId) {
2464
- try {
2465
- const url = `/api/exposed-ports/${port}?session=${encodeURIComponent(sessionId)}`;
2466
- return await this.delete(url);
2467
- } catch (error) {
2468
- throw error;
2469
- }
2351
+ const url = `/api/exposed-ports/${port}?session=${encodeURIComponent(sessionId)}`;
2352
+ return await this.delete(url);
2470
2353
  }
2471
2354
  /**
2472
2355
  * Get all currently exposed ports
2473
2356
  * @param sessionId - The session ID for this operation
2474
2357
  */
2475
2358
  async getExposedPorts(sessionId) {
2476
- try {
2477
- const url = `/api/exposed-ports?session=${encodeURIComponent(sessionId)}`;
2478
- return await this.get(url);
2479
- } catch (error) {
2480
- throw error;
2481
- }
2359
+ const url = `/api/exposed-ports?session=${encodeURIComponent(sessionId)}`;
2360
+ return await this.get(url);
2482
2361
  }
2483
2362
  /**
2484
2363
  * Watch a port for readiness via SSE stream
@@ -2486,11 +2365,7 @@ var PortClient = class extends BaseHttpClient {
2486
2365
  * @returns SSE stream that emits PortWatchEvent objects
2487
2366
  */
2488
2367
  async watchPort(request) {
2489
- try {
2490
- return await this.doStreamFetch("/api/port-watch", request);
2491
- } catch (error) {
2492
- throw error;
2493
- }
2368
+ return await this.doStreamFetch("/api/port-watch", request);
2494
2369
  }
2495
2370
  };
2496
2371
 
@@ -2507,90 +2382,62 @@ var ProcessClient = class extends BaseHttpClient {
2507
2382
  * @param options - Optional settings (processId)
2508
2383
  */
2509
2384
  async startProcess(command, sessionId, options) {
2510
- try {
2511
- const data = {
2512
- command,
2513
- sessionId,
2514
- ...options?.origin !== void 0 && { origin: options.origin },
2515
- ...options?.processId !== void 0 && { processId: options.processId },
2516
- ...options?.timeoutMs !== void 0 && { timeoutMs: options.timeoutMs },
2517
- ...options?.env !== void 0 && { env: options.env },
2518
- ...options?.cwd !== void 0 && { cwd: options.cwd },
2519
- ...options?.encoding !== void 0 && { encoding: options.encoding },
2520
- ...options?.autoCleanup !== void 0 && { autoCleanup: options.autoCleanup }
2521
- };
2522
- return await this.post("/api/process/start", data);
2523
- } catch (error) {
2524
- throw error;
2525
- }
2385
+ const data = {
2386
+ command,
2387
+ sessionId,
2388
+ ...options?.origin !== void 0 && { origin: options.origin },
2389
+ ...options?.processId !== void 0 && { processId: options.processId },
2390
+ ...options?.timeoutMs !== void 0 && { timeoutMs: options.timeoutMs },
2391
+ ...options?.env !== void 0 && { env: options.env },
2392
+ ...options?.cwd !== void 0 && { cwd: options.cwd },
2393
+ ...options?.encoding !== void 0 && { encoding: options.encoding },
2394
+ ...options?.autoCleanup !== void 0 && { autoCleanup: options.autoCleanup }
2395
+ };
2396
+ return await this.post("/api/process/start", data);
2526
2397
  }
2527
2398
  /**
2528
2399
  * List all processes (sandbox-scoped, not session-scoped)
2529
2400
  */
2530
2401
  async listProcesses() {
2531
- try {
2532
- return await this.get(`/api/process/list`);
2533
- } catch (error) {
2534
- throw error;
2535
- }
2402
+ return await this.get(`/api/process/list`);
2536
2403
  }
2537
2404
  /**
2538
2405
  * Get information about a specific process (sandbox-scoped, not session-scoped)
2539
2406
  * @param processId - ID of the process to retrieve
2540
2407
  */
2541
2408
  async getProcess(processId) {
2542
- try {
2543
- const url = `/api/process/${processId}`;
2544
- return await this.get(url);
2545
- } catch (error) {
2546
- throw error;
2547
- }
2409
+ const url = `/api/process/${processId}`;
2410
+ return await this.get(url);
2548
2411
  }
2549
2412
  /**
2550
2413
  * Kill a specific process (sandbox-scoped, not session-scoped)
2551
2414
  * @param processId - ID of the process to kill
2552
2415
  */
2553
2416
  async killProcess(processId) {
2554
- try {
2555
- const url = `/api/process/${processId}`;
2556
- return await this.delete(url);
2557
- } catch (error) {
2558
- throw error;
2559
- }
2417
+ const url = `/api/process/${processId}`;
2418
+ return await this.delete(url);
2560
2419
  }
2561
2420
  /**
2562
2421
  * Kill all running processes (sandbox-scoped, not session-scoped)
2563
2422
  */
2564
2423
  async killAllProcesses() {
2565
- try {
2566
- return await this.delete(`/api/process/kill-all`);
2567
- } catch (error) {
2568
- throw error;
2569
- }
2424
+ return await this.delete(`/api/process/kill-all`);
2570
2425
  }
2571
2426
  /**
2572
2427
  * Get logs from a specific process (sandbox-scoped, not session-scoped)
2573
2428
  * @param processId - ID of the process to get logs from
2574
2429
  */
2575
2430
  async getProcessLogs(processId) {
2576
- try {
2577
- const url = `/api/process/${processId}/logs`;
2578
- return await this.get(url);
2579
- } catch (error) {
2580
- throw error;
2581
- }
2431
+ const url = `/api/process/${processId}/logs`;
2432
+ return await this.get(url);
2582
2433
  }
2583
2434
  /**
2584
2435
  * Stream logs from a specific process (sandbox-scoped, not session-scoped)
2585
2436
  * @param processId - ID of the process to stream logs from
2586
2437
  */
2587
2438
  async streamProcessLogs(processId) {
2588
- try {
2589
- const url = `/api/process/${processId}/stream`;
2590
- return await this.doStreamFetch(url, void 0, "GET");
2591
- } catch (error) {
2592
- throw error;
2593
- }
2439
+ const url = `/api/process/${processId}/stream`;
2440
+ return await this.doStreamFetch(url, void 0, "GET");
2594
2441
  }
2595
2442
  };
2596
2443
 
@@ -2604,43 +2451,27 @@ var UtilityClient = class extends BaseHttpClient {
2604
2451
  * Ping the sandbox to check if it's responsive
2605
2452
  */
2606
2453
  async ping() {
2607
- try {
2608
- return (await this.get("/api/ping")).message;
2609
- } catch (error) {
2610
- throw error;
2611
- }
2454
+ return (await this.get("/api/ping")).message;
2612
2455
  }
2613
2456
  /**
2614
2457
  * Get list of available commands in the sandbox environment
2615
2458
  */
2616
2459
  async getCommands() {
2617
- try {
2618
- return (await this.get("/api/commands")).availableCommands;
2619
- } catch (error) {
2620
- throw error;
2621
- }
2460
+ return (await this.get("/api/commands")).availableCommands;
2622
2461
  }
2623
2462
  /**
2624
2463
  * Create a new execution session
2625
2464
  * @param options - Session configuration (id, env, cwd)
2626
2465
  */
2627
2466
  async createSession(options) {
2628
- try {
2629
- return await this.post("/api/session/create", options);
2630
- } catch (error) {
2631
- throw error;
2632
- }
2467
+ return await this.post("/api/session/create", options);
2633
2468
  }
2634
2469
  /**
2635
2470
  * Delete an execution session
2636
2471
  * @param sessionId - Session ID to delete
2637
2472
  */
2638
2473
  async deleteSession(sessionId) {
2639
- try {
2640
- return await this.post("/api/session/delete", { sessionId });
2641
- } catch (error) {
2642
- throw error;
2643
- }
2474
+ return await this.post("/api/session/delete", { sessionId });
2644
2475
  }
2645
2476
  /**
2646
2477
  * Get the container version
@@ -2682,12 +2513,8 @@ var WatchClient = class extends BaseHttpClient {
2682
2513
  * @param request - Watch request with path and options
2683
2514
  */
2684
2515
  async watch(request) {
2685
- try {
2686
- const stream = await this.doStreamFetch("/api/watch", request);
2687
- return await this.waitForReadiness(stream);
2688
- } catch (error) {
2689
- throw error;
2690
- }
2516
+ const stream = await this.doStreamFetch("/api/watch", request);
2517
+ return await this.waitForReadiness(stream);
2691
2518
  }
2692
2519
  /**
2693
2520
  * Read SSE chunks until the `watching` event appears, then return a
@@ -2873,6 +2700,128 @@ const BACKUP_ALLOWED_PREFIXES = [
2873
2700
  "/app"
2874
2701
  ];
2875
2702
 
2703
+ //#endregion
2704
+ //#region src/file-stream.ts
2705
+ /**
2706
+ * Parse SSE (Server-Sent Events) lines from a stream
2707
+ */
2708
+ async function* parseSSE(stream) {
2709
+ const reader = stream.getReader();
2710
+ const decoder = new TextDecoder();
2711
+ let buffer = "";
2712
+ let currentEvent = { data: [] };
2713
+ try {
2714
+ while (true) {
2715
+ const { done, value } = await reader.read();
2716
+ if (done) break;
2717
+ buffer += decoder.decode(value, { stream: true });
2718
+ const parsed = parseSSEFrames(buffer, currentEvent);
2719
+ buffer = parsed.remaining;
2720
+ currentEvent = parsed.currentEvent;
2721
+ for (const frame of parsed.events) try {
2722
+ yield JSON.parse(frame.data);
2723
+ } catch {}
2724
+ }
2725
+ const finalParsed = parseSSEFrames(`${buffer}\n\n`, currentEvent);
2726
+ for (const frame of finalParsed.events) try {
2727
+ yield JSON.parse(frame.data);
2728
+ } catch {}
2729
+ } finally {
2730
+ try {
2731
+ await reader.cancel();
2732
+ } catch {}
2733
+ reader.releaseLock();
2734
+ }
2735
+ }
2736
+ /**
2737
+ * Stream a file from the sandbox with automatic base64 decoding for binary files
2738
+ *
2739
+ * @param stream - The ReadableStream from readFileStream()
2740
+ * @returns AsyncGenerator that yields FileChunk (string for text, Uint8Array for binary)
2741
+ *
2742
+ * @example
2743
+ * ```ts
2744
+ * const stream = await sandbox.readFileStream('/path/to/file.png');
2745
+ * for await (const chunk of streamFile(stream)) {
2746
+ * if (chunk instanceof Uint8Array) {
2747
+ * // Binary chunk
2748
+ * console.log('Binary chunk:', chunk.length, 'bytes');
2749
+ * } else {
2750
+ * // Text chunk
2751
+ * console.log('Text chunk:', chunk);
2752
+ * }
2753
+ * }
2754
+ * ```
2755
+ */
2756
+ async function* streamFile(stream) {
2757
+ let metadata = null;
2758
+ for await (const event of parseSSE(stream)) switch (event.type) {
2759
+ case "metadata":
2760
+ metadata = {
2761
+ mimeType: event.mimeType,
2762
+ size: event.size,
2763
+ isBinary: event.isBinary,
2764
+ encoding: event.encoding
2765
+ };
2766
+ break;
2767
+ case "chunk":
2768
+ if (!metadata) throw new Error("Received chunk before metadata");
2769
+ if (metadata.isBinary && metadata.encoding === "base64") {
2770
+ const binaryString = atob(event.data);
2771
+ const bytes = new Uint8Array(binaryString.length);
2772
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
2773
+ yield bytes;
2774
+ } else yield event.data;
2775
+ break;
2776
+ case "complete":
2777
+ if (!metadata) throw new Error("Stream completed without metadata");
2778
+ return metadata;
2779
+ case "error": throw new Error(`File streaming error: ${event.error}`);
2780
+ }
2781
+ throw new Error("Stream ended unexpectedly");
2782
+ }
2783
+ /**
2784
+ * Collect an entire file into memory from a stream
2785
+ *
2786
+ * @param stream - The ReadableStream from readFileStream()
2787
+ * @returns Object containing the file content and metadata
2788
+ *
2789
+ * @example
2790
+ * ```ts
2791
+ * const stream = await sandbox.readFileStream('/path/to/file.txt');
2792
+ * const { content, metadata } = await collectFile(stream);
2793
+ * console.log('Content:', content);
2794
+ * console.log('MIME type:', metadata.mimeType);
2795
+ * ```
2796
+ */
2797
+ async function collectFile(stream) {
2798
+ const chunks = [];
2799
+ const generator = streamFile(stream);
2800
+ let result = await generator.next();
2801
+ while (!result.done) {
2802
+ chunks.push(result.value);
2803
+ result = await generator.next();
2804
+ }
2805
+ const metadata = result.value;
2806
+ if (!metadata) throw new Error("Failed to get file metadata");
2807
+ if (metadata.isBinary) {
2808
+ const totalLength = chunks.reduce((sum, chunk) => sum + (chunk instanceof Uint8Array ? chunk.length : 0), 0);
2809
+ const combined = new Uint8Array(totalLength);
2810
+ let offset = 0;
2811
+ for (const chunk of chunks) if (chunk instanceof Uint8Array) {
2812
+ combined.set(chunk, offset);
2813
+ offset += chunk.length;
2814
+ }
2815
+ return {
2816
+ content: combined,
2817
+ metadata
2818
+ };
2819
+ } else return {
2820
+ content: chunks.filter((c) => typeof c === "string").join(""),
2821
+ metadata
2822
+ };
2823
+ }
2824
+
2876
2825
  //#endregion
2877
2826
  //#region src/security.ts
2878
2827
  /**
@@ -3124,55 +3073,220 @@ function asyncIterableToSSEStream(events, options) {
3124
3073
  }
3125
3074
 
3126
3075
  //#endregion
3127
- //#region src/local-mount-sync.ts
3128
- const DEFAULT_POLL_INTERVAL_MS = 1e3;
3129
- const DEFAULT_ECHO_SUPPRESS_TTL_MS = 2e3;
3130
- const MAX_BACKOFF_MS = 3e4;
3131
- const SYNC_CONCURRENCY = 5;
3076
+ //#region src/storage-mount/errors.ts
3132
3077
  /**
3133
- * Manages bidirectional sync between an R2 binding and a container directory.
3078
+ * Bucket mount and unmount error classes
3134
3079
  *
3135
- * R2 -> Container: polls bucket.list() to detect changes, then transfers diffs.
3136
- * Container -> R2: uses inotifywait via the watch API to detect file changes.
3080
+ * Validation errors (InvalidMountConfigError, MissingCredentialsError) are thrown
3081
+ * before any container interaction. BucketUnmountError is thrown after a failed
3082
+ * fusermount call inside the container.
3137
3083
  */
3138
- var LocalMountSyncManager = class {
3139
- bucket;
3140
- mountPath;
3141
- prefix;
3142
- readOnly;
3143
- client;
3144
- sessionId;
3145
- logger;
3146
- pollIntervalMs;
3147
- echoSuppressTtlMs;
3148
- snapshot = /* @__PURE__ */ new Map();
3149
- echoSuppressSet = /* @__PURE__ */ new Set();
3150
- pollTimer = null;
3151
- watchReconnectTimer = null;
3152
- watchAbortController = null;
3153
- running = false;
3154
- consecutivePollFailures = 0;
3155
- consecutiveWatchFailures = 0;
3156
- constructor(options) {
3157
- this.bucket = options.bucket;
3158
- this.mountPath = options.mountPath;
3159
- this.prefix = options.prefix;
3160
- this.readOnly = options.readOnly;
3161
- this.client = options.client;
3162
- this.sessionId = options.sessionId;
3163
- this.logger = options.logger.child({ operation: "local-mount-sync" });
3164
- this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
3165
- this.echoSuppressTtlMs = options.echoSuppressTtlMs ?? DEFAULT_ECHO_SUPPRESS_TTL_MS;
3084
+ /**
3085
+ * Base error for bucket mounting operations
3086
+ */
3087
+ var BucketMountError = class extends Error {
3088
+ code;
3089
+ constructor(message, code = ErrorCode.BUCKET_MOUNT_ERROR) {
3090
+ super(message);
3091
+ this.name = "BucketMountError";
3092
+ this.code = code;
3166
3093
  }
3167
- /**
3168
- * Start bidirectional sync. Performs initial full sync, then starts
3169
- * the R2 poll loop and (if not readOnly) the container watch loop.
3170
- */
3171
- async start() {
3172
- this.running = true;
3173
- await this.client.files.mkdir(this.mountPath, this.sessionId, { recursive: true });
3174
- await this.fullSyncR2ToContainer();
3175
- this.schedulePoll();
3094
+ };
3095
+ /**
3096
+ * Thrown when S3FS mount command fails
3097
+ */
3098
+ var S3FSMountError = class extends BucketMountError {
3099
+ constructor(message) {
3100
+ super(message, ErrorCode.S3FS_MOUNT_ERROR);
3101
+ this.name = "S3FSMountError";
3102
+ }
3103
+ };
3104
+ /**
3105
+ * Thrown when fusermount -u fails to unmount a FUSE filesystem
3106
+ */
3107
+ var BucketUnmountError = class extends BucketMountError {
3108
+ constructor(message) {
3109
+ super(message, ErrorCode.BUCKET_UNMOUNT_ERROR);
3110
+ this.name = "BucketUnmountError";
3111
+ }
3112
+ };
3113
+ /**
3114
+ * Thrown when no credentials found in environment
3115
+ */
3116
+ var MissingCredentialsError = class extends BucketMountError {
3117
+ constructor(message) {
3118
+ super(message, ErrorCode.MISSING_CREDENTIALS);
3119
+ this.name = "MissingCredentialsError";
3120
+ }
3121
+ };
3122
+ /**
3123
+ * Thrown when bucket name, mount path, or options are invalid
3124
+ */
3125
+ var InvalidMountConfigError = class extends BucketMountError {
3126
+ constructor(message) {
3127
+ super(message, ErrorCode.INVALID_MOUNT_CONFIG);
3128
+ this.name = "InvalidMountConfigError";
3129
+ }
3130
+ };
3131
+
3132
+ //#endregion
3133
+ //#region src/storage-mount/credential-detection.ts
3134
+ /**
3135
+ * Detect credentials for bucket mounting from environment variables
3136
+ * Priority order:
3137
+ * 1. Explicit options.credentials
3138
+ * 2. Standard AWS env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
3139
+ * 3. Standard R2 env vars: R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY
3140
+ * 4. Error: no credentials found
3141
+ *
3142
+ * @param options - Mount options
3143
+ * @param envVars - Environment variables
3144
+ * @returns Detected credentials
3145
+ * @throws MissingCredentialsError if no credentials found
3146
+ */
3147
+ function detectCredentials(options, envVars) {
3148
+ if (options.credentials) return options.credentials;
3149
+ const awsAccessKeyId = envVars.AWS_ACCESS_KEY_ID;
3150
+ const awsSecretAccessKey = envVars.AWS_SECRET_ACCESS_KEY;
3151
+ if (awsAccessKeyId && awsSecretAccessKey) return {
3152
+ accessKeyId: awsAccessKeyId,
3153
+ secretAccessKey: awsSecretAccessKey
3154
+ };
3155
+ /**
3156
+ * Priority 3: Standard R2 env vars
3157
+ *
3158
+ * AWS vars still take precedence over R2 vars in case both are set
3159
+ */
3160
+ const r2AccessKeyId = envVars.R2_ACCESS_KEY_ID;
3161
+ const r2SecretAccessKey = envVars.R2_SECRET_ACCESS_KEY;
3162
+ if (r2AccessKeyId && r2SecretAccessKey) return {
3163
+ accessKeyId: r2AccessKeyId,
3164
+ secretAccessKey: r2SecretAccessKey
3165
+ };
3166
+ throw new MissingCredentialsError("No credentials found. Set R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY or AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, or pass explicit credentials in options.");
3167
+ }
3168
+
3169
+ //#endregion
3170
+ //#region src/storage-mount/provider-detection.ts
3171
+ /**
3172
+ * Detect provider from endpoint URL using pattern matching
3173
+ */
3174
+ function detectProviderFromUrl(endpoint) {
3175
+ try {
3176
+ const hostname = new URL(endpoint).hostname.toLowerCase();
3177
+ if (hostname.endsWith(".r2.cloudflarestorage.com")) return "r2";
3178
+ if (hostname.endsWith(".amazonaws.com") || hostname === "s3.amazonaws.com") return "s3";
3179
+ if (hostname === "storage.googleapis.com") return "gcs";
3180
+ return null;
3181
+ } catch {
3182
+ return null;
3183
+ }
3184
+ }
3185
+ /**
3186
+ * Get s3fs flags for a given provider
3187
+ *
3188
+ * Based on s3fs-fuse wiki recommendations:
3189
+ * https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3
3190
+ */
3191
+ function getProviderFlags(provider) {
3192
+ if (!provider) return ["use_path_request_style"];
3193
+ switch (provider) {
3194
+ case "r2": return ["nomixupload"];
3195
+ case "s3": return [];
3196
+ case "gcs": return [];
3197
+ default: return ["use_path_request_style"];
3198
+ }
3199
+ }
3200
+ /**
3201
+ * Resolve s3fs options by combining provider defaults with user overrides
3202
+ */
3203
+ function resolveS3fsOptions(provider, userOptions) {
3204
+ const providerFlags = getProviderFlags(provider);
3205
+ if (!userOptions || userOptions.length === 0) return providerFlags;
3206
+ const allFlags = [...providerFlags, ...userOptions];
3207
+ const flagMap = /* @__PURE__ */ new Map();
3208
+ for (const flag of allFlags) {
3209
+ const [flagName] = flag.split("=");
3210
+ flagMap.set(flagName, flag);
3211
+ }
3212
+ return Array.from(flagMap.values());
3213
+ }
3214
+
3215
+ //#endregion
3216
+ //#region src/storage-mount/validation.ts
3217
+ function validatePrefix(prefix) {
3218
+ if (!prefix.startsWith("/")) throw new InvalidMountConfigError(`Prefix must start with '/': "${prefix}"`);
3219
+ }
3220
+ function validateBucketName(bucket, mountPath) {
3221
+ if (bucket.includes(":")) {
3222
+ const [bucketName, prefixPart] = bucket.split(":");
3223
+ throw new InvalidMountConfigError(`Bucket name cannot contain ':'. To mount a prefix, use the 'prefix' option:\n mountBucket('${bucketName}', '${mountPath}', { ...options, prefix: '${prefixPart}' })`);
3224
+ }
3225
+ if (!/^[a-z0-9]([a-z0-9.-]{0,61}[a-z0-9])?$/.test(bucket)) throw new InvalidMountConfigError(`Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, dots, or hyphens, and cannot start/end with dots or hyphens.`);
3226
+ }
3227
+ /**
3228
+ * Builds the s3fs source string from bucket name and optional prefix.
3229
+ * Format: "bucket" or "bucket:/prefix/" for subdirectory mounts.
3230
+ *
3231
+ * @param bucket - The bucket name
3232
+ * @param prefix - Optional prefix/subdirectory path
3233
+ * @returns The s3fs source string
3234
+ */
3235
+ function buildS3fsSource(bucket, prefix) {
3236
+ return prefix ? `${bucket}:${prefix}` : bucket;
3237
+ }
3238
+
3239
+ //#endregion
3240
+ //#region src/local-mount-sync.ts
3241
+ const DEFAULT_POLL_INTERVAL_MS = 1e3;
3242
+ const DEFAULT_ECHO_SUPPRESS_TTL_MS = 2e3;
3243
+ const MAX_BACKOFF_MS = 3e4;
3244
+ const SYNC_CONCURRENCY = 5;
3245
+ /**
3246
+ * Manages bidirectional sync between an R2 binding and a container directory.
3247
+ *
3248
+ * R2 -> Container: polls bucket.list() to detect changes, then transfers diffs.
3249
+ * Container -> R2: uses inotifywait via the watch API to detect file changes.
3250
+ */
3251
+ var LocalMountSyncManager = class {
3252
+ bucket;
3253
+ mountPath;
3254
+ prefix;
3255
+ readOnly;
3256
+ client;
3257
+ sessionId;
3258
+ logger;
3259
+ pollIntervalMs;
3260
+ echoSuppressTtlMs;
3261
+ snapshot = /* @__PURE__ */ new Map();
3262
+ echoSuppressSet = /* @__PURE__ */ new Set();
3263
+ pollTimer = null;
3264
+ watchReconnectTimer = null;
3265
+ watchAbortController = null;
3266
+ running = false;
3267
+ consecutivePollFailures = 0;
3268
+ consecutiveWatchFailures = 0;
3269
+ constructor(options) {
3270
+ this.bucket = options.bucket;
3271
+ this.mountPath = options.mountPath;
3272
+ if (options.prefix !== void 0) validatePrefix(options.prefix);
3273
+ this.prefix = options.prefix?.replace(/^\//, "") || void 0;
3274
+ this.readOnly = options.readOnly;
3275
+ this.client = options.client;
3276
+ this.sessionId = options.sessionId;
3277
+ this.logger = options.logger.child({ operation: "local-mount-sync" });
3278
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
3279
+ this.echoSuppressTtlMs = options.echoSuppressTtlMs ?? DEFAULT_ECHO_SUPPRESS_TTL_MS;
3280
+ }
3281
+ /**
3282
+ * Start bidirectional sync. Performs initial full sync, then starts
3283
+ * the R2 poll loop and (if not readOnly) the container watch loop.
3284
+ */
3285
+ async start() {
3286
+ this.running = true;
3287
+ await this.client.files.mkdir(this.mountPath, this.sessionId, { recursive: true });
3288
+ await this.fullSyncR2ToContainer();
3289
+ this.schedulePoll();
3176
3290
  if (!this.readOnly) this.startContainerWatch();
3177
3291
  this.logger.info("Local mount sync started", {
3178
3292
  mountPath: this.mountPath,
@@ -3532,181 +3646,23 @@ function isLocalhostPattern(hostname) {
3532
3646
  }
3533
3647
 
3534
3648
  //#endregion
3535
- //#region src/storage-mount/errors.ts
3536
- /**
3537
- * Bucket mount and unmount error classes
3538
- *
3539
- * Validation errors (InvalidMountConfigError, MissingCredentialsError) are thrown
3540
- * before any container interaction. BucketUnmountError is thrown after a failed
3541
- * fusermount call inside the container.
3542
- */
3543
- /**
3544
- * Base error for bucket mounting operations
3545
- */
3546
- var BucketMountError = class extends Error {
3547
- code;
3548
- constructor(message, code = ErrorCode.BUCKET_MOUNT_ERROR) {
3549
- super(message);
3550
- this.name = "BucketMountError";
3551
- this.code = code;
3552
- }
3553
- };
3554
- /**
3555
- * Thrown when S3FS mount command fails
3556
- */
3557
- var S3FSMountError = class extends BucketMountError {
3558
- constructor(message) {
3559
- super(message, ErrorCode.S3FS_MOUNT_ERROR);
3560
- this.name = "S3FSMountError";
3561
- }
3562
- };
3563
- /**
3564
- * Thrown when fusermount -u fails to unmount a FUSE filesystem
3565
- */
3566
- var BucketUnmountError = class extends BucketMountError {
3567
- constructor(message) {
3568
- super(message, ErrorCode.BUCKET_UNMOUNT_ERROR);
3569
- this.name = "BucketUnmountError";
3570
- }
3571
- };
3572
- /**
3573
- * Thrown when no credentials found in environment
3574
- */
3575
- var MissingCredentialsError = class extends BucketMountError {
3576
- constructor(message) {
3577
- super(message, ErrorCode.MISSING_CREDENTIALS);
3578
- this.name = "MissingCredentialsError";
3579
- }
3580
- };
3649
+ //#region src/version.ts
3581
3650
  /**
3582
- * Thrown when bucket name, mount path, or options are invalid
3651
+ * SDK version - automatically synchronized with package.json by Changesets
3652
+ * This file is auto-updated by .github/changeset-version.ts during releases
3653
+ * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
3583
3654
  */
3584
- var InvalidMountConfigError = class extends BucketMountError {
3585
- constructor(message) {
3586
- super(message, ErrorCode.INVALID_MOUNT_CONFIG);
3587
- this.name = "InvalidMountConfigError";
3588
- }
3589
- };
3590
-
3591
- //#endregion
3592
- //#region src/storage-mount/credential-detection.ts
3593
- /**
3594
- * Detect credentials for bucket mounting from environment variables
3595
- * Priority order:
3596
- * 1. Explicit options.credentials
3597
- * 2. Standard AWS env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
3598
- * 3. Standard R2 env vars: R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY
3599
- * 4. Error: no credentials found
3600
- *
3601
- * @param options - Mount options
3602
- * @param envVars - Environment variables
3603
- * @returns Detected credentials
3604
- * @throws MissingCredentialsError if no credentials found
3605
- */
3606
- function detectCredentials(options, envVars) {
3607
- if (options.credentials) return options.credentials;
3608
- const awsAccessKeyId = envVars.AWS_ACCESS_KEY_ID;
3609
- const awsSecretAccessKey = envVars.AWS_SECRET_ACCESS_KEY;
3610
- if (awsAccessKeyId && awsSecretAccessKey) return {
3611
- accessKeyId: awsAccessKeyId,
3612
- secretAccessKey: awsSecretAccessKey
3613
- };
3614
- /**
3615
- * Priority 3: Standard R2 env vars
3616
- *
3617
- * AWS vars still take precedence over R2 vars in case both are set
3618
- */
3619
- const r2AccessKeyId = envVars.R2_ACCESS_KEY_ID;
3620
- const r2SecretAccessKey = envVars.R2_SECRET_ACCESS_KEY;
3621
- if (r2AccessKeyId && r2SecretAccessKey) return {
3622
- accessKeyId: r2AccessKeyId,
3623
- secretAccessKey: r2SecretAccessKey
3624
- };
3625
- throw new MissingCredentialsError("No credentials found. Set R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY or AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, or pass explicit credentials in options.");
3626
- }
3627
-
3628
- //#endregion
3629
- //#region src/storage-mount/provider-detection.ts
3630
- /**
3631
- * Detect provider from endpoint URL using pattern matching
3632
- */
3633
- function detectProviderFromUrl(endpoint) {
3634
- try {
3635
- const hostname = new URL(endpoint).hostname.toLowerCase();
3636
- if (hostname.endsWith(".r2.cloudflarestorage.com")) return "r2";
3637
- if (hostname.endsWith(".amazonaws.com") || hostname === "s3.amazonaws.com") return "s3";
3638
- if (hostname === "storage.googleapis.com") return "gcs";
3639
- return null;
3640
- } catch {
3641
- return null;
3642
- }
3643
- }
3644
- /**
3645
- * Get s3fs flags for a given provider
3646
- *
3647
- * Based on s3fs-fuse wiki recommendations:
3648
- * https://github.com/s3fs-fuse/s3fs-fuse/wiki/Non-Amazon-S3
3649
- */
3650
- function getProviderFlags(provider) {
3651
- if (!provider) return ["use_path_request_style"];
3652
- switch (provider) {
3653
- case "r2": return ["nomixupload"];
3654
- case "s3": return [];
3655
- case "gcs": return [];
3656
- default: return ["use_path_request_style"];
3657
- }
3658
- }
3659
- /**
3660
- * Resolve s3fs options by combining provider defaults with user overrides
3661
- */
3662
- function resolveS3fsOptions(provider, userOptions) {
3663
- const providerFlags = getProviderFlags(provider);
3664
- if (!userOptions || userOptions.length === 0) return providerFlags;
3665
- const allFlags = [...providerFlags, ...userOptions];
3666
- const flagMap = /* @__PURE__ */ new Map();
3667
- for (const flag of allFlags) {
3668
- const [flagName] = flag.split("=");
3669
- flagMap.set(flagName, flag);
3670
- }
3671
- return Array.from(flagMap.values());
3672
- }
3673
-
3674
- //#endregion
3675
- //#region src/storage-mount/validation.ts
3676
- function validatePrefix(prefix) {
3677
- if (!prefix.startsWith("/")) throw new InvalidMountConfigError(`Prefix must start with '/': "${prefix}"`);
3678
- }
3679
- function validateBucketName(bucket, mountPath) {
3680
- if (bucket.includes(":")) {
3681
- const [bucketName, prefixPart] = bucket.split(":");
3682
- throw new InvalidMountConfigError(`Bucket name cannot contain ':'. To mount a prefix, use the 'prefix' option:\n mountBucket('${bucketName}', '${mountPath}', { ...options, prefix: '${prefixPart}' })`);
3683
- }
3684
- if (!/^[a-z0-9]([a-z0-9.-]{0,61}[a-z0-9])?$/.test(bucket)) throw new InvalidMountConfigError(`Invalid bucket name: "${bucket}". Bucket names must be 3-63 characters, lowercase alphanumeric, dots, or hyphens, and cannot start/end with dots or hyphens.`);
3685
- }
3686
- /**
3687
- * Builds the s3fs source string from bucket name and optional prefix.
3688
- * Format: "bucket" or "bucket:/prefix/" for subdirectory mounts.
3689
- *
3690
- * @param bucket - The bucket name
3691
- * @param prefix - Optional prefix/subdirectory path
3692
- * @returns The s3fs source string
3693
- */
3694
- function buildS3fsSource(bucket, prefix) {
3695
- return prefix ? `${bucket}:${prefix}` : bucket;
3696
- }
3697
-
3698
- //#endregion
3699
- //#region src/version.ts
3700
- /**
3701
- * SDK version - automatically synchronized with package.json by Changesets
3702
- * This file is auto-updated by .github/changeset-version.ts during releases
3703
- * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
3704
- */
3705
- const SDK_VERSION = "0.8.11";
3655
+ const SDK_VERSION = "0.9.0";
3706
3656
 
3707
3657
  //#endregion
3708
3658
  //#region src/sandbox.ts
3709
3659
  const sandboxConfigurationCache = /* @__PURE__ */ new WeakMap();
3660
+ const BACKUP_DEFAULT_TTL_SECONDS = 259200;
3661
+ const BACKUP_MAX_NAME_LENGTH = 256;
3662
+ const BACKUP_CONTAINER_DIR = "/var/backups";
3663
+ const BACKUP_STORAGE_PREFIX = "backups";
3664
+ const BACKUP_ARCHIVE_OBJECT_NAME = "data.sqsh";
3665
+ const BACKUP_METADATA_OBJECT_NAME = "meta.json";
3710
3666
  function getNamespaceConfigurationCache(namespace) {
3711
3667
  const existing = sandboxConfigurationCache.get(namespace);
3712
3668
  if (existing) return existing;
@@ -3723,14 +3679,14 @@ function buildSandboxConfiguration(effectiveId, options, cached) {
3723
3679
  name: effectiveId,
3724
3680
  normalizeId: options?.normalizeId
3725
3681
  };
3726
- if (options?.baseUrl !== void 0 && cached?.baseUrl !== options.baseUrl) configuration.baseUrl = options.baseUrl;
3727
3682
  if (options?.sleepAfter !== void 0 && cached?.sleepAfter !== options.sleepAfter) configuration.sleepAfter = options.sleepAfter;
3728
3683
  if (options?.keepAlive !== void 0 && cached?.keepAlive !== options.keepAlive) configuration.keepAlive = options.keepAlive;
3729
3684
  if (options?.containerTimeouts && !sameContainerTimeouts(cached?.containerTimeouts, options.containerTimeouts)) configuration.containerTimeouts = options.containerTimeouts;
3685
+ if (options?.transport !== void 0 && cached?.transport !== options.transport) configuration.transport = options.transport;
3730
3686
  return configuration;
3731
3687
  }
3732
3688
  function hasSandboxConfiguration(configuration) {
3733
- return configuration.sandboxName !== void 0 || configuration.baseUrl !== void 0 || configuration.sleepAfter !== void 0 || configuration.keepAlive !== void 0 || configuration.containerTimeouts !== void 0;
3689
+ return configuration.sandboxName !== void 0 || configuration.sleepAfter !== void 0 || configuration.keepAlive !== void 0 || configuration.containerTimeouts !== void 0 || configuration.transport !== void 0;
3734
3690
  }
3735
3691
  function mergeSandboxConfiguration(cached, configuration) {
3736
3692
  return {
@@ -3739,20 +3695,20 @@ function mergeSandboxConfiguration(cached, configuration) {
3739
3695
  sandboxName: configuration.sandboxName.name,
3740
3696
  normalizeId: configuration.sandboxName.normalizeId
3741
3697
  },
3742
- ...configuration.baseUrl !== void 0 && { baseUrl: configuration.baseUrl },
3743
3698
  ...configuration.sleepAfter !== void 0 && { sleepAfter: configuration.sleepAfter },
3744
3699
  ...configuration.keepAlive !== void 0 && { keepAlive: configuration.keepAlive },
3745
- ...configuration.containerTimeouts !== void 0 && { containerTimeouts: configuration.containerTimeouts }
3700
+ ...configuration.containerTimeouts !== void 0 && { containerTimeouts: configuration.containerTimeouts },
3701
+ ...configuration.transport !== void 0 && { transport: configuration.transport }
3746
3702
  };
3747
3703
  }
3748
3704
  function applySandboxConfiguration(stub, configuration) {
3749
3705
  if (stub.configure) return stub.configure(configuration);
3750
3706
  const operations = [];
3751
3707
  if (configuration.sandboxName) operations.push(stub.setSandboxName?.(configuration.sandboxName.name, configuration.sandboxName.normalizeId) ?? Promise.resolve());
3752
- if (configuration.baseUrl !== void 0) operations.push(stub.setBaseUrl?.(configuration.baseUrl) ?? Promise.resolve());
3753
3708
  if (configuration.sleepAfter !== void 0) operations.push(stub.setSleepAfter?.(configuration.sleepAfter) ?? Promise.resolve());
3754
3709
  if (configuration.keepAlive !== void 0) operations.push(stub.setKeepAlive?.(configuration.keepAlive) ?? Promise.resolve());
3755
3710
  if (configuration.containerTimeouts !== void 0) operations.push(stub.setContainerTimeouts?.(configuration.containerTimeouts) ?? Promise.resolve());
3711
+ if (configuration.transport !== void 0) operations.push(stub.setTransport?.(configuration.transport) ?? Promise.resolve());
3756
3712
  return Promise.all(operations).then(() => void 0);
3757
3713
  }
3758
3714
  function getSandbox(ns, id, options) {
@@ -3823,13 +3779,21 @@ var Sandbox = class Sandbox extends Container {
3823
3779
  codeInterpreter;
3824
3780
  sandboxName = null;
3825
3781
  normalizeId = false;
3826
- baseUrl = null;
3827
3782
  defaultSession = null;
3783
+ containerGeneration = 0;
3784
+ defaultSessionInit = null;
3828
3785
  envVars = {};
3829
3786
  logger;
3830
3787
  keepAliveEnabled = false;
3831
3788
  activeMounts = /* @__PURE__ */ new Map();
3832
3789
  transport = "http";
3790
+ /**
3791
+ * True once transport has been written to storage at least once (either
3792
+ * via setTransport or restored on cold start). Gates the idempotency
3793
+ * check so a first explicit call persists even when the requested value
3794
+ * already equals the env-derived in-memory default.
3795
+ */
3796
+ hasStoredTransport = false;
3833
3797
  backupBucket = null;
3834
3798
  /**
3835
3799
  * Serializes backup operations to prevent concurrent create/restore on the same sandbox.
@@ -3863,6 +3827,15 @@ var Sandbox = class Sandbox extends Container {
3863
3827
  */
3864
3828
  containerTimeouts = { ...this.DEFAULT_CONTAINER_TIMEOUTS };
3865
3829
  /**
3830
+ * True once containerTimeouts has been written to storage at least once
3831
+ * (either via setContainerTimeouts or restored on cold start). Gates the
3832
+ * idempotency check in setContainerTimeouts so a first explicit call
3833
+ * persists even when the requested values already equal the in-memory
3834
+ * defaults, distinguishing "user intent recorded" from "running on
3835
+ * env/SDK defaults".
3836
+ */
3837
+ hasStoredContainerTimeouts = false;
3838
+ /**
3866
3839
  * Desktop environment operations.
3867
3840
  * Within the DO, this getter provides direct access to DesktopClient.
3868
3841
  * Over RPC, the getSandbox() proxy intercepts this property and routes
@@ -3967,16 +3940,17 @@ var Sandbox = class Sandbox extends Container {
3967
3940
  this.client = this.createSandboxClient();
3968
3941
  this.codeInterpreter = new CodeInterpreter(this);
3969
3942
  this.ctx.blockConcurrencyWhile(async () => {
3970
- this.sandboxName = await this.ctx.storage.get("sandboxName") || null;
3971
- this.normalizeId = await this.ctx.storage.get("normalizeId") || false;
3972
- this.defaultSession = await this.ctx.storage.get("defaultSession") || null;
3973
- this.keepAliveEnabled = await this.ctx.storage.get("keepAliveEnabled") || false;
3943
+ this.sandboxName = await this.ctx.storage.get("sandboxName") ?? null;
3944
+ this.normalizeId = await this.ctx.storage.get("normalizeId") ?? false;
3945
+ this.defaultSession = await this.ctx.storage.get("defaultSession") ?? null;
3946
+ this.keepAliveEnabled = await this.ctx.storage.get("keepAliveEnabled") ?? false;
3974
3947
  const storedTimeouts = await this.ctx.storage.get("containerTimeouts");
3975
3948
  if (storedTimeouts) {
3976
3949
  this.containerTimeouts = {
3977
3950
  ...this.containerTimeouts,
3978
3951
  ...storedTimeouts
3979
3952
  };
3953
+ this.hasStoredContainerTimeouts = true;
3980
3954
  this.client.setRetryTimeoutMs(this.computeRetryTimeoutMs());
3981
3955
  }
3982
3956
  const storedSleepAfter = await this.ctx.storage.get("sleepAfter");
@@ -3984,6 +3958,15 @@ var Sandbox = class Sandbox extends Container {
3984
3958
  this.sleepAfter = storedSleepAfter;
3985
3959
  this.renewActivityTimeout();
3986
3960
  }
3961
+ const storedTransport = await this.ctx.storage.get("transport");
3962
+ if (storedTransport && storedTransport !== this.transport) {
3963
+ this.transport = storedTransport;
3964
+ const previousClient = this.client;
3965
+ this.client = this.createSandboxClient();
3966
+ this.codeInterpreter = new CodeInterpreter(this);
3967
+ previousClient.disconnect();
3968
+ }
3969
+ if (storedTransport) this.hasStoredTransport = true;
3987
3970
  if (this.interceptHttps) this.envVars = {
3988
3971
  ...this.envVars,
3989
3972
  SANDBOX_INTERCEPT_HTTPS: "1"
@@ -3991,34 +3974,29 @@ var Sandbox = class Sandbox extends Container {
3991
3974
  });
3992
3975
  }
3993
3976
  async setSandboxName(name, normalizeId) {
3994
- if (!this.sandboxName) {
3995
- this.sandboxName = name;
3996
- this.normalizeId = normalizeId || false;
3997
- await this.ctx.storage.put("sandboxName", name);
3998
- await this.ctx.storage.put("normalizeId", this.normalizeId);
3999
- }
3977
+ if (this.sandboxName !== null) return;
3978
+ const effectiveNormalizeId = normalizeId ?? false;
3979
+ await Promise.all([this.ctx.storage.put("sandboxName", name), this.ctx.storage.put("normalizeId", effectiveNormalizeId)]);
3980
+ this.sandboxName = name;
3981
+ this.normalizeId = effectiveNormalizeId;
4000
3982
  }
4001
3983
  async configure(configuration) {
4002
3984
  if (configuration.sandboxName) await this.setSandboxName(configuration.sandboxName.name, configuration.sandboxName.normalizeId);
4003
- if (configuration.baseUrl !== void 0) await this.setBaseUrl(configuration.baseUrl);
4004
3985
  if (configuration.sleepAfter !== void 0) await this.setSleepAfter(configuration.sleepAfter);
4005
3986
  if (configuration.keepAlive !== void 0) await this.setKeepAlive(configuration.keepAlive);
4006
3987
  if (configuration.containerTimeouts !== void 0) await this.setContainerTimeouts(configuration.containerTimeouts);
4007
- }
4008
- async setBaseUrl(baseUrl) {
4009
- if (!this.baseUrl) {
4010
- this.baseUrl = baseUrl;
4011
- await this.ctx.storage.put("baseUrl", baseUrl);
4012
- } else if (this.baseUrl !== baseUrl) throw new Error("Base URL already set and different from one previously provided");
3988
+ if (configuration.transport !== void 0) await this.setTransport(configuration.transport);
4013
3989
  }
4014
3990
  async setSleepAfter(sleepAfter) {
4015
- this.sleepAfter = sleepAfter;
3991
+ if (this.sleepAfter === sleepAfter) return;
4016
3992
  await this.ctx.storage.put("sleepAfter", sleepAfter);
3993
+ this.sleepAfter = sleepAfter;
4017
3994
  this.renewActivityTimeout();
4018
3995
  }
4019
3996
  async setKeepAlive(keepAlive) {
4020
- this.keepAliveEnabled = keepAlive;
3997
+ if (this.keepAliveEnabled === keepAlive) return;
4021
3998
  await this.ctx.storage.put("keepAliveEnabled", keepAlive);
3999
+ this.keepAliveEnabled = keepAlive;
4022
4000
  if (!keepAlive) this.renewActivityTimeout();
4023
4001
  }
4024
4002
  async setEnvVars(envVars) {
@@ -4042,19 +4020,45 @@ var Sandbox = class Sandbox extends Container {
4042
4020
  }
4043
4021
  }
4044
4022
  /**
4045
- * RPC method to configure container startup timeouts
4023
+ * RPC method to configure container startup timeouts. Idempotent once
4024
+ * the values have been persisted: re-applying the same timeout set is a
4025
+ * no-op. The transport retry budget is recomputed only when at least
4026
+ * one timeout actually changes. Storage is written before the in-memory
4027
+ * mirror and derived state are updated.
4046
4028
  */
4047
4029
  async setContainerTimeouts(timeouts) {
4048
4030
  const validated = { ...this.containerTimeouts };
4049
4031
  if (timeouts.instanceGetTimeoutMS !== void 0) validated.instanceGetTimeoutMS = this.validateTimeout(timeouts.instanceGetTimeoutMS, "instanceGetTimeoutMS", 5e3, 3e5);
4050
4032
  if (timeouts.portReadyTimeoutMS !== void 0) validated.portReadyTimeoutMS = this.validateTimeout(timeouts.portReadyTimeoutMS, "portReadyTimeoutMS", 1e4, 6e5);
4051
4033
  if (timeouts.waitIntervalMS !== void 0) validated.waitIntervalMS = this.validateTimeout(timeouts.waitIntervalMS, "waitIntervalMS", 100, 5e3);
4034
+ if (this.hasStoredContainerTimeouts && validated.instanceGetTimeoutMS === this.containerTimeouts.instanceGetTimeoutMS && validated.portReadyTimeoutMS === this.containerTimeouts.portReadyTimeoutMS && validated.waitIntervalMS === this.containerTimeouts.waitIntervalMS) return;
4035
+ await this.ctx.storage.put("containerTimeouts", validated);
4052
4036
  this.containerTimeouts = validated;
4053
- await this.ctx.storage.put("containerTimeouts", this.containerTimeouts);
4037
+ this.hasStoredContainerTimeouts = true;
4054
4038
  this.client.setRetryTimeoutMs(this.computeRetryTimeoutMs());
4055
4039
  this.logger.debug("Container timeouts updated", this.containerTimeouts);
4056
4040
  }
4057
4041
  /**
4042
+ * RPC method to set the transport protocol. Idempotent once the value
4043
+ * has been persisted: re-applying the same transport is a no-op.
4044
+ * Storage is written before the in-memory state and client are updated.
4045
+ */
4046
+ async setTransport(transport) {
4047
+ if (transport !== "http" && transport !== "websocket") {
4048
+ this.logger.warn(`Invalid transport value: "${transport}". Must be "http" or "websocket". Ignoring.`);
4049
+ return;
4050
+ }
4051
+ if (this.hasStoredTransport && this.transport === transport) return;
4052
+ await this.ctx.storage.put("transport", transport);
4053
+ const previousClient = this.client;
4054
+ this.transport = transport;
4055
+ this.hasStoredTransport = true;
4056
+ this.client = this.createSandboxClient();
4057
+ this.codeInterpreter = new CodeInterpreter(this);
4058
+ previousClient.disconnect();
4059
+ this.logger.debug("Transport updated", { transport });
4060
+ }
4061
+ /**
4058
4062
  * Validate a timeout value is within acceptable range
4059
4063
  * Throws error if invalid - used for user-provided values
4060
4064
  */
@@ -4102,6 +4106,7 @@ var Sandbox = class Sandbox extends Container {
4102
4106
  * @throws InvalidMountConfigError if bucket name, mount path, or endpoint is invalid
4103
4107
  */
4104
4108
  async mountBucket(bucket, mountPath, options) {
4109
+ if (options.prefix !== void 0) validatePrefix(options.prefix);
4105
4110
  if ("localBucket" in options && options.localBucket) {
4106
4111
  await this.mountBucketLocal(bucket, mountPath, options);
4107
4112
  return;
@@ -4294,7 +4299,6 @@ var Sandbox = class Sandbox extends Container {
4294
4299
  validateBucketName(bucket, mountPath);
4295
4300
  if (!mountPath.startsWith("/")) throw new InvalidMountConfigError(`Mount path must be absolute (start with /): "${mountPath}"`);
4296
4301
  if (this.activeMounts.has(mountPath)) throw new InvalidMountConfigError(`Mount path "${mountPath}" is already in use by bucket "${this.activeMounts.get(mountPath)?.bucket}". Unmount the existing bucket first or use a different mount path.`);
4297
- if (options.prefix !== void 0) validatePrefix(options.prefix);
4298
4302
  }
4299
4303
  /**
4300
4304
  * Generate unique password file path for s3fs credentials
@@ -4376,6 +4380,7 @@ var Sandbox = class Sandbox extends Container {
4376
4380
  await this.deletePasswordFile(mountInfo.passwordFilePath);
4377
4381
  }
4378
4382
  }
4383
+ await this.ctx.storage.delete("portTokens");
4379
4384
  outcome = "success";
4380
4385
  await super.destroy();
4381
4386
  } catch (error) {
@@ -4392,11 +4397,84 @@ var Sandbox = class Sandbox extends Container {
4392
4397
  });
4393
4398
  }
4394
4399
  }
4395
- onStart() {
4400
+ async onStart() {
4396
4401
  this.logger.debug("Sandbox started");
4397
4402
  this.checkVersionCompatibility().catch((error) => {
4398
4403
  this.logger.error("Version compatibility check failed", error instanceof Error ? error : new Error(String(error)));
4399
4404
  });
4405
+ try {
4406
+ await this.restoreExposedPorts();
4407
+ } catch (error) {
4408
+ this.logger.error("Failed to restore exposed ports after container start", error instanceof Error ? error : new Error(String(error)));
4409
+ }
4410
+ }
4411
+ /**
4412
+ * Re-expose ports on the container runtime using tokens persisted in DO
4413
+ * storage. Called from onStart() after a container (re)start.
4414
+ *
4415
+ * The DO storage holds the source of truth for which ports should be
4416
+ * exposed, which tokens authorize them, and the friendly name (if any)
4417
+ * that the caller set when first exposing the port. If a port is already
4418
+ * exposed on the container this is a no-op for that port. Individual port
4419
+ * failures are logged but do not abort the overall restore — a transient
4420
+ * failure for one port must not prevent the others from being restored.
4421
+ */
4422
+ async restoreExposedPorts() {
4423
+ const savedTokens = await this.readPortTokens();
4424
+ const portEntries = Object.entries(savedTokens);
4425
+ if (portEntries.length === 0) return;
4426
+ const startTime = Date.now();
4427
+ let restored = 0;
4428
+ let skipped = 0;
4429
+ let failed = 0;
4430
+ const sessionId = await this.ensureDefaultSession();
4431
+ const exposedSet = await this.client.ports.getExposedPorts(sessionId).then((response) => new Set(response.ports.map((p) => p.port))).catch((error) => {
4432
+ this.logger.warn("Failed to fetch exposed ports for restore; assuming none exposed", { error: error instanceof Error ? error.message : String(error) });
4433
+ return /* @__PURE__ */ new Set();
4434
+ });
4435
+ for (const [portStr, entry] of portEntries) {
4436
+ const port = Number.parseInt(portStr, 10);
4437
+ if (!Number.isFinite(port) || !validatePort(port)) {
4438
+ this.logger.warn("Skipping restore of invalid port in storage", { port: portStr });
4439
+ failed++;
4440
+ continue;
4441
+ }
4442
+ if (exposedSet.has(port)) {
4443
+ skipped++;
4444
+ continue;
4445
+ }
4446
+ try {
4447
+ await this.client.ports.exposePort(port, sessionId, entry.name);
4448
+ restored++;
4449
+ } catch (error) {
4450
+ failed++;
4451
+ this.logger.warn("Failed to re-expose port on container restart", {
4452
+ port,
4453
+ error: error instanceof Error ? error.message : String(error)
4454
+ });
4455
+ }
4456
+ }
4457
+ logCanonicalEvent(this.logger, {
4458
+ event: "port.restore",
4459
+ outcome: failed === 0 ? "success" : "error",
4460
+ durationMs: Date.now() - startTime,
4461
+ restored,
4462
+ skipped,
4463
+ failed,
4464
+ total: portEntries.length
4465
+ });
4466
+ }
4467
+ /**
4468
+ * Read the `portTokens` map from DO storage, normalizing the legacy
4469
+ * string-valued format (just a token) to the current object format
4470
+ * ({ token, name? }). The legacy format predates port-name persistence and
4471
+ * can appear on any DO whose storage was written before that change.
4472
+ */
4473
+ async readPortTokens() {
4474
+ const raw = await this.ctx.storage.get("portTokens") ?? {};
4475
+ const normalized = {};
4476
+ for (const [port, value] of Object.entries(raw)) normalized[port] = typeof value === "string" ? { token: value } : value;
4477
+ return normalized;
4400
4478
  }
4401
4479
  /**
4402
4480
  * Check if the container version matches the SDK version
@@ -4427,10 +4505,12 @@ var Sandbox = class Sandbox extends Container {
4427
4505
  }
4428
4506
  async onStop() {
4429
4507
  this.logger.debug("Sandbox stopped");
4430
- for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
4508
+ this.containerGeneration++;
4431
4509
  this.defaultSession = null;
4510
+ this.defaultSessionInit = null;
4511
+ for (const [, m] of this.activeMounts) if (m.mountType === "local-sync") await m.syncManager.stop().catch(() => {});
4432
4512
  this.activeMounts.clear();
4433
- await Promise.all([this.ctx.storage.delete("portTokens"), this.ctx.storage.delete("defaultSession")]);
4513
+ await this.ctx.storage.delete("defaultSession");
4434
4514
  }
4435
4515
  onError(error) {
4436
4516
  this.logger.error("Sandbox error", error instanceof Error ? error : new Error(String(error)));
@@ -4681,33 +4761,46 @@ var Sandbox = class Sandbox extends Container {
4681
4761
  return 3e3;
4682
4762
  }
4683
4763
  /**
4684
- * Ensure default session exists - lazy initialization
4685
- * This is called automatically by all public methods that need a session
4686
- *
4687
- * The session ID is persisted to DO storage. On container restart, if the
4688
- * container already has this session (from a previous instance), we sync
4689
- * our state rather than failing on duplicate creation.
4764
+ * Return the default session id, lazily creating the container session
4765
+ * on first use. Called by every public method that needs a session.
4766
+ * Concurrent callers that target the same sessionId share one
4767
+ * in-flight initialization promise.
4690
4768
  */
4691
4769
  async ensureDefaultSession() {
4692
4770
  const sessionId = `sandbox-${this.sandboxName || "default"}`;
4693
4771
  if (this.defaultSession === sessionId) return this.defaultSession;
4772
+ const generation = this.containerGeneration;
4773
+ const pending = this.defaultSessionInit;
4774
+ if (pending?.sessionId === sessionId && pending.generation === generation) return pending.promise;
4775
+ const promise = this.initializeDefaultSession(sessionId, generation);
4776
+ const init = {
4777
+ sessionId,
4778
+ generation,
4779
+ promise
4780
+ };
4781
+ this.defaultSessionInit = init;
4782
+ try {
4783
+ return await promise;
4784
+ } finally {
4785
+ if (this.defaultSessionInit === init) this.defaultSessionInit = null;
4786
+ }
4787
+ }
4788
+ async initializeDefaultSession(sessionId, generation) {
4694
4789
  try {
4695
4790
  await this.client.utils.createSession({
4696
4791
  id: sessionId,
4697
4792
  env: this.envVars || {},
4698
4793
  cwd: "/workspace"
4699
4794
  });
4700
- this.defaultSession = sessionId;
4701
- await this.ctx.storage.put("defaultSession", sessionId);
4702
- this.logger.debug("Default session initialized", { sessionId });
4703
4795
  } catch (error) {
4704
- if (error instanceof SessionAlreadyExistsError) {
4705
- this.logger.debug("Session exists in container but not in DO state, syncing", { sessionId });
4706
- this.defaultSession = sessionId;
4707
- await this.ctx.storage.put("defaultSession", sessionId);
4708
- } else throw error;
4796
+ if (!(error instanceof SessionAlreadyExistsError)) throw error;
4797
+ this.logger.debug("Session exists in container but not in DO state, syncing", { sessionId });
4709
4798
  }
4710
- return this.defaultSession;
4799
+ if (generation !== this.containerGeneration) throw new Error("Default session initialization was invalidated by a container stop");
4800
+ await this.ctx.storage.put("defaultSession", sessionId);
4801
+ this.defaultSession = sessionId;
4802
+ this.logger.debug("Default session initialized", { sessionId });
4803
+ return sessionId;
4711
4804
  }
4712
4805
  async exec(command, options) {
4713
4806
  const session = await this.ensureDefaultSession();
@@ -5270,8 +5363,8 @@ var Sandbox = class Sandbox extends Container {
5270
5363
  token: options?.token
5271
5364
  })).url;
5272
5365
  } catch {
5273
- const existingToken = (await this.ctx.storage.get("portTokens") || {})["6080"];
5274
- if (existingToken && this.sandboxName) url = this.constructPreviewUrl(6080, this.sandboxName, hostname, existingToken);
5366
+ const existingEntry = (await this.readPortTokens())["6080"];
5367
+ if (existingEntry && this.sandboxName) url = this.constructPreviewUrl(6080, this.sandboxName, hostname, existingEntry.token);
5275
5368
  else throw new Error("Failed to get desktop stream URL: port 6080 could not be exposed and no existing token found.");
5276
5369
  }
5277
5370
  try {
@@ -5330,6 +5423,12 @@ var Sandbox = class Sandbox extends Container {
5330
5423
  /**
5331
5424
  * Expose a port and get a preview URL for accessing services running in the sandbox
5332
5425
  *
5426
+ * Preview URLs survive transient container restarts: the token and any
5427
+ * friendly name are persisted in Durable Object storage, and the port is
5428
+ * automatically re-exposed on the container when it comes back up. Tokens
5429
+ * are cleared only on explicit `unexposePort()` or full sandbox
5430
+ * `destroy()`.
5431
+ *
5333
5432
  * @param port - Port number to expose (1024-65535)
5334
5433
  * @param options - Configuration options
5335
5434
  * @param options.hostname - Your Worker's domain name (required for preview URL construction)
@@ -5370,12 +5469,15 @@ var Sandbox = class Sandbox extends Container {
5370
5469
  this.validateCustomToken(options.token);
5371
5470
  token = options.token;
5372
5471
  } else token = this.generatePortToken();
5373
- const tokens = await this.ctx.storage.get("portTokens") || {};
5374
- const existingPort = Object.entries(tokens).find(([p, t]) => t === token && p !== port.toString());
5472
+ const tokens = await this.readPortTokens();
5473
+ const existingPort = Object.entries(tokens).find(([p, entry]) => entry.token === token && p !== port.toString());
5375
5474
  if (existingPort) throw new SecurityError(`Token '${token}' is already in use by port ${existingPort[0]}. Please use a different token.`);
5376
5475
  const sessionId = await this.ensureDefaultSession();
5377
5476
  await this.client.ports.exposePort(port, sessionId, options?.name);
5378
- tokens[port.toString()] = token;
5477
+ tokens[port.toString()] = {
5478
+ token,
5479
+ name: options?.name
5480
+ };
5379
5481
  await this.ctx.storage.put("portTokens", tokens);
5380
5482
  const url = this.constructPreviewUrl(port, this.sandboxName, options.hostname, token);
5381
5483
  outcome = "success";
@@ -5405,13 +5507,17 @@ var Sandbox = class Sandbox extends Container {
5405
5507
  let caughtError;
5406
5508
  try {
5407
5509
  if (!validatePort(port)) throw new SecurityError(`Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).`);
5408
- const sessionId = await this.ensureDefaultSession();
5409
- await this.client.ports.unexposePort(port, sessionId);
5410
- const tokens = await this.ctx.storage.get("portTokens") || {};
5510
+ const tokens = await this.readPortTokens();
5411
5511
  if (tokens[port.toString()]) {
5412
5512
  delete tokens[port.toString()];
5413
5513
  await this.ctx.storage.put("portTokens", tokens);
5414
5514
  }
5515
+ const sessionId = await this.ensureDefaultSession();
5516
+ try {
5517
+ await this.client.ports.unexposePort(port, sessionId);
5518
+ } catch (error) {
5519
+ if (!(error instanceof PortNotExposedError)) throw error;
5520
+ }
5415
5521
  outcome = "success";
5416
5522
  } catch (error) {
5417
5523
  caughtError = error instanceof Error ? error : new Error(String(error));
@@ -5430,15 +5536,18 @@ var Sandbox = class Sandbox extends Container {
5430
5536
  const sessionId = await this.ensureDefaultSession();
5431
5537
  const response = await this.client.ports.getExposedPorts(sessionId);
5432
5538
  if (!this.sandboxName) throw new Error("Sandbox name not available. Ensure sandbox is accessed through getSandbox()");
5433
- const tokens = await this.ctx.storage.get("portTokens") || {};
5434
- return response.ports.map((port) => {
5435
- const token = tokens[port.port.toString()];
5436
- if (!token) throw new Error(`Port ${port.port} is exposed but has no token. This should not happen.`);
5437
- return {
5438
- url: this.constructPreviewUrl(port.port, this.sandboxName, hostname, token),
5539
+ const tokens = await this.readPortTokens();
5540
+ return response.ports.flatMap((port) => {
5541
+ const entry = tokens[port.port.toString()];
5542
+ if (!entry) {
5543
+ this.logger.warn("Port exposed on container but no token in storage; omitting from preview URL list", { port: port.port });
5544
+ return [];
5545
+ }
5546
+ return [{
5547
+ url: this.constructPreviewUrl(port.port, this.sandboxName, hostname, entry.token),
5439
5548
  port: port.port,
5440
5549
  status: port.status
5441
- };
5550
+ }];
5442
5551
  });
5443
5552
  }
5444
5553
  async isPortExposed(port) {
@@ -5451,14 +5560,10 @@ var Sandbox = class Sandbox extends Container {
5451
5560
  }
5452
5561
  }
5453
5562
  async validatePortToken(port, token) {
5454
- if (!await this.isPortExposed(port)) return false;
5455
- const storedToken = (await this.ctx.storage.get("portTokens") || {})[port.toString()];
5456
- if (!storedToken) {
5457
- this.logger.error("Port is exposed but has no token - bug detected", void 0, { port });
5458
- return false;
5459
- }
5563
+ const entry = (await this.readPortTokens())[port.toString()];
5564
+ if (!entry) return false;
5460
5565
  const encoder = new TextEncoder();
5461
- const a = encoder.encode(storedToken);
5566
+ const a = encoder.encode(entry.token);
5462
5567
  const b = encoder.encode(token);
5463
5568
  try {
5464
5569
  return crypto.subtle.timingSafeEqual(a, b);
@@ -5732,18 +5837,6 @@ var Sandbox = class Sandbox extends Container {
5732
5837
  };
5733
5838
  }
5734
5839
  /**
5735
- * Generate a presigned GET URL for downloading an object from R2.
5736
- * The container can curl this URL directly without credentials.
5737
- */
5738
- async generatePresignedGetUrl(r2Key) {
5739
- const { client, accountId, bucketName } = this.requirePresignedUrlSupport();
5740
- const encodedBucket = encodeURIComponent(bucketName);
5741
- const encodedKey = r2Key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
5742
- const url = new URL(`https://${accountId}.r2.cloudflarestorage.com/${encodedBucket}/${encodedKey}`);
5743
- url.searchParams.set("X-Amz-Expires", String(Sandbox.PRESIGNED_URL_EXPIRY_SECONDS));
5744
- return (await client.sign(new Request(url), { aws: { signQuery: true } })).url;
5745
- }
5746
- /**
5747
5840
  * Generate a presigned PUT URL for uploading an object to R2.
5748
5841
  * The container can curl PUT to this URL directly without credentials.
5749
5842
  */
@@ -5880,15 +5973,14 @@ var Sandbox = class Sandbox extends Container {
5880
5973
  * under the `backups/` prefix after the desired retention period.
5881
5974
  */
5882
5975
  async createBackup(options) {
5976
+ if (options.localBucket) return this.enqueueBackupOp(() => this.doCreateBackupLocal(options));
5883
5977
  this.requireBackupBucket();
5884
5978
  return this.enqueueBackupOp(() => this.doCreateBackup(options));
5885
5979
  }
5886
5980
  async doCreateBackup(options) {
5887
5981
  const bucket = this.requireBackupBucket();
5888
5982
  this.requirePresignedUrlSupport();
5889
- const DEFAULT_TTL_SECONDS = 259200;
5890
- const MAX_NAME_LENGTH = 256;
5891
- const { dir, name, ttl = DEFAULT_TTL_SECONDS, gitignore = false, excludes = [] } = options;
5983
+ const { dir, name, ttl = BACKUP_DEFAULT_TTL_SECONDS, gitignore = false, excludes = [] } = options;
5892
5984
  const backupStartTime = Date.now();
5893
5985
  let backupId;
5894
5986
  let sizeBytes;
@@ -5898,11 +5990,11 @@ var Sandbox = class Sandbox extends Container {
5898
5990
  try {
5899
5991
  Sandbox.validateBackupDir(dir, "BackupOptions.dir");
5900
5992
  if (name !== void 0) {
5901
- if (typeof name !== "string" || name.length > MAX_NAME_LENGTH) throw new InvalidBackupConfigError({
5902
- message: `BackupOptions.name must be a string of at most ${MAX_NAME_LENGTH} characters`,
5993
+ if (typeof name !== "string" || name.length > BACKUP_MAX_NAME_LENGTH) throw new InvalidBackupConfigError({
5994
+ message: `BackupOptions.name must be a string of at most ${BACKUP_MAX_NAME_LENGTH} characters`,
5903
5995
  code: ErrorCode.INVALID_BACKUP_CONFIG,
5904
5996
  httpStatus: 400,
5905
- context: { reason: `name must be a string of at most ${MAX_NAME_LENGTH} characters` },
5997
+ context: { reason: `name must be a string of at most ${BACKUP_MAX_NAME_LENGTH} characters` },
5906
5998
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
5907
5999
  });
5908
6000
  if (/[\u0000-\u001f\u007f]/.test(name)) throw new InvalidBackupConfigError({
@@ -5936,7 +6028,7 @@ var Sandbox = class Sandbox extends Container {
5936
6028
  });
5937
6029
  backupSession = await this.ensureBackupSession();
5938
6030
  backupId = crypto.randomUUID();
5939
- const archivePath = `/var/backups/${backupId}.sqsh`;
6031
+ const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
5940
6032
  const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, gitignore, excludes);
5941
6033
  if (!createResult.success) throw new BackupCreateError({
5942
6034
  message: "Container failed to create backup archive",
@@ -5949,8 +6041,8 @@ var Sandbox = class Sandbox extends Container {
5949
6041
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
5950
6042
  });
5951
6043
  sizeBytes = createResult.sizeBytes;
5952
- const r2Key = `backups/${backupId}/data.sqsh`;
5953
- const metaKey = `backups/${backupId}/meta.json`;
6044
+ const r2Key = `${BACKUP_STORAGE_PREFIX}/${backupId}/${BACKUP_ARCHIVE_OBJECT_NAME}`;
6045
+ const metaKey = `${BACKUP_STORAGE_PREFIX}/${backupId}/${BACKUP_METADATA_OBJECT_NAME}`;
5954
6046
  await this.uploadBackupPresigned(archivePath, r2Key, createResult.sizeBytes, backupId, dir, backupSession);
5955
6047
  const metadata = {
5956
6048
  id: backupId,
@@ -5970,9 +6062,9 @@ var Sandbox = class Sandbox extends Container {
5970
6062
  } catch (error) {
5971
6063
  caughtError = error instanceof Error ? error : new Error(String(error));
5972
6064
  if (backupId && backupSession) {
5973
- const archivePath = `/var/backups/${backupId}.sqsh`;
5974
- const r2Key = `backups/${backupId}/data.sqsh`;
5975
- const metaKey = `backups/${backupId}/meta.json`;
6065
+ const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
6066
+ const r2Key = `${BACKUP_STORAGE_PREFIX}/${backupId}/${BACKUP_ARCHIVE_OBJECT_NAME}`;
6067
+ const metaKey = `${BACKUP_STORAGE_PREFIX}/${backupId}/${BACKUP_METADATA_OBJECT_NAME}`;
5976
6068
  await this.execWithSession(`rm -f ${shellEscape(archivePath)}`, backupSession, { origin: "internal" }).catch(() => {});
5977
6069
  await bucket.delete(r2Key).catch(() => {});
5978
6070
  await bucket.delete(metaKey).catch(() => {});
@@ -5993,61 +6085,200 @@ var Sandbox = class Sandbox extends Container {
5993
6085
  }
5994
6086
  }
5995
6087
  /**
5996
- * Restore a backup from R2 into a directory.
5997
- *
5998
- * Flow:
5999
- * 1. DO reads metadata from R2 and checks TTL
6000
- * 2. Container mounts the backup archive from R2 via s3fs
6001
- * 3. Container mounts the squashfs archive with FUSE overlayfs
6002
- *
6003
- * The target directory becomes an overlay mount with the backup as a
6004
- * read-only lower layer and a writable upper layer for copy-on-write.
6005
- * Any processes writing to the directory should be stopped first.
6006
- *
6007
- * **Mount Lifecycle**: The FUSE overlay mount persists only while the
6008
- * container is running. When the sandbox sleeps or the container restarts,
6009
- * the mount is lost and the directory becomes empty. Re-restore from the
6010
- * backup handle to recover. This is an ephemeral restore, not a persistent
6011
- * extraction.
6012
- *
6013
- * The backup is restored into `backup.dir`. This may differ from the
6014
- * directory that was originally backed up, allowing cross-directory restore.
6015
- *
6016
- * Overlapping backups are independent: restoring a parent directory
6017
- * overwrites everything inside it, including subdirectories that were
6018
- * backed up separately. When restoring both, restore the parent first.
6019
- *
6020
- * Concurrent backup/restore calls on the same sandbox are serialized.
6088
+ * Local-dev implementation of createBackup.
6089
+ * Uses the R2 binding directly instead of presigned URLs.
6090
+ * Archive format is identical to production (squashfs + meta.json).
6021
6091
  */
6022
- async restoreBackup(backup) {
6023
- this.requireBackupBucket();
6024
- return this.enqueueBackupOp(() => this.doRestoreBackup(backup));
6025
- }
6026
- async doRestoreBackup(backup) {
6027
- const restoreStartTime = Date.now();
6028
- const bucket = this.requireBackupBucket();
6029
- this.requirePresignedUrlSupport();
6030
- const { id, dir } = backup;
6092
+ async doCreateBackupLocal(options) {
6093
+ const { dir, name, ttl = BACKUP_DEFAULT_TTL_SECONDS, gitignore = false, excludes = [] } = options;
6094
+ const backupStartTime = Date.now();
6095
+ let backupId;
6096
+ let sizeBytes;
6031
6097
  let outcome = "error";
6032
6098
  let caughtError;
6033
6099
  let backupSession;
6100
+ const bucket = this.env.BACKUP_BUCKET;
6101
+ if (!bucket || !isR2Bucket(bucket)) throw new InvalidBackupConfigError({
6102
+ message: "BACKUP_BUCKET R2 binding not found in env. Add a BACKUP_BUCKET R2 binding to your wrangler.jsonc for local backup support.",
6103
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6104
+ httpStatus: 400,
6105
+ context: { reason: "Missing BACKUP_BUCKET R2 binding" },
6106
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6107
+ });
6034
6108
  try {
6035
- if (!id || typeof id !== "string") throw new InvalidBackupConfigError({
6036
- message: "Invalid backup: missing or invalid id",
6109
+ Sandbox.validateBackupDir(dir, "BackupOptions.dir");
6110
+ if (name !== void 0) {
6111
+ if (typeof name !== "string" || name.length > BACKUP_MAX_NAME_LENGTH) throw new InvalidBackupConfigError({
6112
+ message: `BackupOptions.name must be a string of at most ${BACKUP_MAX_NAME_LENGTH} characters`,
6113
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6114
+ httpStatus: 400,
6115
+ context: { reason: `name must be a string of at most ${BACKUP_MAX_NAME_LENGTH} characters` },
6116
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6117
+ });
6118
+ if (/[\u0000-\u001f\u007f]/.test(name)) throw new InvalidBackupConfigError({
6119
+ message: "BackupOptions.name must not contain control characters",
6120
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6121
+ httpStatus: 400,
6122
+ context: { reason: "name must not contain control characters" },
6123
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6124
+ });
6125
+ }
6126
+ if (ttl <= 0) throw new InvalidBackupConfigError({
6127
+ message: "BackupOptions.ttl must be a positive number of seconds",
6037
6128
  code: ErrorCode.INVALID_BACKUP_CONFIG,
6038
6129
  httpStatus: 400,
6039
- context: { reason: "missing or invalid id" },
6130
+ context: { reason: "ttl must be a positive number of seconds" },
6040
6131
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6041
6132
  });
6042
- if (!Sandbox.UUID_REGEX.test(id)) throw new InvalidBackupConfigError({
6043
- message: "Invalid backup: id must be a valid UUID (e.g. from createBackup)",
6133
+ if (typeof gitignore !== "boolean") throw new InvalidBackupConfigError({
6134
+ message: "BackupOptions.gitignore must be a boolean",
6044
6135
  code: ErrorCode.INVALID_BACKUP_CONFIG,
6045
6136
  httpStatus: 400,
6046
- context: { reason: "id must be a valid UUID" },
6137
+ context: { reason: "gitignore must be a boolean" },
6138
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6139
+ });
6140
+ if (!Array.isArray(excludes) || !excludes.every((e) => typeof e === "string")) throw new InvalidBackupConfigError({
6141
+ message: "BackupOptions.excludes must be an array of strings",
6142
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6143
+ httpStatus: 400,
6144
+ context: { reason: "excludes must be an array of strings" },
6145
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6146
+ });
6147
+ backupSession = await this.ensureBackupSession();
6148
+ backupId = crypto.randomUUID();
6149
+ const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
6150
+ const createResult = await this.client.backup.createArchive(dir, archivePath, backupSession, gitignore, excludes);
6151
+ if (!createResult.success) throw new BackupCreateError({
6152
+ message: "Container failed to create backup archive",
6153
+ code: ErrorCode.BACKUP_CREATE_FAILED,
6154
+ httpStatus: 500,
6155
+ context: {
6156
+ dir,
6157
+ backupId
6158
+ },
6159
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6160
+ });
6161
+ sizeBytes = createResult.sizeBytes;
6162
+ const r2Key = `${BACKUP_STORAGE_PREFIX}/${backupId}/${BACKUP_ARCHIVE_OBJECT_NAME}`;
6163
+ const metaKey = `${BACKUP_STORAGE_PREFIX}/${backupId}/${BACKUP_METADATA_OBJECT_NAME}`;
6164
+ const { content } = await collectFile(await this.client.files.readFileStream(archivePath, backupSession));
6165
+ const archiveData = content instanceof Uint8Array ? content : new TextEncoder().encode(content);
6166
+ await bucket.put(r2Key, archiveData);
6167
+ const head = await bucket.head(r2Key);
6168
+ if (!head || head.size !== createResult.sizeBytes) throw new BackupCreateError({
6169
+ message: `Upload verification failed: expected ${createResult.sizeBytes} bytes, got ${head?.size ?? 0}`,
6170
+ code: ErrorCode.BACKUP_CREATE_FAILED,
6171
+ httpStatus: 500,
6172
+ context: {
6173
+ dir,
6174
+ backupId
6175
+ },
6176
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6177
+ });
6178
+ const metadata = {
6179
+ id: backupId,
6180
+ dir,
6181
+ name: name || null,
6182
+ sizeBytes: createResult.sizeBytes,
6183
+ ttl,
6184
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
6185
+ };
6186
+ await bucket.put(metaKey, JSON.stringify(metadata));
6187
+ outcome = "success";
6188
+ await this.execWithSession(`rm -f ${shellEscape(archivePath)}`, backupSession, { origin: "internal" }).catch(() => {});
6189
+ return {
6190
+ id: backupId,
6191
+ dir,
6192
+ localBucket: true
6193
+ };
6194
+ } catch (error) {
6195
+ caughtError = error instanceof Error ? error : new Error(String(error));
6196
+ if (backupId && backupSession) {
6197
+ const archivePath = `${BACKUP_CONTAINER_DIR}/${backupId}.sqsh`;
6198
+ const r2Key = `${BACKUP_STORAGE_PREFIX}/${backupId}/${BACKUP_ARCHIVE_OBJECT_NAME}`;
6199
+ const metaKey = `${BACKUP_STORAGE_PREFIX}/${backupId}/${BACKUP_METADATA_OBJECT_NAME}`;
6200
+ await this.execWithSession(`rm -f ${shellEscape(archivePath)}`, backupSession, { origin: "internal" }).catch(() => {});
6201
+ await bucket.delete(r2Key).catch(() => {});
6202
+ await bucket.delete(metaKey).catch(() => {});
6203
+ }
6204
+ throw error;
6205
+ } finally {
6206
+ if (backupSession) await this.client.utils.deleteSession(backupSession).catch(() => {});
6207
+ logCanonicalEvent(this.logger, {
6208
+ event: "backup.create",
6209
+ outcome,
6210
+ durationMs: Date.now() - backupStartTime,
6211
+ backupId,
6212
+ dir,
6213
+ name,
6214
+ sizeBytes,
6215
+ provider: "local-binding",
6216
+ error: caughtError
6217
+ });
6218
+ }
6219
+ }
6220
+ /**
6221
+ * Restore a backup from R2 into a directory.
6222
+ *
6223
+ * **Production flow** (`localBucket` not set):
6224
+ * 1. DO reads metadata from R2 and checks TTL
6225
+ * 2. Container mounts the backup archive from R2 via s3fs
6226
+ * 3. Container mounts the squashfs archive with FUSE overlayfs
6227
+ *
6228
+ * The target directory becomes an overlay mount with the backup as a
6229
+ * read-only lower layer and a writable upper layer for copy-on-write.
6230
+ * Any processes writing to the directory should be stopped first.
6231
+ *
6232
+ * **Mount Lifecycle**: The FUSE overlay mount persists only while the
6233
+ * container is running. When the sandbox sleeps or the container restarts,
6234
+ * the mount is lost and the directory becomes empty. Re-restore from the
6235
+ * backup handle to recover. This is an ephemeral restore, not a persistent
6236
+ * extraction.
6237
+ *
6238
+ * **Local-dev flow** (`localBucket: true` on the originating `createBackup` call):
6239
+ * 1. DO reads metadata and checks TTL via R2 binding
6240
+ * 2. DO downloads the archive from R2 and writes it to the container
6241
+ * 3. Container extracts the archive with `unsquashfs` (no FUSE needed)
6242
+ *
6243
+ * The backup is restored into `backup.dir`. This may differ from the
6244
+ * directory that was originally backed up, allowing cross-directory restore.
6245
+ *
6246
+ * Overlapping backups are independent: restoring a parent directory
6247
+ * overwrites everything inside it, including subdirectories that were
6248
+ * backed up separately. When restoring both, restore the parent first.
6249
+ *
6250
+ * Concurrent backup/restore calls on the same sandbox are serialized.
6251
+ */
6252
+ async restoreBackup(backup) {
6253
+ if (backup.localBucket) return this.enqueueBackupOp(() => this.doRestoreBackupLocal(backup));
6254
+ this.requireBackupBucket();
6255
+ return this.enqueueBackupOp(() => this.doRestoreBackup(backup));
6256
+ }
6257
+ async doRestoreBackup(backup) {
6258
+ const restoreStartTime = Date.now();
6259
+ const bucket = this.requireBackupBucket();
6260
+ this.requirePresignedUrlSupport();
6261
+ const { id, dir } = backup;
6262
+ let outcome = "error";
6263
+ let caughtError;
6264
+ let backupSession;
6265
+ try {
6266
+ if (!id || typeof id !== "string") throw new InvalidBackupConfigError({
6267
+ message: "Invalid backup: missing or invalid id",
6268
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6269
+ httpStatus: 400,
6270
+ context: { reason: "missing or invalid id" },
6271
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6272
+ });
6273
+ if (!Sandbox.UUID_REGEX.test(id)) throw new InvalidBackupConfigError({
6274
+ message: "Invalid backup: id must be a valid UUID (e.g. from createBackup)",
6275
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6276
+ httpStatus: 400,
6277
+ context: { reason: "id must be a valid UUID" },
6047
6278
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6048
6279
  });
6049
6280
  Sandbox.validateBackupDir(dir, "Invalid backup: dir");
6050
- const metaKey = `backups/${id}/meta.json`;
6281
+ const metaKey = `${BACKUP_STORAGE_PREFIX}/${id}/${BACKUP_METADATA_OBJECT_NAME}`;
6051
6282
  const metaObject = await bucket.get(metaKey);
6052
6283
  if (!metaObject) throw new BackupNotFoundError({
6053
6284
  message: `Backup not found: ${id}. Verify the backup ID is correct and the backup has not been deleted.`,
@@ -6080,7 +6311,7 @@ var Sandbox = class Sandbox extends Container {
6080
6311
  },
6081
6312
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6082
6313
  });
6083
- const r2Key = `backups/${id}/data.sqsh`;
6314
+ const r2Key = `${BACKUP_STORAGE_PREFIX}/${id}/${BACKUP_ARCHIVE_OBJECT_NAME}`;
6084
6315
  if (!await bucket.head(r2Key)) throw new BackupNotFoundError({
6085
6316
  message: `Backup archive not found in R2: ${id}. The archive may have been deleted by R2 lifecycle rules.`,
6086
6317
  code: ErrorCode.BACKUP_NOT_FOUND,
@@ -6089,7 +6320,7 @@ var Sandbox = class Sandbox extends Container {
6089
6320
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6090
6321
  });
6091
6322
  backupSession = await this.ensureBackupSession();
6092
- const r2MountPath = `/var/backups/r2mount/${id}`;
6323
+ const r2MountPath = `${BACKUP_CONTAINER_DIR}/r2mount/${id}`;
6093
6324
  const archivePath = `${r2MountPath}/data.sqsh`;
6094
6325
  const mountGlob = `/var/backups/mounts/r2mount/${id}/data`;
6095
6326
  await this.execWithSession(`/usr/bin/fusermount3 -uz ${shellEscape(dir)} 2>/dev/null || true`, backupSession, { origin: "internal" }).catch(() => {});
@@ -6133,130 +6364,139 @@ var Sandbox = class Sandbox extends Container {
6133
6364
  });
6134
6365
  }
6135
6366
  }
6136
- };
6137
-
6138
- //#endregion
6139
- //#region src/file-stream.ts
6140
- /**
6141
- * Parse SSE (Server-Sent Events) lines from a stream
6142
- */
6143
- async function* parseSSE(stream) {
6144
- const reader = stream.getReader();
6145
- const decoder = new TextDecoder();
6146
- let buffer = "";
6147
- let currentEvent = { data: [] };
6148
- try {
6149
- while (true) {
6150
- const { done, value } = await reader.read();
6151
- if (done) break;
6152
- buffer += decoder.decode(value, { stream: true });
6153
- const parsed = parseSSEFrames(buffer, currentEvent);
6154
- buffer = parsed.remaining;
6155
- currentEvent = parsed.currentEvent;
6156
- for (const frame of parsed.events) try {
6157
- yield JSON.parse(frame.data);
6158
- } catch {}
6159
- }
6160
- const finalParsed = parseSSEFrames(`${buffer}\n\n`, currentEvent);
6161
- for (const frame of finalParsed.events) try {
6162
- yield JSON.parse(frame.data);
6163
- } catch {}
6164
- } finally {
6367
+ /**
6368
+ * Local-dev implementation of restoreBackup.
6369
+ * Uses the R2 binding directly instead of presigned URLs, and
6370
+ * unsquashfs for extraction instead of squashfuse + fuse-overlayfs.
6371
+ */
6372
+ async doRestoreBackupLocal(backup) {
6373
+ const restoreStartTime = Date.now();
6374
+ const { id, dir } = backup;
6375
+ let outcome = "error";
6376
+ let caughtError;
6377
+ let backupSession;
6378
+ const bucket = this.env.BACKUP_BUCKET;
6379
+ if (!bucket || !isR2Bucket(bucket)) throw new InvalidBackupConfigError({
6380
+ message: "BACKUP_BUCKET R2 binding not found in env. Add a BACKUP_BUCKET R2 binding to your wrangler.jsonc for local backup support.",
6381
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6382
+ httpStatus: 400,
6383
+ context: { reason: "Missing BACKUP_BUCKET R2 binding" },
6384
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6385
+ });
6165
6386
  try {
6166
- await reader.cancel();
6167
- } catch {}
6168
- reader.releaseLock();
6169
- }
6170
- }
6171
- /**
6172
- * Stream a file from the sandbox with automatic base64 decoding for binary files
6173
- *
6174
- * @param stream - The ReadableStream from readFileStream()
6175
- * @returns AsyncGenerator that yields FileChunk (string for text, Uint8Array for binary)
6176
- *
6177
- * @example
6178
- * ```ts
6179
- * const stream = await sandbox.readFileStream('/path/to/file.png');
6180
- * for await (const chunk of streamFile(stream)) {
6181
- * if (chunk instanceof Uint8Array) {
6182
- * // Binary chunk
6183
- * console.log('Binary chunk:', chunk.length, 'bytes');
6184
- * } else {
6185
- * // Text chunk
6186
- * console.log('Text chunk:', chunk);
6187
- * }
6188
- * }
6189
- * ```
6190
- */
6191
- async function* streamFile(stream) {
6192
- let metadata = null;
6193
- for await (const event of parseSSE(stream)) switch (event.type) {
6194
- case "metadata":
6195
- metadata = {
6196
- mimeType: event.mimeType,
6197
- size: event.size,
6198
- isBinary: event.isBinary,
6199
- encoding: event.encoding
6387
+ if (!id || typeof id !== "string") throw new InvalidBackupConfigError({
6388
+ message: "Invalid backup: missing or invalid id",
6389
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6390
+ httpStatus: 400,
6391
+ context: { reason: "missing or invalid id" },
6392
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6393
+ });
6394
+ if (!Sandbox.UUID_REGEX.test(id)) throw new InvalidBackupConfigError({
6395
+ message: "Invalid backup: id must be a valid UUID (e.g. from createBackup)",
6396
+ code: ErrorCode.INVALID_BACKUP_CONFIG,
6397
+ httpStatus: 400,
6398
+ context: { reason: "id must be a valid UUID" },
6399
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6400
+ });
6401
+ Sandbox.validateBackupDir(dir, "Invalid backup: dir");
6402
+ const metaKey = `${BACKUP_STORAGE_PREFIX}/${id}/${BACKUP_METADATA_OBJECT_NAME}`;
6403
+ const metaObject = await bucket.get(metaKey);
6404
+ if (!metaObject) throw new BackupNotFoundError({
6405
+ message: `Backup not found: ${id}. Verify the backup ID is correct and the backup has not been deleted.`,
6406
+ code: ErrorCode.BACKUP_NOT_FOUND,
6407
+ httpStatus: 404,
6408
+ context: { backupId: id },
6409
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6410
+ });
6411
+ const metadata = await metaObject.json();
6412
+ const TTL_BUFFER_MS = 60 * 1e3;
6413
+ const createdAt = new Date(metadata.createdAt).getTime();
6414
+ if (Number.isNaN(createdAt)) throw new BackupRestoreError({
6415
+ message: `Backup metadata has invalid createdAt timestamp: ${metadata.createdAt}`,
6416
+ code: ErrorCode.BACKUP_RESTORE_FAILED,
6417
+ httpStatus: 500,
6418
+ context: {
6419
+ dir,
6420
+ backupId: id
6421
+ },
6422
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6423
+ });
6424
+ const expiresAt = createdAt + metadata.ttl * 1e3;
6425
+ if (Date.now() + TTL_BUFFER_MS > expiresAt) throw new BackupExpiredError({
6426
+ message: `Backup ${id} has expired (created: ${metadata.createdAt}, TTL: ${metadata.ttl}s). Create a new backup.`,
6427
+ code: ErrorCode.BACKUP_EXPIRED,
6428
+ httpStatus: 400,
6429
+ context: {
6430
+ backupId: id,
6431
+ expiredAt: new Date(expiresAt).toISOString()
6432
+ },
6433
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6434
+ });
6435
+ const r2Key = `${BACKUP_STORAGE_PREFIX}/${id}/${BACKUP_ARCHIVE_OBJECT_NAME}`;
6436
+ const archiveObject = await bucket.get(r2Key);
6437
+ if (!archiveObject) throw new BackupNotFoundError({
6438
+ message: `Backup archive not found in R2: ${id}. The archive may have been deleted by R2 lifecycle rules.`,
6439
+ code: ErrorCode.BACKUP_NOT_FOUND,
6440
+ httpStatus: 404,
6441
+ context: { backupId: id },
6442
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6443
+ });
6444
+ backupSession = await this.ensureBackupSession();
6445
+ const archivePath = `${BACKUP_CONTAINER_DIR}/${id}.sqsh`;
6446
+ const archiveBuffer = await archiveObject.arrayBuffer();
6447
+ const base64Content = Buffer.from(archiveBuffer).toString("base64");
6448
+ await this.execWithSession(`mkdir -p ${BACKUP_CONTAINER_DIR}`, backupSession, { origin: "internal" });
6449
+ const writeResult = await this.client.files.writeFile(archivePath, base64Content, backupSession, { encoding: "base64" });
6450
+ if (!writeResult.success) throw new BackupRestoreError({
6451
+ message: `Failed to write backup archive to ${archivePath}: ${"error" in writeResult && typeof writeResult.error === "object" && writeResult.error !== null && "message" in writeResult.error && typeof writeResult.error.message === "string" ? writeResult.error.message : `File write returned success: false for '${archivePath}'`}`,
6452
+ code: ErrorCode.BACKUP_RESTORE_FAILED,
6453
+ httpStatus: 500,
6454
+ context: {
6455
+ dir,
6456
+ backupId: id
6457
+ },
6458
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6459
+ });
6460
+ const extractResult = await this.execWithSession(`/usr/bin/unsquashfs -f -d ${shellEscape(dir)} ${shellEscape(archivePath)}`, backupSession, { origin: "internal" });
6461
+ if (extractResult.exitCode !== 0) throw new BackupRestoreError({
6462
+ message: `unsquashfs extraction failed (exit code ${extractResult.exitCode}): ${extractResult.stderr}`,
6463
+ code: ErrorCode.BACKUP_RESTORE_FAILED,
6464
+ httpStatus: 500,
6465
+ context: {
6466
+ dir,
6467
+ backupId: id
6468
+ },
6469
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6470
+ });
6471
+ await this.execWithSession(`rm -f ${shellEscape(archivePath)}`, backupSession, { origin: "internal" }).catch(() => {});
6472
+ outcome = "success";
6473
+ return {
6474
+ success: true,
6475
+ dir,
6476
+ id
6200
6477
  };
6201
- break;
6202
- case "chunk":
6203
- if (!metadata) throw new Error("Received chunk before metadata");
6204
- if (metadata.isBinary && metadata.encoding === "base64") {
6205
- const binaryString = atob(event.data);
6206
- const bytes = new Uint8Array(binaryString.length);
6207
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
6208
- yield bytes;
6209
- } else yield event.data;
6210
- break;
6211
- case "complete":
6212
- if (!metadata) throw new Error("Stream completed without metadata");
6213
- return metadata;
6214
- case "error": throw new Error(`File streaming error: ${event.error}`);
6215
- }
6216
- throw new Error("Stream ended unexpectedly");
6217
- }
6218
- /**
6219
- * Collect an entire file into memory from a stream
6220
- *
6221
- * @param stream - The ReadableStream from readFileStream()
6222
- * @returns Object containing the file content and metadata
6223
- *
6224
- * @example
6225
- * ```ts
6226
- * const stream = await sandbox.readFileStream('/path/to/file.txt');
6227
- * const { content, metadata } = await collectFile(stream);
6228
- * console.log('Content:', content);
6229
- * console.log('MIME type:', metadata.mimeType);
6230
- * ```
6231
- */
6232
- async function collectFile(stream) {
6233
- const chunks = [];
6234
- const generator = streamFile(stream);
6235
- let result = await generator.next();
6236
- while (!result.done) {
6237
- chunks.push(result.value);
6238
- result = await generator.next();
6239
- }
6240
- const metadata = result.value;
6241
- if (!metadata) throw new Error("Failed to get file metadata");
6242
- if (metadata.isBinary) {
6243
- const totalLength = chunks.reduce((sum, chunk) => sum + (chunk instanceof Uint8Array ? chunk.length : 0), 0);
6244
- const combined = new Uint8Array(totalLength);
6245
- let offset = 0;
6246
- for (const chunk of chunks) if (chunk instanceof Uint8Array) {
6247
- combined.set(chunk, offset);
6248
- offset += chunk.length;
6478
+ } catch (error) {
6479
+ caughtError = error instanceof Error ? error : new Error(String(error));
6480
+ if (id && backupSession) {
6481
+ const archivePath = `${BACKUP_CONTAINER_DIR}/${id}.sqsh`;
6482
+ await this.execWithSession(`rm -f ${shellEscape(archivePath)}`, backupSession, { origin: "internal" }).catch(() => {});
6483
+ }
6484
+ throw error;
6485
+ } finally {
6486
+ if (backupSession) await this.client.utils.deleteSession(backupSession).catch(() => {});
6487
+ logCanonicalEvent(this.logger, {
6488
+ event: "backup.restore",
6489
+ outcome,
6490
+ durationMs: Date.now() - restoreStartTime,
6491
+ backupId: id,
6492
+ dir,
6493
+ provider: "local-binding",
6494
+ error: caughtError
6495
+ });
6249
6496
  }
6250
- return {
6251
- content: combined,
6252
- metadata
6253
- };
6254
- } else return {
6255
- content: chunks.filter((c) => typeof c === "string").join(""),
6256
- metadata
6257
- };
6258
- }
6497
+ }
6498
+ };
6259
6499
 
6260
6500
  //#endregion
6261
- export { DesktopInvalidOptionsError as A, CommandClient as C, BackupNotFoundError as D, BackupExpiredError as E, InvalidBackupConfigError as F, ProcessExitedBeforeReadyError as I, ProcessReadyTimeoutError as L, DesktopProcessCrashedError as M, DesktopStartFailedError as N, BackupRestoreError as O, DesktopUnavailableError as P, DesktopClient as S, BackupCreateError as T, UtilityClient as _, BucketMountError as a, GitClient as b, MissingCredentialsError as c, proxyTerminal as d, asyncIterableToSSEStream as f, SandboxClient as g, CodeInterpreter as h, getSandbox as i, DesktopNotStartedError as j, DesktopInvalidCoordinatesError as k, S3FSMountError as l, responseToAsyncIterable as m, streamFile as n, BucketUnmountError as o, parseSSEStream as p, Sandbox as r, InvalidMountConfigError as s, collectFile as t, proxyToSandbox as u, ProcessClient as v, BackupClient as w, FileClient as x, PortClient as y };
6262
- //# sourceMappingURL=file-stream-Bn2PceyF.js.map
6501
+ export { DesktopInvalidOptionsError as A, CommandClient as C, BackupNotFoundError as D, BackupExpiredError as E, InvalidBackupConfigError as F, ProcessExitedBeforeReadyError as I, ProcessReadyTimeoutError as L, DesktopProcessCrashedError as M, DesktopStartFailedError as N, BackupRestoreError as O, DesktopUnavailableError as P, SessionTerminatedError as R, DesktopClient as S, BackupCreateError as T, UtilityClient as _, BucketMountError as a, GitClient as b, MissingCredentialsError as c, parseSSEStream as d, responseToAsyncIterable as f, SandboxClient as g, streamFile as h, proxyTerminal as i, DesktopNotStartedError as j, DesktopInvalidCoordinatesError as k, S3FSMountError as l, collectFile as m, getSandbox as n, BucketUnmountError as o, CodeInterpreter as p, proxyToSandbox as r, InvalidMountConfigError as s, Sandbox as t, asyncIterableToSSEStream as u, ProcessClient as v, BackupClient as w, FileClient as x, PortClient as y };
6502
+ //# sourceMappingURL=sandbox-Cf_Wjrzq.js.map