@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.
- package/dist/bridge/index.js +8 -8
- package/dist/bridge/index.js.map +1 -1
- package/dist/{contexts-icMN26lE.d.ts → contexts-D6kt6WyG.d.ts} +7 -2
- package/dist/contexts-D6kt6WyG.d.ts.map +1 -0
- package/dist/{errors-Bz21XTBJ.js → errors-Dk2rApYI.js} +3 -1
- package/dist/errors-Dk2rApYI.js.map +1 -0
- package/dist/index.d.ts +14 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/openai/index.d.ts +1 -1
- package/dist/opencode/index.d.ts +2 -2
- package/dist/opencode/index.d.ts.map +1 -1
- package/dist/opencode/index.js +1 -1
- package/dist/{file-stream-Bn2PceyF.js → sandbox-Cf_Wjrzq.js} +1126 -886
- package/dist/sandbox-Cf_Wjrzq.js.map +1 -0
- package/dist/{sandbox-C0Tjs0dj.d.ts → sandbox-Chr1Ebo-.d.ts} +105 -22
- package/dist/sandbox-Chr1Ebo-.d.ts.map +1 -0
- package/package.json +1 -1
- package/dist/contexts-icMN26lE.d.ts.map +0 -1
- package/dist/errors-Bz21XTBJ.js.map +0 -1
- package/dist/file-stream-Bn2PceyF.js.map +0 -1
- package/dist/sandbox-C0Tjs0dj.d.ts.map +0 -1
|
@@ -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-
|
|
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
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
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
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
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
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
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
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
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
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
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
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
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
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
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
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
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
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
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
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
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
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
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
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
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
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
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
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
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
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
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
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
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
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
if (options
|
|
2246
|
-
|
|
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
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
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
|
-
|
|
2465
|
-
|
|
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
|
-
|
|
2477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2543
|
-
|
|
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
|
-
|
|
2555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2577
|
-
|
|
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
|
-
|
|
2589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2686
|
-
|
|
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/
|
|
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
|
-
*
|
|
3078
|
+
* Bucket mount and unmount error classes
|
|
3134
3079
|
*
|
|
3135
|
-
*
|
|
3136
|
-
*
|
|
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
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
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
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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")
|
|
3971
|
-
this.normalizeId = await this.ctx.storage.get("normalizeId")
|
|
3972
|
-
this.defaultSession = await this.ctx.storage.get("defaultSession")
|
|
3973
|
-
this.keepAliveEnabled = await this.ctx.storage.get("keepAliveEnabled")
|
|
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 (
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
4685
|
-
*
|
|
4686
|
-
*
|
|
4687
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5274
|
-
if (
|
|
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.
|
|
5374
|
-
const existingPort = Object.entries(tokens).find(([p,
|
|
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()] =
|
|
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
|
|
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.
|
|
5434
|
-
return response.ports.
|
|
5435
|
-
const
|
|
5436
|
-
if (!
|
|
5437
|
-
|
|
5438
|
-
|
|
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
|
-
|
|
5455
|
-
|
|
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(
|
|
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
|
|
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 >
|
|
5902
|
-
message: `BackupOptions.name must be a string of at most ${
|
|
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 ${
|
|
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 =
|
|
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 =
|
|
5953
|
-
const metaKey =
|
|
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 =
|
|
5974
|
-
const r2Key =
|
|
5975
|
-
const metaKey =
|
|
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
|
-
*
|
|
5997
|
-
*
|
|
5998
|
-
*
|
|
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
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
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
|
-
|
|
6036
|
-
|
|
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: "
|
|
6130
|
+
context: { reason: "ttl must be a positive number of seconds" },
|
|
6040
6131
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
6041
6132
|
});
|
|
6042
|
-
if (
|
|
6043
|
-
message: "
|
|
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: "
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
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
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
}
|
|
6171
|
-
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
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
|
-
|
|
6202
|
-
|
|
6203
|
-
if (
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
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
|
-
|
|
6251
|
-
|
|
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,
|
|
6262
|
-
//# sourceMappingURL=
|
|
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
|