@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,273 @@
|
|
|
1
|
+
import { RpcTarget } from "@fungi.computer/capnweb";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { localAuthInputLimit, runLocalAuthCli, } from "./auth-cli.js";
|
|
4
|
+
const requestSchema = z.object({
|
|
5
|
+
command: z.enum(["login", "logout", "status"]),
|
|
6
|
+
provider: z.string().optional(),
|
|
7
|
+
authType: z.enum(["oauth", "api_key"]).optional(),
|
|
8
|
+
method: z.string().optional(),
|
|
9
|
+
});
|
|
10
|
+
const authStatusSchema = z.discriminatedUnion("state", [
|
|
11
|
+
z.object({
|
|
12
|
+
providerId: z.string(),
|
|
13
|
+
providerName: z.string(),
|
|
14
|
+
state: z.literal("unconfigured"),
|
|
15
|
+
}),
|
|
16
|
+
z.object({
|
|
17
|
+
providerId: z.string(),
|
|
18
|
+
providerName: z.string(),
|
|
19
|
+
state: z.literal("configured"),
|
|
20
|
+
type: z.enum(["oauth", "api_key"]),
|
|
21
|
+
}),
|
|
22
|
+
]);
|
|
23
|
+
const authResultSchema = z.discriminatedUnion("kind", [
|
|
24
|
+
z.object({ kind: z.literal("completed") }),
|
|
25
|
+
z.object({
|
|
26
|
+
kind: z.literal("status"),
|
|
27
|
+
providers: z.array(authStatusSchema),
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
30
|
+
// Only Pi's serializable presentation crosses RPC; AbortSignals stay local.
|
|
31
|
+
const promptSchema = z.discriminatedUnion("type", [
|
|
32
|
+
z.object({
|
|
33
|
+
type: z.literal("text"),
|
|
34
|
+
message: z.string(),
|
|
35
|
+
placeholder: z.string().optional(),
|
|
36
|
+
}),
|
|
37
|
+
z.object({
|
|
38
|
+
type: z.literal("secret"),
|
|
39
|
+
message: z.string(),
|
|
40
|
+
placeholder: z.string().optional(),
|
|
41
|
+
}),
|
|
42
|
+
z.object({
|
|
43
|
+
type: z.literal("manual_code"),
|
|
44
|
+
message: z.string(),
|
|
45
|
+
placeholder: z.string().optional(),
|
|
46
|
+
}),
|
|
47
|
+
z.object({
|
|
48
|
+
type: z.literal("select"),
|
|
49
|
+
message: z.string(),
|
|
50
|
+
options: z.array(z.object({
|
|
51
|
+
id: z.string(),
|
|
52
|
+
label: z.string(),
|
|
53
|
+
description: z.string().optional(),
|
|
54
|
+
})),
|
|
55
|
+
}),
|
|
56
|
+
]);
|
|
57
|
+
const linkSchema = z.object({ url: z.string(), label: z.string().optional() });
|
|
58
|
+
const eventSchema = z.discriminatedUnion("type", [
|
|
59
|
+
z.object({
|
|
60
|
+
type: z.literal("info"),
|
|
61
|
+
message: z.string(),
|
|
62
|
+
links: z.array(linkSchema).optional(),
|
|
63
|
+
}),
|
|
64
|
+
z.object({ type: z.literal("progress"), message: z.string() }),
|
|
65
|
+
z.object({
|
|
66
|
+
type: z.literal("auth_url"),
|
|
67
|
+
url: z.string(),
|
|
68
|
+
instructions: z.string().optional(),
|
|
69
|
+
}),
|
|
70
|
+
z.object({
|
|
71
|
+
type: z.literal("device_code"),
|
|
72
|
+
userCode: z.string(),
|
|
73
|
+
verificationUri: z.string(),
|
|
74
|
+
intervalSeconds: z.number().optional(),
|
|
75
|
+
expiresInSeconds: z.number().optional(),
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
const answerSchema = z.string().max(localAuthInputLimit);
|
|
79
|
+
class AuthPromptTarget extends RpcTarget {
|
|
80
|
+
#controller = new AbortController();
|
|
81
|
+
#result;
|
|
82
|
+
constructor(prompt, interaction) {
|
|
83
|
+
super();
|
|
84
|
+
const signal = interaction.signal === undefined
|
|
85
|
+
? this.#controller.signal
|
|
86
|
+
: AbortSignal.any([this.#controller.signal, interaction.signal]);
|
|
87
|
+
this.#result = Promise.resolve()
|
|
88
|
+
.then(() => {
|
|
89
|
+
signal.throwIfAborted();
|
|
90
|
+
const request = prompt.type === "select"
|
|
91
|
+
? {
|
|
92
|
+
type: "select",
|
|
93
|
+
message: prompt.message,
|
|
94
|
+
signal,
|
|
95
|
+
options: prompt.options.map((option) => option.description === undefined
|
|
96
|
+
? { id: option.id, label: option.label }
|
|
97
|
+
: {
|
|
98
|
+
id: option.id,
|
|
99
|
+
label: option.label,
|
|
100
|
+
description: option.description,
|
|
101
|
+
}),
|
|
102
|
+
}
|
|
103
|
+
: { type: prompt.type, message: prompt.message, signal };
|
|
104
|
+
if (request.type !== "select" &&
|
|
105
|
+
prompt.type !== "select" &&
|
|
106
|
+
prompt.placeholder !== undefined)
|
|
107
|
+
request.placeholder = prompt.placeholder;
|
|
108
|
+
return interaction.prompt(request);
|
|
109
|
+
})
|
|
110
|
+
.then((value) => ({ value: answerSchema.parse(value) }), (cause) => ({ failed: true, cause }));
|
|
111
|
+
}
|
|
112
|
+
async result() {
|
|
113
|
+
const result = await this.#result;
|
|
114
|
+
if ("failed" in result)
|
|
115
|
+
throw new Error("Authentication prompt cancelled or failed", {
|
|
116
|
+
cause: result.cause,
|
|
117
|
+
});
|
|
118
|
+
return result.value;
|
|
119
|
+
}
|
|
120
|
+
cancel() {
|
|
121
|
+
this.#controller.abort();
|
|
122
|
+
}
|
|
123
|
+
[Symbol.dispose]() {
|
|
124
|
+
this.cancel();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
class AuthPresenterTarget extends RpcTarget {
|
|
128
|
+
#interaction;
|
|
129
|
+
constructor(interaction) {
|
|
130
|
+
super();
|
|
131
|
+
this.#interaction = interaction;
|
|
132
|
+
}
|
|
133
|
+
openPrompt(request) {
|
|
134
|
+
return new AuthPromptTarget(promptSchema.parse(request), this.#interaction);
|
|
135
|
+
}
|
|
136
|
+
notify(event) {
|
|
137
|
+
const parsed = eventSchema.parse(event);
|
|
138
|
+
let decoded;
|
|
139
|
+
switch (parsed.type) {
|
|
140
|
+
case "info":
|
|
141
|
+
decoded = { type: parsed.type, message: parsed.message };
|
|
142
|
+
if (parsed.links !== undefined)
|
|
143
|
+
decoded.links = parsed.links.map((link) => link.label === undefined
|
|
144
|
+
? { url: link.url }
|
|
145
|
+
: { url: link.url, label: link.label });
|
|
146
|
+
break;
|
|
147
|
+
case "progress":
|
|
148
|
+
decoded = parsed;
|
|
149
|
+
break;
|
|
150
|
+
case "auth_url":
|
|
151
|
+
decoded = { type: parsed.type, url: parsed.url };
|
|
152
|
+
if (parsed.instructions !== undefined)
|
|
153
|
+
decoded.instructions = parsed.instructions;
|
|
154
|
+
break;
|
|
155
|
+
case "device_code":
|
|
156
|
+
decoded = {
|
|
157
|
+
type: parsed.type,
|
|
158
|
+
userCode: parsed.userCode,
|
|
159
|
+
verificationUri: parsed.verificationUri,
|
|
160
|
+
};
|
|
161
|
+
if (parsed.intervalSeconds !== undefined)
|
|
162
|
+
decoded.intervalSeconds = parsed.intervalSeconds;
|
|
163
|
+
if (parsed.expiresInSeconds !== undefined)
|
|
164
|
+
decoded.expiresInSeconds = parsed.expiresInSeconds;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
this.#interaction.notify(decoded);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** One transient connection runs one operation through the existing Pi owner. */
|
|
171
|
+
export class PrivateLocalAuthTarget extends RpcTarget {
|
|
172
|
+
#controller = new AbortController();
|
|
173
|
+
#used = false;
|
|
174
|
+
#run;
|
|
175
|
+
constructor(run = runLocalAuthCli) {
|
|
176
|
+
super();
|
|
177
|
+
this.#run = run;
|
|
178
|
+
}
|
|
179
|
+
cancel() {
|
|
180
|
+
this.#controller.abort();
|
|
181
|
+
}
|
|
182
|
+
[Symbol.dispose]() {
|
|
183
|
+
this.cancel();
|
|
184
|
+
}
|
|
185
|
+
async run(input, presenter) {
|
|
186
|
+
if (this.#used)
|
|
187
|
+
throw new Error("Authentication connection already used");
|
|
188
|
+
this.#used = true;
|
|
189
|
+
presenter.onRpcBroken(() => {
|
|
190
|
+
this.cancel();
|
|
191
|
+
});
|
|
192
|
+
const request = requestSchema.parse(input);
|
|
193
|
+
const args = [request.command];
|
|
194
|
+
if (request.provider !== undefined)
|
|
195
|
+
args.push("--provider", request.provider);
|
|
196
|
+
if (request.authType !== undefined)
|
|
197
|
+
args.push("--auth-type", request.authType);
|
|
198
|
+
if (request.method !== undefined)
|
|
199
|
+
args.push("--method", request.method);
|
|
200
|
+
const signal = this.#controller.signal;
|
|
201
|
+
try {
|
|
202
|
+
const result = await this.#run(args, {
|
|
203
|
+
interaction: {
|
|
204
|
+
signal,
|
|
205
|
+
notify: (event) => {
|
|
206
|
+
void presenter.notify(event).catch((cause) => {
|
|
207
|
+
console.warn("Local authentication presenter notification failed", cause instanceof Error ? cause.name : "NonError");
|
|
208
|
+
this.cancel();
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
prompt: async (request) => {
|
|
212
|
+
const promptSignal = request.signal === undefined
|
|
213
|
+
? signal
|
|
214
|
+
: AbortSignal.any([signal, request.signal]);
|
|
215
|
+
promptSignal.throwIfAborted();
|
|
216
|
+
const pending = presenter.openPrompt(promptSchema.parse(request));
|
|
217
|
+
const cancel = () => {
|
|
218
|
+
void pending.cancel().catch((cause) => {
|
|
219
|
+
console.warn("Local authentication prompt cancellation failed", cause instanceof Error ? cause.name : "NonError");
|
|
220
|
+
this.cancel();
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
promptSignal.addEventListener("abort", cancel, { once: true });
|
|
224
|
+
try {
|
|
225
|
+
if (promptSignal.aborted)
|
|
226
|
+
cancel();
|
|
227
|
+
return answerSchema.parse(await pending.result());
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
promptSignal.removeEventListener("abort", cancel);
|
|
231
|
+
pending[Symbol.dispose]();
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
return authResultSchema.parse(result);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Provider errors can contain credential material; do not serialize them.
|
|
240
|
+
throw new Error(signal.aborted
|
|
241
|
+
? "Authentication cancelled"
|
|
242
|
+
: "Local authentication failed");
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
this.cancel();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Takes ownership of a dedicated auth RPC connection, never the Session client. */
|
|
250
|
+
export async function runPrivateLocalAuthRemote(remote, request, interaction) {
|
|
251
|
+
const controller = new AbortController();
|
|
252
|
+
const signal = interaction.signal === undefined
|
|
253
|
+
? controller.signal
|
|
254
|
+
: AbortSignal.any([controller.signal, interaction.signal]);
|
|
255
|
+
const presenter = new AuthPresenterTarget({ ...interaction, signal });
|
|
256
|
+
const cancel = () => {
|
|
257
|
+
void remote.cancel().catch((cause) => {
|
|
258
|
+
console.warn("Local authentication cancellation failed", cause instanceof Error ? cause.name : "NonError");
|
|
259
|
+
remote[Symbol.dispose]();
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
263
|
+
try {
|
|
264
|
+
signal.throwIfAborted();
|
|
265
|
+
const result = await remote.run(request, presenter);
|
|
266
|
+
return authResultSchema.parse(result);
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
signal.removeEventListener("abort", cancel);
|
|
270
|
+
controller.abort();
|
|
271
|
+
remote[Symbol.dispose]();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { FetchLike } from "@modelcontextprotocol/client";
|
|
2
|
+
import { type LocalBackendStatus } from "./private-local-computer-backend.js";
|
|
3
|
+
export type ControlOptions = Readonly<{
|
|
4
|
+
credentialPath?: string;
|
|
5
|
+
fetch?: FetchLike;
|
|
6
|
+
}>;
|
|
7
|
+
export type PrivateLocalComputerAttachResult = Readonly<{
|
|
8
|
+
kind: "attached";
|
|
9
|
+
computerId: string;
|
|
10
|
+
computerName: string;
|
|
11
|
+
backendId: string;
|
|
12
|
+
rootPath: string;
|
|
13
|
+
}> | Readonly<{
|
|
14
|
+
kind: "not-found" | "uncertain" | "denied" | "conflict" | "invalid";
|
|
15
|
+
}>;
|
|
16
|
+
/** Attach `folderPath` to a Team Computer, or return the existing attachment. */
|
|
17
|
+
export declare function attachPrivateLocalComputer(rawEndpoint: string | URL, input: Readonly<{
|
|
18
|
+
computer: string;
|
|
19
|
+
folderPath: string;
|
|
20
|
+
hostLabel?: string;
|
|
21
|
+
}>, options?: ControlOptions): Promise<PrivateLocalComputerAttachResult>;
|
|
22
|
+
export type PrivateLocalComputerDetachResult = Readonly<{
|
|
23
|
+
kind: "detached" | "not-attached" | "uncertain" | "denied";
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* Detach a Computer's attachment on the Hub, then forget it here. A pending
|
|
27
|
+
* attach is replayed first so the Hub never keeps an attachment Local forgot.
|
|
28
|
+
*/
|
|
29
|
+
export declare function detachPrivateLocalComputer(rawEndpoint: string | URL, computer: string, options?: ControlOptions): Promise<PrivateLocalComputerDetachResult>;
|
|
30
|
+
/** What `fungi computer status` shows: never a key. */
|
|
31
|
+
export declare function listPrivateLocalComputers(options?: Readonly<{
|
|
32
|
+
credentialPath?: string;
|
|
33
|
+
}>): Promise<readonly Readonly<{
|
|
34
|
+
teamId: string;
|
|
35
|
+
computerId: string;
|
|
36
|
+
computerName: string;
|
|
37
|
+
rootPath: string;
|
|
38
|
+
backendId: string | null;
|
|
39
|
+
}>[]>;
|
|
40
|
+
export type PrivateLocalComputerRunOptions = {
|
|
41
|
+
credentialPath?: string;
|
|
42
|
+
signal: AbortSignal;
|
|
43
|
+
onStatus?: (status: LocalBackendStatus) => void;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Serve one attached folder until `signal` aborts or the Hub refuses the
|
|
47
|
+
* machine (it was detached elsewhere), in which case Local forgets it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function runPrivateLocalComputer(rawEndpoint: string | URL, computer: string, options: PrivateLocalComputerRunOptions): Promise<"stopped" | "detached">;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { basename, resolve } from "node:path";
|
|
5
|
+
import { defineIdFamily } from "@fungi.computer/cairn/id";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { callFungiMcpTeamControl, fungiMcpTeamEndpoint, readFungiMcpLocalBackends, writeFungiMcpLocalBackends, } from "./private-fungi-account.js";
|
|
8
|
+
import { runLocalComputerBackend, startLocalComputerd, } from "./private-local-computer-backend.js";
|
|
9
|
+
/**
|
|
10
|
+
* `fungi computer attach|status|detach`: this machine's folders as execution
|
|
11
|
+
* backends of Team Computers. Attaching generates a device key here, stores
|
|
12
|
+
* the folder path and private key in Local's owner-only credential file, and
|
|
13
|
+
* sends the Hub only the public key and two labels through the member's
|
|
14
|
+
* existing Team MCP login. Every request id is persisted before the network
|
|
15
|
+
* call so an uncertain outcome is retried, never duplicated.
|
|
16
|
+
*/
|
|
17
|
+
const requestIds = defineIdFamily({
|
|
18
|
+
family: "local-computer-backend",
|
|
19
|
+
version: 1,
|
|
20
|
+
kinds: { attach: { prefix: "local_attach_request" } },
|
|
21
|
+
});
|
|
22
|
+
const computerPageSchema = z.looseObject({
|
|
23
|
+
computers: z.array(z.looseObject({ computerId: z.string(), name: z.string() })),
|
|
24
|
+
nextOffset: z.number().int().nonnegative().nullable(),
|
|
25
|
+
});
|
|
26
|
+
const attachResultSchema = z.discriminatedUnion("kind", [
|
|
27
|
+
z.looseObject({
|
|
28
|
+
kind: z.literal("attached"),
|
|
29
|
+
requestId: z.string(),
|
|
30
|
+
backendId: z.string().regex(/^local-[a-z2-7]{16}$/),
|
|
31
|
+
}),
|
|
32
|
+
z.looseObject({
|
|
33
|
+
kind: z.enum(["uncertain", "denied", "conflict", "invalid"]),
|
|
34
|
+
requestId: z.string(),
|
|
35
|
+
}),
|
|
36
|
+
]);
|
|
37
|
+
const detachResultSchema = z.discriminatedUnion("kind", [
|
|
38
|
+
z.looseObject({ kind: z.literal("detached"), detached: z.boolean() }),
|
|
39
|
+
z.looseObject({ kind: z.enum(["denied", "uncertain", "invalid"]) }),
|
|
40
|
+
]);
|
|
41
|
+
async function control(teamEndpoint, call, schema, options) {
|
|
42
|
+
const text = await callFungiMcpTeamControl(teamEndpoint, `return await ${call};`, options);
|
|
43
|
+
if (text === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
const parsed = schema.safeParse(JSON.parse(text));
|
|
46
|
+
return parsed.success ? parsed.data : undefined;
|
|
47
|
+
}
|
|
48
|
+
/** A Team Computer by id or name, through the member's own directory view. */
|
|
49
|
+
async function findComputer(teamEndpoint, computer, options) {
|
|
50
|
+
let offset = 0;
|
|
51
|
+
while (offset !== null) {
|
|
52
|
+
const page = await control(teamEndpoint, `computers.list(${JSON.stringify({ limit: 100, offset })})`, computerPageSchema, options);
|
|
53
|
+
if (page === undefined)
|
|
54
|
+
throw new Error("The Hub did not list this Team's Computers; retry");
|
|
55
|
+
const found = page.computers.find((candidate) => candidate.computerId === computer || candidate.name === computer);
|
|
56
|
+
if (found !== undefined)
|
|
57
|
+
return found;
|
|
58
|
+
offset = page.nextOffset;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function sameComputer(record, computer) {
|
|
63
|
+
return record.computerId === computer || record.computerName === computer;
|
|
64
|
+
}
|
|
65
|
+
/** A fresh Ed25519 device key: raw public key and PKCS#8 private key, base64. */
|
|
66
|
+
function deviceKey() {
|
|
67
|
+
const pair = generateKeyPairSync("ed25519");
|
|
68
|
+
const jwk = z
|
|
69
|
+
.object({ x: z.string() })
|
|
70
|
+
.parse(pair.publicKey.export({ format: "jwk" }));
|
|
71
|
+
return {
|
|
72
|
+
publicKey: Buffer.from(jwk.x, "base64url").toString("base64"),
|
|
73
|
+
privateKey: pair.privateKey
|
|
74
|
+
.export({ format: "der", type: "pkcs8" })
|
|
75
|
+
.toString("base64"),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Attach `folderPath` to a Team Computer, or return the existing attachment. */
|
|
79
|
+
export async function attachPrivateLocalComputer(rawEndpoint, input, options = {}) {
|
|
80
|
+
const { endpoint, teamId } = fungiMcpTeamEndpoint(rawEndpoint);
|
|
81
|
+
const rootPath = await realpath(resolve(input.folderPath));
|
|
82
|
+
if (!(await stat(rootPath)).isDirectory())
|
|
83
|
+
throw new Error("The folder to attach is not a directory");
|
|
84
|
+
const saved = await readFungiMcpLocalBackends(options);
|
|
85
|
+
if (saved.issuer !== endpoint.origin)
|
|
86
|
+
throw new Error(`Fungi MCP sign-in is required; run fungi login --fungi-mcp ${endpoint.href}`);
|
|
87
|
+
let record = saved.backends.find((candidate) => candidate.teamId === teamId && sameComputer(candidate, input.computer));
|
|
88
|
+
if (record !== undefined && record.rootPath !== rootPath)
|
|
89
|
+
throw new Error(`This machine is already attached to ${record.computerName} with another folder; run fungi computer detach first`);
|
|
90
|
+
if (record === undefined) {
|
|
91
|
+
const computer = await findComputer(endpoint, input.computer, options);
|
|
92
|
+
if (computer === undefined)
|
|
93
|
+
return { kind: "not-found" };
|
|
94
|
+
record = {
|
|
95
|
+
requestId: requestIds.attach.generate(),
|
|
96
|
+
teamId,
|
|
97
|
+
computerId: computer.computerId,
|
|
98
|
+
computerName: computer.name,
|
|
99
|
+
rootPath,
|
|
100
|
+
hostLabel: z
|
|
101
|
+
.string()
|
|
102
|
+
.trim()
|
|
103
|
+
.min(1)
|
|
104
|
+
.max(256)
|
|
105
|
+
.parse(input.hostLabel ?? hostname()),
|
|
106
|
+
folderLabel: z.string().min(1).max(256).parse(basename(rootPath)),
|
|
107
|
+
...deviceKey(),
|
|
108
|
+
};
|
|
109
|
+
const pending = record;
|
|
110
|
+
await writeFungiMcpLocalBackends((current) => [...current, pending], options);
|
|
111
|
+
}
|
|
112
|
+
if (record.backendId !== undefined)
|
|
113
|
+
return {
|
|
114
|
+
kind: "attached",
|
|
115
|
+
computerId: record.computerId,
|
|
116
|
+
computerName: record.computerName,
|
|
117
|
+
backendId: record.backendId,
|
|
118
|
+
rootPath,
|
|
119
|
+
};
|
|
120
|
+
const result = await control(endpoint, `computers.attachLocal(${JSON.stringify({
|
|
121
|
+
computerId: record.computerId,
|
|
122
|
+
publicKey: record.publicKey,
|
|
123
|
+
hostLabel: record.hostLabel,
|
|
124
|
+
folderLabel: record.folderLabel,
|
|
125
|
+
requestId: record.requestId,
|
|
126
|
+
})})`, attachResultSchema, options);
|
|
127
|
+
if (result === undefined)
|
|
128
|
+
return { kind: "uncertain" };
|
|
129
|
+
if (result.kind !== "attached")
|
|
130
|
+
return { kind: result.kind };
|
|
131
|
+
const attached = { ...record, backendId: result.backendId };
|
|
132
|
+
await writeFungiMcpLocalBackends((current) => current.map((candidate) => candidate.requestId === attached.requestId ? attached : candidate), options);
|
|
133
|
+
return {
|
|
134
|
+
kind: "attached",
|
|
135
|
+
computerId: attached.computerId,
|
|
136
|
+
computerName: attached.computerName,
|
|
137
|
+
backendId: result.backendId,
|
|
138
|
+
rootPath,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Detach a Computer's attachment on the Hub, then forget it here. A pending
|
|
143
|
+
* attach is replayed first so the Hub never keeps an attachment Local forgot.
|
|
144
|
+
*/
|
|
145
|
+
export async function detachPrivateLocalComputer(rawEndpoint, computer, options = {}) {
|
|
146
|
+
const { endpoint, teamId } = fungiMcpTeamEndpoint(rawEndpoint);
|
|
147
|
+
const saved = await readFungiMcpLocalBackends(options);
|
|
148
|
+
const record = saved.backends.find((candidate) => candidate.teamId === teamId && sameComputer(candidate, computer));
|
|
149
|
+
if (record === undefined)
|
|
150
|
+
return { kind: "not-attached" };
|
|
151
|
+
let backendId = record.backendId;
|
|
152
|
+
if (backendId === undefined) {
|
|
153
|
+
const replay = await attachPrivateLocalComputer(endpoint, { computer: record.computerId, folderPath: record.rootPath }, options);
|
|
154
|
+
if (replay.kind !== "attached")
|
|
155
|
+
return { kind: "uncertain" };
|
|
156
|
+
backendId = replay.backendId;
|
|
157
|
+
}
|
|
158
|
+
const result = await control(endpoint, `computers.detachLocal(${JSON.stringify({
|
|
159
|
+
computerId: record.computerId,
|
|
160
|
+
backendId,
|
|
161
|
+
})})`, detachResultSchema, options);
|
|
162
|
+
if (result === undefined)
|
|
163
|
+
return { kind: "uncertain" };
|
|
164
|
+
if (result.kind === "denied")
|
|
165
|
+
return { kind: "denied" };
|
|
166
|
+
if (result.kind !== "detached")
|
|
167
|
+
return { kind: "uncertain" };
|
|
168
|
+
await writeFungiMcpLocalBackends((current) => current.filter((candidate) => candidate.requestId !== record.requestId), options);
|
|
169
|
+
return { kind: "detached" };
|
|
170
|
+
}
|
|
171
|
+
/** What `fungi computer status` shows: never a key. */
|
|
172
|
+
export async function listPrivateLocalComputers(options = {}) {
|
|
173
|
+
const saved = await readFungiMcpLocalBackends(options);
|
|
174
|
+
return saved.backends.map((record) => ({
|
|
175
|
+
teamId: record.teamId,
|
|
176
|
+
computerId: record.computerId,
|
|
177
|
+
computerName: record.computerName,
|
|
178
|
+
rootPath: record.rootPath,
|
|
179
|
+
backendId: record.backendId ?? null,
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Serve one attached folder until `signal` aborts or the Hub refuses the
|
|
184
|
+
* machine (it was detached elsewhere), in which case Local forgets it.
|
|
185
|
+
*/
|
|
186
|
+
export async function runPrivateLocalComputer(rawEndpoint, computer, options) {
|
|
187
|
+
const { endpoint, teamId } = fungiMcpTeamEndpoint(rawEndpoint);
|
|
188
|
+
const saved = await readFungiMcpLocalBackends(options);
|
|
189
|
+
const record = saved.backends.find((candidate) => candidate.teamId === teamId && sameComputer(candidate, computer));
|
|
190
|
+
if (record?.backendId === undefined)
|
|
191
|
+
throw new Error(`This machine is not attached to ${computer}`);
|
|
192
|
+
const daemon = await startLocalComputerd({ root: record.rootPath });
|
|
193
|
+
try {
|
|
194
|
+
const outcome = await runLocalComputerBackend({
|
|
195
|
+
target: {
|
|
196
|
+
hubOrigin: endpoint.origin,
|
|
197
|
+
teamId,
|
|
198
|
+
computerId: record.computerId,
|
|
199
|
+
backendId: record.backendId,
|
|
200
|
+
deviceId: record.backendId.slice("local-".length),
|
|
201
|
+
privateKey: record.privateKey,
|
|
202
|
+
},
|
|
203
|
+
root: record.rootPath,
|
|
204
|
+
computerdPort: daemon.port,
|
|
205
|
+
signal: options.signal,
|
|
206
|
+
onStatus: options.onStatus,
|
|
207
|
+
});
|
|
208
|
+
if (outcome === "stopped")
|
|
209
|
+
return "stopped";
|
|
210
|
+
await writeFungiMcpLocalBackends((current) => current.filter((candidate) => candidate.requestId !== record.requestId), options);
|
|
211
|
+
return "detached";
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await daemon.stop();
|
|
215
|
+
}
|
|
216
|
+
}
|