hyperbrane 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +43 -3
  2. package/dist/agent/agentService.js +3 -2
  3. package/dist/agent/api.d.ts +26 -2
  4. package/dist/agent/control.js +1 -1
  5. package/dist/agent/dimensionAgentClient.d.ts +8 -0
  6. package/dist/agent/dimensionAgentClient.js +99 -0
  7. package/dist/agent/session/backend.d.ts +2 -1
  8. package/dist/agent/session/claude/transcript.d.ts +1 -0
  9. package/dist/agent/session/claude/transcript.js +1 -1
  10. package/dist/agent/session/worker/protocol.d.ts +6 -3
  11. package/dist/agent/session/worker/protocol.js +1 -1
  12. package/dist/agent/session/worker/workerParams.js +10 -3
  13. package/dist/agent/vendor-types/manifest.json +5 -60
  14. package/dist/agent/workerBundle.generated.js +1 -1
  15. package/dist/dimension/api.d.ts +3 -0
  16. package/dist/dimension/control.d.ts +2 -1
  17. package/dist/dimension/control.js +18 -0
  18. package/dist/dimension/service.js +27 -1
  19. package/dist/dimension/transportBundle.generated.js +1 -1
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.js +3 -1
  22. package/dist/lib/control.d.ts +4 -1
  23. package/dist/lib/control.js +2 -2
  24. package/dist/lib/jsonDirectory.js +24 -3
  25. package/dist/lib/unixSocket.d.ts +2 -0
  26. package/dist/lib/unixSocket.js +28 -0
  27. package/dist/portal-gateway/api.d.ts +1 -0
  28. package/dist/portal-gateway/gatewayService.js +14 -0
  29. package/dist/portal-gateway/transportBundle.generated.js +1 -1
  30. package/dist/restore.d.ts +9 -0
  31. package/dist/restore.js +10 -0
  32. package/dist/sandbox/api.d.ts +5 -0
  33. package/dist/sandbox/client.d.ts +2 -1
  34. package/dist/sandbox/client.js +4 -1
  35. package/dist/sandbox/lib/sshKey.d.ts +6 -0
  36. package/dist/sandbox/lib/sshKey.js +75 -0
  37. package/dist/sandbox/providers/boat/boatService.d.ts +2 -1
  38. package/dist/sandbox/providers/boat/boatService.js +72 -20
  39. package/dist/sandbox/providers/exedev/exeDevService.js +4 -2
  40. package/dist/sandbox/providers/exedev/exeDevToken.js +2 -6
  41. package/dist/sandbox/providers/local/localService.js +4 -26
  42. package/dist/sandbox/providers/ssh/sshService.js +2 -1
  43. package/dist/sandbox/service.d.ts +2 -1
  44. package/dist/sandbox/service.js +9 -1
  45. package/dist/vendor-types/claude/agent-sdk/bridge.d.ts +20 -4
  46. package/dist/vendor-types/claude/agent-sdk/browser-sdk.d.ts +24 -0
  47. package/dist/vendor-types/claude/agent-sdk/core.d.ts +2 -0
  48. package/dist/vendor-types/claude/agent-sdk/sdk-tools.d.ts +75 -6
  49. package/dist/vendor-types/claude/agent-sdk/sdk.d.ts +569 -100
  50. package/package.json +2 -1
