@volter/browser-sandboxes 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,64 +1,132 @@
1
- // A remote runtime client with the same method shape as the in-page runtime.
2
- // Differences forced by the wire: ports() and the file surface are async.
3
- // Browser-safe: no Node globals, so a tab can drive a hosted sandbox with it.
4
- // The client negotiates the agent's descriptor before it trusts the wire and
5
- // states the protocol it speaks on every request.
6
- import { SUBSTRATE_AGENT_PROTOCOL, SUBSTRATE_PROTOCOL_BASE_PATH, SUBSTRATE_PROTOCOL_HEADER, hostedProtocolHeader, negotiateHostedProtocol, } from "@volter/browser-runtime/browser-hosted-protocol.js";
7
- /** A fresh ArrayBuffer copy satisfies the fetch body type for any typed array. */
1
+ // A client of the container's wire (ADR-0026): Docker's Engine API, the
2
+ // named subset, over fetch and a WebSocket, with the in-page runtime's method
3
+ // names so a tab drives a hosted container the way it drives its own. Where
4
+ // Docker's API lacks a thing the transfer needs, the client speaks the row
5
+ // the daemon names: a port through the daemon, an event stream, a file
6
+ // listing and removal, and an archive `exclude`, `replace` and `strip`.
7
+ // Browser-safe: no Node globals.
8
+ import { DOCKER_ENGINE_BASE_PATH, multiplexFrame } from "@volter/browser-runtime/docker-engine.js";
9
+ import { packTar, unpackTar } from "@volter/browser-runtime/browser-tar.js";
10
+ const KILL_CLOSE_CODE = 4001;
8
11
  function toBody(data) {
9
- return typeof data === "string" ? data : data.slice().buffer;
12
+ return typeof data === "string" ? data : new Uint8Array(data).buffer;
13
+ }
14
+ function dirname(path) { const index = path.lastIndexOf("/"); return index <= 0 ? "/" : path.slice(0, index); }
15
+ function basename(path) { return path.slice(path.lastIndexOf("/") + 1); }
16
+ /** Splits Docker's multiplexed stream into stdout and stderr chunks, across message boundaries. */
17
+ class FrameParser {
18
+ onFrame;
19
+ buffered = new Uint8Array(0);
20
+ constructor(onFrame) {
21
+ this.onFrame = onFrame;
22
+ }
23
+ push(bytes) {
24
+ const joined = new Uint8Array(this.buffered.byteLength + bytes.byteLength);
25
+ joined.set(this.buffered);
26
+ joined.set(bytes, this.buffered.byteLength);
27
+ let offset = 0;
28
+ while (joined.byteLength - offset >= 8) {
29
+ const size = new DataView(joined.buffer, joined.byteOffset + offset).getUint32(4, false);
30
+ if (joined.byteLength - offset < 8 + size)
31
+ break;
32
+ this.onFrame(joined[offset], joined.subarray(offset + 8, offset + 8 + size));
33
+ offset += 8 + size;
34
+ }
35
+ this.buffered = joined.slice(offset);
36
+ }
10
37
  }
