@fargowealth/fargo-ai-cli 0.3.0 → 0.4.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/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.4.1
4
+
5
+ - **Windows behavior change:** WorkBuddy credentials now use `%APPDATA%\Fargo AI\credentials.json` instead of DPAPI, because sandboxed WorkBuddy sessions may be unable to read or update DPAPI-protected data.
6
+ - `workbuddy install` writes `FARGO_CREDENTIAL_STORE=file` plus absolute credential and refresh-lock paths into the Windows MCP configuration.
7
+ - Existing DPAPI credentials are not migrated. Windows users must reconnect once with 0.4.1.
8
+
9
+ ## 0.4.0
10
+
11
+ - **Behavior change:** Fargo CLI is explicitly WorkBuddy-only. On macOS, credentials use the sandbox-compatible private file at `~/.fargo/credentials.json`; the unsupported Keychain mode is rejected. Windows remains protected by DPAPI.
12
+ - `workbuddy install` writes the macOS credential mode and absolute credential/refresh-lock paths into WorkBuddy's MCP configuration.
13
+ - On macOS, `workbuddy install` resolves an absolute `npx` executable and supplies a stable GUI `PATH`.
14
+ - Windows opens the complete OAuth authorization URL directly through Explorer instead of `cmd.exe start`, preventing `&` query separators from truncating `redirect_uri`.
15
+ - Private-file writes are atomic and `fsync`-backed; directory/file modes are repaired to `0700`/`0600`, and symbolic-link credential reads are rejected.
16
+ - Existing macOS 0.3.x Keychain credentials are not copied into the file store. Users must reconnect once; the previous Keychain item should be removed separately if it exists.
package/README.md CHANGED
@@ -1,17 +1,27 @@
1
- # Fargo CLI
1
+ # Fargo CLI for WorkBuddy
2
2
 
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.
3
+ `fargo-cli` is the compatibility bridge that connects WorkBuddy to the hosted Fargo AI MCP server. It uses OAuth Authorization Code + PKCE over an ephemeral loopback callback and exposes the remote MCP over local stdio.
4
4
 
5
5
  ```sh
6
- npm install --global @fargowealth/fargo-ai-cli
7
- fargo workbuddy install --open-browser=always --output=jsonl
8
- fargo auth login --open-browser=always --output=jsonl
9
- fargo auth device --open-browser=always --output=jsonl
10
- fargo auth status --output=json
11
- fargo mcp serve
12
- fargo auth logout
6
+ npx -y @fargowealth/fargo-ai-cli@latest workbuddy install --open-browser=always --output=jsonl
7
+ npx -y @fargowealth/fargo-ai-cli@latest auth status --output=json
8
+ npx -y @fargowealth/fargo-ai-cli@latest auth logout --output=json
13
9
  ```
