@fungi.computer/computer 0.0.0-stage → 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 (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 +418 -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 +450 -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 +28220 -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
@@ -0,0 +1,44 @@
1
+ export type ShellRoute = Readonly<{
2
+ where: "light";
3
+ }> | Readonly<{
4
+ where: "full";
5
+ reason: string;
6
+ }> | Readonly<{
7
+ where: "invalid";
8
+ message: string;
9
+ }>;
10
+ export declare const lightShellCommands: readonly string[];
11
+ /** Route the maintained parser's AST before either backend executes anything. */
12
+ export declare function routeShellCommand(source: string): ShellRoute;
13
+ export type CommandCompletion = Readonly<{
14
+ name: string;
15
+ where: "light" | "full";
16
+ }>;
17
+ /** First-word completion reads the maintained AST; quoted/path completion is deferred. */
18
+ export declare function completeShellCommand(source: string): readonly CommandCompletion[] | null | Readonly<{
19
+ kind: "invalid";
20
+ message: string;
21
+ }>;
22
+ /**
23
+ * `cd`/`export` route light (they need no external program), but the exec
24
+ * primitive behind a light command has no state event yet: each run is its
25
+ * own process, so a bare `cd`/`export` would report success and change
26
+ * nothing the next command sees. True only for a line that is exactly one
27
+ * such invocation, so the caller can refuse it honestly instead of faking a
28
+ * result; a `cd`/`export` combined with other work is not detected here and
29
+ * still runs, silently without the navigation/export effect, as today.
30
+ */
31
+ export declare function bareNavigationCommand(source: string): Readonly<{
32
+ name: "cd" | "export";
33
+ }> | null;
34
+ /** Prompt controls never become a backend command or change model shell grammar. */
35
+ export declare function prepareShellPrompt(source: string): Readonly<{
36
+ kind: "command";
37
+ command: string;
38
+ route: ShellRoute;
39
+ }> | Readonly<{
40
+ kind: "light" | "exit";
41
+ }> | Readonly<{
42
+ kind: "invalid";
43
+ message: string;
44
+ }>;
@@ -0,0 +1,376 @@
1
+ import * as z from "zod";
2
+ import { Bash, getCommandNames } from "just-bash/browser";
3
+ // These optional groups are intentionally absent from the Worker shell.
4
+ const deferredGroups = new Set(["sqlite3", "html-to-markdown"]);
5
+ export const lightShellCommands = [
6
+ ...getCommandNames().filter((name) => !deferredGroups.has(name)),
7
+ "cd",
8
+ "export",
9
+ ];
10
+ const lightCommands = new Set(lightShellCommands);
11
+ // Constructed lazily: a module that only needs the command set or types
12
+ // (for example, a mounting React tree before any command is typed) should
13
+ // never pay for a real just-bash parser instance.
14
+ let parser;
15
+ function shellParser() {
16
+ parser ??= new Bash({ commands: [] });
17
+ return parser;
18
+ }
19
+ function literalWord(parts) {
20
+ let value = "";
21
+ for (const part of parts) {
22
+ if (part.type === "DoubleQuoted") {
23
+ const quoted = literalWord(part.parts);
24
+ if (quoted === null)
25
+ return null;
26
+ value += quoted;
27
+ }
28
+ else if (part.type === "Literal" ||
29
+ part.type === "SingleQuoted" ||
30
+ part.type === "Escaped") {
31
+ value += part.value;
32
+ }
33
+ else
34
+ return null;
35
+ }
36
+ return value;
37
+ }
38
+ function routeStatements(statements) {
39
+ for (const statement of statements) {
40
+ if (statement.background)
41
+ return { where: "full", reason: "background job" };
42
+ if (statement.deferredError)
43
+ return { where: "invalid", message: statement.deferredError.message };
44
+ for (const pipeline of statement.pipelines) {
45
+ for (const command of pipeline.commands) {
46
+ const route = routeCommand(command);
47
+ if (route.where !== "light")
48
+ return route;
49
+ }
50
+ }
51
+ }
52
+ return { where: "light" };
53
+ }
54
+ function routeCommand(command) {
55
+ for (const redirection of command.redirections) {
56
+ if (redirection.target.type === "HereDoc" && redirection.target.quoted)
57
+ continue;
58
+ const word = redirection.target.type === "HereDoc"
59
+ ? redirection.target.content
60
+ : redirection.target;
61
+ const route = routeWord(word.parts);
62
+ if (route.where !== "light")
63
+ return route;
64
+ }
65
+ if (command.type === "SimpleCommand") {
66
+ const name = command.name === null ? null : literalWord(command.name.parts);
67
+ if (name !== null && !lightCommands.has(name))
68
+ return { where: "full", reason: name };
69
+ const words = [
70
+ ...(command.name === null ? [] : [command.name]),
71
+ ...command.args,
72
+ ...command.assignments.flatMap((assignment) => [
73
+ ...(assignment.value === null ? [] : [assignment.value]),
74
+ ...(assignment.array ?? []),
75
+ ]),
76
+ ];
77
+ for (const word of words) {
78
+ const route = routeWord(word.parts);
79
+ if (route.where !== "light")
80
+ return route;
81
+ }
82
+ return { where: "light" };
83
+ }
84
+ if (command.type === "If") {
85
+ for (const clause of command.clauses) {
86
+ const route = routeStatements([...clause.condition, ...clause.body]);
87
+ if (route.where !== "light")
88
+ return route;
89
+ }
90
+ return routeStatements(command.elseBody ?? []);
91
+ }
92
+ if (command.type === "While" || command.type === "Until")
93
+ return routeStatements([...command.condition, ...command.body]);
94
+ if (command.type === "Subshell" || command.type === "Group")
95
+ return routeStatements(command.body);
96
+ if (command.type === "FunctionDef")
97
+ return routeCommand(command.body);
98
+ if (command.type === "For") {
99
+ for (const word of command.words ?? []) {
100
+ const route = routeWord(word.parts);
101
+ if (route.where !== "light")
102
+ return route;
103
+ }
104
+ return routeStatements(command.body);
105
+ }
106
+ if (command.type === "Case") {
107
+ const route = routeWord(command.word.parts);
108
+ if (route.where !== "light")
109
+ return route;
110
+ for (const item of command.items) {
111
+ for (const pattern of item.patterns) {
112
+ const route = routeWord(pattern.parts);
113
+ if (route.where !== "light")
114
+ return route;
115
+ }
116
+ const route = routeStatements(item.body);
117
+ if (route.where !== "light")
118
+ return route;
119
+ }
120
+ return { where: "light" };
121
+ }
122
+ return { where: "full", reason: "shell expression" };
123
+ }
124
+ function routeWord(parts) {
125
+ for (const part of parts) {
126
+ if (part.type === "CommandSubstitution" ||
127
+ part.type === "ProcessSubstitution") {
128
+ const nested = routeStatements(part.body.statements);
129
+ if (nested.where !== "light")
130
+ return nested;
131
+ }
132
+ else if (part.type === "DoubleQuoted") {
133
+ const nested = routeWord(part.parts);
134
+ if (nested.where !== "light")
135
+ return nested;
136
+ }
137
+ else if (part.type === "ArithmeticExpansion") {
138
+ const nested = routeArithmetic(part.expression.expression);
139
+ if (nested.where !== "light")
140
+ return nested;
141
+ }
142
+ else if (part.type === "ParameterExpansion") {
143
+ const nested = routeParameter(part.operation);
144
+ if (nested.where !== "light")
145
+ return nested;
146
+ }
147
+ }
148
+ return { where: "light" };
149
+ }
150
+ function routeArithmetic(expression) {
151
+ if (expression.type === "ArithCommandSubst")
152
+ return routeShellCommand(expression.command);
153
+ // These forms expand and reparse text at execution time. Never replay them.
154
+ if (expression.type === "ArithBracedExpansion" ||
155
+ expression.type === "ArithDynamicBase" ||
156
+ expression.type === "ArithDynamicNumber")
157
+ return { where: "full", reason: "dynamic arithmetic" };
158
+ const children = [
159
+ ...("left" in expression ? [expression.left] : []),
160
+ ...("right" in expression ? [expression.right] : []),
161
+ ...("operand" in expression ? [expression.operand] : []),
162
+ ...("condition" in expression ? [expression.condition] : []),
163
+ ...("consequent" in expression ? [expression.consequent] : []),
164
+ ...("alternate" in expression ? [expression.alternate] : []),
165
+ ...(expression.type === "ArithAssignment" ||
166
+ expression.type === "ArithDynamicAssignment"
167
+ ? [expression.value]
168
+ : []),
169
+ ...("subscript" in expression ? [expression.subscript] : []),
170
+ ...("target" in expression ? [expression.target] : []),
171
+ ...("nameExpr" in expression ? [expression.nameExpr] : []),
172
+ ...("index" in expression ? [expression.index] : []),
173
+ ...("expression" in expression ? [expression.expression] : []),
174
+ ...("parts" in expression ? expression.parts : []),
175
+ ];
176
+ for (const child of children) {
177
+ const route = routeArithmetic(child);
178
+ if (route.where !== "light")
179
+ return route;
180
+ }
181
+ return { where: "light" };
182
+ }
183
+ function routeParameter(operation) {
184
+ if (!operation)
185
+ return { where: "light" };
186
+ const words = [
187
+ ...("word" in operation && operation.word ? [operation.word] : []),
188
+ ...("pattern" in operation && operation.pattern ? [operation.pattern] : []),
189
+ ...("replacement" in operation && operation.replacement
190
+ ? [operation.replacement]
191
+ : []),
192
+ ];
193
+ for (const word of words) {
194
+ const route = routeWord(word.parts);
195
+ if (route.where !== "light")
196
+ return route;
197
+ }
198
+ return "innerOp" in operation
199
+ ? routeParameter(operation.innerOp)
200
+ : { where: "light" };
201
+ }
202
+ function parseShell(source) {
203
+ try {
204
+ return { kind: "parsed", ast: shellParser().transform(source).ast };
205
+ }
206
+ catch (cause) {
207
+ return {
208
+ kind: "invalid",
209
+ message: cause instanceof Error ? cause.message : "Invalid shell syntax",
210
+ };
211
+ }
212
+ }
213
+ /** Route the maintained parser's AST before either backend executes anything. */
214
+ export function routeShellCommand(source) {
215
+ const parsed = parseShell(source);
216
+ return parsed.kind === "invalid"
217
+ ? { where: "invalid", message: parsed.message }
218
+ : routeStatements(parsed.ast.statements);
219
+ }
220
+ const fullCompletionCommands = [
221
+ "bun",
222
+ "cargo",
223
+ "curl",
224
+ "docker",
225
+ "git",
226
+ "go",
227
+ "make",
228
+ "nano",
229
+ "node",
230
+ "npm",
231
+ "npx",
232
+ "pnpm",
233
+ "python3",
234
+ "ssh",
235
+ "vim",
236
+ "wget",
237
+ ];
238
+ const completionCommands = [
239
+ ...new Set([...lightShellCommands, ...fullCompletionCommands]),
240
+ ].sort();
241
+ /** First-word completion reads the maintained AST; quoted/path completion is deferred. */
242
+ export function completeShellCommand(source) {
243
+ const parsed = parseShell(source);
244
+ if (parsed.kind === "invalid")
245
+ return parsed;
246
+ const statement = parsed.ast.statements[0];
247
+ if (statement === undefined ||
248
+ parsed.ast.statements.length !== 1 ||
249
+ statement.background ||
250
+ statement.deferredError ||
251
+ statement.pipelines.length !== 1)
252
+ return null;
253
+ const pipeline = statement.pipelines[0];
254
+ if (pipeline === undefined)
255
+ return null;
256
+ const command = pipeline.commands[0];
257
+ if (command === undefined ||
258
+ pipeline.commands.length !== 1 ||
259
+ pipeline.negated ||
260
+ pipeline.timed ||
261
+ command.type !== "SimpleCommand" ||
262
+ command.assignments.length !== 0 ||
263
+ command.redirections.length !== 0 ||
264
+ command.args.length !== 0 ||
265
+ command.name === null)
266
+ return null;
267
+ const parts = command.name.parts;
268
+ const part = parts[0];
269
+ if (parts.length !== 1 || part?.type !== "Literal" || part.value !== source)
270
+ return null;
271
+ return completionCommands
272
+ .filter((name) => name.startsWith(source))
273
+ .map((name) => ({
274
+ name,
275
+ where: lightCommands.has(name) ? "light" : "full",
276
+ }));
277
+ }
278
+ /**
279
+ * `cd`/`export` route light (they need no external program), but the exec
280
+ * primitive behind a light command has no state event yet: each run is its
281
+ * own process, so a bare `cd`/`export` would report success and change
282
+ * nothing the next command sees. True only for a line that is exactly one
283
+ * such invocation, so the caller can refuse it honestly instead of faking a
284
+ * result; a `cd`/`export` combined with other work is not detected here and
285
+ * still runs, silently without the navigation/export effect, as today.
286
+ */
287
+ export function bareNavigationCommand(source) {
288
+ const parsed = parseShell(source);
289
+ if (parsed.kind === "invalid")
290
+ return null;
291
+ const statement = parsed.ast.statements[0];
292
+ if (statement === undefined ||
293
+ parsed.ast.statements.length !== 1 ||
294
+ statement.background ||
295
+ statement.deferredError ||
296
+ statement.pipelines.length !== 1)
297
+ return null;
298
+ const pipeline = statement.pipelines[0];
299
+ if (pipeline === undefined)
300
+ return null;
301
+ const command = pipeline.commands[0];
302
+ if (command === undefined ||
303
+ pipeline.commands.length !== 1 ||
304
+ pipeline.negated ||
305
+ pipeline.timed ||
306
+ command.type !== "SimpleCommand" ||
307
+ command.redirections.length !== 0)
308
+ return null;
309
+ const name = command.name === null ? null : literalWord(command.name.parts);
310
+ return name === "cd" || name === "export" ? { name } : null;
311
+ }
312
+ const promptControlSchema = z.enum(["ordinary", "full", "light", "exit"]);
313
+ let promptParser;
314
+ function shellPromptParser() {
315
+ if (promptParser !== undefined)
316
+ return promptParser;
317
+ const configured = new Bash({ commands: [] });
318
+ configured.registerTransformPlugin({
319
+ name: "fungi-shell-prompt",
320
+ transform: ({ ast }) => {
321
+ const statement = ast.statements[0];
322
+ const pipeline = statement?.pipelines[0];
323
+ const command = pipeline?.commands[0];
324
+ if (command?.type !== "SimpleCommand" || command.assignments.length !== 0)
325
+ return { ast, metadata: { control: "ordinary" } };
326
+ const name = command.name === null ? null : literalWord(command.name.parts);
327
+ const standalone = ast.statements.length === 1 &&
328
+ statement?.pipelines.length === 1 &&
329
+ pipeline?.commands.length === 1 &&
330
+ !statement.background &&
331
+ command.redirections.length === 0;
332
+ if ((name === "light" || name === "exit") &&
333
+ standalone &&
334
+ command.args.length === 0)
335
+ return { ast, metadata: { control: name } };
336
+ if (name !== "full")
337
+ return { ast, metadata: { control: "ordinary" } };
338
+ const next = command.args.shift();
339
+ if (next !== undefined) {
340
+ command.name = next;
341
+ return { ast, metadata: { control: "full" } };
342
+ }
343
+ return standalone
344
+ ? { ast: { ...ast, statements: [] }, metadata: { control: "full" } }
345
+ : { ast, metadata: { control: "ordinary" } };
346
+ },
347
+ });
348
+ promptParser = configured;
349
+ return configured;
350
+ }
351
+ /** Prompt controls never become a backend command or change model shell grammar. */
352
+ export function prepareShellPrompt(source) {
353
+ try {
354
+ const transformed = shellPromptParser().transform(source);
355
+ const control = promptControlSchema.parse(transformed.metadata.control);
356
+ if (control === "light" || control === "exit")
357
+ return { kind: control };
358
+ const command = control === "full" ? transformed.script : source;
359
+ const route = routeStatements(transformed.ast.statements);
360
+ return {
361
+ kind: "command",
362
+ command,
363
+ route: route.where === "invalid"
364
+ ? route
365
+ : control === "full"
366
+ ? { where: "full", reason: "explicit full" }
367
+ : route,
368
+ };
369
+ }
370
+ catch (cause) {
371
+ return {
372
+ kind: "invalid",
373
+ message: cause instanceof Error ? cause.message : "Invalid shell syntax",
374
+ };
375
+ }
376
+ }
@@ -0,0 +1,52 @@
1
+ import type { BackendHandle, WorkspaceBackend } from "@fungi.computer/cloudflare-computer";
2
+ import { type LocalBackend } from "./computer-backends.js";
3
+ /**
4
+ * A member's machine as a Workspace backend.
5
+ *
6
+ * The machine runs the same `computerd` as the container image and dials out
7
+ * to its Computer; there is no listener to reach. The Hub hands the accepted
8
+ * WebSocket to this Computer, which parks it here. `connect()` opens a capnweb
9
+ * session over the parked socket and returns the machine's `WorkspaceRPC` as a
10
+ * `sync: "remote"` handle, exactly as the container backend returns its
11
+ * `computerd`. With nothing parked the machine is offline and `connect()`
12
+ * fails at once, so routing can move on.
13
+ */
14
+ export declare class LocalBackendOfflineError extends Error {
15
+ readonly backendId: string;
16
+ constructor(backendId: string);
17
+ }
18
+ /**
19
+ * The live dial-ins of one Computer, keyed by backend id. It outlives each
20
+ * Workspace generation so a rebuild does not forget a connected machine.
21
+ */
22
+ export declare class LocalBackendConnections {
23
+ #private;
24
+ constructor(connected?: (id: string) => void);
25
+ online(id: string): boolean;
26
+ socket(id: string): WebSocket | undefined;
27
+ /** Park a verified dial-in; a newer dial from the same device replaces it. */
28
+ park(id: string, socket: WebSocket): void;
29
+ close(id: string, reason: string): void;
30
+ /** Close every dial-in whose backend is no longer attached. */
31
+ retain(attached: ReadonlySet<string>): void;
32
+ }
33
+ export declare class LocalWorkspaceBackend implements WorkspaceBackend {
34
+ #private;
35
+ readonly type = "fungi-local";
36
+ readonly id: string;
37
+ constructor(backend: LocalBackend, connections: LocalBackendConnections);
38
+ connect(): Promise<BackendHandle>;
39
+ }
40
+ /** The audience one attached local backend's dial-in is signed for. */
41
+ export declare function localBackendAudience(computerName: string, backendId: string): string;
42
+ /**
43
+ * Verify a machine's signed dial-in against the public key its attachment
44
+ * pinned. The WebSocket upgrade carries no body, so it signs an unsigned
45
+ * payload over the exact path.
46
+ */
47
+ export declare function verifyLocalBackendDial(request: Request, computerName: string, backend: LocalBackend): Promise<boolean>;
48
+ /**
49
+ * The public dial path: `/api/teams/:team/computers/:computer/backends/:id/connect`.
50
+ * The Hub forwards it unchanged so the signature covers what the machine sent.
51
+ */
52
+ export declare const localBackendDialPath: RegExp;
@@ -0,0 +1,143 @@
1
+ import { workspaceStream } from "./workspace-stream.js";
2
+ import { newWebSocketRpcSession } from "@fungi.computer/capnweb";
3
+ import { SeamReplayGuard, importSeamVerifiers, seamHeader, verifySeamRequest, } from "@fungi.computer/seam";
4
+ import { attachedBackendId } from "./computer-backends.js";
5
+ /**
6
+ * A member's machine as a Workspace backend.
7
+ *
8
+ * The machine runs the same `computerd` as the container image and dials out
9
+ * to its Computer; there is no listener to reach. The Hub hands the accepted
10
+ * WebSocket to this Computer, which parks it here. `connect()` opens a capnweb
11
+ * session over the parked socket and returns the machine's `WorkspaceRPC` as a
12
+ * `sync: "remote"` handle, exactly as the container backend returns its
13
+ * `computerd`. With nothing parked the machine is offline and `connect()`
14
+ * fails at once, so routing can move on.
15
+ */
16
+ export class LocalBackendOfflineError extends Error {
17
+ backendId;
18
+ constructor(backendId) {
19
+ super(`Local Computer backend ${backendId} is offline`);
20
+ this.backendId = backendId;
21
+ this.name = "LocalBackendOfflineError";
22
+ }
23
+ }
24
+ /**
25
+ * The live dial-ins of one Computer, keyed by backend id. It outlives each
26
+ * Workspace generation so a rebuild does not forget a connected machine.
27
+ */
28
+ export class LocalBackendConnections {
29
+ #sockets = new Map();
30
+ #connected;
31
+ constructor(connected = () => undefined) {
32
+ this.#connected = connected;
33
+ }
34
+ online(id) {
35
+ return this.#sockets.has(id);
36
+ }
37
+ socket(id) {
38
+ return this.#sockets.get(id);
39
+ }
40
+ /** Park a verified dial-in; a newer dial from the same device replaces it. */
41
+ park(id, socket) {
42
+ this.close(id, "replaced by a newer connection");
43
+ this.#sockets.set(id, socket);
44
+ const forget = () => {
45
+ if (this.#sockets.get(id) === socket)
46
+ this.#sockets.delete(id);
47
+ };
48
+ socket.addEventListener("close", forget);
49
+ socket.addEventListener("error", forget);
50
+ this.#connected(id);
51
+ }
52
+ close(id, reason) {
53
+ const socket = this.#sockets.get(id);
54
+ if (socket === undefined)
55
+ return;
56
+ this.#sockets.delete(id);
57
+ socket.close(1000, reason);
58
+ }
59
+ /** Close every dial-in whose backend is no longer attached. */
60
+ retain(attached) {
61
+ for (const id of this.#sockets.keys())
62
+ if (!attached.has(id))
63
+ this.close(id, "backend detached");
64
+ }
65
+ }
66
+ export class LocalWorkspaceBackend {
67
+ type = "fungi-local";
68
+ id;
69
+ #connections;
70
+ constructor(backend, connections) {
71
+ this.id = attachedBackendId(backend);
72
+ this.#connections = connections;
73
+ }
74
+ connect() {
75
+ const socket = this.#connections.socket(this.id);
76
+ if (socket === undefined)
77
+ return Promise.reject(new LocalBackendOfflineError(this.id));
78
+ const session = newWebSocketRpcSession(socket);
79
+ const closed = new Promise((resolve) => {
80
+ const settle = () => {
81
+ resolve();
82
+ };
83
+ socket.addEventListener("close", settle, { once: true });
84
+ socket.addEventListener("error", settle, { once: true });
85
+ session.onRpcBroken(settle);
86
+ });
87
+ const rpc = {
88
+ shell: session.shell,
89
+ sync: {
90
+ push: (input) => session.sync.push(input),
91
+ fetchChanges: (input) => session.sync.fetchChanges(input),
92
+ watermarks: () => session.sync.watermarks(),
93
+ readEntry: (path) => session.sync.readEntry(path),
94
+ hasObjects: (hashes) => session.sync.hasObjects(hashes),
95
+ pushObjects: (objects) => session.sync.pushObjects(objects),
96
+ // capnweb resolves a streamed RPC result asynchronously. Workspace
97
+ // consumes a synchronous stream handle, so bridge one chunk per pull.
98
+ fetchObjects: (hashes) => workspaceStream(session.sync.fetchObjects(hashes)),
99
+ },
100
+ };
101
+ return Promise.resolve({
102
+ rpc,
103
+ sync: "remote",
104
+ closed,
105
+ close: () => {
106
+ try {
107
+ session[Symbol.dispose]();
108
+ }
109
+ catch (cause) {
110
+ console.error("Local Computer backend operation failed", cause);
111
+ // The session already ended.
112
+ }
113
+ this.#connections.close(this.id, "Workspace closed");
114
+ return Promise.resolve();
115
+ },
116
+ });
117
+ }
118
+ }
119
+ /** The audience one attached local backend's dial-in is signed for. */
120
+ export function localBackendAudience(computerName, backendId) {
121
+ return `fungi-local-backend:${computerName}:${backendId}`;
122
+ }
123
+ const dialReplay = new SeamReplayGuard();
124
+ /**
125
+ * Verify a machine's signed dial-in against the public key its attachment
126
+ * pinned. The WebSocket upgrade carries no body, so it signs an unsigned
127
+ * payload over the exact path.
128
+ */
129
+ export async function verifyLocalBackendDial(request, computerName, backend) {
130
+ try {
131
+ await verifySeamRequest(await importSeamVerifiers({ [backend.deviceId]: backend.publicKey }), localBackendAudience(computerName, attachedBackendId(backend)), request.method, request.url, request.headers.get(seamHeader), null, dialReplay);
132
+ return true;
133
+ }
134
+ catch (cause) {
135
+ console.error("Local Computer backend operation failed", cause);
136
+ return false;
137
+ }
138
+ }
139
+ /**
140
+ * The public dial path: `/api/teams/:team/computers/:computer/backends/:id/connect`.
141
+ * The Hub forwards it unchanged so the signature covers what the machine sent.
142
+ */
143
+ export const localBackendDialPath = /^\/api\/teams\/([^/]+)\/computers\/([^/]+)\/backends\/(local-[a-z2-7]{16})\/connect$/;
@@ -0,0 +1,26 @@
1
+ import type { IWorkspaceContainerAPI, WorkspaceRef } from "@fungi.computer/cloudflare-computer/backends/container";
2
+ import type { ContainerRuntimeStart, HostedBacking } from "./computer-backends.js";
3
+ /**
4
+ * A container host that reports each runtime it starts. Every other call
5
+ * passes through unchanged, so the container backend cannot tell it apart
6
+ * from the host it wraps.
7
+ */
8
+ export declare class MeteredContainerHost implements IWorkspaceContainerAPI {
9
+ #private;
10
+ constructor(host: IWorkspaceContainerAPI, backing: HostedBacking, started: (start: ContainerRuntimeStart) => void);
11
+ start(env: Record<string, string>, enableInternet: boolean): Promise<{
12
+ runtimeId: string;
13
+ }>;
14
+ restart(env: Record<string, string>, enableInternet: boolean): Promise<{
15
+ runtimeId: string;
16
+ }>;
17
+ status(): ReturnType<IWorkspaceContainerAPI["status"]>;
18
+ exitInfo(): ReturnType<IWorkspaceContainerAPI["exitInfo"]>;
19
+ interceptOutboundHttp(host: string, workspace: WorkspaceRef): Promise<void>;
20
+ interceptAllOutboundHttp(workspace: WorkspaceRef, token: string): Promise<void>;
21
+ fetchPort(port: number, input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
22
+ port(port: number): {
23
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
24
+ connect(address: SocketAddress | string, options?: SocketOptions): Socket;
25
+ };
26
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A container host that reports each runtime it starts. Every other call
3
+ * passes through unchanged, so the container backend cannot tell it apart
4
+ * from the host it wraps.
5
+ */
6
+ export class MeteredContainerHost {
7
+ #host;
8
+ #backing;
9
+ #started;
10
+ constructor(host, backing, started) {
11
+ this.#host = host;
12
+ this.#backing = backing;
13
+ this.#started = started;
14
+ }
15
+ async #report(boot) {
16
+ const runtime = await boot;
17
+ this.#started({
18
+ runtimeId: runtime.runtimeId,
19
+ backing: this.#backing,
20
+ startedAt: Date.now(),
21
+ });
22
+ return runtime;
23
+ }
24
+ start(env, enableInternet) {
25
+ return this.#report(this.#host.start(env, enableInternet));
26
+ }
27
+ restart(env, enableInternet) {
28
+ return this.#report(this.#host.restart(env, enableInternet));
29
+ }
30
+ status() {
31
+ return this.#host.status();
32
+ }
33
+ exitInfo() {
34
+ return this.#host.exitInfo();
35
+ }
36
+ interceptOutboundHttp(host, workspace) {
37
+ return this.#host.interceptOutboundHttp(host, workspace);
38
+ }
39
+ interceptAllOutboundHttp(workspace, token) {
40
+ return this.#host.interceptAllOutboundHttp(workspace, token);
41
+ }
42
+ fetchPort(port, input, init) {
43
+ return this.#host.fetchPort(port, input, init);
44
+ }
45
+ port(port) {
46
+ return this.#host.port(port);
47
+ }
48
+ }