@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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +294 -2
  3. package/computerd-loopback.cjs +16 -0
  4. package/dist/auth-cli.d.ts +36 -0
  5. package/dist/auth-cli.js +351 -0
  6. package/dist/fungi-account-command.d.ts +18 -0
  7. package/dist/fungi-account-command.js +92 -0
  8. package/dist/index.d.ts +19 -0
  9. package/dist/index.js +17 -0
  10. package/dist/internal/private-workerd-sandbox-worker.mjs +27488 -0
  11. package/dist/internal/sandbox-metafile.json +6726 -0
  12. package/dist/linear-auth.d.ts +4 -0
  13. package/dist/linear-auth.js +72 -0
  14. package/dist/local-extensions.d.ts +97 -0
  15. package/dist/local-extensions.js +484 -0
  16. package/dist/local-login.d.ts +55 -0
  17. package/dist/local-login.js +167 -0
  18. package/dist/local-models.d.ts +14 -0
  19. package/dist/local-models.js +77 -0
  20. package/dist/local-skill-source.d.ts +6 -0
  21. package/dist/local-skill-source.js +357 -0
  22. package/dist/log-event.d.ts +2 -0
  23. package/dist/log-event.js +9 -0
  24. package/dist/private-bun-shiitake-host.d.ts +11 -0
  25. package/dist/private-bun-shiitake-host.js +66 -0
  26. package/dist/private-file-credential-store.d.ts +33 -0
  27. package/dist/private-file-credential-store.js +283 -0
  28. package/dist/private-files-patch.d.ts +3 -0
  29. package/dist/private-files-patch.js +86 -0
  30. package/dist/private-fungi-account.d.ts +235 -0
  31. package/dist/private-fungi-account.js +1149 -0
  32. package/dist/private-fungi-auth-error.d.ts +5 -0
  33. package/dist/private-fungi-auth-error.js +7 -0
  34. package/dist/private-fungi-mcp-connection.d.ts +22 -0
  35. package/dist/private-fungi-mcp-connection.js +143 -0
  36. package/dist/private-fungi-mcp-endpoint.d.ts +2 -0
  37. package/dist/private-fungi-mcp-endpoint.js +15 -0
  38. package/dist/private-fungi-mcp-selection.d.ts +14 -0
  39. package/dist/private-fungi-mcp-selection.js +36 -0
  40. package/dist/private-fungi-model-api.d.ts +6 -0
  41. package/dist/private-fungi-model-api.js +125 -0
  42. package/dist/private-fungi-model-catalog.d.ts +6 -0
  43. package/dist/private-fungi-model-catalog.js +78 -0
  44. package/dist/private-fungi-model-provider.d.ts +6 -0
  45. package/dist/private-fungi-model-provider.js +46 -0
  46. package/dist/private-fungi-pending-logouts.d.ts +34 -0
  47. package/dist/private-fungi-pending-logouts.js +136 -0
  48. package/dist/private-invocation-computer.d.ts +37 -0
  49. package/dist/private-invocation-computer.js +459 -0
  50. package/dist/private-invocation-files.d.ts +15 -0
  51. package/dist/private-invocation-files.js +449 -0
  52. package/dist/private-local-acp-stdio.d.ts +17 -0
  53. package/dist/private-local-acp-stdio.js +142 -0
  54. package/dist/private-local-auth-rpc.d.ts +69 -0
  55. package/dist/private-local-auth-rpc.js +273 -0
  56. package/dist/private-local-computer-attach.d.ts +49 -0
  57. package/dist/private-local-computer-attach.js +216 -0
  58. package/dist/private-local-computer-backend.d.ts +282 -0
  59. package/dist/private-local-computer-backend.js +402 -0
  60. package/dist/private-local-computer-processes.d.ts +199 -0
  61. package/dist/private-local-computer-processes.js +589 -0
  62. package/dist/private-local-default-model.d.ts +11 -0
  63. package/dist/private-local-default-model.js +45 -0
  64. package/dist/private-local-delegation.d.ts +29 -0
  65. package/dist/private-local-delegation.js +78 -0
  66. package/dist/private-local-effective-model.d.ts +19 -0
  67. package/dist/private-local-effective-model.js +14 -0
  68. package/dist/private-local-exec-launch.d.ts +41 -0
  69. package/dist/private-local-exec-launch.js +51 -0
  70. package/dist/private-local-exec-path.d.ts +6 -0
  71. package/dist/private-local-exec-path.js +40 -0
  72. package/dist/private-local-git-exclude.d.ts +2 -0
  73. package/dist/private-local-git-exclude.js +33 -0
  74. package/dist/private-local-http-dispatch.d.ts +5 -0
  75. package/dist/private-local-http-dispatch.js +14 -0
  76. package/dist/private-local-mail.d.ts +11 -0
  77. package/dist/private-local-mail.js +69 -0
  78. package/dist/private-local-model-settings.d.ts +5 -0
  79. package/dist/private-local-model-settings.js +31 -0
  80. package/dist/private-local-runtime-host.d.ts +141 -0
  81. package/dist/private-local-runtime-host.js +988 -0
  82. package/dist/private-local-service-contract.d.ts +90 -0
  83. package/dist/private-local-service-contract.js +1 -0
  84. package/dist/private-local-service-discovery.d.ts +101 -0
  85. package/dist/private-local-service-discovery.js +221 -0
  86. package/dist/private-local-service-ids.d.ts +3 -0
  87. package/dist/private-local-service-ids.js +8 -0
  88. package/dist/private-local-service-listener.d.ts +15 -0
  89. package/dist/private-local-service-listener.js +211 -0
  90. package/dist/private-local-service-owner.d.ts +1567 -0
  91. package/dist/private-local-service-owner.js +371 -0
  92. package/dist/private-local-service-process.d.ts +1 -0
  93. package/dist/private-local-service-process.js +6 -0
  94. package/dist/private-local-service.d.ts +17 -0
  95. package/dist/private-local-service.js +330 -0
  96. package/dist/private-local-session-discovery.d.ts +19 -0
  97. package/dist/private-local-session-discovery.js +157 -0
  98. package/dist/private-local-shiitake-host.d.ts +44 -0
  99. package/dist/private-local-shiitake-host.js +143 -0
  100. package/dist/private-local-subagents.d.ts +27 -0
  101. package/dist/private-local-subagents.js +77 -0
  102. package/dist/private-local-worker-server.d.ts +15 -0
  103. package/dist/private-local-worker-server.js +136 -0
  104. package/dist/private-sqlite-owner.d.ts +7 -0
  105. package/dist/private-sqlite-owner.js +103 -0
  106. package/dist/private-team-computers.d.ts +38 -0
  107. package/dist/private-team-computers.js +101 -0
  108. package/dist/private-workerd-sandbox.d.ts +3 -0
  109. package/dist/private-workerd-sandbox.js +347 -0
  110. package/dist/team-apps.d.ts +24 -0
  111. package/dist/team-apps.js +123 -0
  112. package/dist/tracker-git-credential.d.ts +15 -0
  113. package/dist/tracker-git-credential.js +87 -0
  114. package/native/bin/build.json +4037 -0
  115. package/native/bin/fungi-apply-patch +0 -0
  116. package/package.json +148 -3
