@fungi.computer/computer 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/Dockerfile +85 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/container/computer-init.mjs +119 -0
  5. package/container/computer-start.sh +24 -0
  6. package/container/package.json +14 -0
  7. package/container/pnpm-lock.yaml +378 -0
  8. package/container/pnpm-workspace.yaml +5 -0
  9. package/container/resource-bounds.mjs +54 -0
  10. package/container/runtime-privileges.cc +37 -0
  11. package/container/runtime-privileges.mjs +11 -0
  12. package/container/terminal-assets/README.md +35 -0
  13. package/container/terminal-assets/bashrc +49 -0
  14. package/container/terminal-assets/git-prompt.sh +586 -0
  15. package/container/terminal-protocol.mjs +58 -0
  16. package/container/terminal-runtime.mjs +10 -0
  17. package/container/terminal-server.mjs +243 -0
  18. package/dist/apply-patch/LICENSE.codex +201 -0
  19. package/dist/apply-patch/build.mjs +98 -0
  20. package/dist/apply-patch/cache.mjs +168 -0
  21. package/dist/apply-patch/compile.mjs +199 -0
  22. package/dist/apply-patch/docker/darwin.Dockerfile +15 -0
  23. package/dist/apply-patch/docker/linux-arm64.Dockerfile +38 -0
  24. package/dist/apply-patch/docker/linux-x64.Dockerfile +23 -0
  25. package/dist/apply-patch/main.rs +320 -0
  26. package/dist/apply-patch/notices.mjs +44 -0
  27. package/dist/apply-patch/pin.mjs +8 -0
  28. package/dist/apply-patch/toolchain.mjs +39 -0
  29. package/dist/computer-backends.d.ts +94 -0
  30. package/dist/computer-backends.js +103 -0
  31. package/dist/computerd.cjs +18010 -0
  32. package/dist/container-policy.d.ts +7 -0
  33. package/dist/container-policy.js +24 -0
  34. package/dist/container-system-trust.d.ts +8 -0
  35. package/dist/container-system-trust.js +66 -0
  36. package/dist/egress-policy.d.ts +89 -0
  37. package/dist/egress-policy.js +240 -0
  38. package/dist/execution-result.d.ts +3 -0
  39. package/dist/execution-result.js +28 -0
  40. package/dist/files-patch.d.ts +20 -0
  41. package/dist/files-patch.js +108 -0
  42. package/dist/files.d.ts +75 -0
  43. package/dist/files.js +136 -0
  44. package/dist/fleet/admission.d.ts +14 -0
  45. package/dist/fleet/admission.js +10 -0
  46. package/dist/fleet/backing.d.ts +52 -0
  47. package/dist/fleet/backing.js +242 -0
  48. package/dist/fleet/computers.d.ts +62 -0
  49. package/dist/fleet/computers.js +35 -0
  50. package/dist/fleet/index.d.ts +11 -0
  51. package/dist/fleet/index.js +11 -0
  52. package/dist/fleet/inventory-wire.d.ts +29 -0
  53. package/dist/fleet/inventory-wire.js +15 -0
  54. package/dist/fleet/inventory.d.ts +24 -0
  55. package/dist/fleet/inventory.js +152 -0
  56. package/dist/fleet/lifecycle.d.ts +99 -0
  57. package/dist/fleet/lifecycle.js +423 -0
  58. package/dist/fleet/runtime-usage.d.ts +50 -0
  59. package/dist/fleet/runtime-usage.js +145 -0
  60. package/dist/fleet/slots.d.ts +30 -0
  61. package/dist/fleet/slots.js +46 -0
  62. package/dist/fleet/wire.d.ts +228 -0
  63. package/dist/fleet/wire.js +127 -0
  64. package/dist/host-config.d.ts +12 -0
  65. package/dist/host-config.js +10 -0
  66. package/dist/index.d.ts +282 -0
  67. package/dist/index.js +1237 -0
  68. package/dist/light-shell.d.ts +44 -0
  69. package/dist/light-shell.js +376 -0
  70. package/dist/local-backend.d.ts +52 -0
  71. package/dist/local-backend.js +143 -0
  72. package/dist/metered-container-host.d.ts +26 -0
  73. package/dist/metered-container-host.js +48 -0
  74. package/dist/on-box-vm.d.ts +119 -0
  75. package/dist/on-box-vm.js +294 -0
  76. package/dist/operation-contract.d.ts +456 -0
  77. package/dist/operation-contract.js +452 -0
  78. package/dist/operation-record.d.ts +50 -0
  79. package/dist/operation-record.js +162 -0
  80. package/dist/preview.d.ts +55 -0
  81. package/dist/preview.js +151 -0
  82. package/dist/process-inventory.d.ts +80 -0
  83. package/dist/process-inventory.js +427 -0
  84. package/dist/pull-pending.d.ts +26 -0
  85. package/dist/pull-pending.js +35 -0
  86. package/dist/team-candidate-builder.mjs +28170 -0
  87. package/dist/transport-backend.d.ts +3 -0
  88. package/dist/transport-backend.js +99 -0
  89. package/dist/workspace-stream.d.ts +2 -0
  90. package/dist/workspace-stream.js +31 -0
  91. package/package.json +126 -3
