hyperbrane 0.1.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.
Files changed (123) hide show
  1. package/README.md +41 -0
  2. package/dist/agent/agentService.d.ts +31 -0
  3. package/dist/agent/agentService.js +388 -0
  4. package/dist/agent/agentStorage.d.ts +27 -0
  5. package/dist/agent/agentStorage.js +52 -0
  6. package/dist/agent/api.d.ts +264 -0
  7. package/dist/agent/api.js +14 -0
  8. package/dist/agent/gatewaySignIns.d.ts +5 -0
  9. package/dist/agent/gatewaySignIns.js +11 -0
  10. package/dist/agent/session/client.d.ts +14 -0
  11. package/dist/agent/session/client.js +47 -0
  12. package/dist/agent/session/sessionStorage.d.ts +66 -0
  13. package/dist/agent/session/sessionStorage.js +89 -0
  14. package/dist/agent/session/sharedConnection.d.ts +13 -0
  15. package/dist/agent/session/sharedConnection.js +55 -0
  16. package/dist/agent/session/worker/jsonRpc.d.ts +39 -0
  17. package/dist/agent/session/worker/jsonRpc.js +102 -0
  18. package/dist/agent/session/worker/protocol.d.ts +120 -0
  19. package/dist/agent/session/worker/protocol.js +32 -0
  20. package/dist/agent/session/workerConnection.d.ts +29 -0
  21. package/dist/agent/session/workerConnection.js +44 -0
  22. package/dist/agent/session/workerSseConnection.d.ts +15 -0
  23. package/dist/agent/session/workerSseConnection.js +26 -0
  24. package/dist/agent/workerBundle.d.ts +19 -0
  25. package/dist/agent/workerBundle.generated.d.ts +1 -0
  26. package/dist/agent/workerBundle.generated.js +2 -0
  27. package/dist/agent/workerBundle.js +31 -0
  28. package/dist/cli/agent.d.ts +3 -0
  29. package/dist/cli/agent.js +423 -0
  30. package/dist/cli/command.d.ts +46 -0
  31. package/dist/cli/command.js +98 -0
  32. package/dist/cli/dimension.d.ts +2 -0
  33. package/dist/cli/dimension.js +85 -0
  34. package/dist/cli/gateway.d.ts +6 -0
  35. package/dist/cli/gateway.js +245 -0
  36. package/dist/cli/main.d.ts +2 -0
  37. package/dist/cli/main.js +125 -0
  38. package/dist/cli/portal.d.ts +2 -0
  39. package/dist/cli/portal.js +39 -0
  40. package/dist/cli/services.d.ts +18 -0
  41. package/dist/cli/services.js +33 -0
  42. package/dist/cli/streamPrinter.d.ts +23 -0
  43. package/dist/cli/streamPrinter.js +251 -0
  44. package/dist/cli/table.d.ts +7 -0
  45. package/dist/cli/table.js +54 -0
  46. package/dist/dimension/api.d.ts +67 -0
  47. package/dist/dimension/api.js +81 -0
  48. package/dist/dimension/http.d.ts +7 -0
  49. package/dist/dimension/http.js +77 -0
  50. package/dist/dimension/image.d.ts +13 -0
  51. package/dist/dimension/image.js +43 -0
  52. package/dist/dimension/imageFiles.generated.d.ts +1 -0
  53. package/dist/dimension/imageFiles.generated.js +5 -0
  54. package/dist/dimension/paths.d.ts +5 -0
  55. package/dist/dimension/paths.js +7 -0
  56. package/dist/dimension/service.d.ts +22 -0
  57. package/dist/dimension/service.js +242 -0
  58. package/dist/dimension/storage.d.ts +39 -0
  59. package/dist/dimension/storage.js +78 -0
  60. package/dist/index.d.ts +18 -0
  61. package/dist/index.js +25 -0
  62. package/dist/lib/docker-api.d.ts +13787 -0
  63. package/dist/lib/docker-api.js +5 -0
  64. package/dist/lib/docker.d.ts +77 -0
  65. package/dist/lib/docker.js +214 -0
  66. package/dist/lib/image.d.ts +14 -0
  67. package/dist/lib/image.js +29 -0
  68. package/dist/lib/jsonDirectory.d.ts +28 -0
  69. package/dist/lib/jsonDirectory.js +102 -0
  70. package/dist/lib/sleep.d.ts +2 -0
  71. package/dist/lib/sleep.js +4 -0
  72. package/dist/lib/tar.d.ts +2 -0
  73. package/dist/lib/tar.js +65 -0
  74. package/dist/lib/version.d.ts +1 -0
  75. package/dist/lib/version.js +1 -0
  76. package/dist/portal-gateway/api.d.ts +98 -0
  77. package/dist/portal-gateway/api.js +12 -0
  78. package/dist/portal-gateway/fakes.d.ts +3 -0
  79. package/dist/portal-gateway/fakes.js +11 -0
  80. package/dist/portal-gateway/gatewayService.d.ts +37 -0
  81. package/dist/portal-gateway/gatewayService.js +380 -0
  82. package/dist/portal-gateway/gatewayStorage.d.ts +66 -0
  83. package/dist/portal-gateway/gatewayStorage.js +125 -0
  84. package/dist/portal-gateway/image.d.ts +6 -0
  85. package/dist/portal-gateway/image.js +13 -0
  86. package/dist/portal-gateway/imageFiles.generated.d.ts +1 -0
  87. package/dist/portal-gateway/imageFiles.generated.js +8 -0
  88. package/dist/portal-gateway/portalService.d.ts +48 -0
  89. package/dist/portal-gateway/portalService.js +327 -0
  90. package/dist/portal-gateway/portalStorage.d.ts +34 -0
  91. package/dist/portal-gateway/portalStorage.js +44 -0
  92. package/dist/portal-gateway/upstream.d.ts +157 -0
  93. package/dist/portal-gateway/upstream.js +4 -0
  94. package/dist/sandbox/api.d.ts +184 -0
  95. package/dist/sandbox/api.js +14 -0
  96. package/dist/sandbox/boatService.d.ts +52 -0
  97. package/dist/sandbox/boatService.js +335 -0
  98. package/dist/sandbox/containerService.d.ts +15 -0
  99. package/dist/sandbox/containerService.js +177 -0
  100. package/dist/sandbox/endpointProxy.d.ts +90 -0
  101. package/dist/sandbox/endpointProxy.js +701 -0
  102. package/dist/sandbox/exeDevAuth.d.ts +16 -0
  103. package/dist/sandbox/exeDevAuth.js +49 -0
  104. package/dist/sandbox/localService.d.ts +19 -0
  105. package/dist/sandbox/localService.js +182 -0
  106. package/dist/sandbox/sseConnection.d.ts +41 -0
  107. package/dist/sandbox/sseConnection.js +166 -0
  108. package/dist/sandbox/sshService.d.ts +30 -0
  109. package/dist/sandbox/sshService.js +647 -0
  110. package/dist/upstreams/anthropic.d.ts +7 -0
  111. package/dist/upstreams/anthropic.js +27 -0
  112. package/dist/upstreams/index.d.ts +6 -0
  113. package/dist/upstreams/index.js +7 -0
  114. package/dist/upstreams/openai.d.ts +7 -0
  115. package/dist/upstreams/openai.js +40 -0
  116. package/dist/vendor-types/claude/agent-sdk/agentSdkTypes.d.ts +1 -0
  117. package/dist/vendor-types/claude/agent-sdk/bridge.d.ts +388 -0
  118. package/dist/vendor-types/claude/agent-sdk/browser-sdk.d.ts +184 -0
  119. package/dist/vendor-types/claude/agent-sdk/core.d.ts +20 -0
  120. package/dist/vendor-types/claude/agent-sdk/extractFromBunfs.d.ts +1 -0
  121. package/dist/vendor-types/claude/agent-sdk/sdk-tools.d.ts +4153 -0
  122. package/dist/vendor-types/claude/agent-sdk/sdk.d.ts +9833 -0
  123. package/package.json +55 -0
