@fargowealth/fargo-ai-cli 0.3.0 → 0.4.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/CHANGELOG.md +10 -0
- package/README.md +21 -11
- package/package.json +3 -2
- package/src/auth.mjs +14 -2
- package/src/constants.mjs +1 -1
- package/src/credential-store.mjs +60 -29
- package/src/workbuddy-install.mjs +55 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- `workbuddy install` writes the macOS credential mode and absolute credential/refresh-lock paths into WorkBuddy's MCP configuration.
|
|
7
|
+
- On macOS, `workbuddy install` resolves an absolute `npx` executable and supplies a stable GUI `PATH`.
|
|
8
|
+
- Windows opens the complete OAuth authorization URL directly through Explorer instead of `cmd.exe start`, preventing `&` query separators from truncating `redirect_uri`.
|
|
9
|
+
- Private-file writes are atomic and `fsync`-backed; directory/file modes are repaired to `0700`/`0600`, and symbolic-link credential reads are rejected.
|
|
10
|
+
- 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`
|
|
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
|
-
|
|
7
|
-
fargo
|
|
8
|
-
fargo auth
|
|
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
|
|
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
|
-
|
|
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:** DPAPI `CurrentUser`, stored as encrypted bytes under `%APPDATA%\Fargo AI\credentials.bin`.
|
|
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`. 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.
|
|
4
|
-
"description": "Fargo AI
|
|
3
|
+
"version": "0.4.0",
|
|
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
|
|
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.
|
|
1
|
+
export const CLI_VERSION = "0.4.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";
|
package/src/credential-store.mjs
CHANGED
|
@@ -1,46 +1,32 @@
|
|
|
1
|
-
import { mkdir, open, readFile, rm, stat
|
|
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
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
7
7
|
|
|
8
|
-
const KEYCHAIN_SERVICE = "com.fargowealth.fargo-cli.oauth";
|
|
9
|
-
|
|
10
8
|
export function createCredentialStore(options = {}) {
|
|
11
9
|
const platform = options.platform ?? process.platform;
|
|
12
10
|
const env = options.env ?? process.env;
|
|
13
11
|
const home = options.home ?? homedir();
|
|
14
12
|
const run = options.run ?? runCommand;
|
|
13
|
+
const storeKind = env.FARGO_CREDENTIAL_STORE;
|
|
14
|
+
if (storeKind === "keychain") {
|
|
15
|
+
throw new Error("FARGO_CREDENTIAL_STORE=keychain is not supported by the WorkBuddy CLI; use file on macOS");
|
|
16
|
+
}
|
|
17
|
+
if (storeKind && !["dpapi", "file"].includes(storeKind)) {
|
|
18
|
+
throw new Error(`Unsupported FARGO_CREDENTIAL_STORE: ${storeKind}`);
|
|
19
|
+
}
|
|
20
|
+
if (storeKind === "dpapi" && platform !== "win32") {
|
|
21
|
+
throw new Error("FARGO_CREDENTIAL_STORE=dpapi is only supported on Windows");
|
|
22
|
+
}
|
|
15
23
|
const filePath = env.FARGO_CREDENTIAL_PATH ?? join(home, ".fargo", "credentials.json");
|
|
16
24
|
const refreshLockPath = env.FARGO_REFRESH_LOCK_PATH ?? (platform === "win32"
|
|
17
25
|
? join(env.APPDATA || home, "Fargo AI", "oauth-refresh.lock")
|
|
18
26
|
: join(home, ".fargo", "oauth-refresh.lock"));
|
|
19
|
-
const forceFile = env.FARGO_CREDENTIAL_STORE === "file";
|
|
20
27
|
const withRefreshLock = (callback) => withFileLock(refreshLockPath, callback);
|
|
21
28
|
|
|
22
|
-
if (
|
|
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
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (!forceFile && platform === "win32") {
|
|
29
|
+
if (platform === "win32" && storeKind !== "file") {
|
|
44
30
|
const protectedPath = env.FARGO_CREDENTIAL_PATH ?? join(env.APPDATA || home, "Fargo AI", "credentials.bin");
|
|
45
31
|
return {
|
|
46
32
|
kind: "windows-dpapi",
|
|
@@ -67,6 +53,7 @@ export function createCredentialStore(options = {}) {
|
|
|
67
53
|
withRefreshLock,
|
|
68
54
|
async load() {
|
|
69
55
|
try {
|
|
56
|
+
await secureExistingFile(filePath);
|
|
70
57
|
return parseCredential(await readFile(filePath, "utf8"));
|
|
71
58
|
} catch (error) {
|
|
72
59
|
if (error?.code === "ENOENT") return undefined;
|
|
@@ -74,17 +61,35 @@ export function createCredentialStore(options = {}) {
|
|
|
74
61
|
}
|
|
75
62
|
},
|
|
76
63
|
async save(value) {
|
|
77
|
-
|
|
78
|
-
await
|
|
64
|
+
const directory = dirname(filePath);
|
|
65
|
+
await ensurePrivateDirectory(directory);
|
|
66
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
67
|
+
let handle;
|
|
68
|
+
try {
|
|
69
|
+
handle = await open(temporaryPath, "wx", 0o600);
|
|
70
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
|
|
71
|
+
await handle.sync();
|
|
72
|
+
await handle.close();
|
|
73
|
+
handle = undefined;
|
|
74
|
+
await chmod(temporaryPath, 0o600);
|
|
75
|
+
await rename(temporaryPath, filePath);
|
|
76
|
+
await secureExistingFile(filePath);
|
|
77
|
+
} finally {
|
|
78
|
+
await handle?.close().catch(() => {});
|
|
79
|
+
await rm(temporaryPath, { force: true });
|
|
80
|
+
}
|
|
79
81
|
},
|
|
80
82
|
async clear() {
|
|
83
|
+
await secureExistingFile(filePath).catch((error) => {
|
|
84
|
+
if (error?.code !== "ENOENT") throw error;
|
|
85
|
+
});
|
|
81
86
|
await rm(filePath, { force: true });
|
|
82
87
|
}
|
|
83
88
|
};
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
async function withFileLock(lockPath, callback) {
|
|
87
|
-
await
|
|
92
|
+
await ensurePrivateDirectory(dirname(lockPath));
|
|
88
93
|
const deadline = Date.now() + 15_000;
|
|
89
94
|
const owner = `${process.pid}:${randomUUID()}`;
|
|
90
95
|
let handle;
|
|
@@ -120,6 +125,32 @@ async function withFileLock(lockPath, callback) {
|
|
|
120
125
|
}
|
|
121
126
|
}
|
|
122
127
|
|
|
128
|
+
async function ensurePrivateDirectory(directoryPath) {
|
|
129
|
+
await mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
130
|
+
const details = await lstat(directoryPath);
|
|
131
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
132
|
+
throw new Error(`Fargo credential directory is not a private directory: ${directoryPath}`);
|
|
133
|
+
}
|
|
134
|
+
assertCurrentUserOwns(details, "credential directory");
|
|
135
|
+
await chmod(directoryPath, 0o700);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function secureExistingFile(filePath) {
|
|
139
|
+
const details = await lstat(filePath);
|
|
140
|
+
if (!details.isFile() || details.isSymbolicLink()) {
|
|
141
|
+
throw new Error(`Fargo credential path is not a regular file: ${filePath}`);
|
|
142
|
+
}
|
|
143
|
+
assertCurrentUserOwns(details, "credential file");
|
|
144
|
+
await chmod(filePath, 0o600);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertCurrentUserOwns(details, label) {
|
|
148
|
+
if (typeof process.getuid !== "function") return;
|
|
149
|
+
if (details.uid !== process.getuid()) {
|
|
150
|
+
throw new Error(`Fargo ${label} is not owned by the current user`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
123
154
|
function parseCredential(value) {
|
|
124
155
|
const parsed = JSON.parse(value);
|
|
125
156
|
if (!parsed || typeof parsed !== "object" || typeof parsed.refresh_token !== "string") {
|
|
@@ -1,22 +1,38 @@
|
|
|
1
|
-
import {
|
|
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
|
-
??
|
|
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:
|
|
23
|
+
command: npxCommand,
|
|
17
24
|
args: ["-y", packageSpec, "mcp", "serve"],
|
|
18
25
|
timeout: 60000
|
|
19
26
|
};
|
|
27
|
+
if (platform === "darwin") {
|
|
28
|
+
const credentialDirectory = join(homeDirectory, ".fargo");
|
|
29
|
+
nextServer.env = {
|
|
30
|
+
FARGO_CREDENTIAL_PATH: join(credentialDirectory, "credentials.json"),
|
|
31
|
+
FARGO_CREDENTIAL_STORE: "file",
|
|
32
|
+
FARGO_REFRESH_LOCK_PATH: join(credentialDirectory, "oauth-refresh.lock"),
|
|
33
|
+
PATH: buildMacPath(npxCommand)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
20
36
|
const previousServer = config.mcpServers?.["fargo-ai"];
|
|
21
37
|
const changed = JSON.stringify(previousServer) !== JSON.stringify(nextServer);
|
|
22
38
|
|
|
@@ -37,6 +53,40 @@ export async function installWorkBuddyMcp(options = {}) {
|
|
|
37
53
|
};
|
|
38
54
|
}
|
|
39
55
|
|
|
56
|
+
export async function resolveNpxCommand(options = {}) {
|
|
57
|
+
const platform = options.platform ?? process.platform;
|
|
58
|
+
const env = options.env ?? process.env;
|
|
59
|
+
const execPath = options.execPath ?? process.execPath;
|
|
60
|
+
const executableName = platform === "win32" ? "npx.cmd" : "npx";
|
|
61
|
+
const pathDelimiter = platform === "win32" ? ";" : ":";
|
|
62
|
+
const candidates = [
|
|
63
|
+
join(dirname(execPath), executableName),
|
|
64
|
+
...String(env.PATH ?? "").split(pathDelimiter).filter(Boolean).map((entry) => join(entry, executableName))
|
|
65
|
+
];
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
const absoluteCandidate = isAbsolute(candidate) ? candidate : resolve(candidate);
|
|
68
|
+
try {
|
|
69
|
+
await access(absoluteCandidate, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
|
70
|
+
return absoluteCandidate;
|
|
71
|
+
} catch {
|
|
72
|
+
// Keep searching for an executable that will also be visible to the GUI host.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw new Error("Unable to locate an absolute npx executable for WorkBuddy");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildMacPath(npxCommand) {
|
|
79
|
+
return [...new Set([
|
|
80
|
+
dirname(npxCommand),
|
|
81
|
+
"/opt/homebrew/bin",
|
|
82
|
+
"/usr/local/bin",
|
|
83
|
+
"/usr/bin",
|
|
84
|
+
"/bin",
|
|
85
|
+
"/usr/sbin",
|
|
86
|
+
"/sbin"
|
|
87
|
+
])].join(":");
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
export function defaultWorkBuddyConfigPath(homeDirectory = homedir()) {
|
|
41
91
|
return join(homeDirectory, ".workbuddy-ai", "mcp.json");
|
|
42
92
|
}
|