package/dist/index.js ADDED
@@ -0,0 +1,1237 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
53
+ import { getWorkspace, SQLiteWorkspaceProvider, Workspace, WorkspaceProxy, WorkspaceServiceProxy, } from "@fungi.computer/cloudflare-computer";
54
+ import { WorkerShellBackend, } from "@fungi.computer/cloudflare-computer/backends/worker-shell";
55
+ import jqCommands from "@fungi.computer/cloudflare-computer/shell/jq";
56
+ import yqCommands from "@fungi.computer/cloudflare-computer/shell/yq";
57
+ import xanCommands from "@fungi.computer/cloudflare-computer/shell/xan";
58
+ import fileCommands from "@fungi.computer/cloudflare-computer/shell/file";
59
+ import { withWorkspaceContainer, } from "@fungi.computer/cloudflare-computer/backends/container";
60
+ import { DurableObject } from "cloudflare:workers";
61
+ import { z } from "zod";
62
+ import { SystemTrustContainerBackend } from "./container-system-trust.js";
63
+ import { computerTransportBackend } from "./transport-backend.js";
64
+ import { PullPendingTracker } from "./pull-pending.js";
65
+ import { ComputerFileConflict, ComputerFileRangeError, ComputerFileTooLargeError, capabilityNameSchema, computerValueSchema, computerCapabilities, computerPortInputSchema, computerPortOutputSchema, computerPortRemovalOutputSchema, computerPortsOutputSchema, computerPortSchema, ComputerPortLimitError, ComputerPortReservedError, computerTerminalPort, isComputerReservedPort, maximumExposedPorts, maximumProcessRecords, execInputSchema, execOutputSchema, computerToolResult, computerExecutionRequestSchema, genericComputerExecutionBudget, mintComputerRequestId, listInputSchema, listOutputSchema, maximumFileBytes, maximumStreamBytes, mkdirInputSchema, mkdirOutputSchema, readInputSchema, readBytesInputSchema, readOutputSchema, replaceInputSchema, writeInputSchema, writeOutputSchema, processIdInputSchema, processKillOutputSchema, processListOutputSchema, processStartInputSchema, processStartToolInputSchema, ComputerProcessNotFoundError, } from "./operation-contract.js";
66
+ import { createWorkspaceFilesPort, readBoundedWorkspaceText } from "./files.js";
67
+ import { computerExecutionResult } from "./execution-result.js";
68
+ import { ComputerOperationRecord } from "./operation-record.js";
69
+ import { applyWorkspacePatch } from "./files-patch.js";
70
+ import { ComputerProcessInventory, drainProcessExit, observeProcess, } from "./process-inventory.js";
71
+ import { assertComputerContainersEnabled, computerContainerSleepAfterMs, ComputerContainersPausedError, configureComputerContainer, hostedComputerContainersPaused, } from "./container-policy.js";
72
+ import { OnBoxVmContainerHost, onBoxVmPlacement, } from "./on-box-vm.js";
73
+ import { attachedBackendId, describeAttachedBackend, routeComputerBackend, } from "./computer-backends.js";
74
+ import { MeteredContainerHost } from "./metered-container-host.js";
75
+ import { LocalBackendConnections, LocalWorkspaceBackend, localBackendDialPath, verifyLocalBackendDial, } from "./local-backend.js";
76
+ const computerBinding = "COMPUTER";
77
+ const utf8 = new TextEncoder();
78
+ const terminalDefaultCols = 80;
79
+ const terminalDefaultRows = 24;
80
+ /** How often an attached terminal's container changes reach the Workspace. */
81
+ const terminalSyncIntervalMs = 3_000;
82
+ const terminalSessionsSchema = z.strictObject({
83
+ attached: z.number().int().nonnegative(),
84
+ });
85
+ async function sha256(value) {
86
+ const bytes = new TextEncoder().encode(value);
87
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
88
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
89
+ }
90
+ const terminalQuerySchema = z.strictObject({
91
+ session: z
92
+ .string()
93
+ .min(1)
94
+ .max(128)
95
+ .regex(/^[A-Za-z0-9._-]+$/),
96
+ cols: z.coerce.number().int().min(2).max(500).catch(terminalDefaultCols),
97
+ rows: z.coerce.number().int().min(2).max(300).catch(terminalDefaultRows),
98
+ });
99
+ function entryType(entry) {
100
+ if (entry.isFile)
101
+ return "file";
102
+ if (entry.isDirectory)
103
+ return "directory";
104
+ return "symlink";
105
+ }
106
+ function truncate(value) {
107
+ const bytes = utf8.encode(value);
108
+ if (bytes.byteLength <= maximumStreamBytes) {
109
+ return { value, truncated: false };
110
+ }
111
+ return {
112
+ value: new TextDecoder().decode(bytes.slice(0, maximumStreamBytes)),
113
+ truncated: true,
114
+ };
115
+ }
116
+ async function read(workspace, input) {
117
+ const { path } = readInputSchema.parse(input);
118
+ const stat = await workspace.fs.stat(path);
119
+ if (!stat.isFile)
120
+ throw new Error(`Computer path is not a file: ${path}`);
121
+ if (stat.size > maximumFileBytes) {
122
+ throw new Error("File exceeds the Computer read bound");
123
+ }
124
+ return readOutputSchema.parse({
125
+ path,
126
+ content: await workspace.fs.readFile(path, "utf8"),
127
+ });
128
+ }
129
+ async function list(workspace, input) {
130
+ const { path, limit, offset } = listInputSchema.parse(input);
131
+ const rows = await workspace.fs.readdir(path, {
132
+ limit: limit + 1,
133
+ offset,
134
+ });
135
+ return listOutputSchema.parse({
136
+ path,
137
+ entries: rows.slice(0, limit).map((entry) => ({
138
+ name: entry.name,
139
+ size: entry.size,
140
+ modifiedAt: entry.mtime,
141
+ type: entryType(entry),
142
+ })),
143
+ nextOffset: rows.length > limit ? offset + limit : null,
144
+ });
145
+ }
146
+ async function mkdir(workspace, input) {
147
+ const { path, recursive } = mkdirInputSchema.parse(input);
148
+ await workspace.fs.mkdir(path, { recursive });
149
+ return mkdirOutputSchema.parse({ path });
150
+ }
151
+ async function write(workspace, input) {
152
+ const { path, content } = writeInputSchema.parse(input);
153
+ await workspace.fs.writeFile(path, content);
154
+ return writeOutputSchema.parse({
155
+ path,
156
+ bytes: utf8.encode(content).byteLength,
157
+ });
158
+ }
159
+ async function exec(workspace, input, backend, signal) {
160
+ const env_1 = { stack: [], error: void 0, hasError: false };
161
+ try {
162
+ // The host request has already been decoded under its admitted budget.
163
+ const { command, cwd, timeoutMs } = input;
164
+ const backendOption = backend === undefined ? {} : { backend };
165
+ const options = cwd === undefined
166
+ ? { encoding: "utf8", timeoutMs, ...backendOption }
167
+ : { encoding: "utf8", timeoutMs, cwd, ...backendOption };
168
+ const run = __addDisposableResource(env_1, await workspace.runtime.exec(command, options), false);
169
+ const result = await computerExecutionResult(run, signal);
170
+ const synchronizationFailure = result.sync.status === "pending"
171
+ ? `Computer command filesystem synchronization failed: ${result.sync.error}`
172
+ : null;
173
+ const stdout = truncate(result.stdout);
174
+ const stderr = truncate(synchronizationFailure === null
175
+ ? result.stderr
176
+ : `${synchronizationFailure}${result.stderr.length === 0 ? "" : `\n${result.stderr}`}`);
177
+ return execOutputSchema.parse({
178
+ status: synchronizationFailure === null ? result.status : "failed",
179
+ exitCode: synchronizationFailure !== null && result.exitCode === 0
180
+ ? 1
181
+ : result.exitCode,
182
+ stdout: stdout.value,
183
+ stderr: stderr.value,
184
+ truncated: stdout.truncated || stderr.truncated,
185
+ });
186
+ }
187
+ catch (e_1) {
188
+ env_1.error = e_1;
189
+ env_1.hasError = true;
190
+ }
191
+ finally {
192
+ __disposeResources(env_1);
193
+ }
194
+ }
195
+ /** The liveness probe reads computerd executions on one hosted backend. */
196
+ function processRuntime(workspace, backend) {
197
+ return {
198
+ getExec: (id, options) => workspace.runtime.getExec(id, { ...options, backend }),
199
+ };
200
+ }
201
+ /** A command could not be placed on an attached backend. */
202
+ export class ComputerBackendUnavailableError extends Error {
203
+ code;
204
+ constructor(code) {
205
+ super(code === "no_backend"
206
+ ? "No attached Computer backend is available"
207
+ : "The named Computer backend is not available");
208
+ this.code = code;
209
+ this.name = "ComputerBackendUnavailableError";
210
+ }
211
+ }
212
+ class ComputerDurableObject extends DurableObject {
213
+ get bindings() {
214
+ return this.env;
215
+ }
216
+ get state() {
217
+ return this.ctx;
218
+ }
219
+ }
220
+ const ContainerComputerBase = withWorkspaceContainer(ComputerDurableObject);
221
+ /** One Workspace built for one attached-backend set. */
222
+ /** The directory every Computer backend mounts as its guest workspace. */
223
+ const workspaceMountRoot = "/workspace";
224
+ /** A VM's container upgrade, rewritten by the verified on-box ingress door. */
225
+ const vmUpgradePath = /^\/__vm\/([a-z0-9][a-z0-9-]{0,62})(\/ws)$/;
226
+ /**
227
+ * The Computer owns its Workspace. `@cloudflare/computer` fixes a Workspace's
228
+ * backends when it is constructed, so the Workspace is rebuilt whenever the
229
+ * attached set changes; the previous generation is closed and a command still
230
+ * running on it ends as a transport failure. A local machine connecting or
231
+ * leaving changes status, not the set, and rebuilds nothing.
232
+ */
233
+ class ContainerComputer extends ContainerComputerBase {
234
+ #onBoxVms = new Map();
235
+ #generation;
236
+ #rootedWorkspaces = new WeakSet();
237
+ /** Parked dial-ins from attached machines; survives Workspace rebuilds. */
238
+ localConnections = new LocalBackendConnections((id) => {
239
+ this.state.waitUntil(this.#catchUpLocal(id));
240
+ });
241
+ /**
242
+ * Preserves `@cloudflare/computer`'s own pull-retry capability (its
243
+ * `retryScheduler` option) across every Workspace generation: whoever
244
+ * later wires a Durable Object alarm to `Workspace.retryPendingSync`
245
+ * needs its real, dedup'd-by-runtime intents, not a stand-in. Per-exit
246
+ * sync status is attributed per drain instead (`drainProcessExit`,
247
+ * `observeSync`), not read from this tracker.
248
+ */
249
+ #pullPending = new PullPendingTracker();
250
+ constructor(ctx, env) {
251
+ super(ctx, env);
252
+ if (ctx.container === undefined ||
253
+ hostedComputerContainersPaused(env.HOSTED_COMPUTERS))
254
+ return;
255
+ ctx.waitUntil(configureComputerContainer(ctx.container));
256
+ }
257
+ getWorkspaceContainer() {
258
+ assertComputerContainersEnabled(this.bindings.HOSTED_COMPUTERS);
259
+ return super.getWorkspaceContainer();
260
+ }
261
+ /**
262
+ * This Computer's attached backends, in attach order. The standalone
263
+ * runtime attaches its Worker's default host; a Computer owner (Hub)
264
+ * overrides this with the list its Team projected down.
265
+ */
266
+ attachedBackends() {
267
+ const hostId = this.bindings.COMPUTER_VM_DEFAULT_HOST;
268
+ return [
269
+ hostId === undefined
270
+ ? { kind: "platform-container" }
271
+ : { kind: "on-box-vm", hostId },
272
+ ];
273
+ }
274
+ /**
275
+ * A machine that dials in catches up without waiting for a command. The
276
+ * Computer wins (Levi, 2026-09-28): push the Workspace's changes first, so
277
+ * a path changed or deleted on both sides takes the Computer's version,
278
+ * then pull what only the folder changed. A command brackets the same way.
279
+ */
280
+ async #catchUpLocal(id) {
281
+ try {
282
+ const workspace = await this.readyWorkspace();
283
+ await workspace.push(id);
284
+ await workspace.pull(id);
285
+ }
286
+ catch (error) {
287
+ throw new Error(`Local Computer backend ${id} catch-up failed`, {
288
+ cause: error,
289
+ });
290
+ }
291
+ }
292
+ /** Accept a verified dial-in from an attached machine and park it. */
293
+ async #acceptLocalDial(request, computerName, backendId) {
294
+ const backend = this.attachedBackends().find((candidate) => candidate.kind === "local" &&
295
+ attachedBackendId(candidate) === backendId);
296
+ if (backend === undefined ||
297
+ computerName !== this.state.id.name ||
298
+ !(await verifyLocalBackendDial(request, computerName, backend)))
299
+ return new Response(null, { status: 404 });
300
+ // A verified plain request lets the machine tell attached from detached.
301
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket")
302
+ return new Response("Expected WebSocket upgrade", { status: 426 });
303
+ // Registering the backend first keeps its id in this generation.
304
+ this.workspace();
305
+ const pair = new WebSocketPair();
306
+ const [client, server] = [pair[0], pair[1]];
307
+ server.accept();
308
+ this.localConnections.park(backendId, server);
309
+ return new Response(null, { status: 101, webSocket: client });
310
+ }
311
+ /** A runtime of a hosted backend started; owners meter it. */
312
+ containerRuntimeStarted(start) {
313
+ void start;
314
+ }
315
+ /** The single routing decision over this Computer's attached backends. */
316
+ routeBackend(backend, need) {
317
+ const route = routeComputerBackend(this.attachedBackends(), (id) => this.localConnections.online(id), { backend, need });
318
+ if (route.kind !== "routed")
319
+ throw new ComputerBackendUnavailableError(route.kind);
320
+ return route;
321
+ }
322
+ /**
323
+ * The hosted backend that terminals, processes and ports use: the first in
324
+ * the default order, or none when only local machines are attached.
325
+ */
326
+ hostedRoute() {
327
+ const route = routeComputerBackend(this.attachedBackends(), () => false, {
328
+ need: "hosted",
329
+ });
330
+ if (route.kind !== "routed" || route.backend.kind === "local")
331
+ return undefined;
332
+ return { id: route.id, backing: route.backend };
333
+ }
334
+ /** The hosted route, or the typed failure a caller maps to unavailable. */
335
+ requireHostedRoute() {
336
+ const route = this.hostedRoute();
337
+ if (route === undefined)
338
+ throw new ComputerBackendUnavailableError("no_backend");
339
+ return route;
340
+ }
341
+ /** This Computer's attached backends with their live status. */
342
+ describeBackends() {
343
+ return this.attachedBackends().map((backend) => describeAttachedBackend(backend, this.localConnections.online(attachedBackendId(backend))));
344
+ }
345
+ /** The metered container host behind one hosted backend. */
346
+ hostedContainerHost(backing) {
347
+ const host = backing.kind === "platform-container"
348
+ ? this.getWorkspaceContainer()
349
+ : this.onBoxVmHost(backing.hostId);
350
+ return new MeteredContainerHost(host, backing, (start) => {
351
+ this.containerRuntimeStarted(start);
352
+ });
353
+ }
354
+ /** The on-box VM host service for `hostId`, one per placement. */
355
+ onBoxVmHost(hostId) {
356
+ assertComputerContainersEnabled(this.bindings.HOSTED_COMPUTERS);
357
+ const placement = onBoxVmPlacement(this.bindings, hostId);
358
+ if (placement === undefined)
359
+ throw new Error(`Computer VM host ${hostId} is not registered`);
360
+ let host = this.#onBoxVms.get(hostId);
361
+ if (host === undefined) {
362
+ host = this.createOnBoxVmHost(placement);
363
+ this.#onBoxVms.set(hostId, host);
364
+ }
365
+ return host;
366
+ }
367
+ /** Let the consuming owner admit boots while preserving the runtime host seam. */
368
+ createOnBoxVmHost(placement) {
369
+ return new OnBoxVmContainerHost(placement, this.bindings, this.state.id.toString(), computerContainerSleepAfterMs);
370
+ }
371
+ /** Whether a hosted backend's runtime is running now. */
372
+ async containerRunning(backing) {
373
+ if (backing.kind === "platform-container")
374
+ return this.state.container?.running === true;
375
+ return await this.onBoxVmHost(backing.hostId).running();
376
+ }
377
+ #containerBackend(backing) {
378
+ return new SystemTrustContainerBackend({
379
+ id: attachedBackendId(backing),
380
+ container: () => ({
381
+ getWorkspaceContainer: () => this.hostedContainerHost(backing),
382
+ }),
383
+ workspace: {
384
+ binding: computerBinding,
385
+ id: this.state.id.toString(),
386
+ },
387
+ egress: this.bindings.COMPUTER_EGRESS
388
+ ? {
389
+ mode: "http-gateway",
390
+ gateway: this.bindings.COMPUTER_EGRESS({
391
+ props: { computerObjectId: this.state.id.toString() },
392
+ }),
393
+ }
394
+ : { mode: "none" },
395
+ // The library heartbeat competes with a busy container command and
396
+ // previously closed healthy long-running executions.
397
+ heartbeatIntervalMs: 0,
398
+ });
399
+ }
400
+ /** A consuming host supplies its admitted shell loopback context. */
401
+ workerShellContext() {
402
+ return this.state;
403
+ }
404
+ /** The Workspace for the current attached set, rebuilt when it changes. */
405
+ workspace() {
406
+ const attached = this.attachedBackends();
407
+ const key = JSON.stringify(attached);
408
+ if (this.#generation?.key === key)
409
+ return this.#generation.workspace;
410
+ const previous = this.#generation;
411
+ const containers = new Map();
412
+ const backends = [];
413
+ if (this.bindings.LOADER)
414
+ backends.push(new WorkerShellBackend({
415
+ loader: this.bindings.LOADER,
416
+ workspace: {
417
+ binding: computerBinding,
418
+ id: this.state.id.toString(),
419
+ },
420
+ ctx: this.workerShellContext(),
421
+ commands: [jqCommands, yqCommands, xanCommands, fileCommands],
422
+ egress: { mode: "none" },
423
+ }));
424
+ for (const backend of attached) {
425
+ if (backend.kind === "local") {
426
+ backends.push(computerTransportBackend(new LocalWorkspaceBackend(backend, this.localConnections)));
427
+ continue;
428
+ }
429
+ const container = this.#containerBackend(backend);
430
+ containers.set(container.id, container);
431
+ backends.push(computerTransportBackend(container));
432
+ }
433
+ this.#generation = {
434
+ key,
435
+ containers,
436
+ workspace: new Workspace({
437
+ storage: {
438
+ sql: {
439
+ exec: (query, ...bindings) => this.state.storage.sql.exec(query, ...bindings),
440
+ },
441
+ transactionSync: (body) => this.state.storage.transactionSync(body),
442
+ },
443
+ backends,
444
+ retryScheduler: this.#pullPending.scheduler,
445
+ }),
446
+ };
447
+ this.localConnections.retain(new Set(attached
448
+ .filter((backend) => backend.kind === "local")
449
+ .map(attachedBackendId)));
450
+ if (previous !== undefined)
451
+ this.state.waitUntil(previous.workspace.close().catch((cause) => {
452
+ console.error("Computer runtime operation failed", cause);
453
+ return undefined;
454
+ }));
455
+ return this.#generation.workspace;
456
+ }
457
+ /**
458
+ * The current Workspace once its mount index is ready and its guest mount
459
+ * root exists. Every backend mounts `/workspace`, so a host write there
460
+ * (such as a Workflow step's one-shot Git credential file) must not depend
461
+ * on a guest having booted first.
462
+ */
463
+ async readyWorkspace() {
464
+ const workspace = this.workspace();
465
+ await workspace.ready();
466
+ if (!this.#rootedWorkspaces.has(workspace)) {
467
+ await workspace.fs.mkdir(workspaceMountRoot, { recursive: true });
468
+ this.#rootedWorkspaces.add(workspace);
469
+ }
470
+ return workspace;
471
+ }
472
+ /** `getWorkspace(stub)` reaches the current generation through this door. */
473
+ async __getWorkspaceStub() {
474
+ return (await this.readyWorkspace()).stub();
475
+ }
476
+ /**
477
+ * Backend doors. An attached machine dials in on its public connect path.
478
+ * A container backend's `/ws` upgrade and egress: the platform container's
479
+ * arrive as `/ws` through WorkspaceProxy; an on-box VM's through the
480
+ * verified ingress door as `/__vm/<hostId>/ws`.
481
+ */
482
+ fetch(request) {
483
+ const url = new URL(request.url);
484
+ const dial = localBackendDialPath.exec(url.pathname);
485
+ if (dial !== null)
486
+ return this.#acceptLocalDial(request, dial[2] ?? "", dial[3] ?? "");
487
+ const vm = vmUpgradePath.exec(url.pathname);
488
+ const id = vm === null ? "container" : `vm-${vm[1] ?? ""}`;
489
+ if (vm !== null)
490
+ url.pathname = vm[2] ?? "/ws";
491
+ const container = this.#generation?.containers.get(id);
492
+ if (container === undefined)
493
+ return Promise.resolve(new Response("not found", { status: 404 }));
494
+ return container.handleFetch(new Request(url, request));
495
+ }
496
+ }
497
+ /** One upstream Cloudflare Workspace that advertises and runs its capabilities. */
498
+ export class ComputerDO extends ContainerComputer {
499
+ #binaryFileTail = Promise.resolve(undefined);
500
+ #operationRecord;
501
+ #processRecord;
502
+ #terminalSync;
503
+ get #processes() {
504
+ this.#processRecord ??= new ComputerProcessInventory(this.state.storage, () => Date.now());
505
+ return this.#processRecord;
506
+ }
507
+ get #operations() {
508
+ this.#operationRecord ??= new ComputerOperationRecord(this.state.storage, () => Date.now());
509
+ return this.#operationRecord;
510
+ }
511
+ #ensureExposedPorts() {
512
+ void this.#processes;
513
+ }
514
+ #readExposedPorts() {
515
+ this.#ensureExposedPorts();
516
+ const rows = this.state.storage.sql
517
+ .exec("SELECT port FROM computer_exposed_ports ORDER BY port")
518
+ .toArray();
519
+ // Rows admitted before a port became reserved stay removable but are
520
+ // never presented as previewable.
521
+ return computerPortsOutputSchema.parse({
522
+ ports: rows
523
+ .map(({ port }) => port)
524
+ .filter((port) => !isComputerReservedPort(port)),
525
+ }).ports;
526
+ }
527
+ /** Persist the bounded set of ports admitted for preview on this Computer. */
528
+ exposePort(input) {
529
+ return this.exposePortAfterAcceptance(input, () => undefined);
530
+ }
531
+ /** Host checks current authority at the write boundary. */
532
+ exposePortAfterAcceptance(input, accept) {
533
+ const { port } = computerPortInputSchema.parse(input);
534
+ if (isComputerReservedPort(port))
535
+ throw new ComputerPortReservedError(port);
536
+ this.#ensureExposedPorts();
537
+ const exposed = this.state.storage.transactionSync(() => {
538
+ accept();
539
+ const existing = this.state.storage.sql
540
+ .exec("SELECT port FROM computer_exposed_ports WHERE port = ?", port)
541
+ .toArray().length > 0;
542
+ if (!existing) {
543
+ const count = this.state.storage.sql
544
+ .exec("SELECT COUNT(*) AS count FROM computer_exposed_ports")
545
+ .toArray()[0]?.count;
546
+ if (count === undefined || count >= maximumExposedPorts) {
547
+ throw new ComputerPortLimitError();
548
+ }
549
+ }
550
+ this.state.storage.sql.exec("INSERT INTO computer_exposed_ports (port, process_id) VALUES (?, NULL) ON CONFLICT(port) DO UPDATE SET process_id = NULL", port);
551
+ return true;
552
+ });
553
+ return Promise.resolve(computerPortOutputSchema.parse({ port, exposed }));
554
+ }
555
+ /** List only the durable port-admission metadata for this Computer. */
556
+ listPorts() {
557
+ return this.listPortsAfterAcceptance(() => undefined);
558
+ }
559
+ /** Host checks current authority before returning the admitted port set. */
560
+ listPortsAfterAcceptance(accept) {
561
+ this.#ensureExposedPorts();
562
+ accept();
563
+ return Promise.resolve(computerPortsOutputSchema.parse({
564
+ ports: this.#readExposedPorts(),
565
+ }));
566
+ }
567
+ /** Remove one port from the Computer's durable preview admission set. */
568
+ unexposePort(input) {
569
+ return this.unexposePortAfterAcceptance(input, () => undefined);
570
+ }
571
+ /** Host checks current authority at the write boundary. */
572
+ unexposePortAfterAcceptance(input, accept) {
573
+ const { port } = computerPortInputSchema.parse(input);
574
+ this.#ensureExposedPorts();
575
+ const exposed = this.state.storage.transactionSync(() => {
576
+ accept();
577
+ const rows = this.state.storage.sql.exec("DELETE FROM computer_exposed_ports WHERE port = ? RETURNING port", port);
578
+ return rows.toArray().length > 0;
579
+ });
580
+ return Promise.resolve(computerPortRemovalOutputSchema.parse({ port, removed: exposed }));
581
+ }
582
+ /**
583
+ * Proxy one admitted request to this Computer's container. The host must
584
+ * pass a Request whose URL is service-relative, using the `http://localhost`
585
+ * placeholder origin and a path/query with the Hub route prefix removed.
586
+ */
587
+ async portPreviewAfterAcceptance(portInput, request, accept) {
588
+ const port = computerPortSchema.parse(portInput);
589
+ if (isComputerReservedPort(port)) {
590
+ return Response.json({ error: "port is reserved" }, { status: 400 });
591
+ }
592
+ const incoming = new URL(request.url);
593
+ if (incoming.origin !== "http://localhost") {
594
+ return Response.json({ error: "preview request must use a service-relative URL" }, { status: 400 });
595
+ }
596
+ const target = new URL("http://localhost");
597
+ target.pathname = incoming.pathname;
598
+ target.search = incoming.search;
599
+ this.#ensureExposedPorts();
600
+ const exposed = this.state.storage.transactionSync(() => {
601
+ const found = this.state.storage.sql
602
+ .exec("SELECT port FROM computer_exposed_ports WHERE port = ?", port)
603
+ .toArray().length > 0;
604
+ if (found)
605
+ accept();
606
+ return found;
607
+ });
608
+ if (!exposed)
609
+ return Response.json({ error: "port is not exposed" }, { status: 404 });
610
+ if (request.headers.has("upgrade"))
611
+ return Response.json({ error: "preview WebSocket upgrades are not supported" }, { status: 400 });
612
+ try {
613
+ const headers = new Headers(request.headers);
614
+ for (const name of [
615
+ "authorization",
616
+ "cookie",
617
+ "proxy-authorization",
618
+ "cf-access-jwt-assertion",
619
+ "cf-access-client-id",
620
+ "cf-access-client-secret",
621
+ "cf-access-authenticated-user-email",
622
+ "x-authenticated-user",
623
+ "x-authenticated-email",
624
+ "x-auth-request-user",
625
+ "x-auth-request-email",
626
+ "x-user-id",
627
+ "x-user-email",
628
+ "x-forwarded-user",
629
+ "x-forwarded-email",
630
+ ])
631
+ headers.delete(name);
632
+ const forwarded = new Request(target, request);
633
+ const hosted = this.hostedRoute();
634
+ if (hosted === undefined)
635
+ throw new ComputerBackendUnavailableError("no_backend");
636
+ const response = await this.withHostedResponse(hosted.backing, () => this.hostedContainerHost(hosted.backing)
637
+ .port(port)
638
+ .fetch(new Request(forwarded, { headers })));
639
+ const safeHeaders = new Headers(response.headers);
640
+ safeHeaders.delete("set-cookie");
641
+ return new Response(response.body, {
642
+ status: response.status,
643
+ statusText: response.statusText,
644
+ headers: safeHeaders,
645
+ });
646
+ }
647
+ catch (cause) {
648
+ console.error("Computer runtime operation failed", cause);
649
+ return Response.json({ error: "Computer port service is unavailable" }, { status: 502 });
650
+ }
651
+ }
652
+ async #withBinaryFile(operation) {
653
+ const previous = this.#binaryFileTail;
654
+ const next = Promise.withResolvers();
655
+ this.#binaryFileTail = next.promise;
656
+ await previous;
657
+ try {
658
+ return await operation();
659
+ }
660
+ finally {
661
+ next.resolve(undefined);
662
+ }
663
+ }
664
+ /**
665
+ * Move changes between the Workspace and a hosted runtime that is already
666
+ * running, through the library's own sync cursors. It never boots one: a
667
+ * stopped runtime catches up in its next command or terminal bracket. A
668
+ * failure leaves the cursor in place for the next sync and is logged, not
669
+ * thrown, because the Workspace operation itself has committed.
670
+ */
671
+ async #syncRunningHosted(direction) {
672
+ const hosted = this.hostedRoute();
673
+ if (hosted === undefined)
674
+ return;
675
+ try {
676
+ if (!(await this.containerRunning(hosted.backing)))
677
+ return;
678
+ }
679
+ catch (cause) {
680
+ if (cause instanceof ComputerContainersPausedError)
681
+ return;
682
+ throw cause;
683
+ }
684
+ try {
685
+ const workspace = await this.readyWorkspace();
686
+ if (direction === "push")
687
+ await workspace.push(hosted.id);
688
+ else
689
+ await workspace.pull(hosted.id);
690
+ }
691
+ catch (cause) {
692
+ console.error(`Computer Workspace ${direction} failed`, cause);
693
+ }
694
+ }
695
+ /** One loop per Computer while any terminal socket is attached. */
696
+ #startTerminalSync(backing) {
697
+ if (this.#terminalSync !== undefined)
698
+ return;
699
+ this.#terminalSync = this.#terminalSyncLoop(backing).finally(() => {
700
+ this.#terminalSync = undefined;
701
+ });
702
+ this.state.waitUntil(this.#terminalSync);
703
+ }
704
+ async #terminalSyncLoop(backing) {
705
+ for (;;) {
706
+ await new Promise((resolve) => setTimeout(resolve, terminalSyncIntervalMs));
707
+ try {
708
+ if (!(await this.containerRunning(backing)))
709
+ return;
710
+ const response = await this.hostedContainerHost(backing)
711
+ .port(computerTerminalPort)
712
+ .fetch(new Request("http://localhost/sessions"));
713
+ if (!response.ok)
714
+ return;
715
+ const { attached } = terminalSessionsSchema.parse(await response.json());
716
+ if (attached === 0)
717
+ return;
718
+ }
719
+ catch (cause) {
720
+ console.error("Computer terminal sync probe failed", cause);
721
+ return;
722
+ }
723
+ await this.#syncRunningHosted("push");
724
+ await this.#syncRunningHosted("pull");
725
+ }
726
+ }
727
+ getCapabilities() {
728
+ return Promise.resolve(computerCapabilities());
729
+ }
730
+ /** Read bounded native bytes before releasing the single-owner Workspace. */
731
+ async readFile(input) {
732
+ return (await this.readFileAfterAcceptance(input, () => undefined)).body;
733
+ }
734
+ /** Resource hosts may accept authority after the file queue and Workspace are ready. */
735
+ async readFileAfterAcceptance(input, accept, maximumBytes = maximumFileBytes) {
736
+ const { path, byteOffset = 0, byteLength, } = readBytesInputSchema.parse(input);
737
+ return await this.#withBinaryFile(async () => {
738
+ const workspace = await this.readyWorkspace();
739
+ accept();
740
+ await this.#syncRunningHosted("pull");
741
+ const stat = await workspace.fs.stat(path);
742
+ if (!stat.isFile)
743
+ throw new Error("Computer path is not a file");
744
+ if (byteOffset > stat.size)
745
+ throw new ComputerFileRangeError(stat.size);
746
+ const size = Math.min(byteLength ?? stat.size, stat.size - byteOffset);
747
+ if (size > maximumBytes)
748
+ throw new ComputerFileTooLargeError();
749
+ accept();
750
+ const source = await workspace.fs.readFile(path, {
751
+ byteOffset,
752
+ byteLength: size,
753
+ });
754
+ const reader = source.getReader();
755
+ const chunks = [];
756
+ let length = 0;
757
+ let done = false;
758
+ try {
759
+ while (!done) {
760
+ const next = await reader.read();
761
+ done = next.done;
762
+ if (next.done)
763
+ continue;
764
+ length += next.value.byteLength;
765
+ if (length > maximumBytes) {
766
+ await reader.cancel("File exceeds the Computer read bound");
767
+ throw new ComputerFileTooLargeError();
768
+ }
769
+ chunks.push(next.value);
770
+ }
771
+ }
772
+ finally {
773
+ reader.releaseLock();
774
+ }
775
+ const bytes = new Uint8Array(length);
776
+ let offset = 0;
777
+ for (const chunk of chunks) {
778
+ bytes.set(chunk, offset);
779
+ offset += chunk.byteLength;
780
+ }
781
+ if (length !== size)
782
+ throw new Error("File changed during byte read");
783
+ return {
784
+ offset: byteOffset,
785
+ total: stat.size,
786
+ length,
787
+ body: new ReadableStream({
788
+ start(controller) {
789
+ controller.enqueue(bytes);
790
+ controller.close();
791
+ },
792
+ }),
793
+ };
794
+ });
795
+ }
796
+ /** Completion means the maintained Workspace writer consumed and committed the stream. */
797
+ async writeFile(input, content) {
798
+ await this.writeFileAfterAcceptance(input, content, () => undefined);
799
+ }
800
+ /** Resource hosts may accept authority after the file queue and Workspace are ready. */
801
+ async writeFileAfterAcceptance(input, content, accept) {
802
+ const { path } = readInputSchema.parse(input);
803
+ await this.#withBinaryFile(async () => {
804
+ const workspace = await this.readyWorkspace();
805
+ accept();
806
+ await workspace.fs.writeFile(path, content);
807
+ });
808
+ await this.#syncRunningHosted("push");
809
+ }
810
+ /** Keep one Workspace lease around one authorized structured Files operation. */
811
+ async withFilesAfterAcceptance(accept, operation) {
812
+ const workspace = await this.readyWorkspace();
813
+ accept();
814
+ await this.#syncRunningHosted("pull");
815
+ return await operation(createWorkspaceFilesPort(workspace, this));
816
+ }
817
+ /** A Files write uses the packaged native engine and the ordinary metered backend. */
818
+ async applyPatchAfterAcceptance(input, accept) {
819
+ const route = this.routeBackend(undefined, "hosted");
820
+ return await this.runOnBackend(route, async () => {
821
+ const workspace = await this.readyWorkspace();
822
+ accept();
823
+ return await applyWorkspacePatch(workspace, route.id, input);
824
+ });
825
+ }
826
+ /** Write bounded UTF-8 text after the resource host accepts local authority. */
827
+ async writeTextAfterAcceptance(input, accept) {
828
+ const workspace = await this.readyWorkspace();
829
+ accept();
830
+ const result = await write(workspace, input);
831
+ await this.#syncRunningHosted("push");
832
+ return result;
833
+ }
834
+ async call(capabilityId, input) {
835
+ if (capabilityNameSchema.parse(capabilityId) === "start_process") {
836
+ const toolInput = processStartToolInputSchema.parse(input);
837
+ return await this.startProcessAfterAcceptance(computerValueSchema.parse({
838
+ ...toolInput,
839
+ requestId: mintComputerRequestId(),
840
+ requestScope: "native-tool",
841
+ }), () => undefined);
842
+ }
843
+ return await this.callAfterAcceptance(capabilityId, input, () => undefined);
844
+ }
845
+ /** Resource hosts may accept authority once the Workspace is ready to dispatch. */
846
+ async callAfterAcceptance(capabilityId, input, accept) {
847
+ const selected = capabilityNameSchema.parse(capabilityId);
848
+ if (selected === "bash" || selected === "exec") {
849
+ accept();
850
+ // The native call is one tool invocation with no host retry: mint here.
851
+ return computerToolResult(await this.executeOperationAfterAcceptance({
852
+ ...execInputSchema.parse(input),
853
+ requestId: mintComputerRequestId(),
854
+ }, () => undefined, selected));
855
+ }
856
+ switch (selected) {
857
+ case "start_process":
858
+ return await this.startProcessAfterAcceptance(input, accept);
859
+ case "list_processes":
860
+ return await this.listProcessesAfterAcceptance(accept);
861
+ case "kill_process":
862
+ return await this.killProcessAfterAcceptance(input, accept);
863
+ }
864
+ const workspace = await this.readyWorkspace();
865
+ accept();
866
+ switch (selected) {
867
+ case "read":
868
+ await this.#syncRunningHosted("pull");
869
+ return await read(workspace, input);
870
+ case "list":
871
+ await this.#syncRunningHosted("pull");
872
+ return await list(workspace, input);
873
+ case "mkdir": {
874
+ const result = await mkdir(workspace, input);
875
+ await this.#syncRunningHosted("push");
876
+ return result;
877
+ }
878
+ case "write": {
879
+ const result = await write(workspace, input);
880
+ await this.#syncRunningHosted("push");
881
+ return result;
882
+ }
883
+ default:
884
+ throw new Error(`Computer capability is unavailable: ${selected}`);
885
+ }
886
+ }
887
+ /**
888
+ * Execute a host request once, after the host accepts current authority.
889
+ * `exec` runs on the Workspace's built-in Worker shell unless it names an
890
+ * attached backend; `bash` always runs on an attached backend, the named one
891
+ * or the first available in the default order.
892
+ */
893
+ async executeOperationAfterAcceptance(input, accept, kind, budget = genericComputerExecutionBudget, signal) {
894
+ const { requestId, ...command } = computerExecutionRequestSchema(budget).parse(input);
895
+ const route = kind === "exec" && command.backend === undefined
896
+ ? undefined
897
+ : this.routeBackend(command.backend, "exec");
898
+ const run = async () => {
899
+ const workspace = await this.readyWorkspace();
900
+ const inputHash = await sha256(JSON.stringify({
901
+ kind,
902
+ command: command.command,
903
+ cwd: command.cwd ?? null,
904
+ timeoutMs: command.timeoutMs,
905
+ backend: command.backend ?? null,
906
+ }));
907
+ const operations = this.#operations;
908
+ signal?.throwIfAborted();
909
+ const recorded = operations.admit(requestId, inputHash, accept);
910
+ if (recorded !== undefined)
911
+ return recorded;
912
+ operations.markRunning(requestId);
913
+ try {
914
+ operations.settle(requestId, await exec(workspace, command, route?.id, signal));
915
+ }
916
+ catch (cause) {
917
+ operations.settleUnknown(requestId, cause instanceof Error
918
+ ? `${cause.name}: ${cause.message}`
919
+ : "Computer execution rejected without an Error");
920
+ }
921
+ return operations.status(requestId, () => undefined);
922
+ };
923
+ return route === undefined
924
+ ? await run()
925
+ : await this.runOnBackend(route, run);
926
+ }
927
+ /**
928
+ * Run one command on a routed backend. Owners admit and meter hosted
929
+ * backends here; a member's own machine is neither reserved nor metered.
930
+ */
931
+ async runOnBackend(route, run) {
932
+ void route;
933
+ return await run();
934
+ }
935
+ /** Start one long-running container process with a durable identity. */
936
+ async startProcess(input) {
937
+ return await this.startProcessAfterAcceptance(input, () => undefined);
938
+ }
939
+ /** The host accepts authority when the running slot is reserved. */
940
+ async startProcessAfterAcceptance(input, accept) {
941
+ const start = processStartInputSchema.parse(input);
942
+ const { command, cwd } = start;
943
+ const inputHash = await sha256(JSON.stringify({ command, cwd: cwd ?? null, port: start.port ?? null }));
944
+ const replay = this.#processes.replay(start, inputHash, accept);
945
+ if (replay !== undefined)
946
+ return replay;
947
+ if (start.port !== undefined && isComputerReservedPort(start.port))
948
+ throw new ComputerPortReservedError(start.port);
949
+ const backend = this.requireHostedRoute().id;
950
+ const workspace = await this.readyWorkspace();
951
+ const admission = this.#processes.admit(start, inputHash, accept);
952
+ if (!admission.created)
953
+ return admission.process;
954
+ const admitted = admission.process;
955
+ const id = admitted.id;
956
+ let run;
957
+ try {
958
+ // timeoutMs 0 disables computerd's default 320 s command deadline.
959
+ const options = {
960
+ id,
961
+ backend,
962
+ encoding: "utf8",
963
+ timeoutMs: 0,
964
+ };
965
+ if (cwd !== undefined)
966
+ options.cwd = cwd;
967
+ run = await workspace.runtime.exec(command, options);
968
+ }
969
+ catch (error) {
970
+ // A transport failure after dispatch may still have started it.
971
+ const observed = await observeProcess(processRuntime(workspace, backend), id);
972
+ if (observed.state === "exited" && observed.exitCode === null) {
973
+ this.#processes.release(id);
974
+ throw error;
975
+ }
976
+ return this.#settle(id, observed);
977
+ }
978
+ this.state.waitUntil(this.#watchProcess(id, run));
979
+ return admitted;
980
+ }
981
+ /** List this Computer's processes, reconciling live ones with computerd. */
982
+ async listProcesses() {
983
+ return await this.listProcessesAfterAcceptance(() => undefined);
984
+ }
985
+ async listProcessesAfterAcceptance(accept) {
986
+ accept();
987
+ const rows = this.#processes.list();
988
+ const running = rows.filter((row) => row.state === "running");
989
+ if (running.length === 0)
990
+ return processListOutputSchema.parse({
991
+ processes: rows.slice(0, maximumProcessRecords),
992
+ });
993
+ const hosted = this.hostedRoute();
994
+ if (hosted === undefined ||
995
+ !(await this.containerRunning(hosted.backing))) {
996
+ // No container means none of its processes survived; do not wake it.
997
+ for (const row of running)
998
+ this.#processes.exited(row.id, null);
999
+ return processListOutputSchema.parse({
1000
+ processes: this.#processes.list().slice(0, maximumProcessRecords),
1001
+ });
1002
+ }
1003
+ const workspace = await this.readyWorkspace();
1004
+ const runtime = processRuntime(workspace, hosted.id);
1005
+ const observed = new Map(await Promise.all(running.map(async (row) => [
1006
+ row.id,
1007
+ this.#settle(row.id, await observeProcess(runtime, row.id)),
1008
+ ])));
1009
+ return processListOutputSchema.parse({
1010
+ processes: rows
1011
+ .slice(0, maximumProcessRecords)
1012
+ .map((row) => observed.get(row.id) ?? row),
1013
+ });
1014
+ }
1015
+ /** Send SIGTERM to one running process; its exit is recorded when observed. */
1016
+ async killProcess(input) {
1017
+ return await this.killProcessAfterAcceptance(input, () => undefined);
1018
+ }
1019
+ async killProcessAfterAcceptance(input, accept) {
1020
+ const { id } = processIdInputSchema.parse(input);
1021
+ accept();
1022
+ const row = this.#processes.get(id);
1023
+ if (row === undefined)
1024
+ throw new ComputerProcessNotFoundError(id);
1025
+ if (row.state !== "running")
1026
+ return processKillOutputSchema.parse({ process: row, signalled: false });
1027
+ const hosted = this.hostedRoute();
1028
+ if (hosted === undefined || !(await this.containerRunning(hosted.backing)))
1029
+ return processKillOutputSchema.parse({
1030
+ process: this.#processes.exited(id, null),
1031
+ signalled: false,
1032
+ });
1033
+ const workspace = await this.readyWorkspace();
1034
+ try {
1035
+ await workspace.runtime.killExec(id, {
1036
+ backend: hosted.id,
1037
+ signal: "SIGTERM",
1038
+ });
1039
+ }
1040
+ catch (cause) {
1041
+ console.error("Computer runtime operation failed", cause);
1042
+ const observed = await observeProcess(processRuntime(workspace, hosted.id), id);
1043
+ return processKillOutputSchema.parse({
1044
+ process: this.#settle(id, observed),
1045
+ signalled: false,
1046
+ });
1047
+ }
1048
+ return processKillOutputSchema.parse({ process: row, signalled: true });
1049
+ }
1050
+ /**
1051
+ * A "pending" verdict's background continuation keeps the host alive
1052
+ * until it resolves (a stuck transport cannot keep this running past
1053
+ * that drain's own completion, success or not) and then upgrades the
1054
+ * row: pending rows must be updatable, not only running ones.
1055
+ */
1056
+ #watchResync(id, settleLater) {
1057
+ this.state.waitUntil(settleLater.then((sync) => {
1058
+ this.#processes.resync(id, sync);
1059
+ }));
1060
+ }
1061
+ /** Persist a proven exit; report an unreachable check without storing it. */
1062
+ #settle(id, observed) {
1063
+ if (observed.state === "exited") {
1064
+ if (observed.settleLater !== undefined)
1065
+ this.#watchResync(id, observed.settleLater);
1066
+ return this.#processes.exited(id, observed.exitCode, observed.sync);
1067
+ }
1068
+ const row = this.#processes.get(id);
1069
+ if (row === undefined)
1070
+ throw new ComputerProcessNotFoundError(id);
1071
+ return observed.state === "uncertain"
1072
+ ? { ...row, state: "uncertain" }
1073
+ : row;
1074
+ }
1075
+ /**
1076
+ * Record a started process's exit once `drainProcessExit` observes it:
1077
+ * that function is the tested implementation (it also backs
1078
+ * `process-inventory-proof.mjs`'s direct coverage of this drain), so
1079
+ * this method is a thin, DO-specific wrapper around it.
1080
+ */
1081
+ async #watchProcess(id, run) {
1082
+ try {
1083
+ const settled = await drainProcessExit(run);
1084
+ if (settled !== undefined) {
1085
+ if (settled.settleLater !== undefined)
1086
+ this.#watchResync(id, settled.settleLater);
1087
+ this.#processes.exited(id, settled.exitCode, settled.sync);
1088
+ }
1089
+ }
1090
+ catch (cause) {
1091
+ console.error("Computer runtime operation failed", cause);
1092
+ // The stream broke (eviction, transport); list reconciles later.
1093
+ }
1094
+ }
1095
+ getOperationStatusAfterAcceptance(requestId, accept) {
1096
+ return this.#operations.status(requestId, accept);
1097
+ }
1098
+ /**
1099
+ * Relay one authorized terminal upgrade to this Computer's own terminal
1100
+ * server, which keeps the PTY for `session` across socket reconnects.
1101
+ */
1102
+ async terminalAfterAcceptance(request, principalKey, accept) {
1103
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
1104
+ return new Response("Expected WebSocket upgrade", { status: 426 });
1105
+ }
1106
+ const incoming = new URL(request.url);
1107
+ const query = terminalQuerySchema.safeParse({
1108
+ session: incoming.searchParams.get("session") ?? undefined,
1109
+ cols: incoming.searchParams.get("cols"),
1110
+ rows: incoming.searchParams.get("rows"),
1111
+ });
1112
+ if (!query.success) {
1113
+ return Response.json({ error: "invalid terminal options" }, { status: 400 });
1114
+ }
1115
+ const hosted = this.hostedRoute();
1116
+ if (hosted === undefined)
1117
+ return Response.json({ error: "Computer container is unavailable" }, { status: 502 });
1118
+ try {
1119
+ const env_2 = { stack: [], error: void 0, hasError: false };
1120
+ try {
1121
+ const workspace = await this.readyWorkspace();
1122
+ const readiness = __addDisposableResource(env_2, await workspace.runtime.exec("true", {
1123
+ backend: hosted.id,
1124
+ encoding: "utf8",
1125
+ timeoutMs: 10_000,
1126
+ }), false);
1127
+ const result = await readiness.result();
1128
+ if (result.status !== "completed" || result.exitCode !== 0) {
1129
+ return Response.json({ error: "Computer container is unavailable" }, { status: 502 });
1130
+ }
1131
+ }
1132
+ catch (e_2) {
1133
+ env_2.error = e_2;
1134
+ env_2.hasError = true;
1135
+ }
1136
+ finally {
1137
+ __disposeResources(env_2);
1138
+ }
1139
+ }
1140
+ catch (cause) {
1141
+ console.error("Computer runtime operation failed", cause);
1142
+ return Response.json({ error: "Computer container is unavailable" }, { status: 502 });
1143
+ }
1144
+ // Hub rechecks current Computer/operation authority at the dispatch seam.
1145
+ accept();
1146
+ try {
1147
+ const target = new URL("http://localhost/pty");
1148
+ target.search = new URLSearchParams({
1149
+ ...(await this.terminalDisplayEnvironment()),
1150
+ session: await sha256(JSON.stringify([principalKey, query.data.session])),
1151
+ cols: String(query.data.cols),
1152
+ rows: String(query.data.rows),
1153
+ }).toString();
1154
+ const response = await this.withHostedResponse(hosted.backing, () => this.hostedContainerHost(hosted.backing)
1155
+ .port(computerTerminalPort)
1156
+ .fetch(new Request(target, request)));
1157
+ if (response.status === 101)
1158
+ this.#startTerminalSync(hosted.backing);
1159
+ return response;
1160
+ }
1161
+ catch (cause) {
1162
+ console.error("Computer runtime operation failed", cause);
1163
+ return Response.json({ error: "Failed to reach the Computer terminal service" }, { status: 502 });
1164
+ }
1165
+ }
1166
+ withHostedResponse(_backing, run) {
1167
+ return run();
1168
+ }
1169
+ /** Host-owned display labels only; never credentials or provider URLs. */
1170
+ terminalDisplayEnvironment() {
1171
+ return Promise.resolve({
1172
+ FUNGI_COMPUTER_NAME: this.state.id.name ?? "Computer",
1173
+ });
1174
+ }
1175
+ /** Atomically replace one bounded file only when its raw contents still match. */
1176
+ async replaceFileIfUnchanged(input) {
1177
+ return await this.replaceFileIfUnchangedAfterAcceptance(input, () => undefined);
1178
+ }
1179
+ /** Stale-safe replacement with authority checked at the transaction boundary. */
1180
+ async replaceFileIfUnchangedAfterAcceptance(input, accept) {
1181
+ const replacement = replaceInputSchema.parse(input);
1182
+ const workspace = await this.readyWorkspace();
1183
+ await this.#syncRunningHosted("pull");
1184
+ const provider = new SQLiteWorkspaceProvider(workspace.fs.db);
1185
+ const result = writeOutputSchema.parse(this.state.storage.transactionSync(() => {
1186
+ accept();
1187
+ const current = readBoundedWorkspaceText(provider, replacement.path, maximumFileBytes);
1188
+ if (current !== replacement.expectedContent) {
1189
+ throw new ComputerFileConflict();
1190
+ }
1191
+ provider.writeFileSync(replacement.path, replacement.content);
1192
+ return {
1193
+ path: replacement.path,
1194
+ bytes: utf8.encode(replacement.content).byteLength,
1195
+ };
1196
+ }));
1197
+ await this.#syncRunningHosted("push");
1198
+ return result;
1199
+ }
1200
+ }
1201
+ // Runtime backends reach the authoritative Workspace through these loopback
1202
+ // entrypoints. The consuming Worker must re-export both from its main module.
1203
+ export { WorkspaceProxy, WorkspaceServiceProxy };
1204
+ export { OnBoxVmContainerHost, onBoxVmPlacement, onBoxVmIngress, } from "./on-box-vm.js";
1205
+ export { computerContainerSleepAfterMs } from "./container-policy.js";
1206
+ export * from "./computer-backends.js";
1207
+ export { LocalBackendOfflineError, localBackendAudience, localBackendDialPath, verifyLocalBackendDial, } from "./local-backend.js";
1208
+ /** Keep each native Workspace lease inside the operation that uses it. */
1209
+ export function createComputerFilesPort(computer) {
1210
+ async function withFiles(operation) {
1211
+ const env_3 = { stack: [], error: void 0, hasError: false };
1212
+ try {
1213
+ const workspace = __addDisposableResource(env_3, await getWorkspace(computer), false);
1214
+ return await operation(createWorkspaceFilesPort(workspace, computer));
1215
+ }
1216
+ catch (e_3) {
1217
+ env_3.error = e_3;
1218
+ env_3.hasError = true;
1219
+ }
1220
+ finally {
1221
+ __disposeResources(env_3);
1222
+ }
1223
+ }
1224
+ return {
1225
+ read: (input) => withFiles((files) => files.read(input)),
1226
+ list: (input) => withFiles((files) => files.list(input)),
1227
+ glob: (input) => withFiles((files) => files.glob(input)),
1228
+ grep: (input) => withFiles((files) => files.grep(input)),
1229
+ replace: async (input, context) => {
1230
+ context.signal.throwIfAborted();
1231
+ return await computer.replaceFileIfUnchanged(input);
1232
+ },
1233
+ };
1234
+ }
1235
+ export { createWorkspaceFilesPort } from "./files.js";
1236
+ export * from "./operation-contract.js";
1237
+ export { ComputerOperationConflictError, ComputerOperationLimitError, ComputerRequestInvalidError, } from "./operation-record.js";