@fargowealth/fargo-ai-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,17 @@
1
1
  # Fargo CLI
2
2
 
3
- `fargo-cli` provides WorkBuddy with Device Authorization Grant login and a local stdio bridge to the hosted Fargo AI MCP server.
3
+ `fargo-cli` provides WorkBuddy with OAuth Authorization Code + PKCE login over an ephemeral loopback callback and a local stdio bridge to the hosted Fargo AI MCP server. Device Authorization Grant remains available as an explicit fallback for restricted environments.
4
4
 
5
5
  ```sh
6
6
  npm install --global @fargowealth/fargo-ai-cli
7
7
  fargo workbuddy install --open-browser=always --output=jsonl
8
+ fargo auth login --open-browser=always --output=jsonl
8
9
  fargo auth device --open-browser=always --output=jsonl
9
10
  fargo auth status --output=json
10
11
  fargo mcp serve
11
12
  fargo auth logout
12
13
  ```
13
14
 
14
- OAuth credentials are stored in macOS Keychain, protected with Windows DPAPI, or written to an owner-only file on Linux.
15
+ The default login opens the shared Fargo AI consent page and returns to `http://127.0.0.1:<random-port>/oauth/callback/...`. The CLI binds only to the IPv4 loopback interface, validates OAuth `state`, uses PKCE S256, closes the listener after the callback, and never prints the verifier or private OAuth credentials.
16
+
17
+ OAuth credentials are stored in macOS Keychain, protected with Windows DPAPI, or written to an owner-only file on Linux. Refresh-token rotation is serialized across local CLI processes so multiple WorkBuddy tasks can safely share the same credential.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fargowealth/fargo-ai-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Fargo AI authentication and stdio MCP bridge for WorkBuddy",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auth.mjs CHANGED
@@ -1,5 +1,90 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
2
3
  import { CLI_VERSION, DEFAULT_AUTH_URL, DEFAULT_MCP_URL, DEVICE_CODE_GRANT_TYPE, OAUTH_CLIENT_ID, OAUTH_SCOPE } from "./constants.mjs";
4
+ import { createLoopbackReceiver } from "./loopback-receiver.mjs";
5
+
6
+ export async function authorizePkce(store, options = {}) {
7
+ const fetchImpl = options.fetchImpl ?? fetch;
8
+ const authUrl = options.authUrl ?? process.env.FARGO_AUTH_URL ?? DEFAULT_AUTH_URL;
9
+ const mcpUrl = options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL;
10
+ const metadata = await discoverOAuthMetadata(authUrl, fetchImpl);
11
+ const random = options.randomBytes ?? randomBytes;
12
+ const verifier = random(32).toString("base64url");
13
+ const state = random(32).toString("base64url");
14
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
15
+ const receiver = await (options.loopbackReceiverFactory ?? createLoopbackReceiver)({
16
+ expectedState: state,
17
+ randomBytes: random,
18
+ timeoutMs: options.authorizationTimeoutMs
19
+ });
20
+
21
+ try {
22
+ const registrationResponse = await fetchImpl(metadata.registration_endpoint, {
23
+ method: "POST",
24
+ headers: { "Content-Type": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
25
+ body: JSON.stringify({
26
+ agent_type: "workbuddy",
27
+ client_name: "Fargo CLI for WorkBuddy",
28
+ grant_types: ["authorization_code", "refresh_token"],
29
+ redirect_uris: [receiver.redirectUri],
30
+ response_types: ["code"],
31
+ software_id: "@fargowealth/fargo-ai-cli",
32
+ software_version: CLI_VERSION,
33
+ token_endpoint_auth_method: "none"
34
+ })
35
+ });
36
+ const registration = await readOAuthResponse(registrationResponse);
37
+ if (typeof registration.client_id !== "string" || !registration.client_id) {
38
+ throw new Error("OAuth client registration did not return a client_id");
39
+ }
40
+
41
+ const authorizationUrl = new URL(metadata.authorization_endpoint);
42
+ authorizationUrl.search = new URLSearchParams({
43
+ client_id: registration.client_id,
44
+ code_challenge: challenge,
45
+ code_challenge_method: "S256",
46
+ redirect_uri: receiver.redirectUri,
47
+ resource: mcpUrl,
48
+ response_type: "code",
49
+ scope: OAUTH_SCOPE,
50
+ state
51
+ }).toString();
52
+ await options.onAuthorization?.({ authorizationUrl: authorizationUrl.toString(), redirectUri: receiver.redirectUri });
53
+ if (options.openBrowser !== false) (options.browserOpener ?? openBrowser)(authorizationUrl.toString());
54
+
55
+ const callback = await receiver.waitForCallback();
56
+ if (callback.searchParams.get("state") !== state) {
57
+ throw new Error("OAuth callback state did not match the pending Fargo authorization");
58
+ }
59
+ const oauthError = callback.searchParams.get("error");
60
+ if (oauthError) throw new Error(`OAuth authorization was not completed (${oauthError})`);
61
+ const code = callback.searchParams.get("code");
62
+ if (!code) throw new Error("OAuth callback did not contain an authorization code");
63
+
64
+ const tokenResponse = await fetchImpl(metadata.token_endpoint, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
67
+ body: new URLSearchParams({
68
+ client_id: registration.client_id,
69
+ code,
70
+ code_verifier: verifier,
71
+ grant_type: "authorization_code",
72
+ redirect_uri: receiver.redirectUri
73
+ })
74
+ });
75
+ const payload = await readOAuthResponse(tokenResponse);
76
+ const credential = credentialFromToken(payload, authUrl, {
77
+ authorization_method: "authorization_code_pkce",
78
+ client_id: registration.client_id,
79
+ resource_url: mcpUrl,
80
+ token_endpoint: metadata.token_endpoint
81
+ });
82
+ await store.save(credential);
83
+ return credential;
84
+ } finally {
85
+ await receiver.close();
86
+ }
87
+ }
3
88
 
