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.
- package/README.md +43 -3
- package/dist/agent/agentService.js +3 -2
- package/dist/agent/api.d.ts +26 -2
- package/dist/agent/control.js +1 -1
- package/dist/agent/dimensionAgentClient.d.ts +8 -0
- package/dist/agent/dimensionAgentClient.js +99 -0
- package/dist/agent/session/backend.d.ts +2 -1
- package/dist/agent/session/claude/transcript.d.ts +1 -0
- package/dist/agent/session/claude/transcript.js +1 -1
- package/dist/agent/session/worker/protocol.d.ts +6 -3
- package/dist/agent/session/worker/protocol.js +1 -1
- package/dist/agent/session/worker/workerParams.js +10 -3
- package/dist/agent/vendor-types/manifest.json +5 -60
- package/dist/agent/workerBundle.generated.js +1 -1
- package/dist/dimension/api.d.ts +3 -0
- package/dist/dimension/control.d.ts +2 -1
- package/dist/dimension/control.js +18 -0
- package/dist/dimension/service.js +27 -1
- package/dist/dimension/transportBundle.generated.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lib/control.d.ts +4 -1
- package/dist/lib/control.js +2 -2
- package/dist/lib/jsonDirectory.js +24 -3
- package/dist/lib/unixSocket.d.ts +2 -0
- package/dist/lib/unixSocket.js +28 -0
- package/dist/portal-gateway/api.d.ts +1 -0
- package/dist/portal-gateway/gatewayService.js +14 -0
- package/dist/portal-gateway/transportBundle.generated.js +1 -1
- package/dist/restore.d.ts +9 -0
- package/dist/restore.js +10 -0
- package/dist/sandbox/api.d.ts +5 -0
- package/dist/sandbox/client.d.ts +2 -1
- package/dist/sandbox/client.js +4 -1
- package/dist/sandbox/lib/sshKey.d.ts +6 -0
- package/dist/sandbox/lib/sshKey.js +75 -0
- package/dist/sandbox/providers/boat/boatService.d.ts +2 -1
- package/dist/sandbox/providers/boat/boatService.js +72 -20
- package/dist/sandbox/providers/exedev/exeDevService.js +4 -2
- package/dist/sandbox/providers/exedev/exeDevToken.js +2 -6
- package/dist/sandbox/providers/local/localService.js +4 -26
- package/dist/sandbox/providers/ssh/sshService.js +2 -1
- package/dist/sandbox/service.d.ts +2 -1
- package/dist/sandbox/service.js +9 -1
- package/dist/vendor-types/claude/agent-sdk/bridge.d.ts +20 -4
- package/dist/vendor-types/claude/agent-sdk/browser-sdk.d.ts +24 -0
- package/dist/vendor-types/claude/agent-sdk/core.d.ts +2 -0
- package/dist/vendor-types/claude/agent-sdk/sdk-tools.d.ts +75 -6
- package/dist/vendor-types/claude/agent-sdk/sdk.d.ts +569 -100
- 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
|
|
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
|
|
76
|
+
if (STOPPED_CODES.includes(code))
|
|
66
77
|
throw new SandboxStoppedError(`${what}: the Boat sandbox is stopped`);
|
|
67
|
-
|
|
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
|
-
|
|
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) || !
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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)}
|
|
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
|
-
|
|
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
|
|
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
|
|
29
|
-
return { host: "vm.exe.xyz", port: 22, user: `vm+${vmOf(ctx)}`, privateKey
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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[]>;
|
package/dist/sandbox/service.js
CHANGED
|
@@ -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;
|