@fungi.computer/truffle 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +294 -2
- package/computerd-loopback.cjs +16 -0
- package/dist/auth-cli.d.ts +36 -0
- package/dist/auth-cli.js +351 -0
- package/dist/fungi-account-command.d.ts +18 -0
- package/dist/fungi-account-command.js +92 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/internal/private-workerd-sandbox-worker.mjs +27488 -0
- package/dist/internal/sandbox-metafile.json +6726 -0
- package/dist/linear-auth.d.ts +4 -0
- package/dist/linear-auth.js +72 -0
- package/dist/local-extensions.d.ts +97 -0
- package/dist/local-extensions.js +484 -0
- package/dist/local-login.d.ts +55 -0
- package/dist/local-login.js +167 -0
- package/dist/local-models.d.ts +14 -0
- package/dist/local-models.js +77 -0
- package/dist/local-skill-source.d.ts +6 -0
- package/dist/local-skill-source.js +357 -0
- package/dist/log-event.d.ts +2 -0
- package/dist/log-event.js +9 -0
- package/dist/private-bun-shiitake-host.d.ts +11 -0
- package/dist/private-bun-shiitake-host.js +66 -0
- package/dist/private-file-credential-store.d.ts +33 -0
- package/dist/private-file-credential-store.js +283 -0
- package/dist/private-files-patch.d.ts +3 -0
- package/dist/private-files-patch.js +86 -0
- package/dist/private-fungi-account.d.ts +235 -0
- package/dist/private-fungi-account.js +1149 -0
- package/dist/private-fungi-auth-error.d.ts +5 -0
- package/dist/private-fungi-auth-error.js +7 -0
- package/dist/private-fungi-mcp-connection.d.ts +22 -0
- package/dist/private-fungi-mcp-connection.js +143 -0
- package/dist/private-fungi-mcp-endpoint.d.ts +2 -0
- package/dist/private-fungi-mcp-endpoint.js +15 -0
- package/dist/private-fungi-mcp-selection.d.ts +14 -0
- package/dist/private-fungi-mcp-selection.js +36 -0
- package/dist/private-fungi-model-api.d.ts +6 -0
- package/dist/private-fungi-model-api.js +125 -0
- package/dist/private-fungi-model-catalog.d.ts +6 -0
- package/dist/private-fungi-model-catalog.js +78 -0
- package/dist/private-fungi-model-provider.d.ts +6 -0
- package/dist/private-fungi-model-provider.js +46 -0
- package/dist/private-fungi-pending-logouts.d.ts +34 -0
- package/dist/private-fungi-pending-logouts.js +136 -0
- package/dist/private-invocation-computer.d.ts +37 -0
- package/dist/private-invocation-computer.js +459 -0
- package/dist/private-invocation-files.d.ts +15 -0
- package/dist/private-invocation-files.js +449 -0
- package/dist/private-local-acp-stdio.d.ts +17 -0
- package/dist/private-local-acp-stdio.js +142 -0
- package/dist/private-local-auth-rpc.d.ts +69 -0
- package/dist/private-local-auth-rpc.js +273 -0
- package/dist/private-local-computer-attach.d.ts +49 -0
- package/dist/private-local-computer-attach.js +216 -0
- package/dist/private-local-computer-backend.d.ts +282 -0
- package/dist/private-local-computer-backend.js +402 -0
- package/dist/private-local-computer-processes.d.ts +199 -0
- package/dist/private-local-computer-processes.js +589 -0
- package/dist/private-local-default-model.d.ts +11 -0
- package/dist/private-local-default-model.js +45 -0
- package/dist/private-local-delegation.d.ts +29 -0
- package/dist/private-local-delegation.js +78 -0
- package/dist/private-local-effective-model.d.ts +19 -0
- package/dist/private-local-effective-model.js +14 -0
- package/dist/private-local-exec-launch.d.ts +41 -0
- package/dist/private-local-exec-launch.js +51 -0
- package/dist/private-local-exec-path.d.ts +6 -0
- package/dist/private-local-exec-path.js +40 -0
- package/dist/private-local-git-exclude.d.ts +2 -0
- package/dist/private-local-git-exclude.js +33 -0
- package/dist/private-local-http-dispatch.d.ts +5 -0
- package/dist/private-local-http-dispatch.js +14 -0
- package/dist/private-local-mail.d.ts +11 -0
- package/dist/private-local-mail.js +69 -0
- package/dist/private-local-model-settings.d.ts +5 -0
- package/dist/private-local-model-settings.js +31 -0
- package/dist/private-local-runtime-host.d.ts +141 -0
- package/dist/private-local-runtime-host.js +988 -0
- package/dist/private-local-service-contract.d.ts +90 -0
- package/dist/private-local-service-contract.js +1 -0
- package/dist/private-local-service-discovery.d.ts +101 -0
- package/dist/private-local-service-discovery.js +221 -0
- package/dist/private-local-service-ids.d.ts +3 -0
- package/dist/private-local-service-ids.js +8 -0
- package/dist/private-local-service-listener.d.ts +15 -0
- package/dist/private-local-service-listener.js +211 -0
- package/dist/private-local-service-owner.d.ts +1567 -0
- package/dist/private-local-service-owner.js +371 -0
- package/dist/private-local-service-process.d.ts +1 -0
- package/dist/private-local-service-process.js +6 -0
- package/dist/private-local-service.d.ts +17 -0
- package/dist/private-local-service.js +330 -0
- package/dist/private-local-session-discovery.d.ts +19 -0
- package/dist/private-local-session-discovery.js +157 -0
- package/dist/private-local-shiitake-host.d.ts +44 -0
- package/dist/private-local-shiitake-host.js +143 -0
- package/dist/private-local-subagents.d.ts +27 -0
- package/dist/private-local-subagents.js +77 -0
- package/dist/private-local-worker-server.d.ts +15 -0
- package/dist/private-local-worker-server.js +136 -0
- package/dist/private-sqlite-owner.d.ts +7 -0
- package/dist/private-sqlite-owner.js +103 -0
- package/dist/private-team-computers.d.ts +38 -0
- package/dist/private-team-computers.js +101 -0
- package/dist/private-workerd-sandbox.d.ts +3 -0
- package/dist/private-workerd-sandbox.js +347 -0
- package/dist/team-apps.d.ts +24 -0
- package/dist/team-apps.js +123 -0
- package/dist/tracker-git-credential.d.ts +15 -0
- package/dist/tracker-git-credential.js +87 -0
- package/native/bin/build.json +4037 -0
- package/native/bin/fungi-apply-patch +0 -0
- package/package.json +148 -3
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { RpcTarget } from "@fungi.computer/capnweb";
|
|
2
|
+
/**
|
|
3
|
+
* A member's folder as an execution backend of a Team Computer.
|
|
4
|
+
*
|
|
5
|
+
* Local runs the same `computerd` 0.2.1 as the Computer container image,
|
|
6
|
+
* built from vendored source and run under the user's Node in shim mode, with its shim mount rooted at the chosen folder, and dials out to
|
|
7
|
+
* the Hub. The Computer then syncs and runs commands against this folder with
|
|
8
|
+
* the library's own push/pull, exactly as it does against a container.
|
|
9
|
+
*
|
|
10
|
+
* `computerd` names files by their absolute path on this machine. The Hub
|
|
11
|
+
* must only ever see `/workspace/...`, so Local serves the Hub a
|
|
12
|
+
* `WorkspaceRPC` of its own that forwards to `computerd` and translates paths
|
|
13
|
+
* at this one boundary. Entries outside the folder never cross it.
|
|
14
|
+
*/
|
|
15
|
+
/** The Computer-side root every backend's files live under. */
|
|
16
|
+
export declare const computerWorkspaceRoot = "/workspace";
|
|
17
|
+
export type ChangeCursor = {
|
|
18
|
+
rev: number;
|
|
19
|
+
path: string | null;
|
|
20
|
+
};
|
|
21
|
+
export type ChangeEntry = {
|
|
22
|
+
kind: "file";
|
|
23
|
+
rev: number;
|
|
24
|
+
path: string;
|
|
25
|
+
mode: number;
|
|
26
|
+
mtime: number;
|
|
27
|
+
size: number;
|
|
28
|
+
chunks: {
|
|
29
|
+
hash: Uint8Array;
|
|
30
|
+
size: number;
|
|
31
|
+
}[];
|
|
32
|
+
} | {
|
|
33
|
+
kind: "dir";
|
|
34
|
+
rev: number;
|
|
35
|
+
path: string;
|
|
36
|
+
mode: number;
|
|
37
|
+
mtime: number;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "symlink";
|
|
40
|
+
rev: number;
|
|
41
|
+
path: string;
|
|
42
|
+
target: string;
|
|
43
|
+
mode: number;
|
|
44
|
+
mtime: number;
|
|
45
|
+
} | {
|
|
46
|
+
kind: "delete";
|
|
47
|
+
rev: number;
|
|
48
|
+
path: string;
|
|
49
|
+
};
|
|
50
|
+
export type BlobObject = {
|
|
51
|
+
hash: Uint8Array;
|
|
52
|
+
bytes: Uint8Array;
|
|
53
|
+
};
|
|
54
|
+
export type ExecEvent = {
|
|
55
|
+
id: string;
|
|
56
|
+
seq: number;
|
|
57
|
+
name: "stdout" | "stderr";
|
|
58
|
+
value: Uint8Array;
|
|
59
|
+
} | {
|
|
60
|
+
id: string;
|
|
61
|
+
seq: number;
|
|
62
|
+
name: "exit";
|
|
63
|
+
code: number;
|
|
64
|
+
};
|
|
65
|
+
export type FetchChangesInput = {
|
|
66
|
+
after?: ChangeCursor;
|
|
67
|
+
ignore?: string[];
|
|
68
|
+
};
|
|
69
|
+
export type ExecInput = {
|
|
70
|
+
source: string;
|
|
71
|
+
cwd?: string;
|
|
72
|
+
id?: string;
|
|
73
|
+
input?: unknown;
|
|
74
|
+
timeoutMs?: number;
|
|
75
|
+
env?: Record<string, string>;
|
|
76
|
+
stdin?: Uint8Array;
|
|
77
|
+
};
|
|
78
|
+
export type KillSignal = "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP";
|
|
79
|
+
/** computerd's `WorkspaceRPC`, as `@cloudflare/computer` 0.2.1 speaks it. */
|
|
80
|
+
export type ComputerdWorkspaceRpc = {
|
|
81
|
+
sync: {
|
|
82
|
+
push(input: {
|
|
83
|
+
senderRev: number;
|
|
84
|
+
changes: ReadableStream<ChangeEntry>;
|
|
85
|
+
}): Promise<{
|
|
86
|
+
rev: number;
|
|
87
|
+
appliedPushCursor: ChangeCursor;
|
|
88
|
+
}>;
|
|
89
|
+
fetchChanges(input: FetchChangesInput): Promise<{
|
|
90
|
+
currentCursor: ChangeCursor;
|
|
91
|
+
appliedPushCursor: ChangeCursor;
|
|
92
|
+
stream: ReadableStream<ChangeEntry>;
|
|
93
|
+
}>;
|
|
94
|
+
watermarks(): Promise<{
|
|
95
|
+
currentRev: number;
|
|
96
|
+
pushRev: number;
|
|
97
|
+
fetchCursor: ChangeCursor;
|
|
98
|
+
}>;
|
|
99
|
+
readEntry(path: string): Promise<ChangeEntry | null>;
|
|
100
|
+
hasObjects(hashes: Uint8Array[]): Promise<Uint8Array[]>;
|
|
101
|
+
/** Synchronous in computerd; a promise across the RPC session. */
|
|
102
|
+
fetchObjects(hashes: Uint8Array[]): Promise<ReadableStream<BlobObject>>;
|
|
103
|
+
pushObjects(objects: ReadableStream<BlobObject>): Promise<void>;
|
|
104
|
+
};
|
|
105
|
+
shell: {
|
|
106
|
+
exec(input: ExecInput): Promise<{
|
|
107
|
+
id: string;
|
|
108
|
+
events: ReadableStream<ExecEvent>;
|
|
109
|
+
}>;
|
|
110
|
+
getExec(input: {
|
|
111
|
+
id: string;
|
|
112
|
+
after?: number | "tail";
|
|
113
|
+
}): Promise<{
|
|
114
|
+
id: string;
|
|
115
|
+
events: ReadableStream<ExecEvent>;
|
|
116
|
+
}>;
|
|
117
|
+
killExec(input: {
|
|
118
|
+
id: string;
|
|
119
|
+
signal?: KillSignal;
|
|
120
|
+
}): Promise<void>;
|
|
121
|
+
disposeExec(input: {
|
|
122
|
+
id: string;
|
|
123
|
+
}): Promise<void>;
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
/** Bidirectional path mapping between the folder and `/workspace`. */
|
|
127
|
+
export declare class FolderPathMap {
|
|
128
|
+
#private;
|
|
129
|
+
constructor(root: string);
|
|
130
|
+
/** A folder path as the Computer names it, or undefined outside the folder. */
|
|
131
|
+
toComputer(localPath: string): string | undefined;
|
|
132
|
+
/** A Computer path on this machine, or undefined outside `/workspace`. */
|
|
133
|
+
toLocal(computerPath: string): string | undefined;
|
|
134
|
+
}
|
|
135
|
+
declare class FolderSync extends RpcTarget {
|
|
136
|
+
#private;
|
|
137
|
+
constructor(remote: ComputerdWorkspaceRpc["sync"], paths: FolderPathMap);
|
|
138
|
+
push(input: {
|
|
139
|
+
senderRev: number;
|
|
140
|
+
changes: ReadableStream<ChangeEntry>;
|
|
141
|
+
}): Promise<{
|
|
142
|
+
rev: number;
|
|
143
|
+
appliedPushCursor: ChangeCursor;
|
|
144
|
+
}>;
|
|
145
|
+
fetchChanges(input: FetchChangesInput): Promise<{
|
|
146
|
+
currentCursor: ChangeCursor;
|
|
147
|
+
appliedPushCursor: ChangeCursor;
|
|
148
|
+
stream: ReadableStream<ChangeEntry>;
|
|
149
|
+
}>;
|
|
150
|
+
watermarks(): Promise<{
|
|
151
|
+
currentRev: number;
|
|
152
|
+
pushRev: number;
|
|
153
|
+
fetchCursor: ChangeCursor;
|
|
154
|
+
}>;
|
|
155
|
+
readEntry(path: string): Promise<{
|
|
156
|
+
path: string;
|
|
157
|
+
kind: "file";
|
|
158
|
+
rev: number;
|
|
159
|
+
mode: number;
|
|
160
|
+
mtime: number;
|
|
161
|
+
size: number;
|
|
162
|
+
chunks: {
|
|
163
|
+
hash: Uint8Array;
|
|
164
|
+
size: number;
|
|
165
|
+
}[];
|
|
166
|
+
} | {
|
|
167
|
+
path: string;
|
|
168
|
+
kind: "dir";
|
|
169
|
+
rev: number;
|
|
170
|
+
mode: number;
|
|
171
|
+
mtime: number;
|
|
172
|
+
} | {
|
|
173
|
+
path: string;
|
|
174
|
+
kind: "symlink";
|
|
175
|
+
rev: number;
|
|
176
|
+
target: string;
|
|
177
|
+
mode: number;
|
|
178
|
+
mtime: number;
|
|
179
|
+
} | {
|
|
180
|
+
path: string;
|
|
181
|
+
kind: "delete";
|
|
182
|
+
rev: number;
|
|
183
|
+
} | null>;
|
|
184
|
+
hasObjects(hashes: Uint8Array[]): Promise<Uint8Array<ArrayBufferLike>[]>;
|
|
185
|
+
fetchObjects(hashes: Uint8Array[]): Promise<ReadableStream<BlobObject>>;
|
|
186
|
+
pushObjects(objects: ReadableStream<BlobObject>): Promise<void>;
|
|
187
|
+
}
|
|
188
|
+
/** A command's working directory outside the folder is refused, not widened. */
|
|
189
|
+
export declare class FolderCwdError extends Error {
|
|
190
|
+
constructor(cwd: string);
|
|
191
|
+
}
|
|
192
|
+
declare class FolderShell extends RpcTarget {
|
|
193
|
+
#private;
|
|
194
|
+
constructor(remote: ComputerdWorkspaceRpc["shell"], paths: FolderPathMap);
|
|
195
|
+
exec(input: ExecInput): Promise<{
|
|
196
|
+
id: string;
|
|
197
|
+
events: ReadableStream<ExecEvent>;
|
|
198
|
+
}>;
|
|
199
|
+
getExec(input: {
|
|
200
|
+
id: string;
|
|
201
|
+
after?: number | "tail";
|
|
202
|
+
}): Promise<{
|
|
203
|
+
id: string;
|
|
204
|
+
events: ReadableStream<ExecEvent>;
|
|
205
|
+
}>;
|
|
206
|
+
killExec(input: {
|
|
207
|
+
id: string;
|
|
208
|
+
signal?: KillSignal;
|
|
209
|
+
}): Promise<void>;
|
|
210
|
+
disposeExec(input: {
|
|
211
|
+
id: string;
|
|
212
|
+
}): Promise<void>;
|
|
213
|
+
}
|
|
214
|
+
/** The `WorkspaceRPC` the Hub's Workspace sees for this folder. */
|
|
215
|
+
export declare class FolderWorkspaceRpc extends RpcTarget {
|
|
216
|
+
#private;
|
|
217
|
+
constructor(remote: ComputerdWorkspaceRpc, root: string);
|
|
218
|
+
get sync(): FolderSync;
|
|
219
|
+
get shell(): FolderShell;
|
|
220
|
+
}
|
|
221
|
+
/** One running computerd rooted at a folder, reachable on loopback only. */
|
|
222
|
+
export type LocalComputerd = Readonly<{
|
|
223
|
+
port: number;
|
|
224
|
+
stop: () => Promise<void>;
|
|
225
|
+
}>;
|
|
226
|
+
/** Thrown when this machine has no Node new enough to run computerd. */
|
|
227
|
+
export declare class ComputerdNodeError extends Error {
|
|
228
|
+
constructor(found: string);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The Node that runs computerd: FUNGI_NODE, else this process when it is
|
|
232
|
+
* Node, else `node` on the PATH (the fungi CLI itself runs on Bun). The
|
|
233
|
+
* same bundle and shim mode run on Linux and macOS; nothing native loads.
|
|
234
|
+
*/
|
|
235
|
+
export declare function resolveComputerdNode(): string;
|
|
236
|
+
/**
|
|
237
|
+
* Start computerd for `root`. It gets a scrubbed environment: the executable
|
|
238
|
+
* search path, a home, a locale and a terminal type, never this process's
|
|
239
|
+
* ambient variables or credentials.
|
|
240
|
+
*/
|
|
241
|
+
export declare function startLocalComputerd(options: Readonly<{
|
|
242
|
+
root: string;
|
|
243
|
+
/** Defaults to {@link resolveComputerdNode}. */
|
|
244
|
+
node?: string;
|
|
245
|
+
startTimeoutMs?: number;
|
|
246
|
+
}>): Promise<LocalComputerd>;
|
|
247
|
+
/** Where one attached backend dials. */
|
|
248
|
+
export type LocalBackendTarget = Readonly<{
|
|
249
|
+
hubOrigin: string;
|
|
250
|
+
teamId: string;
|
|
251
|
+
computerId: string;
|
|
252
|
+
backendId: string;
|
|
253
|
+
deviceId: string;
|
|
254
|
+
/** The device's Ed25519 private key, base64 PKCS#8. Never sent. */
|
|
255
|
+
privateKey: string;
|
|
256
|
+
}>;
|
|
257
|
+
export type LocalBackendStatus = Readonly<{
|
|
258
|
+
kind: "connecting";
|
|
259
|
+
}> | Readonly<{
|
|
260
|
+
kind: "online";
|
|
261
|
+
}> | Readonly<{
|
|
262
|
+
kind: "offline";
|
|
263
|
+
retryInMs: number;
|
|
264
|
+
reason: string;
|
|
265
|
+
}> | Readonly<{
|
|
266
|
+
kind: "refused";
|
|
267
|
+
}>;
|
|
268
|
+
/**
|
|
269
|
+
* Keep one attached folder connected until `signal` aborts: dial out, serve
|
|
270
|
+
* the folder over the socket, and redial with backoff whenever it drops. A
|
|
271
|
+
* Hub that refuses the dial (a detached backend) stops the loop.
|
|
272
|
+
*/
|
|
273
|
+
export declare function runLocalComputerBackend(options: Readonly<{
|
|
274
|
+
target: LocalBackendTarget;
|
|
275
|
+
root: string;
|
|
276
|
+
computerdPort: number;
|
|
277
|
+
signal: AbortSignal;
|
|
278
|
+
onStatus?: ((status: LocalBackendStatus) => void) | undefined;
|
|
279
|
+
/** Consecutive 404 refusals before the backend counts as detached. */
|
|
280
|
+
refusedLimit?: number;
|
|
281
|
+
}>): Promise<"stopped" | "refused">;
|
|
282
|
+
export type { FolderSync, FolderShell };
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, posix, relative, resolve, sep } from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { RpcTarget, newWebSocketRpcSession } from "@fungi.computer/capnweb";
|
|
7
|
+
import { importSeamSigner, seamHeader, signSeamRequest, unsignedPayload, } from "@fungi.computer/seam";
|
|
8
|
+
/**
|
|
9
|
+
* A member's folder as an execution backend of a Team Computer.
|
|
10
|
+
*
|
|
11
|
+
* Local runs the same `computerd` 0.2.1 as the Computer container image,
|
|
12
|
+
* built from vendored source and run under the user's Node in shim mode, with its shim mount rooted at the chosen folder, and dials out to
|
|
13
|
+
* the Hub. The Computer then syncs and runs commands against this folder with
|
|
14
|
+
* the library's own push/pull, exactly as it does against a container.
|
|
15
|
+
*
|
|
16
|
+
* `computerd` names files by their absolute path on this machine. The Hub
|
|
17
|
+
* must only ever see `/workspace/...`, so Local serves the Hub a
|
|
18
|
+
* `WorkspaceRPC` of its own that forwards to `computerd` and translates paths
|
|
19
|
+
* at this one boundary. Entries outside the folder never cross it.
|
|
20
|
+
*/
|
|
21
|
+
/** The Computer-side root every backend's files live under. */
|
|
22
|
+
export const computerWorkspaceRoot = "/workspace";
|
|
23
|
+
/** Bidirectional path mapping between the folder and `/workspace`. */
|
|
24
|
+
export class FolderPathMap {
|
|
25
|
+
#root;
|
|
26
|
+
constructor(root) {
|
|
27
|
+
this.#root = resolve(root);
|
|
28
|
+
}
|
|
29
|
+
/** A folder path as the Computer names it, or undefined outside the folder. */
|
|
30
|
+
toComputer(localPath) {
|
|
31
|
+
const inside = relative(this.#root, localPath);
|
|
32
|
+
if (inside === "")
|
|
33
|
+
return computerWorkspaceRoot;
|
|
34
|
+
if (inside.startsWith("..") || resolve(this.#root, inside) !== localPath)
|
|
35
|
+
return undefined;
|
|
36
|
+
return posix.join(computerWorkspaceRoot, ...inside.split(sep));
|
|
37
|
+
}
|
|
38
|
+
/** A Computer path on this machine, or undefined outside `/workspace`. */
|
|
39
|
+
toLocal(computerPath) {
|
|
40
|
+
const normalized = posix.normalize(computerPath);
|
|
41
|
+
if (normalized === computerWorkspaceRoot)
|
|
42
|
+
return this.#root;
|
|
43
|
+
if (!normalized.startsWith(`${computerWorkspaceRoot}/`))
|
|
44
|
+
return undefined;
|
|
45
|
+
return resolve(this.#root, ...normalized.slice(computerWorkspaceRoot.length + 1).split("/"));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function mapCursor(cursor, map) {
|
|
49
|
+
// A cursor path outside the mapping can only name the folder's ancestors,
|
|
50
|
+
// which computerd creates once at startup; the revision alone orders it.
|
|
51
|
+
if (cursor.path === null)
|
|
52
|
+
return cursor;
|
|
53
|
+
const path = map(cursor.path);
|
|
54
|
+
return path === undefined
|
|
55
|
+
? { rev: cursor.rev, path: null }
|
|
56
|
+
: { rev: cursor.rev, path };
|
|
57
|
+
}
|
|
58
|
+
function mapEntries(stream, map) {
|
|
59
|
+
return stream.pipeThrough(new TransformStream({
|
|
60
|
+
transform(entry, controller) {
|
|
61
|
+
const path = map(entry.path);
|
|
62
|
+
if (path !== undefined)
|
|
63
|
+
controller.enqueue({ ...entry, path });
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
class FolderSync extends RpcTarget {
|
|
68
|
+
#remote;
|
|
69
|
+
#paths;
|
|
70
|
+
constructor(remote, paths) {
|
|
71
|
+
super();
|
|
72
|
+
this.#remote = remote;
|
|
73
|
+
this.#paths = paths;
|
|
74
|
+
}
|
|
75
|
+
async push(input) {
|
|
76
|
+
return await this.#remote.push({
|
|
77
|
+
senderRev: input.senderRev,
|
|
78
|
+
changes: mapEntries(input.changes, (path) => this.#paths.toLocal(path)),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async fetchChanges(input) {
|
|
82
|
+
const request = {};
|
|
83
|
+
if (input.after !== undefined)
|
|
84
|
+
request.after = mapCursor(input.after, (path) => this.#paths.toLocal(path));
|
|
85
|
+
if (input.ignore !== undefined)
|
|
86
|
+
request.ignore = input.ignore.flatMap((path) => this.#paths.toLocal(path) ?? []);
|
|
87
|
+
const toComputer = (path) => this.#paths.toComputer(path);
|
|
88
|
+
const result = await this.#remote.fetchChanges(request);
|
|
89
|
+
return {
|
|
90
|
+
currentCursor: mapCursor(result.currentCursor, toComputer),
|
|
91
|
+
appliedPushCursor: mapCursor(result.appliedPushCursor, toComputer),
|
|
92
|
+
stream: mapEntries(result.stream, toComputer),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async watermarks() {
|
|
96
|
+
const marks = await this.#remote.watermarks();
|
|
97
|
+
return {
|
|
98
|
+
currentRev: marks.currentRev,
|
|
99
|
+
pushRev: marks.pushRev,
|
|
100
|
+
fetchCursor: mapCursor(marks.fetchCursor, (path) => this.#paths.toComputer(path)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async readEntry(path) {
|
|
104
|
+
const local = this.#paths.toLocal(path);
|
|
105
|
+
if (local === undefined)
|
|
106
|
+
return null;
|
|
107
|
+
const entry = await this.#remote.readEntry(local);
|
|
108
|
+
return entry === null ? null : { ...entry, path };
|
|
109
|
+
}
|
|
110
|
+
async hasObjects(hashes) {
|
|
111
|
+
return await this.#remote.hasObjects(hashes);
|
|
112
|
+
}
|
|
113
|
+
async fetchObjects(hashes) {
|
|
114
|
+
return await this.#remote.fetchObjects(hashes);
|
|
115
|
+
}
|
|
116
|
+
async pushObjects(objects) {
|
|
117
|
+
await this.#remote.pushObjects(objects);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** A command's working directory outside the folder is refused, not widened. */
|
|
121
|
+
export class FolderCwdError extends Error {
|
|
122
|
+
constructor(cwd) {
|
|
123
|
+
super(`Working directory is outside this machine's folder: ${cwd}`);
|
|
124
|
+
this.name = "FolderCwdError";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
class FolderShell extends RpcTarget {
|
|
128
|
+
#remote;
|
|
129
|
+
#paths;
|
|
130
|
+
constructor(remote, paths) {
|
|
131
|
+
super();
|
|
132
|
+
this.#remote = remote;
|
|
133
|
+
this.#paths = paths;
|
|
134
|
+
}
|
|
135
|
+
async exec(input) {
|
|
136
|
+
const request = { ...input };
|
|
137
|
+
if (input.cwd !== undefined) {
|
|
138
|
+
const cwd = this.#paths.toLocal(input.cwd);
|
|
139
|
+
if (cwd === undefined)
|
|
140
|
+
throw new FolderCwdError(input.cwd);
|
|
141
|
+
request.cwd = cwd;
|
|
142
|
+
}
|
|
143
|
+
return await this.#remote.exec(request);
|
|
144
|
+
}
|
|
145
|
+
async getExec(input) {
|
|
146
|
+
return await this.#remote.getExec(input);
|
|
147
|
+
}
|
|
148
|
+
async killExec(input) {
|
|
149
|
+
await this.#remote.killExec(input);
|
|
150
|
+
}
|
|
151
|
+
async disposeExec(input) {
|
|
152
|
+
await this.#remote.disposeExec(input);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** The `WorkspaceRPC` the Hub's Workspace sees for this folder. */
|
|
156
|
+
export class FolderWorkspaceRpc extends RpcTarget {
|
|
157
|
+
#sync;
|
|
158
|
+
#shell;
|
|
159
|
+
constructor(remote, root) {
|
|
160
|
+
super();
|
|
161
|
+
const paths = new FolderPathMap(root);
|
|
162
|
+
this.#sync = new FolderSync(remote.sync, paths);
|
|
163
|
+
this.#shell = new FolderShell(remote.shell, paths);
|
|
164
|
+
}
|
|
165
|
+
// capnweb reaches only methods and getters of an RpcTarget.
|
|
166
|
+
get sync() {
|
|
167
|
+
return this.#sync;
|
|
168
|
+
}
|
|
169
|
+
get shell() {
|
|
170
|
+
return this.#shell;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const listeningLine = /computerd listening on [^:]+:(\d+) /;
|
|
174
|
+
/** computerd 0.2.1, built from vendored source by the Cordyceps package. */
|
|
175
|
+
const bundledCli = process.env.FUNGI_BUNDLED_CLI;
|
|
176
|
+
const computerdBundle = bundledCli === undefined
|
|
177
|
+
? createRequire(import.meta.url).resolve("@fungi.computer/cordyceps/cli")
|
|
178
|
+
: resolve(dirname(bundledCli), "internal/computerd.cjs");
|
|
179
|
+
const minimumNode = [22, 13];
|
|
180
|
+
/** Thrown when this machine has no Node new enough to run computerd. */
|
|
181
|
+
export class ComputerdNodeError extends Error {
|
|
182
|
+
constructor(found) {
|
|
183
|
+
super(`Attaching a folder runs computerd under Node ${minimumNode.join(".")} or newer; found ${found}. Install Node from https://nodejs.org or set FUNGI_NODE to its path.`);
|
|
184
|
+
this.name = "ComputerdNodeError";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The Node that runs computerd: FUNGI_NODE, else this process when it is
|
|
189
|
+
* Node, else `node` on the PATH (the fungi CLI itself runs on Bun). The
|
|
190
|
+
* same bundle and shim mode run on Linux and macOS; nothing native loads.
|
|
191
|
+
*/
|
|
192
|
+
export function resolveComputerdNode() {
|
|
193
|
+
const node = process.env.FUNGI_NODE ??
|
|
194
|
+
("bun" in process.versions ? "node" : process.execPath);
|
|
195
|
+
const probe = spawnSync(node, ["-p", "process.versions.node"], {
|
|
196
|
+
encoding: "utf8",
|
|
197
|
+
env: { PATH: process.env.PATH ?? "" },
|
|
198
|
+
});
|
|
199
|
+
const version = probe.status === 0 ? probe.stdout.trim() : "";
|
|
200
|
+
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
201
|
+
if (major < minimumNode[0] ||
|
|
202
|
+
(major === minimumNode[0] && minor < minimumNode[1]))
|
|
203
|
+
throw new ComputerdNodeError(version === "" ? "none" : version);
|
|
204
|
+
return node;
|
|
205
|
+
}
|
|
206
|
+
const loopbackPreload = fileURLToPath(new URL("../computerd-loopback.cjs", import.meta.url));
|
|
207
|
+
/**
|
|
208
|
+
* Start computerd for `root`. It gets a scrubbed environment: the executable
|
|
209
|
+
* search path, a home, a locale and a terminal type, never this process's
|
|
210
|
+
* ambient variables or credentials.
|
|
211
|
+
*/
|
|
212
|
+
export async function startLocalComputerd(options) {
|
|
213
|
+
const environment = {
|
|
214
|
+
PORT: "0",
|
|
215
|
+
MOUNT_POINT: options.root,
|
|
216
|
+
FUSE_MOUNT: "shim",
|
|
217
|
+
HOME: options.root,
|
|
218
|
+
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
219
|
+
LANG: process.env.LANG ?? "C.UTF-8",
|
|
220
|
+
TERM: "xterm-256color",
|
|
221
|
+
NODE_OPTIONS: `--require ${JSON.stringify(loopbackPreload)}`,
|
|
222
|
+
NODE_NO_WARNINGS: "1",
|
|
223
|
+
};
|
|
224
|
+
const child = spawn(options.node ?? resolveComputerdNode(), [computerdBundle], {
|
|
225
|
+
cwd: options.root,
|
|
226
|
+
env: environment,
|
|
227
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
228
|
+
});
|
|
229
|
+
const port = await new Promise((resolvePort, reject) => {
|
|
230
|
+
let output = "";
|
|
231
|
+
const timer = setTimeout(() => {
|
|
232
|
+
child.kill("SIGTERM");
|
|
233
|
+
reject(new Error(`computerd did not start: ${output.slice(-500)}`));
|
|
234
|
+
}, options.startTimeoutMs ?? 20_000);
|
|
235
|
+
const read = (chunk) => {
|
|
236
|
+
output += chunk.toString("utf8");
|
|
237
|
+
const match = listeningLine.exec(output);
|
|
238
|
+
if (match?.[1] === undefined)
|
|
239
|
+
return;
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
resolvePort(Number(match[1]));
|
|
242
|
+
};
|
|
243
|
+
child.stdout?.on("data", read);
|
|
244
|
+
child.stderr?.on("data", read);
|
|
245
|
+
child.once("exit", (code) => {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
reject(new Error(`computerd exited (${String(code)}): ${output.slice(-500)}`));
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
port,
|
|
252
|
+
stop: async () => {
|
|
253
|
+
const running = () => child.exitCode === null;
|
|
254
|
+
if (!running())
|
|
255
|
+
return;
|
|
256
|
+
const exited = new Promise((done) => child.once("exit", done));
|
|
257
|
+
child.kill("SIGTERM");
|
|
258
|
+
await Promise.race([
|
|
259
|
+
exited,
|
|
260
|
+
new Promise((done) => setTimeout(done, 5_000)),
|
|
261
|
+
]);
|
|
262
|
+
if (running())
|
|
263
|
+
child.kill("SIGKILL");
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function dialUrl(target) {
|
|
268
|
+
return new URL(`/api/teams/${encodeURIComponent(target.teamId)}/computers/${encodeURIComponent(target.computerId)}/backends/${encodeURIComponent(target.backendId)}/connect`, target.hubOrigin);
|
|
269
|
+
}
|
|
270
|
+
async function signedDialHeader(signer, target, url) {
|
|
271
|
+
return await signSeamRequest(signer, `fungi-local-backend:${target.computerId}:${target.backendId}`, "GET", url.href, unsignedPayload);
|
|
272
|
+
}
|
|
273
|
+
/** A signed plain GET: 426 while attached, 404 once detached. */
|
|
274
|
+
async function probeDial(url, signer, target) {
|
|
275
|
+
try {
|
|
276
|
+
const response = await fetch(url, {
|
|
277
|
+
headers: { [seamHeader]: await signedDialHeader(signer, target, url) },
|
|
278
|
+
signal: AbortSignal.timeout(10_000),
|
|
279
|
+
});
|
|
280
|
+
await response.body?.cancel();
|
|
281
|
+
return { kind: "reachable", status: response.status };
|
|
282
|
+
}
|
|
283
|
+
catch (cause) {
|
|
284
|
+
return { kind: "unavailable", cause };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function openSocket(url, header) {
|
|
288
|
+
const wsUrl = new URL(url);
|
|
289
|
+
wsUrl.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
290
|
+
return new Promise((resolveSocket, reject) => {
|
|
291
|
+
// Bun and Node's undici WebSocket accept handshake headers here.
|
|
292
|
+
const socket = new WebSocket(wsUrl, { headers: { [seamHeader]: header } });
|
|
293
|
+
socket.addEventListener("open", () => {
|
|
294
|
+
resolveSocket(socket);
|
|
295
|
+
}, { once: true });
|
|
296
|
+
socket.addEventListener("error", () => {
|
|
297
|
+
reject(new Error("Hub refused or dropped the connection"));
|
|
298
|
+
}, { once: true });
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Keep one attached folder connected until `signal` aborts: dial out, serve
|
|
303
|
+
* the folder over the socket, and redial with backoff whenever it drops. A
|
|
304
|
+
* Hub that refuses the dial (a detached backend) stops the loop.
|
|
305
|
+
*/
|
|
306
|
+
export async function runLocalComputerBackend(options) {
|
|
307
|
+
const signer = await importSeamSigner(`${options.target.deviceId}:${options.target.privateKey}`);
|
|
308
|
+
const computerd = newWebSocketRpcSession(`ws://127.0.0.1:${String(options.computerdPort)}/ws`);
|
|
309
|
+
const remote = computerd;
|
|
310
|
+
const folder = new FolderWorkspaceRpc(remote, options.root);
|
|
311
|
+
const url = dialUrl(options.target);
|
|
312
|
+
let delay = 500;
|
|
313
|
+
let refused = 0;
|
|
314
|
+
const stopped = () => options.signal.aborted;
|
|
315
|
+
try {
|
|
316
|
+
while (!options.signal.aborted) {
|
|
317
|
+
options.onStatus?.({ kind: "connecting" });
|
|
318
|
+
// Settle the folder into computerd's store before the Computer pushes,
|
|
319
|
+
// so its push overwrites or deletes every offline folder edit it
|
|
320
|
+
// conflicts with instead of racing the shim's next disk poll.
|
|
321
|
+
await settleFolder(remote);
|
|
322
|
+
let socket;
|
|
323
|
+
try {
|
|
324
|
+
socket = await openSocket(url, await signedDialHeader(signer, options.target, url));
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
// A detached backend's dial is refused with 404; anything else is
|
|
328
|
+
// this machine or the Hub being unreachable, retried indefinitely.
|
|
329
|
+
const probe = await probeDial(url, signer, options.target);
|
|
330
|
+
if (probe.kind === "reachable" && probe.status === 404) {
|
|
331
|
+
refused += 1;
|
|
332
|
+
if (refused >= (options.refusedLimit ?? 2)) {
|
|
333
|
+
options.onStatus?.({ kind: "refused" });
|
|
334
|
+
return "refused";
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else
|
|
338
|
+
refused = 0;
|
|
339
|
+
options.onStatus?.({
|
|
340
|
+
kind: "offline",
|
|
341
|
+
retryInMs: delay,
|
|
342
|
+
reason: probe.kind === "unavailable"
|
|
343
|
+
? `Dial probe unavailable: ${probe.cause instanceof Error ? probe.cause.message : String(probe.cause)}`
|
|
344
|
+
: error instanceof Error
|
|
345
|
+
? error.message
|
|
346
|
+
: String(error),
|
|
347
|
+
});
|
|
348
|
+
await sleep(delay, options.signal);
|
|
349
|
+
delay = Math.min(delay * 2, 15_000);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
refused = 0;
|
|
353
|
+
delay = 500;
|
|
354
|
+
const session = newWebSocketRpcSession(socket, folder);
|
|
355
|
+
options.onStatus?.({ kind: "online" });
|
|
356
|
+
await new Promise((closed) => {
|
|
357
|
+
const done = () => {
|
|
358
|
+
closed();
|
|
359
|
+
};
|
|
360
|
+
socket.addEventListener("close", done, { once: true });
|
|
361
|
+
socket.addEventListener("error", done, { once: true });
|
|
362
|
+
options.signal.addEventListener("abort", () => {
|
|
363
|
+
socket.close(1000, "Local stopped");
|
|
364
|
+
done();
|
|
365
|
+
}, { once: true });
|
|
366
|
+
});
|
|
367
|
+
try {
|
|
368
|
+
session[Symbol.dispose]();
|
|
369
|
+
}
|
|
370
|
+
catch (cause) {
|
|
371
|
+
console.error("Local Computer backend operation unavailable", cause);
|
|
372
|
+
// The session already ended with its socket.
|
|
373
|
+
}
|
|
374
|
+
if (!stopped())
|
|
375
|
+
options.onStatus?.({
|
|
376
|
+
kind: "offline",
|
|
377
|
+
retryInMs: delay,
|
|
378
|
+
reason: "connection closed",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return "stopped";
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
computerd[Symbol.dispose]();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** A fetch makes computerd's shim reconcile the folder into its store now. */
|
|
388
|
+
async function settleFolder(remote) {
|
|
389
|
+
const settled = await remote.sync.fetchChanges({
|
|
390
|
+
after: { rev: Number.MAX_SAFE_INTEGER, path: null },
|
|
391
|
+
});
|
|
392
|
+
await settled.stream.cancel();
|
|
393
|
+
}
|
|
394
|
+
function sleep(ms, signal) {
|
|
395
|
+
return new Promise((done) => {
|
|
396
|
+
const timer = setTimeout(done, ms);
|
|
397
|
+
signal.addEventListener("abort", () => {
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
done();
|
|
400
|
+
}, { once: true });
|
|
401
|
+
});
|
|
402
|
+
}
|