@@ -0,0 +1,1149 @@
1
+ import { loginIdentity, pendingLogouts, } from "./private-fungi-pending-logouts.js";
2
+ import { FungiSignInRequiredError } from "./private-fungi-auth-error.js";
3
+ import { createServer, } from "node:http";
4
+ import { randomBytes } from "node:crypto";
5
+ import { spawn } from "node:child_process";
6
+ import process from "node:process";
7
+ import { auth, Client, discoverOAuthServerInfo, refreshAuthorization, OAuthError, StreamableHTTPClientTransport, } from "@modelcontextprotocol/client";
8
+ import { z } from "zod";
9
+ import { createPrivateCredentialStore } from "./private-file-credential-store.js";
10
+ import { parseFungiMcpEndpoint } from "./private-fungi-mcp-endpoint.js";
11
+ import { logEvent } from "./log-event.js";
12
+ import { fungiMcpClientInfo, fungiMcpReachableFetch, rejectedFungiMcpAccess, } from "./private-fungi-mcp-connection.js";
13
+ const credentialId = "fungi";
14
+ const localScopes = "openid profile email offline_access models:invoke git:tracker billing:read";
15
+ const callbackHost = "127.0.0.1";
16
+ const callbackPath = "/oauth/callback";
17
+ const registrationPath = "/client-registration";
18
+ /**
19
+ * A non-interactive provider never serves a callback: it refreshes or fails
20
+ * before redirecting. The Hub matches loopback redirects on any port
21
+ * (RFC 8252 §7.3), so the port-free loopback URI names the registered path.
22
+ */
23
+ const nonInteractiveCallbackUri = `http://${callbackHost}${callbackPath}`;
24
+ /**
25
+ * One folder attached to a Team Computer. The absolute folder path and the
26
+ * device's private key never leave this file; the Hub holds only the public
27
+ * key and the two labels.
28
+ */
29
+ const localBackendRecordSchema = z.strictObject({
30
+ requestId: z.string().min(1).max(256),
31
+ teamId: z.string().min(1).max(256),
32
+ computerId: z.string().min(1).max(256),
33
+ computerName: z.string().min(1).max(256),
34
+ rootPath: z.string().min(1).max(4096),
35
+ hostLabel: z.string().min(1).max(256),
36
+ folderLabel: z.string().min(1).max(256),
37
+ publicKey: z.base64().length(44),
38
+ privateKey: z.base64().min(1).max(512),
39
+ backendId: z
40
+ .string()
41
+ .regex(/^local-[a-z2-7]{16}$/)
42
+ .optional(),
43
+ });
44
+ const teamChoiceSchema = z.object({
45
+ teamId: z.string().min(1).max(256),
46
+ handle: z.string().min(1).max(256),
47
+ name: z.string().min(1).max(256),
48
+ });
49
+ const savedCredentialSchema = z.looseObject({
50
+ type: z.literal("oauth"),
51
+ access: z.string(),
52
+ refresh: z.string(),
53
+ expires: z.number(),
54
+ clientId: z.string().min(1).optional(),
55
+ issuer: z.url().optional(),
56
+ clientInformation: z.json().optional(),
57
+ tokens: z.json().optional(),
58
+ codeVerifier: z.string().optional(),
59
+ localBackends: z.array(localBackendRecordSchema).max(16).optional(),
60
+ team: teamChoiceSchema.optional(),
61
+ teamCount: z.number().int().min(0).max(1000).optional(),
62
+ });
63
+ function jsonValue(value) {
64
+ const serialized = JSON.stringify(value);
65
+ const decoded = JSON.parse(serialized);
66
+ return z.json().parse(decoded);
67
+ }
68
+ function localClientMetadata(redirectUrl) {
69
+ return {
70
+ client_name: "Fungi Local",
71
+ redirect_uris: [redirectUrl],
72
+ grant_types: ["authorization_code", "refresh_token"],
73
+ response_types: ["code"],
74
+ token_endpoint_auth_method: "none",
75
+ scope: localScopes,
76
+ };
77
+ }
78
+ function credentialStore(path) {
79
+ return createPrivateCredentialStore(path);
80
+ }
81
+ async function readCredential(path) {
82
+ const credential = await credentialStore(path).read(credentialId);
83
+ if (credential?.type !== "oauth")
84
+ return undefined;
85
+ const result = savedCredentialSchema.safeParse(credential);
86
+ if (!result.success)
87
+ throw new Error("Fungi Local credential data is invalid");
88
+ return result.data;
89
+ }
90
+ async function updateCredential(patch, path) {
91
+ const store = credentialStore(path);
92
+ await store.modify(credentialId, (current) => {
93
+ const parsed = savedCredentialSchema.safeParse(current);
94
+ const previous = parsed.success
95
+ ? parsed.data
96
+ : {
97
+ type: "oauth",
98
+ access: "",
99
+ refresh: "",
100
+ expires: 0,
101
+ };
102
+ const next = savedCredentialSchema.parse(Object.fromEntries(Object.entries({ ...previous, ...patch }).filter(([, value]) => value !== undefined)));
103
+ return Promise.resolve(next);
104
+ });
105
+ }
106
+ function loginCommand(endpoint) {
107
+ return `fungi login --fungi-mcp ${endpoint instanceof URL ? endpoint.href : endpoint}`;
108
+ }
109
+ function openBrowser(url) {
110
+ process.stderr.write(`Open this URL to sign in to Fungi:\n${url.href}\n`);
111
+ launchPlatformBrowser(url);
112
+ }
113
+ /** Hand a Fungi sign-in page to the platform browser; callers show the URL. */
114
+ export function launchPlatformBrowser(url) {
115
+ if (process.platform !== "darwin" && process.platform !== "linux")
116
+ throw new Error("Fungi OAuth login supports macOS and Linux");
117
+ const child = spawn(process.platform === "darwin" ? "open" : "xdg-open", [url.href], {
118
+ detached: true,
119
+ stdio: "ignore",
120
+ });
121
+ child.once("error", (cause) => {
122
+ logEvent("fungi-account.open-browser", cause);
123
+ });
124
+ child.unref();
125
+ }
126
+ function respond(response, status, message) {
127
+ response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
128
+ response.end(`<!doctype html><meta charset="utf-8"><title>Fungi Local</title><p>${message}</p>`);
129
+ }
130
+ /** Persisted OAuth state uses Local's existing owner-only Pi credential file. */
131
+ export function privateFungiAccountOAuthProvider(options = {}) {
132
+ const interactive = options.interactive ?? false;
133
+ const credentialPath = options.credentialPath;
134
+ const issuer = options.issuer ?? options.endpoint?.origin;
135
+ const redirectUrl = options.redirectUrl ?? nonInteractiveCallbackUri;
136
+ const relogin = loginCommand(options.endpoint ?? `${issuer ?? "https://<hub>"}/api/teams/<team>/mcp`);
137
+ const anotherIssuer = (kind, saved) => new Error(`Saved Fungi OAuth ${kind} another issuer (${saved ?? "unknown"}); run ${relogin} to replace it`);
138
+ let savedAuthorizationUrl;
139
+ let expectedState;
140
+ let discovery;
141
+ return {
142
+ get redirectUrl() {
143
+ return redirectUrl;
144
+ },
145
+ get clientMetadata() {
146
+ return localClientMetadata(redirectUrl);
147
+ },
148
+ saveDiscoveryState(state) {
149
+ if (issuer !== undefined &&
150
+ (new URL(state.authorizationServerUrl).origin !== issuer ||
151
+ (state.authorizationServerMetadata?.issuer !== undefined &&
152
+ new URL(state.authorizationServerMetadata.issuer).origin !==
153
+ issuer)))
154
+ throw new Error("Fungi OAuth discovery belongs to another issuer");
155
+ discovery = state;
156
+ },
157
+ discoveryState() {
158
+ return discovery;
159
+ },
160
+ validateResourceURL(_serverUrl, resource) {
161
+ if (issuer === undefined)
162
+ throw new Error("Fungi account issuer is required");
163
+ const expected = new URL("/api/teams", issuer);
164
+ if (resource !== undefined && resource !== expected.href)
165
+ throw new Error("Fungi OAuth resource did not match this account");
166
+ return Promise.resolve(expected);
167
+ },
168
+ state() {
169
+ expectedState = randomBytes(32).toString("base64url");
170
+ return expectedState;
171
+ },
172
+ async clientInformation() {
173
+ const saved = await readCredential(credentialPath);
174
+ const clientId = saved?.clientId;
175
+ if (clientId === undefined)
176
+ return undefined;
177
+ if (issuer !== undefined && saved?.issuer !== issuer)
178
+ throw anotherIssuer("client belongs to", saved?.issuer);
179
+ const parsed = z
180
+ .looseObject({ client_id: z.string().min(1) })
181
+ .safeParse(saved?.clientInformation ?? { client_id: clientId });
182
+ return parsed.success
183
+ ? {
184
+ ...parsed.data,
185
+ client_id: clientId,
186
+ redirect_uris: [redirectUrl],
187
+ token_endpoint_auth_method: "none",
188
+ }
189
+ : undefined;
190
+ },
191
+ async saveClientInformation(information) {
192
+ const parsed = z
193
+ .looseObject({ client_id: z.string().min(1) })
194
+ .parse(information);
195
+ const patch = {
196
+ clientId: parsed.client_id,
197
+ clientInformation: jsonValue(information),
198
+ };
199
+ if (issuer !== undefined)
200
+ patch.issuer = issuer;
201
+ await updateCredential(patch, credentialPath);
202
+ },
203
+ async tokens() {
204
+ const saved = await readCredential(credentialPath);
205
+ if (issuer !== undefined &&
206
+ saved?.tokens !== undefined &&
207
+ saved.issuer !== issuer)
208
+ throw anotherIssuer("tokens belong to", saved.issuer);
209
+ const parsed = z
210
+ .looseObject({
211
+ access_token: z.string().min(1),
212
+ refresh_token: z.string().min(1),
213
+ token_type: z.string().min(1),
214
+ })
215
+ .safeParse(saved?.tokens);
216
+ return parsed.success ? { ...parsed.data } : undefined;
217
+ },
218
+ async saveTokens(tokens) {
219
+ const parsed = jsonValue(tokens);
220
+ const value = z
221
+ .looseObject({
222
+ access_token: z.string().min(1),
223
+ refresh_token: z.string().min(1),
224
+ token_type: z.string().min(1),
225
+ expires_in: z.number().optional(),
226
+ })
227
+ .parse(parsed);
228
+ await updateCredential({
229
+ access: value.access_token,
230
+ refresh: value.refresh_token,
231
+ expires: Date.now() + (value.expires_in ?? 0) * 1_000,
232
+ tokens: jsonValue(tokens),
233
+ team: undefined,
234
+ teamCount: undefined,
235
+ }, credentialPath);
236
+ },
237
+ redirectToAuthorization(url) {
238
+ if (!interactive)
239
+ throw new Error(`Fungi MCP sign-in is missing, expired or revoked; run ${relogin}`);
240
+ savedAuthorizationUrl = new URL(url);
241
+ (options.openBrowser ?? openBrowser)(savedAuthorizationUrl);
242
+ return Promise.resolve();
243
+ },
244
+ async saveCodeVerifier(verifier) {
245
+ await updateCredential({ codeVerifier: verifier }, credentialPath);
246
+ },
247
+ async codeVerifier() {
248
+ const verifier = (await readCredential(credentialPath))?.codeVerifier;
249
+ if (verifier === undefined)
250
+ throw new Error("Fungi OAuth verifier is unavailable");
251
+ return verifier;
252
+ },
253
+ async invalidateCredentials(scope) {
254
+ if (scope === "all") {
255
+ await credentialStore(credentialPath).remove(credentialId, (current) => {
256
+ if ((savedCredentialSchema.parse(current).localBackends ?? [])
257
+ .length > 0)
258
+ throw new Error("Detach this machine's Computers before clearing Fungi account custody");
259
+ return Promise.resolve();
260
+ });
261
+ return;
262
+ }
263
+ if (scope === "client")
264
+ await updateCredential({ clientId: undefined, clientInformation: undefined }, credentialPath);
265
+ if (scope === "tokens")
266
+ await updateCredential({ tokens: undefined, access: "", refresh: "", expires: 0 }, credentialPath);
267
+ if (scope === "verifier")
268
+ await updateCredential({ codeVerifier: undefined }, credentialPath);
269
+ },
270
+ expectedState() {
271
+ return expectedState;
272
+ },
273
+ authorizationUrl() {
274
+ return savedAuthorizationUrl;
275
+ },
276
+ };
277
+ }
278
+ function validateEndpoint(value) {
279
+ return parseFungiMcpEndpoint(value);
280
+ }
281
+ function logoutResult(kind, issuer, unconfirmedComputers = []) {
282
+ const status = kind === "not-logged-in"
283
+ ? `Fungi was not logged in${issuer ? ` to ${issuer}` : ""}.`
284
+ : kind === "revoked" && issuer !== undefined
285
+ ? `Fungi logged out of ${issuer}; the Hub revoked this login.`
286
+ : "Signed out here. Couldn't confirm with Fungi; you can revoke this login under Connected logins.";
287
+ const warnings = unconfirmedComputers.map(({ name, url }) => `Computer ${JSON.stringify(name)} may still be attached and serving commands. Detach it on the Hub Computers page (${url}) or press Ctrl-C in the terminal running fungi computer attach. Cleanup will retry after your next sign-in to this account.`);
288
+ return {
289
+ kind,
290
+ message: [status, ...warnings].join("\n"),
291
+ unconfirmedComputers,
292
+ };
293
+ }
294
+ async function detachForLogout(issuer, target, request) {
295
+ try {
296
+ const path = `/api/v1/teams/${encodeURIComponent(target.teamId)}/computers/${encodeURIComponent(target.computerId)}/backends/local/requests/${encodeURIComponent(target.requestId)}/detach`;
297
+ const response = await request(new URL(path, issuer), { method: "POST" });
298
+ if (!response.ok) {
299
+ await response.body?.cancel();
300
+ return false;
301
+ }
302
+ return z.object({ detached: z.boolean() }).safeParse(await response.json())
303
+ .success;
304
+ }
305
+ catch (cause) {
306
+ logEvent("fungi-account.logout-detach", cause);
307
+ return false;
308
+ }
309
+ }
310
+ /** The configured Hub is the only destination for account protocol traffic. */
311
+ function ownFungiFetch(issuer, fetchImpl, signal) {
312
+ const reachable = fungiMcpReachableFetch(new URL(issuer), fetchImpl);
313
+ return (input, init) => {
314
+ if (new URL(input).origin !== issuer)
315
+ throw new Error("Fungi OAuth refused an endpoint from another issuer");
316
+ const headers = new Headers(init?.headers);
317
+ headers.set("Origin", issuer);
318
+ return reachable(input, {
319
+ ...init,
320
+ headers,
321
+ redirect: "error",
322
+ signal: AbortSignal.any([
323
+ AbortSignal.timeout(30_000),
324
+ ...(signal ? [signal] : []),
325
+ ...(init?.signal ? [init.signal] : []),
326
+ ]),
327
+ });
328
+ };
329
+ }
330
+ /** Revoke the saved refresh grant at the saved issuer's advertised endpoint. */
331
+ async function revokeSavedGrant(saved, fetchImpl) {
332
+ const issuer = saved.issuer;
333
+ const clientId = saved.clientId;
334
+ if (issuer === undefined || clientId === undefined || saved.refresh === "")
335
+ return false;
336
+ try {
337
+ const ownFetch = ownFungiFetch(issuer, fetchImpl);
338
+ const { authorizationServerMetadata } = await discoverOAuthServerInfo(new URL("/api/teams", issuer), { fetchFn: ownFetch });
339
+ // OpenID provider metadata types omit the RFC 7009 field; read it by shape.
340
+ const revocation = z
341
+ .looseObject({ revocation_endpoint: z.url().optional() })
342
+ .safeParse(authorizationServerMetadata).data?.revocation_endpoint;
343
+ // The refresh token goes only to its own issuer.
344
+ if (revocation === undefined || new URL(revocation).origin !== issuer)
345
+ return false;
346
+ const response = await ownFetch(revocation, {
347
+ method: "POST",
348
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
349
+ body: new URLSearchParams({
350
+ token: saved.refresh,
351
+ token_type_hint: "refresh_token",
352
+ client_id: clientId,
353
+ }),
354
+ redirect: "error",
355
+ });
356
+ return response.ok;
357
+ }
358
+ catch (cause) {
359
+ logEvent("fungi-account.revoke", cause);
360
+ // Unreachable or refused: the caller reports that revocation is unconfirmed.
361
+ return false;
362
+ }
363
+ }
364
+ async function removeSavedLogin(saved, options) {
365
+ const pending = pendingLogouts(options.credentialPath);
366
+ const unconfirmed = [];
367
+ const signal = AbortSignal.timeout(30_000);
368
+ const latest = await credentialStore(options.credentialPath).remove(credentialId, async (current) => {
369
+ const value = savedCredentialSchema.parse(current);
370
+ if (value.issuer !== saved.issuer)
371
+ throw new Error("Fungi account changed during sign-out. Retry for the current account.");
372
+ const identity = value.issuer && loginIdentity(value.issuer, value.access);
373
+ if (identity) {
374
+ try {
375
+ await pending.remember(identity, (value.localBackends ?? []).map(({ teamId, computerId, requestId }) => ({
376
+ teamId,
377
+ computerId,
378
+ requestId,
379
+ })));
380
+ }
381
+ catch (cause) {
382
+ // A failed intent write must never retain a credential after logout.
383
+ logEvent("fungi-account.pending-revoke-write", cause);
384
+ }
385
+ }
386
+ // The credential lock keeps concurrent attachment writes out of sign-out.
387
+ // Use the saved bearer directly here: refresh would reacquire that lock.
388
+ if (value.issuer) {
389
+ const issuer = value.issuer;
390
+ const transport = ownFungiFetch(issuer, options.fetch, signal);
391
+ const request = (input, init) => transport(input, {
392
+ ...init,
393
+ headers: { Authorization: `Bearer ${value.access}` },
394
+ });
395
+ for (const target of value.localBackends ?? []) {
396
+ if (await detachForLogout(issuer, target, request)) {
397
+ if (identity) {
398
+ try {
399
+ await pending.confirmed(identity, target);
400
+ }
401
+ catch (cause) {
402
+ logEvent("fungi-account.pending-detach-clear", cause);
403
+ }
404
+ }
405
+ }
406
+ else {
407
+ unconfirmed.push({
408
+ name: target.computerName,
409
+ url: new URL(`/teams/${encodeURIComponent(target.teamId)}/computers`, issuer).href,
410
+ });
411
+ }
412
+ }
413
+ }
414
+ return value;
415
+ });
416
+ if (latest === undefined || latest.refresh === "")
417
+ return logoutResult("not-logged-in", saved.issuer, unconfirmed);
418
+ let revoked = await revokeSavedGrant(latest, options.fetch);
419
+ if (!revoked)
420
+ revoked = await revokeSavedGrant(latest, options.fetch);
421
+ const identity = latest.issuer && loginIdentity(latest.issuer, latest.access);
422
+ if (revoked && identity) {
423
+ try {
424
+ await pending.confirmed(identity);
425
+ }
426
+ catch (cause) {
427
+ logEvent("fungi-account.pending-revoke-clear", cause);
428
+ }
429
+ }
430
+ return logoutResult(revoked ? "revoked" : "removed", latest.issuer, unconfirmed);
431
+ }
432
+ /** Non-secret status of the shared Fungi account. */
433
+ export async function readFungiAccountStatus(options = {}) {
434
+ const saved = await readCredential(options.credentialPath);
435
+ if (saved?.issuer === undefined || saved.access === "")
436
+ return undefined;
437
+ const status = {
438
+ issuer: saved.issuer,
439
+ accessExpiresAt: new Date(saved.expires),
440
+ };
441
+ if (saved.team !== undefined)
442
+ status.team = saved.team;
443
+ if (saved.teamCount !== undefined)
444
+ status.teamCount = saved.teamCount;
445
+ return status;
446
+ }
447
+ /**
448
+ * Remove the saved Fungi MCP login for the endpoint's origin and ask that
449
+ * issuer to revoke its refresh grant. A login for another origin is kept.
450
+ */
451
+ export async function signOutFungiAccount(rawEndpoint, options = {}) {
452
+ const saved = await readCredential(options.credentialPath);
453
+ const origin = rawEndpoint === undefined
454
+ ? saved?.issuer
455
+ : validateEndpoint(rawEndpoint).origin;
456
+ if (saved === undefined || saved.issuer !== origin)
457
+ return logoutResult("not-logged-in", origin);
458
+ return removeSavedLogin(saved, options);
459
+ }
460
+ /**
461
+ * Run one Mycelium control call against the Team MCP endpoint with the saved
462
+ * login, and return the tool's text result. An error result or a transport
463
+ * failure is `undefined`: the caller retries with its persisted request id.
464
+ */
465
+ export async function callFungiMcpTeamControl(teamEndpoint, code, options = {}) {
466
+ const client = new Client(fungiMcpClientInfo);
467
+ const requestInit = { redirect: "error" };
468
+ const connectOptions = {};
469
+ const callOptions = {};
470
+ if (options.signal !== undefined) {
471
+ requestInit.signal = options.signal;
472
+ connectOptions.signal = options.signal;
473
+ callOptions.signal = options.signal;
474
+ }
475
+ if (options.timeoutMs !== undefined)
476
+ callOptions.timeout = options.timeoutMs;
477
+ const transportOptions = {
478
+ authProvider: fungiMcpAccountAuth(teamEndpoint, options),
479
+ requestInit,
480
+ };
481
+ if (options.fetch !== undefined)
482
+ transportOptions.fetch = options.fetch;
483
+ try {
484
+ await client.connect(new StreamableHTTPClientTransport(teamEndpoint, transportOptions), connectOptions);
485
+ const response = z
486
+ .looseObject({
487
+ isError: z.boolean(),
488
+ content: z.array(z.looseObject({ type: z.string(), text: z.string().optional() })),
489
+ })
490
+ .parse(await client.callTool({ name: "execute", arguments: { code } }, callOptions));
491
+ if (response.isError)
492
+ return undefined;
493
+ return response.content.find((item) => item.text !== undefined)?.text;
494
+ }
495
+ catch (cause) {
496
+ logEvent("fungi-mcp.control-request", cause);
497
+ return undefined;
498
+ }
499
+ finally {
500
+ await client.close();
501
+ }
502
+ }
503
+ /** The Team MCP endpoint and Team a control call addresses, validated. */
504
+ export function fungiMcpTeamEndpoint(rawEndpoint) {
505
+ const endpoint = validateEndpoint(rawEndpoint);
506
+ const routeParts = z
507
+ .tuple([
508
+ z.literal("api"),
509
+ z.literal("teams"),
510
+ z.string().min(1).max(256),
511
+ z.literal("mcp"),
512
+ ])
513
+ .safeParse(endpoint.pathname.split("/").filter((part) => part.length > 0));
514
+ if (!routeParts.success)
515
+ throw new Error("A Team MCP endpoint is required: https://<hub>/api/teams/<team>/mcp");
516
+ let teamId;
517
+ try {
518
+ teamId = decodeURIComponent(routeParts.data[2]);
519
+ }
520
+ catch (cause) {
521
+ throw new Error("The Team MCP endpoint's Team ID is invalid", { cause });
522
+ }
523
+ return { endpoint, teamId };
524
+ }
525
+ /** The machine's attached folders, as Local keeps them. */
526
+ export async function readFungiMcpLocalBackends(options = {}) {
527
+ const saved = await readCredential(options.credentialPath);
528
+ return {
529
+ issuer: saved?.issuer,
530
+ backends: saved?.localBackends ?? [],
531
+ };
532
+ }
533
+ /** Replace the attached folder records in the owner-only credential file. */
534
+ export async function writeFungiMcpLocalBackends(update, options = {}) {
535
+ await credentialStore(options.credentialPath).modify(credentialId, (current) => {
536
+ const saved = savedCredentialSchema.parse(current);
537
+ if (saved.access === "")
538
+ throw new Error("Fungi sign-in is required before attaching a folder");
539
+ return Promise.resolve(savedCredentialSchema.parse({
540
+ ...saved,
541
+ localBackends: update(saved.localBackends ?? []),
542
+ }));
543
+ });
544
+ }
545
+ /** A new session, never the saved family ID, authorizes delayed logout cleanup. */
546
+ async function retryPendingLogout(endpoint, options) {
547
+ try {
548
+ const saved = await readCredential(options.credentialPath);
549
+ if (saved?.issuer !== endpoint.origin)
550
+ return;
551
+ const identity = loginIdentity(saved.issuer, saved.access);
552
+ if (identity === undefined)
553
+ return;
554
+ const pending = pendingLogouts(options.credentialPath);
555
+ const records = await pending.forAccount(identity);
556
+ // Recovery must not hold up a successful sign-in across many offline requests.
557
+ const signal = AbortSignal.any([
558
+ AbortSignal.timeout(30_000),
559
+ ...(options.signal ? [options.signal] : []),
560
+ ]);
561
+ const request = fungiAccountFetch(endpoint, { ...options, signal });
562
+ for (const record of records) {
563
+ for (const target of record.detaches) {
564
+ if (signal.aborted)
565
+ return;
566
+ if (await detachForLogout(endpoint.origin, target, request))
567
+ await pending.confirmed(record, target);
568
+ }
569
+ if (!record.revoke)
570
+ continue;
571
+ const response = await request(new URL("/api/local/oauth-logins/revoke", endpoint.origin), {
572
+ method: "POST",
573
+ headers: { "Content-Type": "application/json" },
574
+ body: JSON.stringify({ grantId: record.loginId }),
575
+ });
576
+ await response.body?.cancel();
577
+ if (!response.ok)
578
+ break;
579
+ await pending.confirmed(record);
580
+ }
581
+ }
582
+ catch (cause) {
583
+ // Keep the IDs for the next successful sign-in; never undo the new session.
584
+ logEvent("fungi-account.pending-revoke", cause);
585
+ }
586
+ }
587
+ /** Complete first-party registration and OAuth authorization in a foreground TUI. */
588
+ export async function signInFungiAccount(rawEndpoint, options = {}) {
589
+ options.signal?.throwIfAborted();
590
+ const endpoint = validateEndpoint(rawEndpoint);
591
+ // Check the Hub before offering a browser handoff or replacing a saved login.
592
+ // Any HTTP reply below 500 proves reachability; this is not an auth request.
593
+ const reachabilitySignal = AbortSignal.any([
594
+ AbortSignal.timeout(5_000),
595
+ ...(options.signal === undefined ? [] : [options.signal]),
596
+ ]);
597
+ try {
598
+ const response = await (options.fetch ?? fetch)(endpoint.origin, {
599
+ method: "HEAD",
600
+ redirect: "manual",
601
+ signal: reachabilitySignal,
602
+ });
603
+ await response.body?.cancel();
604
+ if (response.status >= 500)
605
+ throw new Error("Hub unavailable");
606
+ }
607
+ catch {
608
+ options.signal?.throwIfAborted();
609
+ throw new TypeError(`Can't reach ${endpoint.host}. Check your connection and try again.`);
610
+ }
611
+ const saved = await readCredential(options.credentialPath);
612
+ if (saved !== undefined &&
613
+ (saved.clientId !== undefined || saved.tokens !== undefined) &&
614
+ saved.issuer !== endpoint.origin) {
615
+ const savedIssuer = saved.issuer ?? "an earlier Fungi origin";
616
+ const replace = (await options.confirmReplace?.(savedIssuer, endpoint.origin)) ?? false;
617
+ if (!replace)
618
+ throw new Error(`Fungi MCP is logged in to ${savedIssuer}. Run fungi logout --fungi-mcp ${saved.issuer ?? "<url>"} first, or rerun this login in a terminal to replace it.`);
619
+ const logout = await removeSavedLogin(saved, options);
620
+ if (logout.unconfirmedComputers.length > 0)
621
+ throw new Error(logout.message);
622
+ }
623
+ const fetchImpl = ownFungiFetch(endpoint.origin, options.fetch, options.signal);
624
+ const launchBrowser = options.openBrowser ?? openBrowser;
625
+ let resolveRegistration;
626
+ let rejectRegistration;
627
+ const registration = new Promise((resolve, reject) => {
628
+ resolveRegistration = resolve;
629
+ rejectRegistration = reject;
630
+ });
631
+ let resolveAuthorization;
632
+ let rejectAuthorization;
633
+ const authorization = new Promise((resolve, reject) => {
634
+ resolveAuthorization = resolve;
635
+ rejectAuthorization = reject;
636
+ });
637
+ // Callbacks may race ahead of their awaits; a timeout is reported once.
638
+ const registrationOutcome = registration.then((clientId) => ({
639
+ kind: "registered",
640
+ clientId,
641
+ }), (cause) => ({
642
+ kind: "failed",
643
+ cause,
644
+ }));
645
+ const authorizationOutcome = authorization.then((params) => ({
646
+ kind: "authorized",
647
+ params,
648
+ }), (cause) => ({
649
+ kind: "failed",
650
+ cause,
651
+ }));
652
+ const setupState = randomBytes(32).toString("base64url");
653
+ let clientId;
654
+ // RFC 8252 §7.3: the OS picks a free loopback port; the Hub accepts any port.
655
+ const server = createServer();
656
+ await new Promise((resolve, reject) => {
657
+ server.once("error", reject);
658
+ server.listen(0, callbackHost, resolve);
659
+ });
660
+ const { port } = z
661
+ .object({ port: z.number().int().positive() })
662
+ .parse(server.address());
663
+ const callbackOrigin = `http://${callbackHost}:${String(port)}`;
664
+ const redirectUrl = `${callbackOrigin}${callbackPath}`;
665
+ const providerOptions = {
666
+ interactive: true,
667
+ endpoint,
668
+ redirectUrl,
669
+ openBrowser: launchBrowser,
670
+ };
671
+ if (options.credentialPath !== undefined)
672
+ providerOptions.credentialPath = options.credentialPath;
673
+ const provider = privateFungiAccountOAuthProvider(providerOptions);
674
+ // The browser is opened only after this handler is attached.
675
+ server.on("request", (request, response) => {
676
+ const url = new URL(request.url ?? "/", `http://${callbackHost}`);
677
+ if (url.pathname === registrationPath && clientId === undefined) {
678
+ const receivedState = url.searchParams.get("state");
679
+ const receivedClientId = url.searchParams.get("client_id");
680
+ if (receivedState !== setupState || !receivedClientId) {
681
+ respond(response, 400, "Local OAuth registration did not match this login. Return to the terminal and retry.");
682
+ rejectRegistration(new Error("Local OAuth registration response was invalid"));
683
+ return;
684
+ }
685
+ clientId = receivedClientId;
686
+ resolveRegistration(receivedClientId);
687
+ respond(response, 200, "Fungi Local is registered. Return to your terminal to continue sign in.");
688
+ return;
689
+ }
690
+ if (url.pathname === callbackPath) {
691
+ const params = url.searchParams;
692
+ if (params.get("state") !== provider.expectedState()) {
693
+ respond(response, 400, "OAuth state did not match. Return to the terminal and retry.");
694
+ rejectAuthorization(new Error("Fungi OAuth callback state did not match"));
695
+ return;
696
+ }
697
+ resolveAuthorization(params);
698
+ respond(response, 200, "Fungi sign-in returned to Local. Continue in your terminal to choose a Team.");
699
+ return;
700
+ }
701
+ respond(response, 404, "Fungi Local callback not found.");
702
+ });
703
+ const onAbort = () => {
704
+ const cause = new Error("Fungi sign-in cancelled", {
705
+ cause: options.signal?.reason,
706
+ });
707
+ rejectRegistration(cause);
708
+ rejectAuthorization(cause);
709
+ };
710
+ options.signal?.addEventListener("abort", onAbort, { once: true });
711
+ if (options.signal?.aborted)
712
+ onAbort();
713
+ const timeout = setTimeout(() => {
714
+ const error = new Error("Fungi OAuth login timed out");
715
+ rejectRegistration(error);
716
+ rejectAuthorization(error);
717
+ }, 5 * 60_000);
718
+ try {
719
+ // Explicit sign-in refreshes the existing Hub-owned client registration
720
+ // before requesting current scopes. Existing tokens gain no authority.
721
+ const previousClientId = (await provider.clientInformation())?.client_id;
722
+ const setupUrl = new URL("/oauth/local-client", endpoint.origin);
723
+ setupUrl.search = new URLSearchParams({
724
+ redirect_uri: `${callbackOrigin}${registrationPath}`,
725
+ state: setupState,
726
+ }).toString();
727
+ launchBrowser(setupUrl);
728
+ clientId = await Promise.race([
729
+ registrationOutcome.then((outcome) => {
730
+ if (outcome.kind === "failed")
731
+ throw outcome.cause;
732
+ return outcome.clientId;
733
+ }),
734
+ authorizationOutcome.then((outcome) => {
735
+ if (outcome.kind === "failed")
736
+ throw outcome.cause;
737
+ return "";
738
+ }),
739
+ ]);
740
+ if (!clientId)
741
+ throw new Error("OAuth callback arrived before Local registration");
742
+ if (previousClientId !== undefined && clientId !== previousClientId)
743
+ throw new Error("Fungi Local registration belongs to another account. Use the browser account for your saved login, or detach its Computers and sign out before switching accounts.");
744
+ await provider.saveClientInformation({
745
+ client_id: clientId,
746
+ redirect_uris: [redirectUrl],
747
+ token_endpoint_auth_method: "none",
748
+ });
749
+ const resource = new URL("/api/teams", endpoint.origin);
750
+ const ownIssuerFetch = fetchImpl;
751
+ const result = await auth(provider, {
752
+ serverUrl: resource,
753
+ scope: localScopes,
754
+ fetchFn: ownIssuerFetch,
755
+ forceReauthorization: true,
756
+ });
757
+ if (result === "REDIRECT") {
758
+ const callback = await authorizationOutcome;
759
+ if (callback.kind === "failed")
760
+ throw callback.cause;
761
+ const code = callback.params.get("code");
762
+ if (callback.params.has("error") || code === null)
763
+ throw new Error("Fungi sign-in was declined or did not return an authorization code");
764
+ const callbackIssuer = callback.params.get("iss");
765
+ const exchange = {
766
+ serverUrl: resource,
767
+ authorizationCode: code,
768
+ fetchFn: ownIssuerFetch,
769
+ };
770
+ if (callbackIssuer !== null)
771
+ exchange.iss = callbackIssuer;
772
+ await auth(provider, exchange);
773
+ }
774
+ await retryPendingLogout(endpoint, options);
775
+ await chooseFungiAccountTeam(endpoint, options);
776
+ }
777
+ finally {
778
+ clearTimeout(timeout);
779
+ options.signal?.removeEventListener("abort", onAbort);
780
+ await new Promise((resolve) => {
781
+ server.close(() => {
782
+ resolve();
783
+ });
784
+ });
785
+ }
786
+ }
787
+ const teamPageSchema = z.looseObject({
788
+ kind: z.literal("accepted"),
789
+ items: z.array(teamChoiceSchema).max(100),
790
+ nextCursor: z.string().min(1).max(4096).nullable(),
791
+ });
792
+ /** Read current memberships; the selected model Team never changes project MCP addressing. */
793
+ async function accountTeams(rawHub, options) {
794
+ const issuer = validateEndpoint(rawHub).origin;
795
+ const teams = [];
796
+ let cursor = null;
797
+ for (let page = 0; page < 10; page += 1) {
798
+ const url = new URL("/api/v1/teams", issuer);
799
+ url.searchParams.set("limit", "100");
800
+ if (cursor !== null)
801
+ url.searchParams.set("cursor", cursor);
802
+ const response = await fungiAccountFetch(issuer, options)(url, {
803
+ redirect: "error",
804
+ signal: AbortSignal.timeout(30_000),
805
+ });
806
+ if (!response.ok)
807
+ throw new Error(response.status === 401 || response.status === 403
808
+ ? "Sign in to Fungi again to choose a Team"
809
+ : "Fungi is unreachable or could not list your Teams. Retry.");
810
+ const data = teamPageSchema.parse(await response.json());
811
+ teams.push(...data.items);
812
+ if (data.nextCursor === null)
813
+ return teams;
814
+ cursor = data.nextCursor;
815
+ }
816
+ throw new Error("Your Team list is too large for this picker. Contact Fungi support.");
817
+ }
818
+ async function saveSelectedTeam(issuer, team, teamCount, credentialPath) {
819
+ await credentialStore(credentialPath).modify(credentialId, (current) => {
820
+ const saved = savedCredentialSchema.parse(current);
821
+ if (saved.issuer !== issuer || saved.access === "")
822
+ throw new Error("Fungi account changed while choosing a Team. Sign in again.");
823
+ return Promise.resolve(savedCredentialSchema.parse({ ...saved, team, teamCount }));
824
+ });
825
+ }
826
+ async function chooseFungiAccountTeam(rawHub, options) {
827
+ const issuer = validateEndpoint(rawHub).origin;
828
+ const teams = await accountTeams(issuer, options);
829
+ if (teams.length === 0)
830
+ throw new Error(`Create or join a Team at ${issuer} before using Fungi models`);
831
+ const selectedId = teams.length === 1 ? teams[0]?.teamId : await options.chooseTeam?.(teams);
832
+ const team = teams.find((candidate) => candidate.teamId === selectedId);
833
+ if (team === undefined)
834
+ throw new Error("Choose a current Team to pay for Fungi models");
835
+ await saveSelectedTeam(issuer, team, teams.length, options.credentialPath);
836
+ }
837
+ /** Switch only to a fresh membership; failure preserves the prior selected Team. */
838
+ export async function switchFungiAccountTeam(teamId, options = {}) {
839
+ const saved = await readCredential(options.credentialPath);
840
+ if (saved?.issuer === undefined)
841
+ throw new Error("Sign in to Fungi to choose a Team");
842
+ const teams = await accountTeams(saved.issuer, options);
843
+ const selected = teams.find((team) => team.teamId === teamId);
844
+ if (selected === undefined)
845
+ throw new Error("You are not a current member of that Team");
846
+ await saveSelectedTeam(saved.issuer, selected, teams.length, options.credentialPath);
847
+ }
848
+ /** Fresh memberships for the saved account, with the Team that pays for models. */
849
+ export async function readFungiAccountTeams(options = {}) {
850
+ const saved = await readCredential(options.credentialPath);
851
+ if (saved?.issuer === undefined || saved.access === "")
852
+ throw new Error("Sign in to Fungi to list your Teams");
853
+ const teams = await accountTeams(saved.issuer, options);
854
+ return saved.team === undefined
855
+ ? { teams }
856
+ : { teams, selectedTeamId: saved.team.teamId };
857
+ }
858
+ /** The Hub billing read's JSON money snapshot (Billing owns the full shape). */
859
+ const moneySnapshotSchema = z.object({
860
+ amount: z
861
+ .string()
862
+ .max(16)
863
+ .regex(/^(?:0|[1-9]\d*)$/u),
864
+ currency: z.object({
865
+ code: z.literal("USD"),
866
+ base: z.literal(10),
867
+ exponent: z.literal(2),
868
+ }),
869
+ scale: z.literal(5),
870
+ });
871
+ const teamBalanceSchema = z.object({
872
+ teamId: z.string().min(1).max(256),
873
+ balance: moneySnapshotSchema,
874
+ included: z
875
+ .strictObject({
876
+ granted: moneySnapshotSchema,
877
+ remaining: moneySnapshotSchema,
878
+ expiresAt: z.number().int().nonnegative(),
879
+ })
880
+ .nullable(),
881
+ reserved: moneySnapshotSchema,
882
+ available: moneySnapshotSchema,
883
+ });
884
+ export async function readFungiTeamBalance(options = {}) {
885
+ const saved = await readCredential(options.credentialPath);
886
+ if (saved?.issuer === undefined || saved.access === "")
887
+ throw new Error("Sign in to Fungi to read your Team balance");
888
+ if (saved.team === undefined)
889
+ return { kind: "not-available", reason: "no-team" };
890
+ const url = new URL(`/api/teams/${encodeURIComponent(saved.team.teamId)}/billing/balance`, saved.issuer);
891
+ // One request with fresh access: a Hub that refuses this sign-in answers
892
+ // 401, which is an absence here, not a reason to rotate the grant.
893
+ const access = await freshFungiAccess(saved.issuer, options);
894
+ const response = await ownFungiFetch(saved.issuer, options.fetch)(url.href, {
895
+ headers: { Authorization: `Bearer ${access}` },
896
+ });
897
+ const refusal = {
898
+ 401: "sign-in-not-admitted",
899
+ 403: "sign-in-again",
900
+ 404: "not-a-member",
901
+ }[response.status];
902
+ if (refusal !== undefined) {
903
+ await response.body?.cancel();
904
+ return { kind: "not-available", reason: refusal };
905
+ }
906
+ if (!response.ok)
907
+ throw new Error("Fungi is unreachable or could not read your Team balance. Retry.");
908
+ const credit = teamBalanceSchema.parse(await response.json());
909
+ if (credit.teamId !== saved.team.teamId)
910
+ throw new Error("Fungi returned the balance of a different Team");
911
+ return { kind: "balance", credit };
912
+ }
913
+ /** Called only inside the shared credential lock, including Pi's refresh callback. */
914
+ async function refreshSavedCredential(saved, options) {
915
+ const issuer = saved.issuer;
916
+ const clientId = saved.clientId;
917
+ if (issuer === undefined || clientId === undefined || saved.refresh === "")
918
+ throw new FungiSignInRequiredError();
919
+ const endpoint = new URL("/api/teams", validateEndpoint(issuer));
920
+ const ownIssuerFetch = ownFungiFetch(issuer, options.fetch, options.signal);
921
+ try {
922
+ const info = await discoverOAuthServerInfo(endpoint, {
923
+ fetchFn: ownIssuerFetch,
924
+ });
925
+ if (new URL(info.authorizationServerUrl).origin !== issuer ||
926
+ (info.resourceMetadata !== undefined &&
927
+ info.resourceMetadata.resource !== endpoint.href))
928
+ throw new Error("Fungi OAuth discovery did not match the account issuer and resource");
929
+ const refresh = {
930
+ clientInformation: {
931
+ client_id: clientId,
932
+ token_endpoint_auth_method: "none",
933
+ },
934
+ refreshToken: saved.refresh,
935
+ resource: endpoint,
936
+ fetchFn: ownIssuerFetch,
937
+ };
938
+ if (info.authorizationServerMetadata !== undefined)
939
+ refresh.metadata = info.authorizationServerMetadata;
940
+ const tokens = await refreshAuthorization(info.authorizationServerUrl, refresh);
941
+ return savedCredentialSchema.parse({
942
+ ...saved,
943
+ access: tokens.access_token,
944
+ refresh: tokens.refresh_token ?? saved.refresh,
945
+ expires: Date.now() + (tokens.expires_in ?? 0) * 1000,
946
+ tokens: jsonValue(tokens),
947
+ });
948
+ }
949
+ catch (cause) {
950
+ logEvent("fungi-account.refresh", cause);
951
+ if (cause instanceof OAuthError &&
952
+ ["invalid_grant", "invalid_client", "unauthorized_client"].includes(cause.code))
953
+ throw new FungiSignInRequiredError({
954
+ cause,
955
+ });
956
+ throw new Error("Fungi is unreachable or could not refresh your sign-in. Retry or sign in again.", { cause });
957
+ }
958
+ }
959
+ /** True once the saved access token is past its life or already refused. */
960
+ function fungiAccessNeedsRefresh(saved, rejectedAccess) {
961
+ return saved.expires <= Date.now() || saved.access === rejectedAccess;
962
+ }
963
+ /** The cached access token, only when it is this issuer's and still good. */
964
+ function cachedFungiAccess(cached, issuer, rejectedAccess) {
965
+ if (cached?.issuer !== issuer ||
966
+ cached.access === "" ||
967
+ fungiAccessNeedsRefresh(cached, rejectedAccess))
968
+ return undefined;
969
+ return cached.access;
970
+ }
971
+ /**
972
+ * One cross-process credential lock serves MCP and model access, but only
973
+ * when the saved access token actually needs it: a plain read never takes
974
+ * the lock or rewrites the file, so a still-valid credential stays untouched.
975
+ */
976
+ export async function freshFungiAccess(rawHub, options = {}) {
977
+ const issuer = validateEndpoint(rawHub).origin;
978
+ options.signal?.throwIfAborted();
979
+ const cached = await readCredential(options.credentialPath);
980
+ const stillValid = cachedFungiAccess(cached, issuer, options.rejectedAccess);
981
+ if (stillValid !== undefined)
982
+ return stillValid;
983
+ const current = await credentialStore(options.credentialPath).modify(credentialId, async (value) => {
984
+ if (value?.type !== "oauth")
985
+ throw new Error("Sign in to Fungi to use models and Computers");
986
+ const saved = savedCredentialSchema.parse(value);
987
+ if (saved.issuer !== issuer)
988
+ throw new Error("Saved Fungi sign-in belongs to another issuer");
989
+ if (saved.access === "")
990
+ throw new FungiSignInRequiredError();
991
+ return fungiAccessNeedsRefresh(saved, options.rejectedAccess)
992
+ ? refreshSavedCredential(saved, options)
993
+ : saved;
994
+ }, options.signal === undefined ? undefined : { signal: options.signal });
995
+ return savedCredentialSchema.parse(current).access;
996
+ }
997
+ /** Retry an explicitly refused request once; the cloned body is replayable. */
998
+ export function fungiAccountFetch(rawHub, options = {}) {
999
+ const issuer = validateEndpoint(rawHub).origin;
1000
+ const transport = ownFungiFetch(issuer, options.fetch, options.signal);
1001
+ const requestWithRetry = async (input, init) => {
1002
+ const request = input instanceof Request
1003
+ ? new Request(input, { ...init, redirect: "error" })
1004
+ : new Request(String(input), { ...init, redirect: "error" });
1005
+ if (new URL(request.url).origin !== issuer)
1006
+ throw new Error("Fungi refused a request to another issuer");
1007
+ const supplied = request.headers.get("Authorization");
1008
+ const access = supplied?.startsWith("Bearer ")
1009
+ ? supplied.slice(7)
1010
+ : await freshFungiAccess(issuer, options);
1011
+ request.headers.set("Authorization", `Bearer ${access}`);
1012
+ const retry = request.clone();
1013
+ const response = await transport(request.url, {
1014
+ method: request.method,
1015
+ headers: Object.fromEntries(request.headers),
1016
+ body: request.body,
1017
+ signal: request.signal,
1018
+ redirect: "error",
1019
+ duplex: "half",
1020
+ });
1021
+ if (response.status !== 401)
1022
+ return response;
1023
+ await response.body?.cancel();
1024
+ const renewed = await freshFungiAccess(issuer, {
1025
+ ...options,
1026
+ rejectedAccess: access,
1027
+ });
1028
+ retry.headers.set("Authorization", `Bearer ${renewed}`);
1029
+ return transport(retry.url, {
1030
+ method: retry.method,
1031
+ headers: Object.fromEntries(retry.headers),
1032
+ body: retry.body,
1033
+ signal: retry.signal,
1034
+ redirect: "error",
1035
+ duplex: "half",
1036
+ });
1037
+ };
1038
+ return Object.assign(requestWithRetry, {
1039
+ preconnect: globalThis.fetch.preconnect,
1040
+ });
1041
+ }
1042
+ /** The SDK owns exactly one retry; this hook rotates only the refused credential. */
1043
+ export function fungiMcpAccountAuth(endpoint, options = {}) {
1044
+ let lastToken;
1045
+ return {
1046
+ token: async () => {
1047
+ lastToken = await freshFungiAccess(endpoint, options);
1048
+ return lastToken;
1049
+ },
1050
+ onUnauthorized: async ({ response }) => {
1051
+ const rejectedAccess = rejectedFungiMcpAccess(response) ?? lastToken;
1052
+ if (rejectedAccess === undefined)
1053
+ throw new FungiSignInRequiredError();
1054
+ await freshFungiAccess(endpoint, { ...options, rejectedAccess });
1055
+ },
1056
+ };
1057
+ }
1058
+ /** Selected payer metadata belongs to the shared account, never a model request body. */
1059
+ export function fungiAccountTeam(credential) {
1060
+ const saved = savedCredentialSchema.parse(credential);
1061
+ if (saved.team === undefined)
1062
+ throw new Error("Choose a Team with fungi login --provider fungi before using Fungi models");
1063
+ return saved.team;
1064
+ }
1065
+ /** Pi uses its own locked modify; this owner supplies the same refresh as MCP. */
1066
+ export function createFungiAccountOAuth(options = {}) {
1067
+ return {
1068
+ name: "Fungi",
1069
+ loginLabel: "Sign in to Fungi",
1070
+ isSubscription: true,
1071
+ async login(interaction) {
1072
+ const saved = await readCredential(options.credentialPath);
1073
+ await signInFungiAccount(saved?.issuer ?? "https://fungi.computer", {
1074
+ ...options,
1075
+ signal: interaction.signal,
1076
+ openBrowser: (url) => {
1077
+ interaction.notify({ type: "auth_url", url: url.href });
1078
+ },
1079
+ chooseTeam: (teams) => interaction.prompt({
1080
+ type: "select",
1081
+ signal: interaction.signal,
1082
+ message: "Which Team pays for Fungi models?",
1083
+ options: teams.map((team) => ({
1084
+ id: team.teamId,
1085
+ label: team.name,
1086
+ description: team.handle,
1087
+ })),
1088
+ }),
1089
+ });
1090
+ const current = await readCredential(options.credentialPath);
1091
+ if (current === undefined || current.access === "")
1092
+ throw new Error("Fungi sign-in did not finish");
1093
+ return current;
1094
+ },
1095
+ refresh: (credential, signal) => refreshSavedCredential(savedCredentialSchema.parse(credential), {
1096
+ ...options,
1097
+ signal,
1098
+ }),
1099
+ toAuth(credential) {
1100
+ const saved = savedCredentialSchema.parse(credential);
1101
+ if (saved.issuer === undefined || saved.access === "")
1102
+ throw new Error("Sign in to Fungi to use models");
1103
+ const team = fungiAccountTeam(credential);
1104
+ const issuer = validateEndpoint(saved.issuer).origin;
1105
+ if (issuer !== saved.issuer)
1106
+ throw new Error("Saved Fungi account issuer is invalid");
1107
+ return Promise.resolve({
1108
+ apiKey: saved.access,
1109
+ baseUrl: `${issuer}/api/v1/teams/${encodeURIComponent(team.teamId)}`,
1110
+ });
1111
+ },
1112
+ };
1113
+ }
1114
+ /** Pi deletion clears Local custody and reports unconfirmed remote revocation. */
1115
+ export function createFungiAccountCredentialStore(options = {}) {
1116
+ const store = credentialStore(options.credentialPath);
1117
+ return {
1118
+ async read(id, operation) {
1119
+ const current = await store.read(id, operation);
1120
+ if (id !== credentialId || current === undefined)
1121
+ return current;
1122
+ const saved = savedCredentialSchema.parse(current);
1123
+ return saved.access === "" || saved.team === undefined
1124
+ ? undefined
1125
+ : saved;
1126
+ },
1127
+ async list(operation) {
1128
+ const entries = await store.list(operation);
1129
+ const current = await store.read(credentialId, operation);
1130
+ const saved = current === undefined
1131
+ ? undefined
1132
+ : savedCredentialSchema.parse(current);
1133
+ return saved !== undefined &&
1134
+ saved.access !== "" &&
1135
+ saved.team !== undefined
1136
+ ? entries
1137
+ : entries.filter((entry) => entry.providerId !== credentialId);
1138
+ },
1139
+ modify: (id, callback, operation) => store.modify(id, callback, operation),
1140
+ async delete(id, operation) {
1141
+ if (id !== credentialId)
1142
+ return store.delete(id, operation);
1143
+ operation?.signal?.throwIfAborted();
1144
+ const result = await signOutFungiAccount(undefined, options);
1145
+ if (result.kind === "removed" || result.unconfirmedComputers.length > 0)
1146
+ throw new Error(result.message);
1147
+ },
1148
+ };
1149
+ }