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
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # hyperbrane
2
+
3
+ Dimensions (containers bound to a host directory), the portal gateways they reach the outside
4
+ through, and agents (Claude Code, Codex) running in them, on a machine reached locally, over SSH, or
5
+ as a Boat sandbox.
6
+
7
+ ## Requirements
8
+
9
+ - The library: Node ≥ 20.11 or Bun.
10
+ - The CLI: Bun.
11
+ - On the machine driven: docker, and python3 for accessible endpoints.
12
+
13
+ ## CLI
14
+
15
+ ```sh
16
+ bun add -g hyperbrane
17
+ hyperbrane --help
18
+ hyperbrane dimension create --dir ~/work -m 4G --cpus 2 work
19
+ ```
20
+
21
+ ## Library
22
+
23
+ ```ts
24
+ import { createServices, createSshSandbox, MemoryLimit, CPULimit } from "hyperbrane";
25
+
26
+ const host = createSshSandbox("server.example.com", { user: "me", privateKey });
27
+ const { dimensions, agents } = createServices(host);
28
+ await dimensions.createDimension({ name: "work", directory: "work", memoryLimit: MemoryLimit("4G"), cpuLimit: CPULimit("2") });
29
+ await agents.createAgent({ name: "dev", dimensionName: "work" });
30
+ ```
31
+
32
+ The root module exports the services, the sandboxes (`createLocalSandbox`, `createSshSandbox`,
33
+ `createContainerSandbox`, `createBoatSandbox`), the endpoint auths and the agent client. Every module
34
+ is also reachable by its path, e.g. `hyperbrane/sandbox/sshService`.
35
+
36
+ ## Building
37
+
38
+ ```sh
39
+ bun run build # dist/
40
+ bun run check:package # packs it, installs the tarball elsewhere and uses it under Node and Bun
41
+ ```
@@ -0,0 +1,31 @@
1
+ import type { DockerClient } from "../lib/docker.ts";
2
+ import type { Sandbox, SandboxAccessibleEndpoint } from "../sandbox/api.ts";
3
+ import type { AgentStorage } from "./agentStorage.ts";
4
+ import type { GatewaySignIns } from "./gatewaySignIns.ts";
5
+ import { type AgentClient, type AgentEndpoint, type AgentProvision, type AgentSessionCapabilities } from "./api.ts";
6
+ export interface AgentService extends AgentClient, AgentProvision, AgentSessionCapabilities, AgentEndpoint {
7
+ /** Starts the workers that a restart took down (their dimension runs again); returns their agents' names. */
8
+ restoreAgents(): Promise<string[]>;
9
+ }
10
+ /** In the dimension: where the backends are installed (a package with its node_modules). Shared by agents. */
11
+ export declare const WORKER_DIR = "/opt/hyperbrane/worker";
12
+ /**
13
+ * In the dimension: the worker's script. Outside WORKER_DIR on purpose: bun caches the listing of the
14
+ * directory a script is loaded from, and packages installed there afterwards didn't resolve.
15
+ */
16
+ export declare const WORKER_SCRIPT = "/opt/hyperbrane/worker.js";
17
+ /**
18
+ * Runs each agent as a session worker inside its dimension (agent/session/main.ts) and talks
19
+ * JSON-RPC to it over the worker's unix socket, which the host reaches through the dimension's
20
+ * mounted run directory (through `host`). Keeps no state between calls beyond the agent records: every call finds
21
+ * the worker again.
22
+ */
23
+ /**
24
+ * `gatewaySignIns` tells whether a dimension's gateway already holds a backend's sign-in, so a
25
+ * session's login can complete without anyone; without it every login waits for the user.
26
+ */
27
+ /**
28
+ * `host` is the machine docker runs on: workers' sockets and logs are reached through it, and agents
29
+ * are exposed through its endpoints when it declares them.
30
+ */
31
+ export declare function createAgentService(storage: AgentStorage, docker: DockerClient, host: Sandbox | (Sandbox & SandboxAccessibleEndpoint), gatewaySignIns?: GatewaySignIns): AgentService;
@@ -0,0 +1,388 @@
1
+ import { RUN_PATH, STATE_PATH, containerName as dimensionContainerName } from "../dimension/service.js";
2
+ import { createContainerSandbox } from "../sandbox/containerService.js";
3
+ import { InvalidNameError, checkName } from "../lib/jsonDirectory.js";
4
+ import { sleep } from "../lib/sleep.js";
5
+ import { OS_CA_BUNDLE } from "../portal-gateway/portalService.js";
6
+ import { AgentConflictError, AgentInvalidError, AgentNotFoundError, } from "./api.js";
7
+ import { createWorkerClient } from "./session/client.js";
8
+ import { workerSocketConnector } from "./session/workerConnection.js";
9
+ import { workerSocketPath } from "./session/sessionStorage.js";
10
+ import { workerBundle } from "./workerBundle.js";
11
+ /** In the dimension: where the backends are installed (a package with its node_modules). Shared by agents. */
12
+ export const WORKER_DIR = "/opt/hyperbrane/worker";
13
+ /**
14
+ * In the dimension: the worker's script. Outside WORKER_DIR on purpose: bun caches the listing of the
15
+ * directory a script is loaded from, and packages installed there afterwards didn't resolve.
16
+ */
17
+ export const WORKER_SCRIPT = "/opt/hyperbrane/worker.js";
18
+ const START_TIMEOUT_MS = 30_000;
19
+ const STOP_TIMEOUT_MS = 10_000;
20
+ /** The gateway upstream each backend's CLI signs in to (upstreams/). */
21
+ const AUTH_UPSTREAMS = { claude: "anthropic", codex: "openai" };
22
+ /** The worker's log in the dimension's run directory (RUN_PATH or its host side), next to its socket. */
23
+ function workerLogPath(runDirectory, agentName) {
24
+ return `${runDirectory}/${agentName}.log`;
25
+ }
26
+ // Runs as the dimension's user and returns at once: the worker is detached from the exec, so it
27
+ // outlives it (and every later command), until the container stops.
28
+ const START_WORKER_SCRIPT = `set -eu
29
+ nohup setsid bun "$HYB_WORKER_SCRIPT" >> "$HYB_LOG" 2>&1 < /dev/null &
30
+ `;
31
+ // Run when a worker won't stop, as the dimension's user, whom the worker and the CLIs it started run
32
+ // as: they carry its agent in their environment, which root can't read in a container (docker gives
33
+ // it no ptrace capability, and reading another user's environ takes one). The name comes in as
34
+ // AGENT: this script itself mustn't match.
35
+ const KILL_WORKER_SCRIPT = `for process in /proc/[0-9]*; do
36
+ if tr '\\0' '\\n' < "$process/environ" 2>/dev/null | grep -qx "HYB_AGENT=$AGENT"; then kill -9 "\${process#/proc/}" 2>/dev/null || true; fi
37
+ done
38
+ `;
39
+ /**
40
+ * Runs each agent as a session worker inside its dimension (agent/session/main.ts) and talks
41
+ * JSON-RPC to it over the worker's unix socket, which the host reaches through the dimension's
42
+ * mounted run directory (through `host`). Keeps no state between calls beyond the agent records: every call finds
43
+ * the worker again.
44
+ */
45
+ /**
46
+ * `gatewaySignIns` tells whether a dimension's gateway already holds a backend's sign-in, so a
47
+ * session's login can complete without anyone; without it every login waits for the user.
48
+ */
49
+ /**
50
+ * `host` is the machine docker runs on: workers' sockets and logs are reached through it, and agents
51
+ * are exposed through its endpoints when it declares them.
52
+ */
53
+ export function createAgentService(storage, docker, host, gatewaySignIns) {
54
+ const endpoints = "exposeEndpoint" in host ? host : null;
55
+ async function runningDimension(dimensionName) {
56
+ const container = dimensionContainerName(dimensionName);
57
+ const inspected = await docker.inspectContainer(container);
58
+ if (inspected?.Config?.Labels?.["hyperbrane.dimension"] !== dimensionName) {
59
+ throw new AgentNotFoundError(`dimension ${dimensionName} does not exist`);
60
+ }
61
+ if (!inspected.State?.Running)
62
+ throw new AgentConflictError(`dimension ${dimensionName} is not running`);
63
+ const runDirectory = inspected.Mounts?.find((mount) => mount.Destination === RUN_PATH)?.Source;
64
+ const hasState = inspected.Mounts?.some((mount) => mount.Destination === STATE_PATH);
65
+ if (!runDirectory || !hasState) {
66
+ throw new AgentConflictError(`dimension ${dimensionName} lacks the ${RUN_PATH} and ${STATE_PATH} mounts agents need (it predates them); recreate it`);
67
+ }
68
+ return { container, owner: inspected.Config.User || "0:0", runDirectory };
69
+ }
70
+ /** The worker as it is: for starting, stopping and checking on it. */
71
+ function rawWorkerClient(dimension, agentName) {
72
+ return createWorkerClient(workerSocketConnector(host, workerSocketPath(dimension.runDirectory, agentName)));
73
+ }
74
+ /**
75
+ * The worker, started again first if it isn't running: it doesn't come back with its container
76
+ * (a restart, a host reboot, a Boat sandbox resumed after its auto-stop). A connection that can't
77
+ * be made starts it, once, and is tried again.
78
+ */
79
+ function workerClient(dimension, agentName) {
80
+ const connect = workerSocketConnector(host, workerSocketPath(dimension.runDirectory, agentName));
81
+ const reviving = async (onNotification) => {
82
+ try {
83
+ return await connect(onNotification);
84
+ }
85
+ catch {
86
+ await reviveWorker(agentName, dimension);
87
+ return connect(onNotification);
88
+ }
89
+ };
90
+ return createWorkerClient(reviving);
91
+ }
92
+ // One start at a time per agent, however many calls find its worker gone.
93
+ const reviving = new Map();
94
+ function reviveWorker(agentName, dimension) {
95
+ let started = reviving.get(agentName);
96
+ if (!started) {
97
+ started = ensureWorker(agentName, dimension)
98
+ .catch(async () => {
99
+ // Unreachable even freshly started: the container's mounts may no longer be the host's
100
+ // directories (it started while they were something else). A restart binds them again.
101
+ await docker.restartContainer(dimension.container);
102
+ await ensureWorker(agentName, dimension);
103
+ })
104
+ .finally(() => reviving.delete(agentName));
105
+ reviving.set(agentName, started);
106
+ }
107
+ return started;
108
+ }
109
+ /**
110
+ * Starts the worker of every agent whose dimension runs but whose worker doesn't answer (after a
111
+ * restart took them down); returns their names. Agents whose dimension isn't running are left.
112
+ */
113
+ async function restoreAgents() {
114
+ const restored = await Promise.all((await storage.listAgents()).map(async ({ name, dimensionName }) => {
115
+ const dimension = await runningDimension(dimensionName).catch(() => null);
116
+ if (!dimension)
117
+ return null;
118
+ if (await rawWorkerClient(dimension, name).call("worker/health", {}).then(() => true, () => false))
119
+ return null;
120
+ try {
121
+ await reviveWorker(name, dimension);
122
+ return name;
123
+ }
124
+ catch (error) {
125
+ console.warn(`starting agent ${name}'s worker failed:`, error.message);
126
+ return null;
127
+ }
128
+ }));
129
+ return restored.filter((name) => name !== null);
130
+ }
131
+ async function requireAgent(agentName) {
132
+ assertValidName("agent", agentName);
133
+ const agent = await storage.readAgent(agentName);
134
+ if (!agent)
135
+ throw new AgentNotFoundError(`agent ${agentName} does not exist`);
136
+ return agent;
137
+ }
138
+ async function clientFor(agentName) {
139
+ const agent = await requireAgent(agentName);
140
+ return workerClient(await runningDimension(agent.dimensionName), agentName);
141
+ }
142
+ // Re-running replaces a worker running older code, and starts one that stopped with its container.
143
+ async function createAgent(agent) {
144
+ assertValidName("agent", agent.name);
145
+ assertValidName("dimension", agent.dimensionName);
146
+ const dimension = await runningDimension(agent.dimensionName);
147
+ const record = await storage.insertAgent({ name: agent.name, dimensionName: agent.dimensionName });
148
+ if (record.dimensionName !== agent.dimensionName) {
149
+ throw new AgentConflictError(`agent ${agent.name} already exists in dimension ${record.dimensionName}`);
150
+ }
151
+ await ensureWorker(agent.name, dimension);
152
+ return { name: agent.name, dimensionName: agent.dimensionName };
153
+ }
154
+ async function ensureWorker(agentName, dimension) {
155
+ const { container, owner } = dimension;
156
+ const bundle = await workerBundle();
157
+ const client = rawWorkerClient(dimension, agentName);
158
+ const health = await client.call("worker/health", {}).catch(() => null);
159
+ if (health?.build === bundle.build)
160
+ return;
161
+ const root = createContainerSandbox(docker, container, { user: "root" });
162
+ // The run and state directories too: the host created them, possibly as another user than the dimension's.
163
+ const setup = await root.runCommand(["sh", "-c", 'mkdir -p "$W" && chown "$OWNER" "$(dirname "$W")" "$W" "$R" "$S"'], {
164
+ env: { W: WORKER_DIR, R: RUN_PATH, S: STATE_PATH, OWNER: owner },
165
+ });
166
+ if (setup.exitCode !== 0)
167
+ throw new Error(`preparing ${container} for agent ${agentName} failed: ${setup.stderr.trim()}`);
168
+ const sandbox = createContainerSandbox(docker, container);
169
+ await sandbox.writeFile(WORKER_SCRIPT, bundle.source);
170
+ await sandbox.writeFileExclusive(`${WORKER_DIR}/package.json`, JSON.stringify({ name: "hyperbrane-worker", private: true }, null, 2) + "\n");
171
+ // The gateway intercepts every connection, so installs must trust the bundle portals put its CA in.
172
+ await sandbox.writeFile(`${WORKER_DIR}/bunfig.toml`, `[install]\ncafile = "${OS_CA_BUNDLE}"\n`);
173
+ if (health)
174
+ await stopWorker(agentName, dimension);
175
+ // One that doesn't answer may still run (unreachable): it goes, rather than piling up.
176
+ else
177
+ await killWorker(agentName, dimension);
178
+ await startWorker(agentName, dimension, bundle, await socketOwner(agentName, dimension));
179
+ }
180
+ // The host user (uid:gid on the machine), asked once: it connects to workers' sockets.
181
+ let hostUser = null;
182
+ function hostUserIds() {
183
+ hostUser ??= host.runCommand(["sh", "-c", 'printf "%s:%s" "$(id -u)" "$(id -g)"']).then((result) => {
184
+ const [uid, gid] = result.stdout.trim().split(":").map(Number);
185
+ if (result.exitCode !== 0 || !Number.isInteger(uid) || !Number.isInteger(gid)) {
186
+ throw new Error(`reading the host user's ids failed: ${result.stderr.trim() || result.stdout.trim()}`);
187
+ }
188
+ return { uid: uid, gid: gid };
189
+ });
190
+ hostUser.catch(() => (hostUser = null));
191
+ return hostUser;
192
+ }
193
+ /**
194
+ * Who the worker's socket must belong to for the host user to connect (a unix socket takes write
195
+ * permission): no one else when the dimension runs as the host user; the host user when it runs
196
+ * as root (its directory belongs to root), which the worker then hands the socket to. A dimension
197
+ * of a third user can't be reached.
198
+ */
199
+ async function socketOwner(agentName, dimension) {
200
+ const runsAs = Number(dimension.owner.split(":")[0]);
201
+ const { uid, gid } = await hostUserIds();
202
+ if (runsAs === uid)
203
+ return undefined;
204
+ if (runsAs === 0)
205
+ return `${uid}:${gid}`;
206
+ throw new AgentConflictError(`agent ${agentName}'s worker would run as uid ${runsAs} (its dimension's directory belongs to that user), whose socket the host user (uid ${uid}) can't connect to; use a directory owned by uid ${uid} or root`);
207
+ }
208
+ async function startWorker(agentName, dimension, bundle, socketOwnerIds) {
209
+ const sandbox = createContainerSandbox(docker, dimension.container);
210
+ const home = await sandbox.homeDirectory();
211
+ const started = await sandbox.runCommand(["sh", "-c", START_WORKER_SCRIPT], {
212
+ cwd: home,
213
+ env: {
214
+ HOME: home,
215
+ HYB_AGENT: agentName,
216
+ HYB_LOG: workerLogPath(RUN_PATH, agentName),
217
+ HYB_INSTALL_DIR: WORKER_DIR,
218
+ HYB_WORKER_SCRIPT: WORKER_SCRIPT,
219
+ HYB_WORKER_BUILD: bundle.build,
220
+ ...(socketOwnerIds ? { HYB_SOCKET_OWNER: socketOwnerIds } : {}),
221
+ NODE_EXTRA_CA_CERTS: OS_CA_BUNDLE,
222
+ SSL_CERT_FILE: OS_CA_BUNDLE,
223
+ },
224
+ });
225
+ if (started.exitCode !== 0)
226
+ throw new Error(`starting agent ${agentName}'s worker failed: ${started.stderr.trim()}`);
227
+ const client = rawWorkerClient(dimension, agentName);
228
+ const deadline = Date.now() + START_TIMEOUT_MS;
229
+ let lastError = "";
230
+ while (Date.now() < deadline) {
231
+ const health = await client.call("worker/health", {}).catch((error) => {
232
+ lastError = error.message;
233
+ return null;
234
+ });
235
+ if (health?.build === bundle.build)
236
+ return;
237
+ await sleep(250);
238
+ }
239
+ const log = await host
240
+ .readFile(workerLogPath(dimension.runDirectory, agentName))
241
+ .then((bytes) => new TextDecoder().decode(bytes))
242
+ .catch(() => "");
243
+ // A socket the host user can't write to refuses it; Bun reports that as ENOENT, so say who owns it.
244
+ const socket = await host.stat(workerSocketPath(dimension.runDirectory, agentName)).catch(() => null);
245
+ const { uid } = await hostUserIds();
246
+ const owner = socket && socket.uid !== uid ? `; its socket belongs to uid ${socket.uid}, not the host user's ${uid}` : "";
247
+ throw new Error(`agent ${agentName}'s worker did not come up${lastError ? ` (reaching it: ${lastError}${owner})` : owner}:\n${log.slice(-2000)}`);
248
+ }
249
+ // Asked first, so sessions end cleanly; killed if it doesn't go.
250
+ async function stopWorker(agentName, dimension) {
251
+ const client = rawWorkerClient(dimension, agentName);
252
+ await client.call("worker/shutdown", {}).catch(() => { });
253
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
254
+ while (Date.now() < deadline) {
255
+ if ((await client.call("worker/health", {}).catch(() => null)) === null)
256
+ return;
257
+ await sleep(250);
258
+ }
259
+ await killWorker(agentName, dimension);
260
+ }
261
+ async function killWorker(agentName, dimension) {
262
+ await createContainerSandbox(docker, dimension.container).runCommand(["sh", "-c", KILL_WORKER_SCRIPT], { env: { AGENT: agentName } });
263
+ }
264
+ async function stopAgent(agentName) {
265
+ assertValidName("agent", agentName);
266
+ const agent = await storage.readAgent(agentName);
267
+ if (!agent)
268
+ return;
269
+ await closeAgentEndpoints(agentName);
270
+ const dimension = await runningDimension(agent.dimensionName).catch(() => null);
271
+ if (dimension)
272
+ await stopWorker(agentName, dimension);
273
+ await storage.removeAgent(agentName);
274
+ }
275
+ // Lazy: the stored endpoint is served again if its proxy stopped (a reboot), with the same URL and
276
+ // credentials. The proxy connects to the worker's socket per stream, so a worker restart is fine.
277
+ // Each way the host is reached (its accessible host) has an endpoint of its own: several server
278
+ // records may drive the same machine.
279
+ async function exposeAgent(agentName, scheme, allowedOrigins = []) {
280
+ const agent = await requireAgent(agentName);
281
+ if (!endpoints)
282
+ throw new AgentInvalidError("this host doesn't declare accessible endpoints");
283
+ const dimension = await runningDimension(agent.dimensionName);
284
+ // The client connects to the worker's socket through the endpoint: nothing on its way could start
285
+ // a worker a restart took down, so it's started here.
286
+ if (!(await rawWorkerClient(dimension, agentName).call("worker/health", {}).then(() => true, () => false))) {
287
+ await reviveWorker(agentName, dimension);
288
+ }
289
+ const key = endpoints.accessibleHost;
290
+ const stored = (await storage.readEndpoints(agentName))[key];
291
+ const sameOrigins = (origins) => [...(origins ?? [])].sort().join("\n") === [...allowedOrigins].sort().join("\n");
292
+ if (stored && stored.credentials.scheme === scheme && sameOrigins(stored.allowedOrigins) && (await endpoints.ensureEndpoint(stored.id))) {
293
+ return stored;
294
+ }
295
+ if (stored) {
296
+ await endpoints.closeEndpoint(stored.id);
297
+ await storage.removeEndpoint(agentName, key);
298
+ }
299
+ const endpoint = await endpoints.exposeEndpoint({
300
+ target: { unix: workerSocketPath(dimension.runDirectory, agentName) },
301
+ scheme,
302
+ transport: "sse",
303
+ allowedOrigins,
304
+ });
305
+ await storage.saveEndpoint(agentName, key, endpoint);
306
+ return endpoint;
307
+ }
308
+ // Every way the host is reached: any sandbox on the machine can close them.
309
+ async function closeAgentEndpoints(agentName) {
310
+ const stored = Object.values(await storage.readEndpoints(agentName));
311
+ if (endpoints)
312
+ for (const endpoint of stored)
313
+ await endpoints.closeEndpoint(endpoint.id);
314
+ await storage.removeEndpoints(agentName);
315
+ }
316
+ async function listAgents() {
317
+ return (await storage.listAgents()).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
318
+ }
319
+ /** The client of the worker running `ref`'s agent. */
320
+ async function sessionClient({ agentName }) {
321
+ return clientFor(agentName);
322
+ }
323
+ // Agents whose dimension or worker is down have no sessions to show right now.
324
+ async function listSessions() {
325
+ const perAgent = await Promise.all((await storage.listAgents()).map(async (agent) => {
326
+ try {
327
+ return await (await clientFor(agent.name)).call("session/list", {});
328
+ }
329
+ catch (error) {
330
+ console.warn(`listing agent ${agent.name}'s sessions failed:`, error.message);
331
+ return [];
332
+ }
333
+ }));
334
+ return perAgent.flat();
335
+ }
336
+ async function* getStream(ref, request) {
337
+ yield* (await sessionClient(ref)).stream(ref.sessionName, request.after_id);
338
+ }
339
+ // Only the host knows whether the dimension's gateway is signed in.
340
+ async function login(ref) {
341
+ const agent = await requireAgent(ref.agentName);
342
+ const client = workerClient(await runningDimension(agent.dimensionName), ref.agentName);
343
+ const session = (await client.call("session/list", {})).find((candidate) => candidate.name === ref.sessionName);
344
+ if (!session)
345
+ throw new AgentNotFoundError(`session ${ref.sessionName} does not exist`);
346
+ const gatewaySignedIn = (await gatewaySignIns?.(agent.dimensionName, AUTH_UPSTREAMS[session.backend])) ?? false;
347
+ return client.call("auth/login", { session: ref.sessionName, gatewaySignedIn });
348
+ }
349
+ return {
350
+ createAgent,
351
+ stopAgent,
352
+ listAgents,
353
+ listSessions,
354
+ getStream,
355
+ login,
356
+ exposeAgent,
357
+ restoreAgents,
358
+ install: async (agentName, backend) => void (await (await clientFor(agentName)).call("backend/install", { backend })),
359
+ createSession: async (agentName, session) => (await clientFor(agentName)).call("session/create", { session }),
360
+ updateSession: async (ref, limits) => (await sessionClient(ref)).call("session/update", { session: ref.sessionName, limits }),
361
+ stopSession: async (ref) => void (await (await sessionClient(ref)).call("session/stop", { session: ref.sessionName })),
362
+ getTranscript: async (ref, request = {}) => (await sessionClient(ref)).call("session/transcript", { session: ref.sessionName, ...request }),
363
+ cancel: async (ref) => void (await (await sessionClient(ref)).call("session/interrupt", { session: ref.sessionName })),
364
+ sendMessage: async (ref, message) => void (await (await sessionClient(ref)).call("session/send", { session: ref.sessionName, message })),
365
+ getSessionStatus: async (ref) => (await sessionClient(ref)).call("session/status", { session: ref.sessionName }),
366
+ getLastUserMessages: async (ref) => (await sessionClient(ref)).call("session/lastUserMessages", { session: ref.sessionName }),
367
+ listPermissionRequests: async (ref) => (await sessionClient(ref)).call("permission/list", { session: ref.sessionName }),
368
+ respondPermissionRequest: async (ref, request, result) => void (await (await sessionClient(ref)).call("permission/respond", { session: ref.sessionName, request, result })),
369
+ listQuestionRequests: async (ref) => (await sessionClient(ref)).call("question/list", { session: ref.sessionName }),
370
+ respondQuestionRequest: async (ref, request, response) => void (await (await sessionClient(ref)).call("question/respond", { session: ref.sessionName, request, response })),
371
+ listModels: async (ref) => (await sessionClient(ref)).call("model/list", { session: ref.sessionName }),
372
+ setModel: async (ref, model) => void (await (await sessionClient(ref)).call("model/set", { session: ref.sessionName, model })),
373
+ listBackgroundTasks: async (ref) => (await sessionClient(ref)).call("task/list", { session: ref.sessionName }),
374
+ stopTask: async (ref, task) => void (await (await sessionClient(ref)).call("task/stop", { session: ref.sessionName, task })),
375
+ getAuthStatus: async (ref) => (await sessionClient(ref)).call("auth/status", { session: ref.sessionName }),
376
+ submitAuthCode: async (ref, code) => (await sessionClient(ref)).call("auth/code", { session: ref.sessionName, code }),
377
+ };
378
+ }
379
+ function assertValidName(kind, name) {
380
+ try {
381
+ checkName(name);
382
+ }
383
+ catch (error) {
384
+ if (error instanceof InvalidNameError)
385
+ throw new AgentInvalidError(`invalid ${kind} name: ${error.message}`);
386
+ throw error;
387
+ }
388
+ }
@@ -0,0 +1,27 @@
1
+ import type { Sandbox, SandboxEndpoint } from "../sandbox/api.ts";
2
+ import type { Agent } from "./api.ts";
3
+ /** Every name must pass checkName; operations throw InvalidNameError otherwise. */
4
+ export interface AgentStorage {
5
+ /** Stores the record unless one with that name exists; returns whichever record is stored afterwards. */
6
+ insertAgent(agent: Agent): Promise<Agent>;
7
+ /** Null if missing. */
8
+ readAgent(agentName: string): Promise<Agent | null>;
9
+ /** No-op if missing. */
10
+ removeAgent(agentName: string): Promise<void>;
11
+ listAgents(): Promise<Agent[]>;
12
+ /**
13
+ * The agent's accessible endpoints (AgentService.exposeAgent), credentials included, by the
14
+ * accessible host each was exposed for: one per way the machine is reached.
15
+ */
16
+ readEndpoints(agentName: string): Promise<Record<string, SandboxEndpoint>>;
17
+ saveEndpoint(agentName: string, accessibleHost: string, endpoint: SandboxEndpoint): Promise<void>;
18
+ /** No-op if missing. */
19
+ removeEndpoint(agentName: string, accessibleHost: string): Promise<void>;
20
+ /** All of them. No-op if none. */
21
+ removeEndpoints(agentName: string): Promise<void>;
22
+ }
23
+ /**
24
+ * `dir` is a sandbox path; defaults to ~/.local/share/hyperbrane of the sandbox's user. Layout:
25
+ * agents/<agent>.json, agent-endpoints/<agent>.json.
26
+ */
27
+ export declare function createAgentStorage(sandbox: Sandbox, dir?: string): AgentStorage;
@@ -0,0 +1,52 @@
1
+ import { createJsonDirectory, defaultDataDir } from "../lib/jsonDirectory.js";
2
+ /**
3
+ * `dir` is a sandbox path; defaults to ~/.local/share/hyperbrane of the sandbox's user. Layout:
4
+ * agents/<agent>.json, agent-endpoints/<agent>.json.
5
+ */
6
+ export function createAgentStorage(sandbox, dir) {
7
+ const agents = createJsonDirectory(sandbox, async () => `${dir ?? (await defaultDataDir(sandbox))}/agents`);
8
+ // A record used to hold one endpoint; it is read as the entry for its URL's host:port.
9
+ const endpoints = createJsonDirectory(sandbox, async () => `${dir ?? (await defaultDataDir(sandbox))}/agent-endpoints`);
10
+ async function readEndpoints(agentName) {
11
+ const record = await endpoints.read(agentName);
12
+ if (!record)
13
+ return {};
14
+ if (typeof record.id === "string")
15
+ return { [new URL(record.url).host]: record };
16
+ return record;
17
+ }
18
+ async function saveEndpoint(agentName, accessibleHost, endpoint) {
19
+ await endpoints.save(agentName, { ...(await readEndpoints(agentName)), [accessibleHost]: endpoint });
20
+ }
21
+ async function removeEndpoint(agentName, accessibleHost) {
22
+ const { [accessibleHost]: removed, ...rest } = await readEndpoints(agentName);
23
+ if (!removed)
24
+ return;
25
+ if (Object.keys(rest).length === 0)
26
+ await endpoints.remove(agentName);
27
+ else
28
+ await endpoints.save(agentName, rest);
29
+ }
30
+ async function insertAgent(agent) {
31
+ return agents.insert(agent.name, agent);
32
+ }
33
+ async function readAgent(agentName) {
34
+ return agents.read(agentName);
35
+ }
36
+ async function removeAgent(agentName) {
37
+ await agents.remove(agentName);
38
+ }
39
+ async function listAgents() {
40
+ return agents.list();
41
+ }
42
+ return {
43
+ insertAgent,
44
+ readAgent,
45
+ removeAgent,
46
+ listAgents,
47
+ readEndpoints,
48
+ saveEndpoint,
49
+ removeEndpoint,
50
+ removeEndpoints: (agentName) => endpoints.remove(agentName),
51
+ };
52
+ }