4
89
  export async function startDeviceAuthorization(options = {}) {
5
90
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -28,7 +113,12 @@ export async function pollDeviceAuthorization(device, store, options = {}) {
28
113
  });
29
114
  const payload = await response.json().catch(() => ({}));
30
115
  if (response.ok) {
31
- const credential = credentialFromToken(payload, authUrl);
116
+ const credential = credentialFromToken(payload, authUrl, {
117
+ authorization_method: "device_flow",
118
+ client_id: OAUTH_CLIENT_ID,
119
+ resource_url: options.mcpUrl ?? process.env.FARGO_MCP_URL ?? DEFAULT_MCP_URL,
120
+ token_endpoint: `${authUrl}/v1/mcp/oauth/token`
121
+ });
32
122
  await store.save(credential);
33
123
  return credential;
34
124
  }
@@ -42,13 +132,20 @@ export async function pollDeviceAuthorization(device, store, options = {}) {
42
132
  export async function refreshCredential(store, credential, options = {}) {
43
133
  const fetchImpl = options.fetchImpl ?? fetch;
44
134
  const authUrl = credential.auth_url ?? options.authUrl ?? process.env.FARGO_AUTH_URL ?? DEFAULT_AUTH_URL;
45
- const response = await fetchImpl(`${authUrl}/v1/mcp/oauth/token`, {
135
+ const clientId = credential.client_id ?? OAUTH_CLIENT_ID;
136
+ const tokenEndpoint = credential.token_endpoint ?? `${authUrl}/v1/mcp/oauth/token`;
137
+ const response = await fetchImpl(tokenEndpoint, {
46
138
  method: "POST",
47
139
  headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` },
48
- body: new URLSearchParams({ grant_type: "refresh_token", client_id: OAUTH_CLIENT_ID, refresh_token: credential.refresh_token })
140
+ body: new URLSearchParams({ grant_type: "refresh_token", client_id: clientId, refresh_token: credential.refresh_token })
49
141
  });
50
142
  const payload = await readOAuthResponse(response);
51
- const refreshed = credentialFromToken(payload, authUrl);
143
+ const refreshed = credentialFromToken(payload, authUrl, {
144
+ authorization_method: credential.authorization_method,
145
+ client_id: clientId,
146
+ resource_url: credential.resource_url,
147
+ token_endpoint: tokenEndpoint
148
+ });
52
149
  await store.save(refreshed);
53
150
  return refreshed;
54
151
  }
@@ -57,11 +154,27 @@ export async function currentCredential(store, options = {}) {
57
154
  let credential = await store.load();
58
155
  if (!credential) return undefined;
59
156
  if (!credential.access_token || Number(credential.expires_at) <= Date.now() + 60_000) {
60
- credential = await refreshCredential(store, credential, options);
157
+ credential = await refreshCurrentCredential(store, credential, options);
61
158
  }
62
159
  return credential;
63
160
  }
64
161
 
162
+ export async function refreshCurrentCredential(store, observedCredential, options = {}) {
163
+ const refresh = async () => {
164
+ const latest = await store.load();
165
+ if (!latest) return undefined;
166
+ if (latest.refresh_token !== observedCredential.refresh_token) return latest;
167
+ try {
168
+ return await refreshCredential(store, latest, options);
169
+ } catch (error) {
170
+ const recovered = await store.load();
171
+ if (recovered?.refresh_token && recovered.refresh_token !== latest.refresh_token) return recovered;
172
+ throw error;
173
+ }
174
+ };
175
+ return typeof store.withRefreshLock === "function" ? store.withRefreshLock(refresh) : refresh();
176
+ }
177
+
65
178
  export function openBrowser(url, platform = process.platform) {
66
179
  const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd.exe" : "xdg-open";
67
180
  const args = platform === "win32" ? ["/d", "/s", "/c", "start", "", url] : [url];
@@ -69,11 +182,11 @@ export function openBrowser(url, platform = process.platform) {
69
182
  child.unref();
70
183
  }
71
184
 
72
- function credentialFromToken(payload, authUrl) {
185
+ function credentialFromToken(payload, authUrl, metadata = {}) {
73
186
  if (typeof payload.access_token !== "string" || typeof payload.refresh_token !== "string") {
74
187
  throw new Error("OAuth token response did not contain rotating credentials");
75
188
  }
76
- return { ...payload, auth_url: authUrl, obtained_at: Date.now(), expires_at: Date.now() + Math.max(1, Number(payload.expires_in) || 3600) * 1000 };
189
+ return { ...payload, ...metadata, auth_url: authUrl, obtained_at: Date.now(), expires_at: Date.now() + Math.max(1, Number(payload.expires_in) || 3600) * 1000 };
77
190
  }
78
191
 
79
192
  async function readOAuthResponse(response) {
@@ -81,3 +194,23 @@ async function readOAuthResponse(response) {
81
194
  if (!response.ok) throw new Error(payload.error_description || payload.message || payload.error || `OAuth request failed (${response.status})`);
82
195
  return payload;
83
196
  }
197
+
198
+ async function discoverOAuthMetadata(authUrl, fetchImpl) {
199
+ const response = await fetchImpl(`${authUrl.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`, {
200
+ headers: { "Accept": "application/json", "User-Agent": `fargo-cli/${CLI_VERSION} WorkBuddy` }
201
+ });
202
+ const metadata = await readOAuthResponse(response);
203
+ for (const field of ["authorization_endpoint", "registration_endpoint", "token_endpoint"]) {
204
+ if (typeof metadata[field] !== "string" || !metadata[field]) {
205
+ throw new Error(`OAuth metadata did not contain ${field}`);
206
+ }
207
+ const endpoint = new URL(metadata[field]);
208
+ if (endpoint.protocol !== "https:" && endpoint.hostname !== "127.0.0.1" && endpoint.hostname !== "localhost") {
209
+ throw new Error(`OAuth metadata returned an unsafe ${field}`);
210
+ }
211
+ }
212
+ if (!Array.isArray(metadata.code_challenge_methods_supported) || !metadata.code_challenge_methods_supported.includes("S256")) {
213
+ throw new Error("OAuth server does not advertise PKCE S256 support");
214
+ }
215
+ return metadata;
216
+ }
package/src/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createCredentialStore } from "./credential-store.mjs";
2
- import { currentCredential, openBrowser, pollDeviceAuthorization, startDeviceAuthorization } from "./auth.mjs";
2
+ import { authorizePkce, currentCredential, openBrowser, pollDeviceAuthorization, startDeviceAuthorization } from "./auth.mjs";
3
3
  import { CLI_VERSION } from "./constants.mjs";
4
4
  import { runMcpProxy } from "./mcp-proxy.mjs";
5
5
  import { installWorkBuddyMcp } from "./workbuddy-install.mjs";
@@ -17,9 +17,13 @@ export async function runCli(args, options = {}) {
17
17
  await authorizeDevice(args, stdout, store, options);
18
18
  return;
19
19
  }
20
+ if (args[0] === "auth" && (args[1] === "login" || args[1] === "pkce")) {
21
+ await authorizeWithPkce(args, stdout, store, options);
22
+ return;
23
+ }
20
24
  if (args[0] === "auth" && args[1] === "status") {
21
25
  const credential = await store.load();
22
- stdout.write(`${JSON.stringify({ logged_in: Boolean(credential?.refresh_token), method: credential?.refresh_token ? "oauth" : null, credential_store: store.kind, expires_at: credential?.expires_at ?? null })}\n`);
26
+ stdout.write(`${JSON.stringify({ logged_in: Boolean(credential?.refresh_token), method: credential?.authorization_method ?? (credential?.refresh_token ? "oauth" : null), credential_store: store.kind, expires_at: credential?.expires_at ?? null })}\n`);
23
27
  return;
24
28
  }
25
29
  if (args[0] === "auth" && (args[1] === "logout" || args[1] === "revoke")) {
@@ -42,11 +46,14 @@ export async function runCli(args, options = {}) {
42
46
  credential = undefined;
43
47
  }
44
48
  }
45
- if (!credential?.refresh_token) await authorizeDevice(args, stdout, store, options);
49
+ if (credential?.authorization_method !== "authorization_code_pkce") {
50
+ credential = undefined;
51
+ }
52
+ if (!credential?.refresh_token) await authorizeWithPkce(args, stdout, store, options);
46
53
  stdout.write(`${JSON.stringify(await installWorkBuddyMcp(options))}\n`);
47
54
  return;
48
55
  }
49
- throw new Error("Usage: fargo auth device|status|logout | fargo workbuddy install | fargo mcp serve | fargo version");
56
+ throw new Error("Usage: fargo auth login|device|status|logout | fargo workbuddy install | fargo mcp serve | fargo version");
50
57
  } catch (error) {
51
58
  stderr.write(`fargo: ${error instanceof Error ? error.message : "unknown error"}\n`);
52
59
  if (options.throwOnError) throw error;
@@ -54,6 +61,24 @@ export async function runCli(args, options = {}) {
54
61
  }
55
62
  }
56
63
 
64
+ async function authorizeWithPkce(args, stdout, store, options) {
65
+ const browserMode = optionValue(args, "--open-browser") ?? "auto";
66
+ await authorizePkce(store, {
67
+ ...options,
68
+ openBrowser: browserMode !== "never" && options.openBrowser !== false,
69
+ async onAuthorization(details) {
70
+ stdout.write(`${JSON.stringify({
71
+ type: "authorization_required",
72
+ method: "authorization_code_pkce",
73
+ authorization_url: details.authorizationUrl,
74
+ callback: "loopback"
75
+ })}\n`);
76
+ await options.onAuthorization?.(details);
77
+ }
78
+ });
79
+ stdout.write(`${JSON.stringify({ type: "authorization_complete", logged_in: true, method: "authorization_code_pkce" })}\n`);
80
+ }
81
+
57
82
  async function authorizeDevice(args, stdout, store, options) {
58
83
  const device = await startDeviceAuthorization(options);
59
84
  stdout.write(`${JSON.stringify({
package/src/constants.mjs CHANGED
@@ -1,6 +1,6 @@
1
- export const CLI_VERSION = "0.2.0";
1
+ export const CLI_VERSION = "0.3.0";
2
2
  export const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
3
3
  export const DEFAULT_AUTH_URL = "https://auth.fargowealth.app";
4
4
  export const DEFAULT_MCP_URL = "https://mcp.fargowealth.app/mcp";
5
5
  export const OAUTH_CLIENT_ID = "fargo-cli-workbuddy";
6
- export const OAUTH_SCOPE = "fargo:forum:read fargo:hub:read fargo:lego:read fargo:knowledge:read";
6
+ export const OAUTH_SCOPE = "fargo:forum:read fargo:forum:write fargo:hub:read fargo:lego:read fargo:lego:write fargo:knowledge:read fargo:knowledge:write fargo:knowledge:share";
@@ -1,7 +1,9 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
6
+ import { setTimeout as delay } from "node:timers/promises";
5
7
 
6
8
  const KEYCHAIN_SERVICE = "com.fargowealth.fargo-cli.oauth";
7
9
 
@@ -11,12 +13,17 @@ export function createCredentialStore(options = {}) {
11
13
  const home = options.home ?? homedir();
12
14
  const run = options.run ?? runCommand;
13
15
  const filePath = env.FARGO_CREDENTIAL_PATH ?? join(home, ".fargo", "credentials.json");
16
+ const refreshLockPath = env.FARGO_REFRESH_LOCK_PATH ?? (platform === "win32"
17
+ ? join(env.APPDATA || home, "Fargo AI", "oauth-refresh.lock")
18
+ : join(home, ".fargo", "oauth-refresh.lock"));
14
19
  const forceFile = env.FARGO_CREDENTIAL_STORE === "file";
20
+ const withRefreshLock = (callback) => withFileLock(refreshLockPath, callback);
15
21
 
16
22
  if (!forceFile && platform === "darwin") {
17
23
  const account = env.USER || env.LOGNAME || "fargo-user";
18
24
  return {
19
25
  kind: "macos-keychain",
26
+ withRefreshLock,
20
27
  async load() {
21
28
  const result = await run("security", ["find-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE, "-w"], { allowFailure: true });
22
29
  if (result.code !== 0 || !result.stdout.trim()) return undefined;
@@ -37,6 +44,7 @@ export function createCredentialStore(options = {}) {
37
44
  const protectedPath = env.FARGO_CREDENTIAL_PATH ?? join(env.APPDATA || home, "Fargo AI", "credentials.bin");
38
45
  return {
39
46
  kind: "windows-dpapi",
47
+ withRefreshLock,
40
48
  async load() {
41
49
  const script = "$p=$env:FARGO_CREDENTIAL_PATH;if(!(Test-Path -LiteralPath $p)){exit 3};$e=[IO.File]::ReadAllBytes($p);$b=[Security.Cryptography.ProtectedData]::Unprotect($e,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);[Console]::Out.Write([Text.Encoding]::UTF8.GetString($b))";
42
50
  const result = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { allowFailure: true, env: { ...env, FARGO_CREDENTIAL_PATH: protectedPath } });
@@ -56,6 +64,7 @@ export function createCredentialStore(options = {}) {
56
64
 
57
65
  return {
58
66
  kind: "private-file",
67
+ withRefreshLock,
59
68
  async load() {
60
69
  try {
61
70
  return parseCredential(await readFile(filePath, "utf8"));
@@ -74,6 +83,43 @@ export function createCredentialStore(options = {}) {
74
83
  };
75
84
  }
76
85
 
86
+ async function withFileLock(lockPath, callback) {
87
+ await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
88
+ const deadline = Date.now() + 15_000;
89
+ const owner = `${process.pid}:${randomUUID()}`;
90
+ let handle;
91
+ while (!handle) {
92
+ try {
93
+ handle = await open(lockPath, "wx", 0o600);
94
+ } catch (error) {
95
+ if (error?.code !== "EEXIST") throw error;
96
+ try {
97
+ const lockStat = await stat(lockPath);
98
+ if (Date.now() - lockStat.mtimeMs > 120_000) {
99
+ await rm(lockPath, { force: true });
100
+ continue;
101
+ }
102
+ } catch (statError) {
103
+ if (statError?.code !== "ENOENT") throw statError;
104
+ continue;
105
+ }
106
+ if (Date.now() >= deadline) throw new Error("Timed out waiting for another Fargo session to refresh OAuth");
107
+ await delay(75);
108
+ }
109
+ }
110
+ await handle.writeFile(owner, "utf8");
111
+ try {
112
+ return await callback();
113
+ } finally {
114
+ await handle.close();
115
+ const currentOwner = await readFile(lockPath, "utf8").catch((error) => {
116
+ if (error?.code === "ENOENT") return undefined;
117
+ throw error;
118
+ });
119
+ if (currentOwner === owner) await rm(lockPath, { force: true });
120
+ }
121
+ }
122
+
77
123
  function parseCredential(value) {
78
124
  const parsed = JSON.parse(value);
79
125
  if (!parsed || typeof parsed !== "object" || typeof parsed.refresh_token !== "string") {
@@ -0,0 +1,107 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+
4
+ const LOOPBACK_HOST = "127.0.0.1";
5
+
6
+ export async function createLoopbackReceiver(options = {}) {
7
+ const callbackPath = `/oauth/callback/${(options.randomBytes ?? randomBytes)(18).toString("base64url")}`;
8
+ const timeoutMs = Math.max(1_000, Number(options.timeoutMs) || 10 * 60 * 1_000);
9
+ let address;
10
+ let settled = false;
11
+ let timer;
12
+ let resolveCallback;
13
+ let rejectCallback;
14
+ const callback = new Promise((resolve, reject) => {
15
+ resolveCallback = resolve;
16
+ rejectCallback = reject;
17
+ });
18
+
19
+ const server = createServer((request, response) => {
20
+ const requestUrl = new URL(request.url || "/", `http://${LOOPBACK_HOST}:${address?.port || 0}`);
21
+ if (request.method !== "GET" || requestUrl.pathname !== callbackPath) {
22
+ response.writeHead(404, secureHtmlHeaders());
23
+ response.end(completionPage(false, "这个本地回调地址无效,请返回 Fargo AI 重新授权。"));
24
+ return;
25
+ }
26
+ if (settled) {
27
+ response.writeHead(409, secureHtmlHeaders());
28
+ response.end(completionPage(false, "这次授权回调已经处理,请返回 WorkBuddy。"));
29
+ return;
30
+ }
31
+ settled = true;
32
+ clearTimeout(timer);
33
+ if (options.expectedState && requestUrl.searchParams.get("state") !== options.expectedState) {
34
+ response.writeHead(400, secureHtmlHeaders());
35
+ response.end(completionPage(false, "这次授权回调无法验证,请返回 Fargo AI 重新授权。"));
36
+ rejectCallback(new Error("OAuth callback state did not match the pending Fargo authorization"));
37
+ return;
38
+ }
39
+ const failed = Boolean(requestUrl.searchParams.get("error"));
40
+ response.writeHead(failed ? 400 : 200, secureHtmlHeaders());
41
+ response.end(completionPage(!failed, failed
42
+ ? "没有签发任何凭据。你可以关闭此页面后重新尝试。"
43
+ : "Fargo CLI 已收到授权结果,正在安全完成连接。现在可以关闭此页面。"));
44
+ resolveCallback(requestUrl);
45
+ });
46
+
47
+ await new Promise((resolve, reject) => {
48
+ const onError = (error) => {
49
+ server.off("listening", onListening);
50
+ reject(error);
51
+ };
52
+ const onListening = () => {
53
+ server.off("error", onError);
54
+ resolve();
55
+ };
56
+ server.once("error", onError);
57
+ server.once("listening", onListening);
58
+ server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true });
59
+ });
60
+
61
+ address = server.address();
62
+ if (!address || typeof address === "string") {
63
+ await closeServer(server);
64
+ throw new Error("Unable to allocate a Fargo OAuth loopback port");
65
+ }
66
+
67
+ timer = setTimeout(() => {
68
+ if (settled) return;
69
+ settled = true;
70
+ rejectCallback(new Error("Fargo OAuth authorization timed out"));
71
+ void closeServer(server);
72
+ }, timeoutMs);
73
+
74
+ return {
75
+ redirectUri: `http://${LOOPBACK_HOST}:${address.port}${callbackPath}`,
76
+ async waitForCallback() {
77
+ return callback;
78
+ },
79
+ async close() {
80
+ clearTimeout(timer);
81
+ await closeServer(server);
82
+ }
83
+ };
84
+ }
85
+
86
+ function secureHtmlHeaders() {
87
+ return {
88
+ "Cache-Control": "no-store",
89
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
90
+ "Content-Type": "text/html; charset=utf-8",
91
+ "Referrer-Policy": "no-referrer",
92
+ "X-Content-Type-Options": "nosniff"
93
+ };
94
+ }
95
+
96
+ function completionPage(success, message) {
97
+ const title = success ? "授权成功" : "授权未完成";
98
+ const marker = success ? "✓ 安全连接" : "连接未完成";
99
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>
100
+ :root{color-scheme:light;--ink:#11110f;--muted:#68635b;--line:#aaa49a;--green:#27704e}*{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;background:#f3f2ee;color:var(--ink);font-family:Inter,"Noto Sans SC",system-ui,sans-serif;padding:24px}main{width:min(100%,620px);border:1px solid var(--line);border-top:4px solid var(--ink);background:#fff;padding:40px;box-shadow:0 8px 24px rgba(0,0,0,.05)}.marker{color:${success ? "var(--green)" : "var(--muted)"};font-weight:850}h1{font-size:36px;margin:12px 0}p{color:var(--muted);line-height:1.65}
101
+ </style></head><body><main><span class="marker">${marker}</span><h1>${title}</h1><p>${message}</p></main></body></html>`;
102
+ }
103
+
104
+ function closeServer(server) {
105
+ if (!server.listening) return Promise.resolve();
106
+ return new Promise((resolve) => server.close(() => resolve()));
107
+ }
package/src/mcp-proxy.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { CLI_VERSION, DEFAULT_MCP_URL } from "./constants.mjs";
3
- import { currentCredential, refreshCredential } from "./auth.mjs";
3
+ import { currentCredential, refreshCurrentCredential } from "./auth.mjs";
4
4
 
5
5
  export async function runMcpProxy(store, options = {}) {
6
6
  const input = options.input ?? process.stdin;
@@ -39,7 +39,8 @@ export async function runMcpProxy(store, options = {}) {
39
39
  if (sessionId) headers["Mcp-Session-Id"] = sessionId;
40
40
  const response = await fetchImpl(mcpUrl, { method: "POST", headers, body: JSON.stringify(message) });
41
41
  if (response.status === 401 && !didRefresh) {
42
- credential = await refreshCredential(store, credential, { fetchImpl });
42
+ credential = await refreshCurrentCredential(store, credential, { fetchImpl });
43
+ if (!credential) throw new Error("Fargo CLI is not authenticated");
43
44
  return forward(message, true);
44
45
  }
45
46
  if (!response.ok && response.status !== 202) throw new Error(`Fargo MCP returned HTTP ${response.status}`);
@@ -8,7 +8,7 @@ const PACKAGE_NAME = "@fargowealth/fargo-ai-cli";
8
8
  export async function installWorkBuddyMcp(options = {}) {
9
9
  const configPath = options.configPath
10
10
  ?? process.env.FARGO_WORKBUDDY_MCP_CONFIG
11
- ?? join(homedir(), ".workbuddy", "mcp.json");
11
+ ?? defaultWorkBuddyConfigPath();
12
12
  const packageSpec = `${PACKAGE_NAME}@${options.packageVersion ?? CLI_VERSION}`;
13
13
  const config = await readConfig(configPath);
14
14
  const nextServer = {
@@ -37,6 +37,10 @@ export async function installWorkBuddyMcp(options = {}) {
37
37
  };
38
38
  }
39
39
 
40
+ export function defaultWorkBuddyConfigPath(homeDirectory = homedir()) {
41
+ return join(homeDirectory, ".workbuddy-ai", "mcp.json");
42
+ }
43
+
40
44
  async function readConfig(configPath) {
41
45
  let text;
42
46
  try {