11
38
  export class RemoteRuntime {
12
39
  files;
13
40
  base;
14
- /** The catalogue's base path, from the negotiated descriptor; /v1 until then. */
15
- basePath = SUBSTRATE_PROTOCOL_BASE_PATH;
16
- /** The agent's descriptor once negotiated. */
17
- descriptor;
18
41
  token;
19
42
  network;
43
+ makeSocket;
20
44
  listeners = new Map();
21
- outputs = new Map();
22
- pending = new Map();
23
45
  abort = new AbortController();
46
+ /** The one container this daemon holds, known after `connect`. */
47
+ containerId;
48
+ startedAt = Date.now();
24
49
  constructor(baseUrl, token, options = {}) {
25
50
  this.base = baseUrl.replace(/\/$/, "");
26
51
  this.token = token;
27
52
  this.network = options.fetch ?? globalThis.fetch.bind(globalThis);
53
+ this.makeSocket = options.socket ?? ((url) => new WebSocket(url));
28
54
  this.files = {
29
- read: async (p) => new Uint8Array(await (await this.ok(await this.call(`/fs/read?path=${encodeURIComponent(p)}`))).arrayBuffer()),
55
+ read: async (p) => {
56
+ const entries = unpackTar(new Uint8Array(await (await this.ok(await this.call(`${this.container}/archive?path=${encodeURIComponent(p)}`))).arrayBuffer()));
57
+ const file = entries.find((entry) => entry.type === "file");
58
+ if (!file)
59
+ throw new Error(`${p} is not a file`);
60
+ return file.data ?? new Uint8Array(0);
61
+ },
30
62
  readText: async (p) => new TextDecoder().decode(await this.files.read(p)),
31
- write: async (p, data) => this.json(await this.call(`/fs/write?path=${encodeURIComponent(p)}`, { method: "PUT", body: toBody(data) })),
32
- list: async (p) => this.json(await this.call(`/fs/list?path=${encodeURIComponent(p)}`)),
33
- mkdir: async (p) => this.json(await this.call(`/fs/mkdir?path=${encodeURIComponent(p)}`, { method: "POST" })),
34
- remove: async (p) => this.json(await this.call(`/fs/remove?path=${encodeURIComponent(p)}`, { method: "DELETE" })),
35
- snapshot: async () => this.json(await this.call("/fs/snapshot")),
36
- export: async (root = "/workspace", format = "zip", options = {}) => {
63
+ write: async (p, data) => {
64
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
65
+ const tar = packTar([{ path: basename(p), type: "file", mode: 0o644, mtime: Math.floor(Date.now() / 1000), data: bytes }]);
66
+ await this.files.mkdir(dirname(p));
67
+ await this.ok(await this.call(`${this.container}/archive?path=${encodeURIComponent(dirname(p))}`, { method: "PUT", headers: { "content-type": "application/x-tar" }, body: toBody(tar) }));
68
+ return { ok: true };
69
+ },
70
+ list: async (p) => (await this.json(await this.call(`${this.container}/files?path=${encodeURIComponent(p)}`))).map(({ name, type, size }) => ({ name, type, size })),
71
+ mkdir: async (p) => {
72
+ // A directory is made by extracting one into its parent, parent by parent from the root down.
73
+ const parts = p.split("/").filter(Boolean);
74
+ for (let depth = 1; depth <= parts.length; depth += 1) {
75
+ const parent = `/${parts.slice(0, depth - 1).join("/")}`;
76
+ const name = parts[depth - 1];
77
+ const listed = await this.files.list(parent).catch(() => []);
78
+ if (listed.some((entry) => entry.name === name))
79
+ continue;
80
+ const tar = packTar([{ path: name, type: "directory", mode: 0o755, mtime: Math.floor(Date.now() / 1000) }]);
81
+ await this.ok(await this.call(`${this.container}/archive?path=${encodeURIComponent(parent)}`, { method: "PUT", headers: { "content-type": "application/x-tar" }, body: toBody(tar) }));
82
+ }
83
+ return { ok: true };
84
+ },
85
+ remove: async (p) => { await this.ok(await this.call(`${this.container}/files?path=${encodeURIComponent(p)}`, { method: "DELETE" })); return { ok: true }; },
86
+ export: async (root = "/workspace", _format = "tar", options = {}) => {
37
87
  const exclude = options.exclude?.length ? `&exclude=${encodeURIComponent(options.exclude.join(","))}` : "";
38
- const response = await this.ok(await this.call(`/fs/export?root=${encodeURIComponent(root)}&format=${format}${exclude}`));
88
+ const response = await this.ok(await this.call(`${this.container}/archive?path=${encodeURIComponent(root)}${exclude}`));
39
89
  return {
40
- format,
90
+ format: "tar",
41
91
  bytes: new Uint8Array(await response.arrayBuffer()),
42
92
  integrity: response.headers.get("x-substrate-integrity"),
43
93
  entries: Number(response.headers.get("x-substrate-entries")),
44
94
  fileBytes: Number(response.headers.get("x-substrate-file-bytes")),
45
95
  };
46
96
  },
47
- import: async (bytes, { root = "/workspace", replace = true } = {}) => this.json(await this.call(`/fs/import?root=${encodeURIComponent(root)}&replace=${replace}`, { method: "POST", body: toBody(bytes) })),
97
+ import: async (bytes, { root = "/workspace", replace = true } = {}) => {
98
+ await this.files.mkdir(root);
99
+ // The archive's top entry is the tree's own name; `strip=1` lands its contents in `root`.
100
+ const summary = await this.json(await this.call(`${this.container}/archive?path=${encodeURIComponent(root)}&replace=${replace}&strip=1`, { method: "PUT", headers: { "content-type": "application/x-tar" }, body: toBody(bytes) }));
101
+ return { format: "tar", ...summary };
102
+ },
48
103
  };
49
104
  }
105
+ get container() {
106
+ if (!this.containerId)
107
+ throw new Error("connect() first: the container is not known yet.");
108
+ return `/containers/${this.containerId}`;
109
+ }
50
110
  /**
51
- * Negotiates the agent's protocol, then opens the event stream and returns
52
- * the agent's health. A server speaking another protocol or version is
53
- * refused with the reason.
111
+ * Pings the daemon, finds its one container, opens the event stream, and
112
+ * returns the container's health. A server that is not a Docker daemon of
113
+ * ours is refused with its status.
54
114
  */
55
115
  async connect() {
56
- await this.negotiate();
57
- const response = await this.call("/events", { signal: this.abort.signal });
58
- await this.ok(response);
59
- if (response.body)
60
- void this.consume(response.body);
61
- return this.json(await this.call("/health"));
116
+ const ping = await this.call("/_ping");
117
+ if (!ping.ok)
118
+ throw new Error(`${this.base} did not answer Docker's ping: ${ping.status} ${await ping.text().catch(() => "")}`);
119
+ const containers = await this.json(await this.call("/containers/json"));
120
+ const first = containers[0];
121
+ if (!first)
122
+ throw new Error(`${this.base} holds no container.`);
123
+ this.containerId = first.Id;
124
+ this.startedAt = first.Created * 1000;
125
+ const events = await this.call(`${this.container}/events`, { signal: this.abort.signal });
126
+ await this.ok(events);
127
+ if (events.body)
128
+ void this.consume(events.body);
129
+ return this.health();
62
130
  }
63
131
  async consume(body) {
64
132
  const decoder = new TextDecoder();
@@ -86,26 +154,7 @@ export class RemoteRuntime {
86
154
  }
87
155
  }
88
156
  dispatch(event) {
89
- const type = String(event.type);
90
- if (type === "pty-output" || type === "pty-exit") {
91
- const key = `pty:${String(event.id)}`;
92
- const sink = this.outputs.get(key);
93
- if (sink)
94
- sink(event);
95
- else
96
- this.pending.set(key, [...(this.pending.get(key) ?? []), event]);
97
- return;
98
- }
99
- if (type === "output") {
100
- const key = Number(event.pid);
101
- const sink = this.outputs.get(key);
102
- if (sink)
103
- sink(event);
104
- else
105
- this.pending.set(key, [...(this.pending.get(key) ?? []), event]);
106
- return;
107
- }
108
- for (const listener of this.listeners.get(type) ?? [])
157
+ for (const listener of this.listeners.get(String(event.type)) ?? [])
109
158
  listener(event);
110
159
  }
111
160
  on(type, listener) {
@@ -114,77 +163,158 @@ export class RemoteRuntime {
114
163
  this.listeners.set(type, set);
115
164
  return () => { set.delete(listener); };
116
165
  }
117
- async start(command, options = {}) {
118
- if (options.terminal)
119
- return this.startTerminalProcess(command, options);
120
- const { onStdout, onStderr, cwd, env, stdin } = options;
121
- const started = await this.json(await this.call("/processes", {
166
+ async execCreate(cmd, options) {
167
+ const created = await this.json(await this.call(`${this.container}/exec`, {
122
168
  method: "POST",
123
169
  headers: { "content-type": "application/json" },
124
- body: JSON.stringify({ command, cwd, env, stdin }),
170
+ body: JSON.stringify({
171
+ Cmd: cmd,
172
+ Tty: options.tty,
173
+ AttachStdin: options.stdin,
174
+ AttachStdout: true,
175
+ AttachStderr: true,
176
+ ...(options.cwd !== undefined ? { WorkingDir: options.cwd } : {}),
177
+ ...(options.env ? { Env: Object.entries(options.env).map(([key, value]) => `${key}=${value}`) } : {}),
178
+ }),
125
179
  }));
126
- const sink = (event) => (event.stream === "stdout" ? onStdout : onStderr)?.(String(event.chunk));
127
- this.outputs.set(started.pid, sink);
128
- for (const event of this.pending.get(started.pid) ?? [])
129
- sink(event);
130
- this.pending.delete(started.pid);
180
+ return created.Id;
181
+ }
182
+ async execExitCode(id) {
183
+ const inspect = await this.json(await this.call(`/exec/${id}/json`));
184
+ return inspect.ExitCode ?? 1;
185
+ }
186
+ execSocket(id, options = {}) {
187
+ const url = new URL(`${this.base}${DOCKER_ENGINE_BASE_PATH}/exec/${id}/start`);
188
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
189
+ url.searchParams.set("token", this.tokenValue());
190
+ if (options.rows)
191
+ url.searchParams.set("h", String(options.rows));
192
+ if (options.columns)
193
+ url.searchParams.set("w", String(options.columns));
194
+ const socket = this.makeSocket(url.href);
195
+ socket.binaryType = "arraybuffer";
196
+ return socket;
197
+ }
198
+ async start(command, options = {}) {
199
+ const tty = options.terminal !== undefined;
200
+ const wantsStdin = options.stdin !== undefined || options.stdinStream !== undefined || tty;
201
+ const id = await this.execCreate(["sh", "-c", command], { tty, stdin: wantsStdin, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.env ? { env: options.env } : {}) });
202
+ const startedAt = Date.now();
131
203
  let state = "running";
132
- const result = this.json(this.call(`/processes/${started.pid}/result`)).then((settled) => {
133
- this.outputs.delete(started.pid);
134
- state = settled.state;
135
- if (settled.state === "failed" || !settled.result)
136
- throw new Error(settled.error ?? "process failed");
137
- return settled.result;
138
- });
139
- return {
140
- pid: started.pid,
141
- command: started.command,
142
- startedAt: started.startedAt,
143
- get state() { return state; },
144
- result,
145
- kill: () => { void this.call(`/processes/${started.pid}/kill`, { method: "POST" }); },
146
- resize: () => undefined,
204
+ let stdout = "";
205
+ let stderr = "";
206
+ const decoderOut = new TextDecoder();
207
+ const decoderErr = new TextDecoder();
208
+ const onOut = (bytes) => { const text = decoderOut.decode(bytes, { stream: true }); stdout += text; options.onStdout?.(text); };
209
+ const onErr = (bytes) => { const text = decoderErr.decode(bytes, { stream: true }); stderr += text; options.onStderr?.(text); };
210
+ const settle = (exitCode) => {
211
+ state = "exited";
212
+ return { stdout, stderr, exitCode, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.env ? { env: options.env } : {}) };
147
213
  };
214
+ let kill = () => undefined;
215
+ let resize = () => undefined;
216
+ let result;
217
+ if (!wantsStdin) {
218
+ // Output only: the hijacked HTTP form, streamed.
219
+ const controller = new AbortController();
220
+ kill = () => controller.abort();
221
+ result = (async () => {
222
+ const response = await this.ok(await this.call(`/exec/${id}/start`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}", signal: controller.signal }));
223
+ const parser = new FrameParser((stream, payload) => (stream === 2 ? onErr : onOut)(payload));
224
+ const reader = response.body?.getReader();
225
+ if (reader)
226
+ for (;;) {
227
+ const { done, value } = await reader.read();
228
+ if (done)
229
+ break;
230
+ parser.push(value);
231
+ }
232
+ return settle(await this.execExitCode(id));
233
+ })().catch((cause) => { state = "failed"; throw cause; });
234
+ }
235
+ else {
236
+ // Input too: the socket, framed as the daemon frames it.
237
+ const socket = this.execSocket(id, tty ? { rows: options.terminal.rows, columns: options.terminal.columns } : {});
238
+ const parser = new FrameParser((stream, payload) => (stream === 2 ? onErr : onOut)(payload));
239
+ kill = () => socket.close(KILL_CLOSE_CODE, "kill");
240
+ resize = (columns, rows) => { void this.call(`/exec/${id}/resize?h=${rows}&w=${columns}`, { method: "POST" }).catch(() => undefined); };
241
+ const offResize = options.terminal?.onResize?.((columns, rows) => resize(columns, rows));
242
+ result = new Promise((resolve, reject) => {
243
+ socket.onmessage = (event) => {
244
+ const bytes = event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : new TextEncoder().encode(String(event.data));
245
+ if (tty)
246
+ onOut(bytes);
247
+ else
248
+ parser.push(bytes);
249
+ };
250
+ socket.onopen = () => {
251
+ // End of input is an empty binary frame (the daemon's rule); the daemon closes the socket when the process exits.
252
+ if (options.stdin !== undefined) {
253
+ socket.send(options.stdin);
254
+ if (!tty)
255
+ socket.send(new ArrayBuffer(0));
256
+ }
257
+ if (options.stdinStream) {
258
+ void (async () => {
259
+ try {
260
+ for await (const chunk of options.stdinStream)
261
+ socket.send(new Uint8Array(chunk).buffer);
262
+ if (tty)
263
+ socket.send("\u0004");
264
+ else
265
+ socket.send(new ArrayBuffer(0));
266
+ }
267
+ catch {
268
+ socket.close(KILL_CLOSE_CODE, "stdin failed");
269
+ }
270
+ })();
271
+ }
272
+ };
273
+ socket.onerror = () => { state = "failed"; reject(new Error(`the exec's socket failed: ${command}`)); };
274
+ socket.onclose = () => {
275
+ offResize?.();
276
+ this.execExitCode(id).then((code) => resolve(settle(code)), (cause) => { state = "failed"; reject(cause instanceof Error ? cause : new Error(String(cause))); });
277
+ };
278
+ });
279
+ }
280
+ void result.catch(() => undefined);
281
+ return { pid: 0, command, startedAt, get state() { return state; }, result, kill: () => kill(), resize: (columns, rows) => resize(columns, rows) };
148
282
  }
149
283
  async spawn(command, options = {}) {
150
284
  return (await this.start(command, options)).result;
151
285
  }
152
- /** A terminal session on a guest pseudo-terminal; bytes in, bytes out. */
286
+ /** A terminal session: an exec of the command with a TTY over the socket; bytes in, bytes out. */
153
287
  async openPty(options = {}) {
154
- const opened = await this.json(await this.call("/ptys", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(options) }));
155
- const key = `pty:${opened.id}`;
288
+ const { command = "sh", cols = 80, rows = 24, cwd, env } = options;
289
+ const id = await this.execCreate(["sh", "-c", command], { tty: true, stdin: true, ...(cwd !== undefined ? { cwd } : {}), ...(env ? { env } : {}) });
290
+ const socket = this.execSocket(id, { rows, columns: cols });
156
291
  const listeners = new Set();
157
292
  const retained = [];
158
293
  let retainedBytes = 0;
159
- let resolveExit = () => undefined;
160
- const exited = new Promise((resolve) => { resolveExit = resolve; });
161
- const sink = (event) => {
162
- if (event.type === "pty-output") {
163
- const bytes = Uint8Array.from(atob(String(event.data)), (c) => c.charCodeAt(0));
164
- if (listeners.size === 0) {
165
- retained.push(bytes);
166
- retainedBytes += bytes.byteLength;
167
- while (retainedBytes > 64 * 1_024 && retained.length > 1)
168
- retainedBytes -= retained.shift().byteLength;
169
- }
170
- else {
171
- for (const listener of listeners)
172
- listener(bytes);
173
- }
174
- }
175
- else {
176
- this.outputs.delete(key);
177
- resolveExit(Number(event.exitCode));
294
+ const opened = new Promise((resolve, reject) => { socket.onopen = () => resolve(); socket.onerror = () => reject(new Error("the terminal's socket failed")); });
295
+ const exited = new Promise((resolve) => { socket.onclose = () => { void this.execExitCode(id).then(resolve, () => resolve(1)); }; });
296
+ socket.onmessage = (event) => {
297
+ const bytes = event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : new TextEncoder().encode(String(event.data));
298
+ if (listeners.size === 0) {
299
+ retained.push(bytes);
300
+ retainedBytes += bytes.byteLength;
301
+ while (retainedBytes > 64 * 1_024 && retained.length > 1)
302
+ retainedBytes -= retained.shift().byteLength;
178
303
  }
304
+ else
305
+ for (const listener of listeners)
306
+ listener(bytes);
179
307
  };
180
- this.outputs.set(key, sink);
181
- for (const event of this.pending.get(key) ?? [])
182
- sink(event);
183
- this.pending.delete(key);
308
+ await opened;
309
+ let size = { cols, rows };
184
310
  return {
185
- id: opened.id, pid: opened.pid, cols: opened.cols, rows: opened.rows, allocator: opened.allocator, resizable: opened.resizable,
186
- write: (data) => this.call(`/ptys/${opened.id}/input`, { method: "POST", body: toBody(data) }),
187
- resize: (cols, rows) => this.json(this.call(`/ptys/${opened.id}/resize`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ cols, rows }) })),
311
+ id, pid: 0, cols, rows, allocator: "exec", resizable: true,
312
+ write: async (data) => { socket.send(typeof data === "string" ? data : new Uint8Array(data).buffer); },
313
+ resize: async (nextCols, nextRows) => {
314
+ await this.ok(await this.call(`/exec/${id}/resize?h=${nextRows}&w=${nextCols}`, { method: "POST" }));
315
+ size = { cols: nextCols, rows: nextRows };
316
+ return size;
317
+ },
188
318
  onData: (listener) => {
189
319
  listeners.add(listener);
190
320
  for (const bytes of retained.splice(0))
@@ -192,109 +322,53 @@ export class RemoteRuntime {
192
322
  retainedBytes = 0;
193
323
  return () => { listeners.delete(listener); };
194
324
  },
195
- kill: () => { void this.call(`/ptys/${opened.id}/kill`, { method: "POST" }); },
325
+ kill: () => socket.close(KILL_CLOSE_CODE, "kill"),
196
326
  exited,
197
327
  };
198
328
  }
199
- /** Implements the ordinary process surface on the hosted PTY allocator. */
200
- async startTerminalProcess(command, options) {
201
- const terminal = options.terminal;
202
- const startedAt = Date.now();
203
- const pty = await this.openPty({
204
- command,
205
- cols: terminal.columns,
206
- rows: terminal.rows,
207
- ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
208
- ...(options.env !== undefined ? { env: options.env } : {}),
209
- });
210
- const decoder = new TextDecoder();
211
- let stdout = "";
212
- let state = "running";
213
- const offData = pty.onData((bytes) => {
214
- const chunk = decoder.decode(bytes, { stream: true });
215
- stdout += chunk;
216
- options.onStdout?.(chunk);
217
- });
218
- const offResize = terminal.onResize?.((columns, rows) => {
219
- void pty.resize(columns, rows).catch(() => undefined);
220
- });
221
- if (options.stdin !== undefined)
222
- await pty.write(options.stdin);
223
- if (options.stdinStream) {
224
- void (async () => {
225
- try {
226
- for await (const chunk of options.stdinStream)
227
- await pty.write(chunk);
228
- await pty.write("\u0004");
229
- }
230
- catch {
231
- pty.kill();
232
- }
233
- })();
234
- }
235
- const result = pty.exited.then((exitCode) => {
236
- const tail = decoder.decode();
237
- if (tail) {
238
- stdout += tail;
239
- options.onStdout?.(tail);
240
- }
241
- offData();
242
- offResize?.();
243
- state = "exited";
244
- return { stdout, stderr: "", exitCode, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.env !== undefined ? { env: options.env } : {}) };
245
- }, (cause) => {
246
- offData();
247
- offResize?.();
248
- state = "failed";
249
- throw cause;
250
- });
251
- void result.catch(() => undefined);
329
+ /** The container's health, from inspect and top; never counts as activity on the daemon's clock beyond the request. */
330
+ async health() {
331
+ const inspect = await this.json(await this.call(`${this.container}/json`));
332
+ const top = await this.json(await this.call(`${this.container}/top`));
252
333
  return {
253
- pid: pty.pid,
254
- command,
255
- startedAt,
256
- get state() { return state; },
257
- result,
258
- kill: () => pty.kill(),
259
- resize: (columns, rows) => { void pty.resize(columns, rows).catch(() => undefined); },
334
+ ok: true,
335
+ root: inspect.Config.WorkingDir,
336
+ portDiscovery: "inspect",
337
+ ports: Object.keys(inspect.NetworkSettings.Ports).map((key) => Number(key.split("/")[0])),
338
+ startedAt: this.startedAt,
339
+ lastActivityAt: Date.now(),
340
+ liveProcesses: top.Processes.filter((row) => row[2] === "running").length,
260
341
  };
261
342
  }
262
- /** GET / on the agent, judged by the protocol the client speaks. */
263
- async negotiate() {
264
- const response = await this.network(`${this.base}/`, { headers: { authorization: `Bearer ${this.tokenValue()}` } });
265
- const descriptor = response.ok ? await response.json().catch(() => undefined) : undefined;
266
- const negotiated = negotiateHostedProtocol(descriptor, { protocol: SUBSTRATE_AGENT_PROTOCOL });
267
- if (!negotiated.ok)
268
- throw new Error(`${this.base} is not a ${SUBSTRATE_AGENT_PROTOCOL} server: ${negotiated.reason}`);
269
- this.descriptor = negotiated.descriptor;
270
- this.basePath = negotiated.descriptor.basePath.replace(/\/$/, "");
271
- return negotiated.descriptor;
343
+ async processes() {
344
+ const top = await this.json(await this.call(`${this.container}/top`));
345
+ return top.Processes.map((row) => ({ pid: Number(row[0]), startedAt: Date.parse(row[1] ?? "") || 0, state: row[2] ?? "", command: row[3] ?? "" }));
272
346
  }
273
- /** The agent's health; never counts as activity on the agent. */
274
- async health() { return this.json(await this.call("/health")); }
275
- async processes() { return this.json(await this.call("/processes")); }
276
- async ports() { return (await this.json(await this.call("/ports"))).map((entry) => entry.port); }
277
- portUrl(port) { return `${this.base}${this.basePath}/ports/${port}/`; }
347
+ async ports() { return (await this.health()).ports; }
348
+ portUrl(port) { return `${this.base}${DOCKER_ENGINE_BASE_PATH}${this.container}/ports/${port}/`; }
278
349
  request(port, input = "/", init = {}) {
279
- // An absolute virtual path is relative to the port, never to the agent, and
280
- // the agent may itself sit behind a path prefix such as a control plane.
281
- const target = new URL(input.toString().replace(/^\//, ""), `${this.base}${this.basePath}/ports/${port}/`);
282
- return this.call(target.href.slice(this.base.length + this.basePath.length), init);
350
+ // An absolute virtual path is relative to the port, never to the daemon,
351
+ // and the daemon may itself sit behind a path prefix such as a control plane.
352
+ const target = new URL(input.toString().replace(/^\//, ""), this.portUrl(port));
353
+ return this.call(target.href.slice(this.base.length + DOCKER_ENGINE_BASE_PATH.length), init);
354
+ }
355
+ /** The profiler's rows, carried under `substrate` in Docker's stats. */
356
+ async metrics() {
357
+ const stats = await this.json(await this.call(`${this.container}/stats?stream=false`));
358
+ return stats.substrate ?? {};
283
359
  }
284
- async metrics() { return this.json(await this.call("/metrics")); }
285
- /** A WebSocket to a guest port, tunnelled through the agent. */
360
+ /** A WebSocket to a guest port, tunnelled through the daemon. */
286
361
  webSocket(port, input = "/") {
287
- const target = new URL(input.toString().replace(/^\//, ""), `${this.base}${this.basePath}/ports/${port}/`);
362
+ const target = new URL(input.toString().replace(/^\//, ""), this.portUrl(port));
288
363
  target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
289
364
  target.searchParams.set("token", this.tokenValue());
290
- return new WebSocket(target);
365
+ return this.makeSocket(target.href);
291
366
  }
292
367
  close() { this.abort.abort(); }
293
368
  call(route, init = {}) {
294
369
  const headers = new Headers(init.headers);
295
370
  headers.set("authorization", `Bearer ${this.tokenValue()}`);
296
- headers.set(SUBSTRATE_PROTOCOL_HEADER, hostedProtocolHeader(SUBSTRATE_AGENT_PROTOCOL));
297
- return this.network(`${this.base}${this.basePath}${route}`, { ...init, headers });
371
+ return this.network(`${this.base}${DOCKER_ENGINE_BASE_PATH}${route}`, { ...init, headers });
298
372
  }
299
373
  tokenValue() { return typeof this.token === "function" ? (this.token() ?? "") : this.token; }
300
374
  async ok(response) {
@@ -306,4 +380,6 @@ export class RemoteRuntime {
306
380
  return (await this.ok(await responseOrPromise)).json();
307
381
  }
308
382
  }
383
+ /** Frames a chunk the way the daemon frames stdout, for tests and tools that need the shape. */
384
+ export const dockerFrame = multiplexFrame;
309
385
  //# sourceMappingURL=client.js.map