@@ -0,0 +1,16 @@
1
+ import { type SandboxEndpointAuth } from "./endpointProxy.ts";
2
+ export type ExeDevAuthOptions = {
3
+ /**
4
+ * The VM's public port (`ssh exe.dev share port`), which anyone reaches without logging in; null if
5
+ * the VM isn't public. Required: the VM can't tell from inside, and no endpoint may listen on it.
6
+ */
7
+ publicPort: number | null;
8
+ };
9
+ /**
10
+ * Endpoints on an exe.dev VM, behind exe.dev's proxy: it lets through only users logged in with
11
+ * exe.dev that the VM is shared with, so the endpoint proxy checks nothing itself. The sandbox's
12
+ * accessible host must be the VM's <vm>.exe.xyz, with or without a port of 3000-9999 other than the
13
+ * public one. exe.dev's login is per port, so a fixed port means logging in once. Its ports are never
14
+ * opened in the firewall: reached directly, they would skip exe.dev's login.
15
+ */
16
+ export declare function exeDevProxyAuth({ publicPort }: ExeDevAuthOptions): SandboxEndpointAuth;
@@ -0,0 +1,49 @@
1
+ import { SandboxEndpointError } from "./api.js";
2
+ import { checkPort, pinnedPort } from "./endpointProxy.js";
3
+ /** The ports exe.dev's proxy forwards, as https://<vm>.exe.xyz:<port>/ (https://exe.dev/docs/proxy.md). */
4
+ const FORWARDED_PORTS = [3000, 9999];
5
+ const HOST_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?\.exe\.xyz$/i;
6
+ /**
7
+ * Endpoints on an exe.dev VM, behind exe.dev's proxy: it lets through only users logged in with
8
+ * exe.dev that the VM is shared with, so the endpoint proxy checks nothing itself. The sandbox's
9
+ * accessible host must be the VM's <vm>.exe.xyz, with or without a port of 3000-9999 other than the
10
+ * public one. exe.dev's login is per port, so a fixed port means logging in once. Its ports are never
11
+ * opened in the firewall: reached directly, they would skip exe.dev's login.
12
+ */
13
+ export function exeDevProxyAuth({ publicPort }) {
14
+ const [low, high] = FORWARDED_PORTS;
15
+ function checkForwarded(port) {
16
+ checkPort(port);
17
+ if (port < low || port > high)
18
+ throw new SandboxEndpointError(`exe.dev forwards ports ${low}-${high}, not ${port}`);
19
+ if (port === publicPort)
20
+ throw new SandboxEndpointError(`port ${port} is the VM's public port: anyone could reach it without logging in`);
21
+ }
22
+ return {
23
+ bind({ host, port: pinned }) {
24
+ if (!HOST_PATTERN.test(host))
25
+ throw new SandboxEndpointError(`exe.dev endpoints need the VM's <vm>.exe.xyz host, not "${host}"`);
26
+ if (pinned !== null)
27
+ checkForwarded(pinned);
28
+ return {
29
+ firewall: false,
30
+ plan({ scheme, port }) {
31
+ if (scheme !== "exe.dev")
32
+ throw new SandboxEndpointError(`exe.dev endpoints are checked by exe.dev's login, not "${scheme}"`);
33
+ const listen = pinnedPort(pinned, port);
34
+ if (listen !== null)
35
+ checkForwarded(listen);
36
+ return {
37
+ check: null,
38
+ ports: listen === null ? [low, high] : [listen, listen],
39
+ except: publicPort === null ? [] : [publicPort],
40
+ publish: async (listening) => ({
41
+ url: `https://${host}:${listening}`,
42
+ credentials: { scheme: "exe.dev", loginUrl: `https://${host}:${listening}/__exe.dev/login` },
43
+ }),
44
+ };
45
+ },
46
+ };
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,19 @@
1
+ import { type Sandbox, type SandboxAccessibleEndpoint, type SandboxConnection } from "./api.ts";
2
+ import { type SandboxEndpointsOptions } from "./endpointProxy.ts";
3
+ export type LocalSandboxOptions = {
4
+ /** Declares the accessible-endpoint capability (SandboxAccessibleEndpoint): proxies on this machine. */
5
+ endpoints?: SandboxEndpointsOptions;
6
+ };
7
+ /** Runs directly on this machine under `root`: no container, no auth proxy. */
8
+ export declare function createLocalSandbox(root: string, options: LocalSandboxOptions & {
9
+ endpoints: SandboxEndpointsOptions;
10
+ }): Sandbox & SandboxAccessibleEndpoint;
11
+ export declare function createLocalSandbox(root: string, options?: LocalSandboxOptions): Sandbox;
12
+ /** A SandboxConnection over a node stream (a local socket, or an SSH-forwarded one) once it is open. */
13
+ export declare function socketConnection(stream: NodeJS.ReadWriteStream & {
14
+ end(): void;
15
+ destroy(): void;
16
+ once(event: string, listener: (...args: unknown[]) => void): unknown;
17
+ }, path: string, { opened }?: {
18
+ opened?: boolean;
19
+ }): Promise<SandboxConnection>;
@@ -0,0 +1,182 @@
1
+ import { spawn } from "node:child_process";
2
+ import { link, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { request as httpRequest } from "node:http";
4
+ import { createConnection } from "node:net";
5
+ import { homedir } from "node:os";
6
+ import { Readable } from "node:stream";
7
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
8
+ import { SandboxNotFoundError, SandboxPathError, } from "./api.js";
9
+ import { createProxyEndpoints } from "./endpointProxy.js";
10
+ export function createLocalSandbox(root, { endpoints } = {}) {
11
+ const base = resolve(root);
12
+ function hostPath(path) {
13
+ const full = resolve(base, isAbsolute(path) ? `.${path}` : path);
14
+ const rel = relative(base, full);
15
+ if (rel.startsWith("..") || isAbsolute(rel))
16
+ throw new SandboxPathError(`Path escapes sandbox: "${path}"`);
17
+ return full;
18
+ }
19
+ async function notFoundAs(path, operation) {
20
+ try {
21
+ return await operation;
22
+ }
23
+ catch (error) {
24
+ if (error.code === "ENOENT")
25
+ throw new SandboxNotFoundError(`Not found: "${path}"`);
26
+ throw error;
27
+ }
28
+ }
29
+ /** Writes `data` next to `full` under a unique hidden name, so it can be renamed or linked into place. */
30
+ async function writeTemp(full, data) {
31
+ await mkdir(dirname(full), { recursive: true });
32
+ const tmp = join(dirname(full), `.${basename(full)}.${crypto.randomUUID()}.tmp`);
33
+ await writeFile(tmp, data);
34
+ return tmp;
35
+ }
36
+ async function readFileLocal(path) {
37
+ return new Uint8Array(await notFoundAs(path, readFile(hostPath(path))));
38
+ }
39
+ async function writeFileLocal(path, data) {
40
+ const full = hostPath(path);
41
+ await rename(await writeTemp(full, data), full);
42
+ }
43
+ async function writeFileExclusive(path, data) {
44
+ const full = hostPath(path);
45
+ const tmp = await writeTemp(full, data);
46
+ try {
47
+ // link() refuses to replace an existing file, and publishes the fully written temp file.
48
+ await link(tmp, full);
49
+ return true;
50
+ }
51
+ catch (error) {
52
+ if (error.code === "EEXIST")
53
+ return false;
54
+ throw error;
55
+ }
56
+ finally {
57
+ await unlink(tmp);
58
+ }
59
+ }
60
+ async function removeFile(path) {
61
+ await rm(hostPath(path), { force: true });
62
+ }
63
+ async function createDirectory(path) {
64
+ await mkdir(hostPath(path), { recursive: true });
65
+ }
66
+ async function removeDirectory(path) {
67
+ await rm(hostPath(path), { recursive: true, force: true });
68
+ }
69
+ async function listDirectory(path) {
70
+ return notFoundAs(path, readdir(hostPath(path)));
71
+ }
72
+ async function statLocal(path) {
73
+ const stats = await notFoundAs(path, stat(hostPath(path)));
74
+ const type = stats.isFile() ? "file" : stats.isDirectory() ? "directory" : "other";
75
+ return { type, uid: stats.uid, gid: stats.gid };
76
+ }
77
+ async function homeDirectory() {
78
+ const rel = relative(base, homedir());
79
+ if (rel.startsWith("..") || isAbsolute(rel))
80
+ throw new SandboxPathError(`Home directory is outside the sandbox`);
81
+ return `/${rel}`;
82
+ }
83
+ // node:child_process rather than Bun.spawn: the library runs under Node too.
84
+ function runCommand(cmd, { cwd, env = {}, stdin } = {}) {
85
+ return new Promise((resolve, reject) => {
86
+ const [command, ...args] = cmd;
87
+ if (!command)
88
+ return reject(new Error("no command to run"));
89
+ const child = spawn(command, args, {
90
+ cwd: hostPath(cwd ?? "."),
91
+ env: { ...process.env, ...env },
92
+ stdio: [stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
93
+ });
94
+ const stdout = [];
95
+ const stderr = [];
96
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
97
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
98
+ child.once("error", reject);
99
+ // After its output closes too: a command that leaves a detached child (the endpoint proxy) is done then.
100
+ child.once("close", (code) => resolve({ exitCode: code ?? -1, stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString() }));
101
+ if (stdin !== undefined)
102
+ child.stdin.end(stdin);
103
+ });
104
+ }
105
+ async function connect(path) {
106
+ return socketConnection(createConnection({ path: hostPath(path) }), path);
107
+ }
108
+ async function fetchLocal(url, { unix, ...init } = {}) {
109
+ if (unix === undefined || "Bun" in globalThis)
110
+ return fetch(url, { ...init, unix: unix === undefined ? undefined : hostPath(unix) });
111
+ return fetchOverSocket(url, hostPath(unix), init);
112
+ }
113
+ const sandbox = {
114
+ readFile: readFileLocal,
115
+ writeFile: writeFileLocal,
116
+ writeFileExclusive,
117
+ removeFile,
118
+ createDirectory,
119
+ removeDirectory,
120
+ listDirectory,
121
+ stat: statLocal,
122
+ homeDirectory,
123
+ runCommand,
124
+ fetch: fetchLocal,
125
+ connect,
126
+ };
127
+ if (!endpoints)
128
+ return sandbox;
129
+ return { ...sandbox, ...createProxyEndpoints({ runCommand }, endpoints, "this machine") };
130
+ }
131
+ /**
132
+ * HTTP/1.1 over the unix socket at `socketPath`, for Node, whose fetch can't use one (Bun's can).
133
+ * The response streams as it arrives; `timeout` is the idle timeout, as with Bun's fetch.
134
+ */
135
+ function fetchOverSocket(url, socketPath, { method = "GET", headers = {}, body, signal, timeout }) {
136
+ return new Promise((resolve, reject) => {
137
+ const target = new URL(url);
138
+ const request = httpRequest({ socketPath, path: `${target.pathname}${target.search}`, method, headers: { host: target.host, ...headers }, signal }, (response) => {
139
+ const responseHeaders = new Headers();
140
+ for (let i = 0; i + 1 < response.rawHeaders.length; i += 2)
141
+ responseHeaders.append(response.rawHeaders[i], response.rawHeaders[i + 1]);
142
+ const status = response.statusCode ?? 502;
143
+ // A null-body status can't be given a body, even an empty one.
144
+ const nullBody = status === 204 || status === 304 || method === "HEAD";
145
+ if (nullBody)
146
+ response.resume();
147
+ resolve(new Response(nullBody ? null : Readable.toWeb(response), { status, headers: responseHeaders }));
148
+ });
149
+ request.once("error", reject);
150
+ if (typeof timeout === "number")
151
+ request.setTimeout(timeout, () => request.destroy(new Error(`fetch ${url}: no data for ${timeout}ms`)));
152
+ request.end(body === undefined ? undefined : typeof body === "string" ? body : Buffer.from(body));
153
+ });
154
+ }
155
+ /** A SandboxConnection over a node stream (a local socket, or an SSH-forwarded one) once it is open. */
156
+ export function socketConnection(stream, path, { opened = false } = {}) {
157
+ return new Promise((resolve, reject) => {
158
+ let markClosed;
159
+ const closed = new Promise((done) => (markClosed = done));
160
+ const listeners = [];
161
+ stream.on("data", (chunk) => {
162
+ for (const listener of listeners)
163
+ listener(new Uint8Array(chunk));
164
+ });
165
+ stream.once("close", () => markClosed());
166
+ stream.once("end", () => markClosed());
167
+ const connection = {
168
+ write: (data) => void stream.write(data),
169
+ onData: (listener) => void listeners.push(listener),
170
+ closed,
171
+ close: () => stream.end(),
172
+ };
173
+ stream.once("error", (error) => {
174
+ markClosed();
175
+ reject(new Error(`connecting to ${path} failed: ${error.message}`));
176
+ });
177
+ if (opened)
178
+ resolve(connection);
179
+ else
180
+ stream.once("connect", () => resolve(connection));
181
+ });
182
+ }
@@ -0,0 +1,41 @@
1
+ import type { SandboxConnection, SandboxEndpoint } from "./api.ts";
2
+ /** Where an endpoint is reached, and how. */
3
+ export type EndpointAccess = Pick<SandboxEndpoint, "url" | "credentials">;
4
+ /**
5
+ * The URL and fetch options for a request to `path` (e.g. "/", "/<connection>", "/v1/x?y=1") under
6
+ * the endpoint, carrying its credentials as their scheme wants: an Authorization header, or the
7
+ * browser's exe.dev login (its cookie).
8
+ */
9
+ export declare function endpointRequest(endpoint: EndpointAccess, path: string, init?: RequestInit): [string, RequestInit];
10
+ /**
11
+ * A stream connection through an endpoint of the proxy's "sse" transport (endpointProxy.ts): the
12
+ * target's lines arrive as events (handed on with their newline), and each write goes as a POST of
13
+ * its own, concurrently: writes are whole messages (JSON-RPC lines), whose order between each other
14
+ * doesn't matter. Text lines only, which is what the transport
15
+ * carries. Only fetch, so it runs in a browser as well as on a server; in a browser the page's origin
16
+ * must be one of the endpoint's allowedOrigins. Aborting `signal` closes it.
17
+ */
18
+ export declare function openSseConnection(endpoint: EndpointAccess, { signal }?: {
19
+ signal?: AbortSignal;
20
+ }): Promise<SandboxConnection>;
21
+ /** The events of a server-sent event stream as they arrive (comments skipped, data lines joined). */
22
+ export declare function sseEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<{
23
+ event: string;
24
+ data: string;
25
+ }>;
26
+ /**
27
+ * The endpoint couldn't be reached, which for an exe.dev endpoint usually means the browser isn't
28
+ * logged in with exe.dev on its port yet (the proxy being down looks the same to a page). Send the
29
+ * page through exeDevLoginUrl to log in and come back.
30
+ */
31
+ export declare class EndpointLoginRequiredError extends Error {
32
+ readonly loginUrl: string;
33
+ name: string;
34
+ constructor(loginUrl: string, cause?: unknown);
35
+ }
36
+ /**
37
+ * Where to send the page so the user logs in with exe.dev on the endpoint's port and then comes back
38
+ * to `returnTo`: exe.dev's login returns to a path on that port, where the endpoint proxy redirects
39
+ * on to `returnTo` (whose origin must be one of the endpoint's allowedOrigins).
40
+ */
41
+ export declare function exeDevLoginUrl(loginUrl: string, returnTo: string): string;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The URL and fetch options for a request to `path` (e.g. "/", "/<connection>", "/v1/x?y=1") under
3
+ * the endpoint, carrying its credentials as their scheme wants: an Authorization header, or the
4
+ * browser's exe.dev login (its cookie).
5
+ */
6
+ export function endpointRequest(endpoint, path, init = {}) {
7
+ const url = new URL(`${endpoint.url.replace(/\/+$/, "")}${path}`);
8
+ const headers = new Headers(init.headers);
9
+ const { credentials } = endpoint;
10
+ if (credentials.scheme === "bearer")
11
+ headers.set("authorization", `Bearer ${credentials.token}`);
12
+ if (credentials.scheme === "basic")
13
+ headers.set("authorization", `Basic ${btoa(`${credentials.username}:${credentials.password}`)}`);
14
+ return [url.toString(), { ...init, headers, credentials: credentials.scheme === "exe.dev" ? "include" : "same-origin" }];
15
+ }
16
+ /**
17
+ * A stream connection through an endpoint of the proxy's "sse" transport (endpointProxy.ts): the
18
+ * target's lines arrive as events (handed on with their newline), and each write goes as a POST of
19
+ * its own, concurrently: writes are whole messages (JSON-RPC lines), whose order between each other
20
+ * doesn't matter. Text lines only, which is what the transport
21
+ * carries. Only fetch, so it runs in a browser as well as on a server; in a browser the page's origin
22
+ * must be one of the endpoint's allowedOrigins. Aborting `signal` closes it.
23
+ */
24
+ export async function openSseConnection(endpoint, { signal } = {}) {
25
+ signal?.throwIfAborted();
26
+ const leave = new AbortController();
27
+ signal?.addEventListener("abort", () => leave.abort(), { once: true, signal: leave.signal });
28
+ const [url, init] = endpointRequest(endpoint, "/", { headers: { accept: "text/event-stream" }, signal: leave.signal });
29
+ let response;
30
+ try {
31
+ response = await fetch(url, init);
32
+ }
33
+ catch (error) {
34
+ // exe.dev answers a browser that isn't logged in on this port with a redirect to its login,
35
+ // which a cross-origin fetch can't follow: it fails like the network did.
36
+ const { credentials } = endpoint;
37
+ if (credentials.scheme === "exe.dev" && !leave.signal.aborted)
38
+ throw new EndpointLoginRequiredError(credentials.loginUrl, error);
39
+ throw error;
40
+ }
41
+ const where = endpoint.url.replace(/\/+$/, "");
42
+ if (!response.ok || !response.body) {
43
+ leave.abort();
44
+ throw new Error(`connecting to ${where} failed: ${response.status} ${response.statusText}`);
45
+ }
46
+ const events = sseEvents(response.body);
47
+ const first = await events.next().catch((error) => ({ done: true, value: error }));
48
+ if (first.done || first.value.event !== "open") {
49
+ leave.abort();
50
+ throw new Error(`connecting to ${where} failed: no connection opened`);
51
+ }
52
+ const connection = JSON.parse(first.value.data).connection;
53
+ const encoder = new TextEncoder();
54
+ const listeners = [];
55
+ // Lines that arrive before anyone listens wait for the first listener.
56
+ const early = [];
57
+ let markClosed;
58
+ const closed = new Promise((resolve) => (markClosed = resolve));
59
+ // Each write is a whole message (a line) in a POST of its own, written to the target whole (the
60
+ // proxy holds the connection's lock for it), so they go out at once rather than one by one: calls
61
+ // made together take a round trip together. One that fails ends the connection, which fails
62
+ // whatever waits on it.
63
+ void (async () => {
64
+ try {
65
+ for await (const event of events) {
66
+ if (event.event === "close")
67
+ break;
68
+ if (event.event !== "message")
69
+ continue;
70
+ const data = encoder.encode(`${event.data}\n`);
71
+ if (listeners.length === 0)
72
+ early.push(data);
73
+ for (const listener of listeners)
74
+ listener(data);
75
+ }
76
+ }
77
+ catch {
78
+ // Aborted, or the stream broke: either way the connection is over.
79
+ }
80
+ finally {
81
+ leave.abort();
82
+ markClosed();
83
+ }
84
+ })();
85
+ return {
86
+ write(data) {
87
+ const [postUrl, postInit] = endpointRequest(endpoint, `/${connection}`, {
88
+ method: "POST",
89
+ // text/plain keeps it a "simple" request: no preflight where the endpoint has no credentials of its own.
90
+ headers: { "content-type": "text/plain" },
91
+ body: new Uint8Array(data),
92
+ });
93
+ fetch(postUrl, postInit)
94
+ .then((posted) => {
95
+ if (posted.status !== 204)
96
+ throw new Error(`writing to ${where} failed: ${posted.status}`);
97
+ })
98
+ .catch(() => leave.abort());
99
+ },
100
+ onData(listener) {
101
+ listeners.push(listener);
102
+ for (const data of early.splice(0))
103
+ listener(data);
104
+ },
105
+ closed,
106
+ close: () => leave.abort(),
107
+ };
108
+ }
109
+ /** The events of a server-sent event stream as they arrive (comments skipped, data lines joined). */
110
+ export async function* sseEvents(body) {
111
+ const reader = body.getReader();
112
+ const decoder = new TextDecoder();
113
+ let text = "";
114
+ try {
115
+ for (;;) {
116
+ const { value, done } = await reader.read();
117
+ if (done)
118
+ return;
119
+ text += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n");
120
+ let end;
121
+ while ((end = text.indexOf("\n\n")) >= 0) {
122
+ const block = text.slice(0, end);
123
+ text = text.slice(end + 2);
124
+ let event = "message";
125
+ const data = [];
126
+ for (const line of block.split("\n")) {
127
+ if (line.startsWith(":"))
128
+ continue;
129
+ const colon = line.indexOf(":");
130
+ const field = colon < 0 ? line : line.slice(0, colon);
131
+ const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
132
+ if (field === "event")
133
+ event = value;
134
+ else if (field === "data")
135
+ data.push(value);
136
+ }
137
+ if (data.length || event !== "message")
138
+ yield { event, data: data.join("\n") };
139
+ }
140
+ }
141
+ }
142
+ finally {
143
+ reader.releaseLock();
144
+ }
145
+ }
146
+ /**
147
+ * The endpoint couldn't be reached, which for an exe.dev endpoint usually means the browser isn't
148
+ * logged in with exe.dev on its port yet (the proxy being down looks the same to a page). Send the
149
+ * page through exeDevLoginUrl to log in and come back.
150
+ */
151
+ export class EndpointLoginRequiredError extends Error {
152
+ loginUrl;
153
+ name = "EndpointLoginRequiredError";
154
+ constructor(loginUrl, cause) {
155
+ super("logging in with exe.dev is needed to reach the endpoint", { cause });
156
+ this.loginUrl = loginUrl;
157
+ }
158
+ }
159
+ /**
160
+ * Where to send the page so the user logs in with exe.dev on the endpoint's port and then comes back
161
+ * to `returnTo`: exe.dev's login returns to a path on that port, where the endpoint proxy redirects
162
+ * on to `returnTo` (whose origin must be one of the endpoint's allowedOrigins).
163
+ */
164
+ export function exeDevLoginUrl(loginUrl, returnTo) {
165
+ return `${loginUrl}?redirect=${encodeURIComponent(`/__hyb/return?to=${encodeURIComponent(returnTo)}`)}`;
166
+ }
@@ -0,0 +1,30 @@
1
+ import { type Sandbox, type SandboxAccessibleEndpoint } from "./api.ts";
2
+ import { type SandboxEndpointsOptions } from "./endpointProxy.ts";
3
+ export type SshSandboxOptions = {
4
+ user: string;
5
+ port?: number;
6
+ /** The private key itself (PEM or OpenSSH), not a path: nothing is read from or written to disk. */
7
+ privateKey: string | Buffer;
8
+ passphrase?: string;
9
+ /** Checks the server's host key (raw bytes); return false to refuse it. Defaults to accepting any. */
10
+ hostVerifier?: (key: Buffer) => boolean;
11
+ /** How long connecting may take. Defaults to 15 seconds. */
12
+ readyTimeout?: number;
13
+ /** Declares the accessible-endpoint capability (SandboxAccessibleEndpoint): proxies on the remote host. */
14
+ endpoints?: SandboxEndpointsOptions;
15
+ };
16
+ /**
17
+ * Runs on a remote host over SSH, with the ssh2 library — no `ssh` binary needed, so it works in
18
+ * environments that have none (serverless functions). Everything goes over a single connection,
19
+ * opened on first use and reopened if it drops; close() releases it. Sandbox paths are remote paths.
20
+ *
21
+ * Files go through one SFTP channel, and `fetch` and `connect` through OpenSSH's stream-local
22
+ * forwarding to the remote unix socket (both on by default in sshd); only runCommand and
23
+ * removeDirectory run commands, each on a channel of its own. Commands are kept few on purpose:
24
+ * sshd limits the sessions on a connection (MaxSessions), counts a closed one until it has cleaned
25
+ * up after it, and under load may close a command's channel without its exit status.
26
+ */
27
+ export declare function createSshSandbox(host: string, options: SshSandboxOptions & {
28
+ endpoints: SandboxEndpointsOptions;
29
+ }): Sandbox & SandboxAccessibleEndpoint;
30
+ export declare function createSshSandbox(host: string, options: SshSandboxOptions): Sandbox;