@@ -0,0 +1,75 @@
1
+ import { createPrivateKey, randomBytes } from "node:crypto";
2
+ import ssh2 from "ssh2";
3
+ const FORMATS = "an unencrypted private key in OpenSSH, PKCS#8 (Ed25519, RSA, ECDSA), PKCS#1 (RSA) or SEC1 (ECDSA) format";
4
+ const PEM_WIDTH = 70;
5
+ function parsed(privateKey) {
6
+ const result = ssh2.utils.parseKey(privateKey);
7
+ const key = Array.isArray(result) ? result[0] : result;
8
+ if (!key)
9
+ return new Error("no key found");
10
+ if (!(key instanceof Error) && !key.isPrivateKey())
11
+ return new Error("it's a public key");
12
+ return key;
13
+ }
14
+ function sshString(value) {
15
+ const bytes = typeof value === "string" ? Buffer.from(value) : value;
16
+ const length = Buffer.alloc(4);
17
+ length.writeUInt32BE(bytes.length);
18
+ return Buffer.concat([length, bytes]);
19
+ }
20
+ function uint32(value) {
21
+ const bytes = Buffer.alloc(4);
22
+ bytes.writeUInt32BE(value);
23
+ return bytes;
24
+ }
25
+ function openSshEd25519(key) {
26
+ const { d, x } = key.export({ format: "jwk" });
27
+ const seed = Buffer.from(d, "base64url");
28
+ const publicKey = Buffer.from(x, "base64url");
29
+ const check = randomBytes(4);
30
+ const unpadded = Buffer.concat([check, check, sshString("ssh-ed25519"), sshString(publicKey), sshString(Buffer.concat([seed, publicKey])), sshString("")]);
31
+ const padding = Buffer.from(Array.from({ length: (8 - (unpadded.length % 8)) % 8 }, (_, i) => i + 1));
32
+ const blob = Buffer.concat([
33
+ Buffer.from("openssh-key-v1\0"),
34
+ sshString("none"),
35
+ sshString("none"),
36
+ sshString(""),
37
+ uint32(1),
38
+ sshString(Buffer.concat([sshString("ssh-ed25519"), sshString(publicKey)])),
39
+ sshString(Buffer.concat([unpadded, padding])),
40
+ ]);
41
+ const lines = blob.toString("base64").match(new RegExp(`.{1,${PEM_WIDTH}}`, "g"));
42
+ return `-----BEGIN OPENSSH PRIVATE KEY-----\n${lines.join("\n")}\n-----END OPENSSH PRIVATE KEY-----\n`;
43
+ }
44
+ function converted(privateKey) {
45
+ let key;
46
+ try {
47
+ key = createPrivateKey(privateKey);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ switch (key.asymmetricKeyType) {
53
+ case "rsa":
54
+ return key.export({ type: "pkcs1", format: "pem" });
55
+ case "ec":
56
+ return key.export({ type: "sec1", format: "pem" });
57
+ case "ed25519":
58
+ return openSshEd25519(key);
59
+ default:
60
+ return null;
61
+ }
62
+ }
63
+ export function parseSshKey(privateKey, whose) {
64
+ const key = parsed(privateKey);
65
+ if (!(key instanceof Error))
66
+ return { privateKey, key };
67
+ const readable = converted(privateKey);
68
+ const convertedKey = readable === null ? null : parsed(readable);
69
+ if (readable !== null && convertedKey !== null && !(convertedKey instanceof Error))
70
+ return { privateKey: readable, key: convertedKey };
71
+ throw new Error(`${whose} SSH key can't be read (${key.message}): it takes ${FORMATS}`);
72
+ }
73
+ export function sshPrivateKey(privateKey, whose) {
74
+ return parseSshKey(privateKey, whose).privateKey;
75
+ }
@@ -1,2 +1,3 @@
1
- import { type SandboxAuthContext, type SandboxService } from "../../api.ts";
1
+ import { type SandboxAuthContext, type SandboxListing, type SandboxService } from "../../api.ts";
2
+ export declare function listBoatSandboxes(ctx: SandboxAuthContext): Promise<SandboxListing[]>;
2
3
  export declare function createBoatService(ctx: SandboxAuthContext): SandboxService;
@@ -1,20 +1,27 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { posix } from "node:path";
3
3
  import { BoatApi, Configuration, ResponseError, waitUntilReady } from "@boatdev/sdk";
4
- import ssh2 from "ssh2";
5
- import { SandboxNotFoundError, SandboxStoppedError, } from "../../api.js";
4
+ import { SandboxNotFoundError, SandboxPathError, SandboxStoppedError, } from "../../api.js";
6
5
  import { commandLine, quote } from "../../lib/shellCommand.js";
7
6
  import { commandFileOperations } from "../../lib/remoteFileOperations.js";
7
+ import { parseSshKey } from "../../lib/sshKey.js";
8
8
  import { createDescriptor, getDescriptor, openDescriptor } from "../../storage/descriptors.js";
9
9
  import { createEndpoints } from "../../data-endpoints/dataEndpoints.js";
10
10
  import { createSandboxStorage } from "../../storage/sandboxStorage.js";
11
11
  import { sshConnect, sshFetch } from "../ssh/sshTransport.js";
12
12
  const BOAT_API_URL = "https://boat.dev/api/v1";
13
+ const WORK_DIRECTORY = "/home/user";
14
+ const FILE_API_DIRECTORIES = [WORK_DIRECTORY, "/tmp"];
15
+ const REFUSED_PATH = /^Path must resolve under/;
13
16
  const COMMAND_TIMEOUT_S = 600;
14
17
  const READY_TIMEOUT_MS = 180_000;
15
18
  const RUNNING_STATES = ["ready", "idle", "running"];
19
+ const STOPPED_CODES = ["machine_not_running", "sandbox_not_ready"];
16
20
  const UNDELIVERED = ["localhost_relay_failed", "sandbox_direct_failed"];
21
+ const RESTORING = "sandbox_restoring";
17
22
  const UNDELIVERED_RETRY_MS = [1000, 2000, 4000];
23
+ const LISTED_PER_PAGE = 200;
24
+ const UNLISTED_STATES = ["cancelled", "error"];
18
25
  const DEFAULT_PORTS = {
19
26
  from: 8900,
20
27
  to: 8999,
@@ -22,11 +29,15 @@ const DEFAULT_PORTS = {
22
29
  class BoatError extends Error {
23
30
  status;
24
31
  code;
32
+ detail;
33
+ retryable;
25
34
  name = "BoatError";
26
- constructor(status, code, message) {
35
+ constructor(status, code, detail, retryable, message) {
27
36
  super(message);
28
37
  this.status = status;
29
38
  this.code = code;
39
+ this.detail = detail;
40
+ this.retryable = retryable;
30
41
  }
31
42
  }
32
43
  function apiKeyOf(ctx) {
@@ -62,18 +73,26 @@ async function boatOnce(what, call) {
62
73
  body = { message: text };
63
74
  }
64
75
  const code = body.code ?? "";
65
- if (code === "machine_not_running")
76
+ if (STOPPED_CODES.includes(code))
66
77
  throw new SandboxStoppedError(`${what}: the Boat sandbox is stopped`);
67
- throw new BoatError(error.response.status, code, `${what} failed (${error.response.status}${code ? ` ${code}` : ""}): ${body.message ?? text}`);
78
+ const details = body.error?.details ?? {};
79
+ throw new BoatError(error.response.status, code, details.message ?? body.message ?? "", details.retryable, `${what} failed (${error.response.status}${code ? ` ${code}` : ""}): ${body.message ?? text}`);
68
80
  }
69
81
  }
70
- async function boat(what, call) {
82
+ function undelivered(error, reaches) {
83
+ if (reaches === "command")
84
+ return error.code === "sandbox_direct_failed" && error.detail === RESTORING;
85
+ if (reaches === "file")
86
+ return error.code === "localhost_relay_failed" || (error.code === "sandbox_direct_failed" && error.retryable === true);
87
+ return UNDELIVERED.includes(error.code);
88
+ }
89
+ async function boat(what, call, reaches = "api") {
71
90
  for (const wait of [...UNDELIVERED_RETRY_MS, null]) {
72
91
  try {
73
92
  return await boatOnce(what, call);
74
93
  }
75
94
  catch (error) {
76
- if (wait === null || !(error instanceof BoatError) || !UNDELIVERED.includes(error.code))
95
+ if (wait === null || !(error instanceof BoatError) || !undelivered(error, reaches))
77
96
  throw error;
78
97
  await new Promise((resolve) => setTimeout(resolve, wait));
79
98
  }
@@ -113,7 +132,7 @@ async function resuming(ctx, call) {
113
132
  async function command(ctx, line, cwd) {
114
133
  const api = apiOf(ctx);
115
134
  const sandboxId = sandboxIdOf(ctx);
116
- const result = await boat(`a command in ${where(ctx)}`, () => api.command({ sandboxId, command: line, cwd, timeoutSeconds: COMMAND_TIMEOUT_S }));
135
+ const result = await boat(`a command in ${where(ctx)}`, () => api.command({ sandboxId, command: `exec sh -c ${quote(line)}`, cwd, timeoutSeconds: COMMAND_TIMEOUT_S }), "command");
117
136
  if (!("exitCode" in result))
118
137
  throw new Error(`a command in ${where(ctx)} was started in the background`);
119
138
  if (result.timedOut)
@@ -122,18 +141,33 @@ async function command(ctx, line, cwd) {
122
141
  throw new Error(`Boat cut the output of a command in ${where(ctx)} short`);
123
142
  return { exitCode: result.exitCode ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
124
143
  }
144
+ function reachable(ctx, path) {
145
+ if (FILE_API_DIRECTORIES.some((directory) => path === directory || path.startsWith(`${directory}/`)))
146
+ return path;
147
+ throw new SandboxPathError(`${path} is outside ${FILE_API_DIRECTORIES.join(" and ")}, the directories Boat's file API reaches in ${where(ctx)}`);
148
+ }
149
+ function refused(ctx, path, error) {
150
+ if (!(error instanceof BoatError) || !(error.code === "invalid_path" || REFUSED_PATH.test(error.detail)))
151
+ return error;
152
+ return new SandboxPathError(`Boat's file API refuses ${path} in ${where(ctx)}: ${error.detail}`);
153
+ }
125
154
  async function upload(ctx, path, data) {
126
155
  const api = apiOf(ctx);
127
156
  const sandboxId = sandboxIdOf(ctx);
128
157
  const content = Buffer.from(data).toString("base64");
129
- await boat(`writing ${path} in ${where(ctx)}`, () => api.writeFile({ sandboxId, path, content, encoding: "base64" }));
158
+ try {
159
+ await boat(`writing ${path} in ${where(ctx)}`, () => api.writeFile({ sandboxId, path: reachable(ctx, path), content, encoding: "base64" }), "file");
160
+ }
161
+ catch (error) {
162
+ throw refused(ctx, path, error);
163
+ }
130
164
  }
131
165
  async function runCommand(ctx, cmd, { cwd, env, stdin } = {}) {
132
166
  const directory = cwd === undefined ? undefined : posix.resolve("/", cwd);
133
167
  if (stdin === undefined)
134
168
  return resuming(ctx, () => command(ctx, commandLine(cmd, { env }), directory));
135
169
  return resuming(ctx, async () => {
136
- const staged = `/tmp/.hyperbrane-stdin-${randomUUID()}`;
170
+ const staged = `${WORK_DIRECTORY}/.hyperbrane-stdin-${randomUUID()}`;
137
171
  await upload(ctx, staged, typeof stdin === "string" ? new TextEncoder().encode(stdin) : stdin);
138
172
  return command(ctx, `(${commandLine(cmd, { env })}) < ${quote(staged)}; code=$?; rm -f ${quote(staged)}; exit "$code"`, directory);
139
173
  });
@@ -142,20 +176,27 @@ function boatMachine(ctx) {
142
176
  async function readBytes(path, start, end) {
143
177
  const api = apiOf(ctx);
144
178
  const sandboxId = sandboxIdOf(ctx);
179
+ reachable(ctx, path);
145
180
  try {
146
- const { content } = await boat(`reading ${path} in ${where(ctx)}`, () => api.readFile({ sandboxId, path, encoding: "base64" }));
181
+ const { content } = await boat(`reading ${path} in ${where(ctx)}`, () => api.readFile({ sandboxId, path, encoding: "base64" }), "file");
147
182
  const data = new Uint8Array(Buffer.from(content, "base64"));
148
183
  return start === undefined ? data : data.subarray(start, end);
149
184
  }
150
185
  catch (error) {
151
- if (error instanceof BoatError && error.status === 404)
186
+ const failure = refused(ctx, path, error);
187
+ if (failure === error && error instanceof BoatError && (await absent(path)))
152
188
  throw new SandboxNotFoundError(`Not found: "${path}"`);
153
- throw error;
189
+ throw failure;
154
190
  }
155
191
  }
192
+ async function absent(path) {
193
+ const checked = await command(ctx, `test -e ${quote(path)}`).catch(() => null);
194
+ return checked?.exitCode === 1;
195
+ }
156
196
  async function putFile(path, data, exclusive) {
197
+ reachable(ctx, path);
157
198
  return resuming(ctx, async () => {
158
- const staged = `${posix.dirname(path)}/.${posix.basename(path)}.${randomUUID()}.tmp`;
199
+ const staged = `${posix.dirname(path)}/.hyperbrane-${randomUUID()}.tmp`;
159
200
  const made = await command(ctx, `mkdir -p ${quote(posix.dirname(path))}`);
160
201
  if (made.exitCode !== 0)
161
202
  throw new Error(`writing ${path} in ${where(ctx)} failed: ${made.stderr.trim()}`);
@@ -177,13 +218,9 @@ function boatMachine(ctx) {
177
218
  };
178
219
  }
179
220
  async function sshTarget(ctx) {
180
- const privateKey = ctx.sshKey;
181
- if (!privateKey)
221
+ if (!ctx.sshKey)
182
222
  throw new Error(`${where(ctx)} has no shell: its context has no SSH key (sshKey)`);
183
- const parsed = ssh2.utils.parseKey(privateKey);
184
- const key = Array.isArray(parsed) ? parsed[0] : parsed;
185
- if (!key || key instanceof Error)
186
- throw new Error(`${where(ctx)}'s SSH key can't be read${key instanceof Error ? `: ${key.message}` : ""}`);
223
+ const { privateKey, key } = parseSshKey(ctx.sshKey, `${where(ctx)}'s`);
187
224
  await ensureRunning(ctx);
188
225
  const api = apiOf(ctx);
189
226
  const sandboxId = sandboxIdOf(ctx);
@@ -286,6 +323,21 @@ function hasShell(ctx) {
286
323
  async function sshEndpoint(ctx) {
287
324
  return sshTarget(ctx);
288
325
  }
326
+ export async function listBoatSandboxes(ctx) {
327
+ const api = apiOf(ctx);
328
+ const listings = [];
329
+ let cursor;
330
+ do {
331
+ const page = await boat("listing Boat sandboxes", () => api.sandboxes({ limit: LISTED_PER_PAGE, cursor }));
332
+ for (const sandbox of page.sandboxes) {
333
+ if (UNLISTED_STATES.includes(sandbox.state))
334
+ continue;
335
+ listings.push({ id: sandbox.id, name: sandbox.name, state: RUNNING_STATES.includes(sandbox.state) ? "running" : "stopped" });
336
+ }
337
+ cursor = page.pageInfo?.hasMore ? (page.pageInfo.nextCursor ?? undefined) : undefined;
338
+ } while (cursor);
339
+ return listings;
340
+ }
289
341
  export function createBoatService(ctx) {
290
342
  apiKeyOf(ctx);
291
343
  const { fileOps, cmdOps, netOps } = groupsOf(ctx);
@@ -1,5 +1,6 @@
1
1
  import { userInfo } from "node:os";
2
2
  import { commandFileOperations } from "../../lib/remoteFileOperations.js";
3
+ import { sshPrivateKey } from "../../lib/sshKey.js";
3
4
  import { createDescriptor, getDescriptor, openDescriptor } from "../../storage/descriptors.js";
4
5
  import { createEndpoints } from "../../data-endpoints/dataEndpoints.js";
5
6
  import { createSandboxStorage } from "../../storage/sandboxStorage.js";
@@ -24,9 +25,10 @@ function vmOf(ctx) {
24
25
  function target(ctx) {
25
26
  if (!ctx.sshKey)
26
27
  throw new Error(`exe.dev VM ${ctx.id ?? "(no name)"} needs the account's SSH key (sshKey)`);
28
+ const privateKey = sshPrivateKey(ctx.sshKey, "the exe.dev account's");
27
29
  if (ctx.sshHost)
28
- return { host: ctx.sshHost, port: ctx.sshPort ?? 22, user: ctx.sshUser ?? userInfo().username, privateKey: ctx.sshKey };
29
- return { host: "vm.exe.xyz", port: 22, user: `vm+${vmOf(ctx)}`, privateKey: ctx.sshKey };
30
+ return { host: ctx.sshHost, port: ctx.sshPort ?? 22, user: ctx.sshUser ?? userInfo().username, privateKey };
31
+ return { host: "vm.exe.xyz", port: 22, user: `vm+${vmOf(ctx)}`, privateKey };
30
32
  }
31
33
  function where(ctx) {
32
34
  return `exe.dev VM ${ctx.id ?? "(no name)"}`;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import ssh2 from "ssh2";
2
+ import { parseSshKey } from "../../lib/sshKey.js";
3
3
  function sshString(value) {
4
4
  const bytes = typeof value === "string" ? Buffer.from(value) : value;
5
5
  const length = Buffer.alloc(4);
@@ -7,11 +7,7 @@ function sshString(value) {
7
7
  return Buffer.concat([length, bytes]);
8
8
  }
9
9
  function privateKeyOf(privateKey) {
10
- const parsed = ssh2.utils.parseKey(privateKey);
11
- const key = Array.isArray(parsed) ? parsed[0] : parsed;
12
- if (!key || key instanceof Error || !key.isPrivateKey())
13
- throw new Error(`the exe.dev key isn't a usable private key${key instanceof Error ? `: ${key.message}` : ""}`);
14
- return key;
10
+ return parseSshKey(privateKey, "the exe.dev account's").key;
15
11
  }
16
12
  function ecdsaBlob(der) {
17
13
  let offset = 2 + (der[1] & 0x80 ? der[1] & 0x7f : 0);
@@ -1,14 +1,15 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs/promises";
4
- import { connect as netConnect, Socket } from "node:net";
4
+ import { connect as netConnect } from "node:net";
5
5
  import { homedir, userInfo } from "node:os";
6
6
  import { basename, dirname, join, resolve } from "node:path";
7
- import { SandboxConnectionRefusedError, SandboxNotFoundError, } from "../../api.js";
7
+ import { SandboxNotFoundError, } from "../../api.js";
8
8
  import { createDescriptor, getDescriptor, openDescriptor } from "../../storage/descriptors.js";
9
9
  import { createEndpoints } from "../../data-endpoints/dataEndpoints.js";
10
10
  import { firewalledPorts } from "../../data-endpoints/firewalledPorts.js";
11
11
  import { httpOver } from "../../lib/httpOverStream.js";
12
+ import { connectUnixSocket } from "../../../lib/unixSocket.js";
12
13
  import { createSandboxStorage } from "../../storage/sandboxStorage.js";
13
14
  function hostPath(path) {
14
15
  return resolve("/", path);
@@ -142,30 +143,7 @@ async function fetch(url, { unix, ...init } = {}) {
142
143
  return httpOver(url, () => netConnect(hostPath(unix)), init);
143
144
  }
144
145
  function connect(path) {
145
- return new Promise((resolve, reject) => {
146
- const socket = new Socket();
147
- let markClosed;
148
- const closed = new Promise((done) => (markClosed = done));
149
- const listeners = [];
150
- socket.on("data", (chunk) => {
151
- for (const listener of listeners)
152
- listener(new Uint8Array(chunk));
153
- });
154
- socket.once("close", () => markClosed());
155
- socket.once("end", () => markClosed());
156
- socket.on("error", (error) => {
157
- markClosed();
158
- const message = `connecting to ${path} failed: ${error.message}`;
159
- reject(error.code === "ENOENT" || error.code === "ECONNREFUSED" ? new SandboxConnectionRefusedError(message) : new Error(message));
160
- });
161
- socket.once("connect", () => resolve({
162
- write: (data) => void socket.write(data),
163
- onData: (listener) => void listeners.push(listener),
164
- closed,
165
- close: () => socket.end(),
166
- }));
167
- socket.connect({ path: hostPath(path) });
168
- });
146
+ return connectUnixSocket(hostPath(path), path);
169
147
  }
170
148
  export function createLocalService(ctx) {
171
149
  const fileOps = {
@@ -4,13 +4,14 @@ import { createDescriptor, getDescriptor, openDescriptor } from "../../storage/d
4
4
  import { createEndpoints } from "../../data-endpoints/dataEndpoints.js";
5
5
  import { firewalledPorts } from "../../data-endpoints/firewalledPorts.js";
6
6
  import { createSandboxStorage } from "../../storage/sandboxStorage.js";
7
+ import { sshPrivateKey } from "../../lib/sshKey.js";
7
8
  import { sshCommand, sshConnect, sshFetch, sshMachine } from "./sshTransport.js";
8
9
  function target(ctx) {
9
10
  if (!ctx.sshHost)
10
11
  throw new Error("an SSH machine needs its host (sshHost)");
11
12
  if (!ctx.sshKey)
12
13
  throw new Error(`SSH to ${ctx.sshHost} needs a key (sshKey)`);
13
- return { host: ctx.sshHost, port: ctx.sshPort ?? 22, user: ctx.sshUser ?? userInfo().username, privateKey: ctx.sshKey };
14
+ return { host: ctx.sshHost, port: ctx.sshPort ?? 22, user: ctx.sshUser ?? userInfo().username, privateKey: sshPrivateKey(ctx.sshKey, `${where(ctx)}'s`) };
14
15
  }
15
16
  function where(ctx) {
16
17
  return `SSH machine ${ctx.sshHost ?? "(no host)"}`;
@@ -1,2 +1,3 @@
1
- import type { SandboxAuthContext, SandboxService } from "./api.ts";
1
+ import type { SandboxAuthContext, SandboxListing, SandboxService } from "./api.ts";
2
2
  export declare function createSandboxService(ctx: SandboxAuthContext): SandboxService;
3
+ export declare function listSandboxes(ctx: SandboxAuthContext): Promise<SandboxListing[]>;
@@ -1,7 +1,8 @@
1
- import { createBoatService } from "./providers/boat/boatService.js";
1
+ import { createBoatService, listBoatSandboxes } from "./providers/boat/boatService.js";
2
2
  import { createExeDevService } from "./providers/exedev/exeDevService.js";
3
3
  import { createSshService } from "./providers/ssh/sshService.js";
4
4
  import { createLocalService } from "./providers/local/localService.js";
5
+ import { createSandboxStorage } from "./storage/sandboxStorage.js";
5
6
  export function createSandboxService(ctx) {
6
7
  // Create service is a factory that initializes the specific sandbox operations client
7
8
  // It does nothing, just creates the client
@@ -16,3 +17,10 @@ export function createSandboxService(ctx) {
16
17
  return createLocalService(ctx);
17
18
  }
18
19
  }
20
+ export async function listSandboxes(ctx) {
21
+ if (ctx.kind === "boat")
22
+ return listBoatSandboxes(ctx);
23
+ const service = createSandboxService(ctx);
24
+ const descriptor = await createSandboxStorage(service).readDescriptor();
25
+ return descriptor ? [{ id: descriptor.id, name: descriptor.name, state: await service.state() }] : [];
26
+ }
@@ -61,17 +61,21 @@ export type BridgeSessionHandle = {
61
61
  sendControlCancelRequest(requestId: string): void;
62
62
  /**
63
63
  * Swap the underlying transport in place with a fresh JWT (and epoch).
64
- * Carries the SSE sequence number so the server resumes the stream.
64
+ * Carries the SSE sequence number so the server resumes the stream, and
65
+ * hands the old transport's undelivered writes to the new one, including
66
+ * writes made after the transport died, so this call is also how a host
67
+ * recovers them after onClose fires.
65
68
  * Call this when the poll loop re-dispatches work for the same session
66
69
  * with a fresh secret (JWT is 4h; backend mints a new one every dispatch).
67
70
  *
68
71
  * Throws if `createV2ReplTransport` fails (registerWorker error, etc).
69
- * Caller should treat that as a close and drop this handle.
72
+ * Caller should treat that as a close and drop this handle; the
73
+ * undelivered writes are dropped with it.
70
74
  */
71
75
  reconnectTransport(opts: {
72
76
  ingressToken: string;
73
77
  apiBaseUrl: string;
74
- /** Omit to reuse the current transport's epoch (token-refresh reconnect); provide to override. */
78
+ /** Omit to reuse the current transport's epoch (token-refresh reconnect); provide to override. Pass a fresh one after a 4090. */
75
79
  epoch?: number;
76
80
  }): Promise<void>;
77
81
  /**
@@ -131,6 +135,13 @@ export type AttachBridgeSessionOptions = {
131
135
  * not be able to drive it.
132
136
  */
133
137
  outboundOnly?: boolean;
138
+ /**
139
+ * Version of the Claude Code CLI this host runs the session's turns with.
140
+ * Reported to attaching clients as `claude_code_version` on the initialize
141
+ * reply so they can gate features before the first turn's `system`/`init`
142
+ * arrives; omit when unknown and clients wait for that message instead.
143
+ */
144
+ claudeCodeVersion?: string;
134
145
  /**
135
146
  * User message typed on claude.ai. Echoes of outbound writes and
136
147
  * re-deliveries of prompts already forwarded are filtered before this
@@ -221,7 +232,11 @@ export type AttachBridgeSessionOptions = {
221
232
  * (heartbeat/write auth exhaustion — re-attach with a fresh secret,
222
233
  * like 401), 403/404 = permanent SSE HTTP rejection. Transient
223
234
  * disconnects (503, network blips) retry indefinitely inside
224
- * SSETransport and do NOT fire this.
235
+ * SSETransport and do NOT fire this. After it fires, the handle keeps
236
+ * writes it could not deliver: reconnectTransport() hands them to the
237
+ * rebuilt transport, and close() drops them. After a 4090 the epoch
238
+ * may be stale: pass one from a fresh /bridge call, since reusing a
239
+ * stale one closes again.
225
240
  */
226
241
  onClose?: (code?: number) => void;
227
242
  };
@@ -351,6 +366,7 @@ export type CreateSessionFailure = {
351
366
  terminal: true;
352
367
  reason: 'grouping_rejected' | 'request_rejected' | 'malformed_response';
353
368
  status: number;
369
+ /** The service's own message, unchanged and still untrusted, or undefined. It is passed on only if it is one line of at most 1,024 UTF-16 units, with no control or invisible characters and no whitespace other than single ASCII spaces. */
354
370
  detail: string | undefined;
355
371
  };
356
372
  /**
@@ -89,6 +89,13 @@ type BrowserQueryOptionsBase = {
89
89
  * each turn's result. At most one per turn.
90
90
  */
91
91
  promptSuggestions?: boolean;
92
+ /**
93
+ * Dialog kinds (`request_user_dialog` `dialog_kind` values) this consumer's
94
+ * `onUserDialog` can render, declared on `initialize`. The remote CLI sends
95
+ * only kinds an attached client declared, so `onUserDialog` alone does not
96
+ * opt in. A non-empty list without `onUserDialog` throws.
97
+ */
98
+ supportedDialogKinds?: string[];
92
99
  };
93
100
  /**
94
101
  * Exactly one of `websocket` | `sse` must be provided. `sse` is the v1alpha2
@@ -182,3 +189,20 @@ export declare function getCcrEvent(query: Query, message: SDKMessage): {
182
189
  created_at?: string;
183
190
  source?: string;
184
191
  } | undefined;
192
+ /**
193
+ * Closes the event stream of an `sse` query without ending the query. The
194
+ * stream reopens on `resumeSseStream()` or on the query's next send, its own
195
+ * answers to earlier requests included. It then replays the durable events
196
+ * sent meanwhile (or `onCatchUpTruncated` fires). Until then, requests from
197
+ * the remote session, such as permission prompts, wait unanswered. So do the
198
+ * query's own requests in flight and sends still waiting for the first
199
+ * connection. Ephemeral frames and delivery updates sent meanwhile are lost.
200
+ * Returns false for a WebSocket, closed or suspended query.
201
+ */
202
+ export declare function suspendSseStream(query: Query): boolean;
203
+ /**
204
+ * Reopens the event stream that `suspendSseStream()` closed, resuming after
205
+ * the last event received. Returns false unless `query` is suspended and
206
+ * still open.
207
+ */
208
+ export declare function resumeSseStream(query: Query): boolean;
@@ -1,10 +1,12 @@
1
1
  export {
2
2
  AbortError,
3
+ BUILTIN_TOOL_NAMES,
3
4
  createSdkMcpServer,
4
5
  deleteSession,
5
6
  EXIT_REASONS,
6
7
  filterEscalatingDefaultMode,
7
8
  HOOK_EVENTS,
9
+ LEGACY_TOOL_NAME_ALIASES,
8
10
  ORG_POLICY_LIMIT_PREFIXES,
9
11
  query,
10
12
  renameSession,