14
10
 
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.
11
+ The 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 IPv4 loopback, validates OAuth `state`, uses PKCE S256, closes the listener after the callback, and never prints private OAuth credentials.
16
12
 
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.
13
+ ## Credential storage
14
+
15
+ - **WorkBuddy on macOS:** `~/.fargo/credentials.json`. WorkBuddy's sandbox blocks both the `security` CLI and native Keychain Services. The CLI therefore uses a private file by default and never asks for Full Disk Access.
16
+ - **WorkBuddy on Windows:** `%APPDATA%\Fargo AI\credentials.json`. WorkBuddy may also restrict DPAPI access, so the CLI uses a user-profile credential file and does not depend on DPAPI.
17
+ - **WorkBuddy on Linux:** `~/.fargo/credentials.json`.
18
+
19
+ On macOS and Linux, the credential directory is forced to `0700` and the credential file to `0600`. Windows inherits the current user's NTFS ACL from `%APPDATA%`. Writes use an `fsync`-backed temporary file followed by atomic replacement, symbolic-link reads are rejected, and refresh-token rotation is serialized across local CLI processes.
20
+
21
+ The local file contains an OAuth refresh credential. Do not copy, upload, commit, print, or include it in logs. Use `npx -y @fargowealth/fargo-ai-cli@latest auth logout` to remove it when a device is no longer trusted, and revoke the connection from Fargo AI.
22
+
23
+ Device Authorization Grant remains available only as an explicit fallback:
24
+
25
+ ```sh
26
+ npx -y @fargowealth/fargo-ai-cli@latest auth device --open-browser=always --output=jsonl
27
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fargowealth/fargo-ai-cli",
3
- "version": "0.3.0",
4
- "description": "Fargo AI authentication and stdio MCP bridge for WorkBuddy",
3
+ "version": "0.4.1",
4
+ "description": "Fargo AI OAuth and stdio MCP bridge for WorkBuddy",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "fargo": "bin/fargo.mjs",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "bin",
12
12
  "src",
13
+ "CHANGELOG.md",
13
14
  "README.md"
14
15
  ],
15
16
  "engines": {
package/src/auth.mjs CHANGED
@@ -176,12 +176,24 @@ export async function refreshCurrentCredential(store, observedCredential, option
176
176
  }
177
177
 
178
178
  export function openBrowser(url, platform = process.platform) {
179
- const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd.exe" : "xdg-open";
180
- const args = platform === "win32" ? ["/d", "/s", "/c", "start", "", url] : [url];
179
+ const { args, command } = browserLaunchCommand(url, platform);
181
180
  const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
181
+ child.on("error", () => {
182
+ // The authorization URL is emitted before this best-effort launch, so the
183
+ // host can still open it when no platform browser launcher is available.
184
+ });
182
185
  child.unref();
183
186
  }
184
187
 
188
+ export function browserLaunchCommand(url, platform = process.platform) {
189
+ if (platform === "win32") {
190
+ // Do not route OAuth URLs through `cmd.exe start`: unescaped `&` query
191
+ // separators are parsed by CMD and truncate the URL after client_id.
192
+ return { command: "explorer.exe", args: [url] };
193
+ }
194
+ return { command: platform === "darwin" ? "open" : "xdg-open", args: [url] };
195
+ }
196
+
185
197
  function credentialFromToken(payload, authUrl, metadata = {}) {
186
198
  if (typeof payload.access_token !== "string" || typeof payload.refresh_token !== "string") {
187
199
  throw new Error("OAuth token response did not contain rotating credentials");
package/src/constants.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export const CLI_VERSION = "0.3.0";
1
+ export const CLI_VERSION = "0.4.1";
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";
@@ -1,72 +1,36 @@
1
- import { mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
1
+ import { chmod, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- import { spawn } from "node:child_process";
5
4
  import { randomUUID } from "node:crypto";
6
5
  import { setTimeout as delay } from "node:timers/promises";
7
6
 
8
- const KEYCHAIN_SERVICE = "com.fargowealth.fargo-cli.oauth";
9
-
10
7
  export function createCredentialStore(options = {}) {
11
8
  const platform = options.platform ?? process.platform;
12
9
  const env = options.env ?? process.env;
13
10
  const home = options.home ?? homedir();
14
- const run = options.run ?? runCommand;
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"));
19
- const forceFile = env.FARGO_CREDENTIAL_STORE === "file";
20
- const withRefreshLock = (callback) => withFileLock(refreshLockPath, callback);
21
-
22
- if (!forceFile && platform === "darwin") {
23
- const account = env.USER || env.LOGNAME || "fargo-user";
24
- return {
25
- kind: "macos-keychain",
26
- withRefreshLock,
27
- async load() {
28
- const result = await run("security", ["find-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE, "-w"], { allowFailure: true });
29
- if (result.code !== 0 || !result.stdout.trim()) return undefined;
30
- return parseCredential(result.stdout.trim());
31
- },
32
- async save(value) {
33
- const serialized = JSON.stringify(value);
34
- const result = await run("security", ["add-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE, "-w", serialized, "-U"], { allowFailure: true });
35
- if (result.code !== 0) throw new Error("Unable to save Fargo credentials in macOS Keychain");
36
- },
37
- async clear() {
38
- await run("security", ["delete-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE], { allowFailure: true });
39
- }
40
- };
11
+ const storeKind = env.FARGO_CREDENTIAL_STORE;
12
+ if (storeKind === "keychain") {
13
+ throw new Error("FARGO_CREDENTIAL_STORE=keychain is not supported by the WorkBuddy CLI; use file on macOS");
41
14
  }
42
-
43
- if (!forceFile && platform === "win32") {
44
- const protectedPath = env.FARGO_CREDENTIAL_PATH ?? join(env.APPDATA || home, "Fargo AI", "credentials.bin");
45
- return {
46
- kind: "windows-dpapi",
47
- withRefreshLock,
48
- async load() {
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))";
50
- const result = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { allowFailure: true, env: { ...env, FARGO_CREDENTIAL_PATH: protectedPath } });
51
- if (result.code !== 0 || !result.stdout.trim()) return undefined;
52
- return parseCredential(result.stdout.trim());
53
- },
54
- async save(value) {
55
- const script = "$p=$env:FARGO_CREDENTIAL_PATH;$d=Split-Path -Parent $p;[IO.Directory]::CreateDirectory($d)|Out-Null;$s=[Console]::In.ReadToEnd();$b=[Text.Encoding]::UTF8.GetBytes($s);$e=[Security.Cryptography.ProtectedData]::Protect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);[IO.File]::WriteAllBytes($p,$e)";
56
- const result = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { allowFailure: true, env: { ...env, FARGO_CREDENTIAL_PATH: protectedPath }, stdin: JSON.stringify(value) });
57
- if (result.code !== 0) throw new Error("Unable to save Fargo credentials with Windows DPAPI");
58
- },
59
- async clear() {
60
- await rm(protectedPath, { force: true });
61
- }
62
- };
15
+ if (storeKind === "dpapi") {
16
+ throw new Error("FARGO_CREDENTIAL_STORE=dpapi is not supported by the WorkBuddy CLI; use file on Windows");
63
17
  }
18
+ if (storeKind && storeKind !== "file") {
19
+ throw new Error(`Unsupported FARGO_CREDENTIAL_STORE: ${storeKind}`);
20
+ }
21
+ const credentialDirectory = platform === "win32"
22
+ ? join(env.APPDATA || home, "Fargo AI")
23
+ : join(home, ".fargo");
24
+ const filePath = env.FARGO_CREDENTIAL_PATH ?? join(credentialDirectory, "credentials.json");
25
+ const refreshLockPath = env.FARGO_REFRESH_LOCK_PATH ?? join(credentialDirectory, "oauth-refresh.lock");
26
+ const withRefreshLock = (callback) => withFileLock(refreshLockPath, callback);
64
27
 
65
28
  return {
66
29
  kind: "private-file",
67
30
  withRefreshLock,
68
31
  async load() {
69
32
  try {
33
+ await secureExistingFile(filePath);
70
34
  return parseCredential(await readFile(filePath, "utf8"));
71
35
  } catch (error) {
72
36
  if (error?.code === "ENOENT") return undefined;
@@ -74,17 +38,35 @@ export function createCredentialStore(options = {}) {
74
38
  }
75
39
  },
76
40
  async save(value) {
77
- await mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
78
- await writeFile(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
41
+ const directory = dirname(filePath);
42
+ await ensurePrivateDirectory(directory);
43
+ const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
44
+ let handle;
45
+ try {
46
+ handle = await open(temporaryPath, "wx", 0o600);
47
+ await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
48
+ await handle.sync();
49
+ await handle.close();
50
+ handle = undefined;
51
+ await chmod(temporaryPath, 0o600);
52
+ await rename(temporaryPath, filePath);
53
+ await secureExistingFile(filePath);
54
+ } finally {
55
+ await handle?.close().catch(() => {});
56
+ await rm(temporaryPath, { force: true });
57
+ }
79
58
  },
80
59
  async clear() {
60
+ await secureExistingFile(filePath).catch((error) => {
61
+ if (error?.code !== "ENOENT") throw error;
62
+ });
81
63
  await rm(filePath, { force: true });
82
64
  }
83
65
  };
84
66
  }
85
67
 
86
68
  async function withFileLock(lockPath, callback) {
87
- await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
69
+ await ensurePrivateDirectory(dirname(lockPath));
88
70
  const deadline = Date.now() + 15_000;
89
71
  const owner = `${process.pid}:${randomUUID()}`;
90
72
  let handle;
@@ -120,6 +102,32 @@ async function withFileLock(lockPath, callback) {
120
102
  }
121
103
  }
122
104
 
105
+ async function ensurePrivateDirectory(directoryPath) {
106
+ await mkdir(directoryPath, { recursive: true, mode: 0o700 });
107
+ const details = await lstat(directoryPath);
108
+ if (!details.isDirectory() || details.isSymbolicLink()) {
109
+ throw new Error(`Fargo credential directory is not a private directory: ${directoryPath}`);
110
+ }
111
+ assertCurrentUserOwns(details, "credential directory");
112
+ await chmod(directoryPath, 0o700);
113
+ }
114
+
115
+ async function secureExistingFile(filePath) {
116
+ const details = await lstat(filePath);
117
+ if (!details.isFile() || details.isSymbolicLink()) {
118
+ throw new Error(`Fargo credential path is not a regular file: ${filePath}`);
119
+ }
120
+ assertCurrentUserOwns(details, "credential file");
121
+ await chmod(filePath, 0o600);
122
+ }
123
+
124
+ function assertCurrentUserOwns(details, label) {
125
+ if (typeof process.getuid !== "function") return;
126
+ if (details.uid !== process.getuid()) {
127
+ throw new Error(`Fargo ${label} is not owned by the current user`);
128
+ }
129
+ }
130
+
123
131
  function parseCredential(value) {
124
132
  const parsed = JSON.parse(value);
125
133
  if (!parsed || typeof parsed !== "object" || typeof parsed.refresh_token !== "string") {
@@ -127,20 +135,3 @@ function parseCredential(value) {
127
135
  }
128
136
  return parsed;
129
137
  }
130
-
131
- function runCommand(command, args, options = {}) {
132
- return new Promise((resolve, reject) => {
133
- const child = spawn(command, args, { env: options.env ?? process.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
134
- let stdout = "";
135
- let stderr = "";
136
- child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
137
- child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
138
- child.on("error", reject);
139
- child.on("close", (code) => {
140
- const result = { code: code ?? 1, stderr, stdout };
141
- if (!options.allowFailure && result.code !== 0) reject(new Error(stderr.trim() || `${command} failed`));
142
- else resolve(result);
143
- });
144
- child.stdin.end(options.stdin ?? "");
145
- });
146
- }
@@ -1,22 +1,40 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
5
  import { CLI_VERSION } from "./constants.mjs";
5
6
 
6
7
  const PACKAGE_NAME = "@fargowealth/fargo-ai-cli";
7
8
 
8
9
  export async function installWorkBuddyMcp(options = {}) {
10
+ const platform = options.platform ?? process.platform;
11
+ const env = options.env ?? process.env;
12
+ const homeDirectory = options.home ?? homedir();
9
13
  const configPath = options.configPath
10
- ?? process.env.FARGO_WORKBUDDY_MCP_CONFIG
11
- ?? defaultWorkBuddyConfigPath();
14
+ ?? env.FARGO_WORKBUDDY_MCP_CONFIG
15
+ ?? defaultWorkBuddyConfigPath(homeDirectory);
12
16
  const packageSpec = `${PACKAGE_NAME}@${options.packageVersion ?? CLI_VERSION}`;
13
17
  const config = await readConfig(configPath);
18
+ const npxCommand = options.npxCommand ?? (platform === "darwin"
19
+ ? await resolveNpxCommand({ env, execPath: options.execPath, platform })
20
+ : "npx");
14
21
  const nextServer = {
15
22
  type: "stdio",
16
- command: "npx",
23
+ command: npxCommand,
17
24
  args: ["-y", packageSpec, "mcp", "serve"],
18
25
  timeout: 60000
19
26
  };
27
+ if (platform === "darwin" || platform === "win32") {
28
+ const credentialDirectory = platform === "win32"
29
+ ? join(env.APPDATA || homeDirectory, "Fargo AI")
30
+ : join(homeDirectory, ".fargo");
31
+ nextServer.env = {
32
+ FARGO_CREDENTIAL_PATH: join(credentialDirectory, "credentials.json"),
33
+ FARGO_CREDENTIAL_STORE: "file",
34
+ FARGO_REFRESH_LOCK_PATH: join(credentialDirectory, "oauth-refresh.lock")
35
+ };
36
+ if (platform === "darwin") nextServer.env.PATH = buildMacPath(npxCommand);
37
+ }
20
38
  const previousServer = config.mcpServers?.["fargo-ai"];
21
39
  const changed = JSON.stringify(previousServer) !== JSON.stringify(nextServer);
22
40
 
@@ -37,6 +55,40 @@ export async function installWorkBuddyMcp(options = {}) {
37
55
  };
38
56
  }
39
57
 
58
+ export async function resolveNpxCommand(options = {}) {
59
+ const platform = options.platform ?? process.platform;
60
+ const env = options.env ?? process.env;
61
+ const execPath = options.execPath ?? process.execPath;
62
+ const executableName = platform === "win32" ? "npx.cmd" : "npx";
63
+ const pathDelimiter = platform === "win32" ? ";" : ":";
64
+ const candidates = [
65
+ join(dirname(execPath), executableName),
66
+ ...String(env.PATH ?? "").split(pathDelimiter).filter(Boolean).map((entry) => join(entry, executableName))
67
+ ];
68
+ for (const candidate of candidates) {
69
+ const absoluteCandidate = isAbsolute(candidate) ? candidate : resolve(candidate);
70
+ try {
71
+ await access(absoluteCandidate, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
72
+ return absoluteCandidate;
73
+ } catch {
74
+ // Keep searching for an executable that will also be visible to the GUI host.
75
+ }
76
+ }
77
+ throw new Error("Unable to locate an absolute npx executable for WorkBuddy");
78
+ }
79
+
80
+ function buildMacPath(npxCommand) {
81
+ return [...new Set([
82
+ dirname(npxCommand),
83
+ "/opt/homebrew/bin",
84
+ "/usr/local/bin",
85
+ "/usr/bin",
86
+ "/bin",
87
+ "/usr/sbin",
88
+ "/sbin"
89
+ ])].join(":");
90
+ }
91
+
40
92
  export function defaultWorkBuddyConfigPath(homeDirectory = homedir()) {
41
93
  return join(homeDirectory, ".workbuddy-ai", "mcp.json");
42
94
  }