@fargowealth/fargo-ai-cli 0.4.0 → 0.4.2
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 +11 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/constants.mjs +1 -1
- package/src/credential-store.mjs +21 -61
- package/src/workbuddy-install.mjs +10 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2
|
|
4
|
+
|
|
5
|
+
- Windows no longer calls `chmod` while reading or writing credentials; `%APPDATA%` continues to provide the inherited NTFS user ACL without a sandbox-blocked metadata write.
|
|
6
|
+
- `workbuddy install` preserves the Windows executable search `PATH` in the MCP environment so the host can launch `npx` reliably.
|
|
7
|
+
|
|
8
|
+
## 0.4.1
|
|
9
|
+
|
|
10
|
+
- **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.
|
|
11
|
+
- `workbuddy install` writes `FARGO_CREDENTIAL_STORE=file` plus absolute credential and refresh-lock paths into the Windows MCP configuration.
|
|
12
|
+
- Existing DPAPI credentials are not migrated. Windows users must reconnect once with 0.4.1.
|
|
13
|
+
|
|
3
14
|
## 0.4.0
|
|
4
15
|
|
|
5
16
|
- **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.
|
package/README.md
CHANGED
|
@@ -13,10 +13,10 @@ The login opens the shared Fargo AI consent page and returns to `http://127.0.0.
|
|
|
13
13
|
## Credential storage
|
|
14
14
|
|
|
15
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
|
|
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
17
|
- **WorkBuddy on Linux:** `~/.fargo/credentials.json`.
|
|
18
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.
|
|
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
20
|
|
|
21
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
22
|
|
package/package.json
CHANGED
package/src/constants.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.4.
|
|
1
|
+
export const CLI_VERSION = "0.4.2";
|
|
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,7 +1,6 @@
|
|
|
1
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
|
|
|
@@ -9,51 +8,29 @@ export function createCredentialStore(options = {}) {
|
|
|
9
8
|
const platform = options.platform ?? process.platform;
|
|
10
9
|
const env = options.env ?? process.env;
|
|
11
10
|
const home = options.home ?? homedir();
|
|
12
|
-
const run = options.run ?? runCommand;
|
|
13
11
|
const storeKind = env.FARGO_CREDENTIAL_STORE;
|
|
14
12
|
if (storeKind === "keychain") {
|
|
15
13
|
throw new Error("FARGO_CREDENTIAL_STORE=keychain is not supported by the WorkBuddy CLI; use file on macOS");
|
|
16
14
|
}
|
|
17
|
-
if (storeKind
|
|
18
|
-
throw new Error(
|
|
19
|
-
}
|
|
20
|
-
if (storeKind === "dpapi" && platform !== "win32") {
|
|
21
|
-
throw new Error("FARGO_CREDENTIAL_STORE=dpapi is only supported on Windows");
|
|
15
|
+
if (storeKind === "dpapi") {
|
|
16
|
+
throw new Error("FARGO_CREDENTIAL_STORE=dpapi is not supported by the WorkBuddy CLI; use file on Windows");
|
|
22
17
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
? join(env.APPDATA || home, "Fargo AI", "oauth-refresh.lock")
|
|
26
|
-
: join(home, ".fargo", "oauth-refresh.lock"));
|
|
27
|
-
const withRefreshLock = (callback) => withFileLock(refreshLockPath, callback);
|
|
28
|
-
|
|
29
|
-
if (platform === "win32" && storeKind !== "file") {
|
|
30
|
-
const protectedPath = env.FARGO_CREDENTIAL_PATH ?? join(env.APPDATA || home, "Fargo AI", "credentials.bin");
|
|
31
|
-
return {
|
|
32
|
-
kind: "windows-dpapi",
|
|
33
|
-
withRefreshLock,
|
|
34
|
-
async load() {
|
|
35
|
-
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))";
|
|
36
|
-
const result = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { allowFailure: true, env: { ...env, FARGO_CREDENTIAL_PATH: protectedPath } });
|
|
37
|
-
if (result.code !== 0 || !result.stdout.trim()) return undefined;
|
|
38
|
-
return parseCredential(result.stdout.trim());
|
|
39
|
-
},
|
|
40
|
-
async save(value) {
|
|
41
|
-
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)";
|
|
42
|
-
const result = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { allowFailure: true, env: { ...env, FARGO_CREDENTIAL_PATH: protectedPath }, stdin: JSON.stringify(value) });
|
|
43
|
-
if (result.code !== 0) throw new Error("Unable to save Fargo credentials with Windows DPAPI");
|
|
44
|
-
},
|
|
45
|
-
async clear() {
|
|
46
|
-
await rm(protectedPath, { force: true });
|
|
47
|
-
}
|
|
48
|
-
};
|
|
18
|
+
if (storeKind && storeKind !== "file") {
|
|
19
|
+
throw new Error(`Unsupported FARGO_CREDENTIAL_STORE: ${storeKind}`);
|
|
49
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, platform);
|
|
50
27
|
|
|
51
28
|
return {
|
|
52
29
|
kind: "private-file",
|
|
53
30
|
withRefreshLock,
|
|
54
31
|
async load() {
|
|
55
32
|
try {
|
|
56
|
-
await secureExistingFile(filePath);
|
|
33
|
+
await secureExistingFile(filePath, platform);
|
|
57
34
|
return parseCredential(await readFile(filePath, "utf8"));
|
|
58
35
|
} catch (error) {
|
|
59
36
|
if (error?.code === "ENOENT") return undefined;
|
|
@@ -62,7 +39,7 @@ export function createCredentialStore(options = {}) {
|
|
|
62
39
|
},
|
|
63
40
|
async save(value) {
|
|
64
41
|
const directory = dirname(filePath);
|
|
65
|
-
await ensurePrivateDirectory(directory);
|
|
42
|
+
await ensurePrivateDirectory(directory, platform);
|
|
66
43
|
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
67
44
|
let handle;
|
|
68
45
|
try {
|
|
@@ -71,16 +48,16 @@ export function createCredentialStore(options = {}) {
|
|
|
71
48
|
await handle.sync();
|
|
72
49
|
await handle.close();
|
|
73
50
|
handle = undefined;
|
|
74
|
-
await chmod(temporaryPath, 0o600);
|
|
51
|
+
if (platform !== "win32") await chmod(temporaryPath, 0o600);
|
|
75
52
|
await rename(temporaryPath, filePath);
|
|
76
|
-
await secureExistingFile(filePath);
|
|
53
|
+
await secureExistingFile(filePath, platform);
|
|
77
54
|
} finally {
|
|
78
55
|
await handle?.close().catch(() => {});
|
|
79
56
|
await rm(temporaryPath, { force: true });
|
|
80
57
|
}
|
|
81
58
|
},
|
|
82
59
|
async clear() {
|
|
83
|
-
await secureExistingFile(filePath).catch((error) => {
|
|
60
|
+
await secureExistingFile(filePath, platform).catch((error) => {
|
|
84
61
|
if (error?.code !== "ENOENT") throw error;
|
|
85
62
|
});
|
|
86
63
|
await rm(filePath, { force: true });
|
|
@@ -88,8 +65,8 @@ export function createCredentialStore(options = {}) {
|
|
|
88
65
|
};
|
|
89
66
|
}
|
|
90
67
|
|
|
91
|
-
async function withFileLock(lockPath, callback) {
|
|
92
|
-
await ensurePrivateDirectory(dirname(lockPath));
|
|
68
|
+
async function withFileLock(lockPath, callback, platform) {
|
|
69
|
+
await ensurePrivateDirectory(dirname(lockPath), platform);
|
|
93
70
|
const deadline = Date.now() + 15_000;
|
|
94
71
|
const owner = `${process.pid}:${randomUUID()}`;
|
|
95
72
|
let handle;
|
|
@@ -125,23 +102,23 @@ async function withFileLock(lockPath, callback) {
|
|
|
125
102
|
}
|
|
126
103
|
}
|
|
127
104
|
|
|
128
|
-
async function ensurePrivateDirectory(directoryPath) {
|
|
105
|
+
async function ensurePrivateDirectory(directoryPath, platform) {
|
|
129
106
|
await mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
130
107
|
const details = await lstat(directoryPath);
|
|
131
108
|
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
132
109
|
throw new Error(`Fargo credential directory is not a private directory: ${directoryPath}`);
|
|
133
110
|
}
|
|
134
111
|
assertCurrentUserOwns(details, "credential directory");
|
|
135
|
-
await chmod(directoryPath, 0o700);
|
|
112
|
+
if (platform !== "win32") await chmod(directoryPath, 0o700);
|
|
136
113
|
}
|
|
137
114
|
|
|
138
|
-
async function secureExistingFile(filePath) {
|
|
115
|
+
async function secureExistingFile(filePath, platform) {
|
|
139
116
|
const details = await lstat(filePath);
|
|
140
117
|
if (!details.isFile() || details.isSymbolicLink()) {
|
|
141
118
|
throw new Error(`Fargo credential path is not a regular file: ${filePath}`);
|
|
142
119
|
}
|
|
143
120
|
assertCurrentUserOwns(details, "credential file");
|
|
144
|
-
await chmod(filePath, 0o600);
|
|
121
|
+
if (platform !== "win32") await chmod(filePath, 0o600);
|
|
145
122
|
}
|
|
146
123
|
|
|
147
124
|
function assertCurrentUserOwns(details, label) {
|
|
@@ -158,20 +135,3 @@ function parseCredential(value) {
|
|
|
158
135
|
}
|
|
159
136
|
return parsed;
|
|
160
137
|
}
|
|
161
|
-
|
|
162
|
-
function runCommand(command, args, options = {}) {
|
|
163
|
-
return new Promise((resolve, reject) => {
|
|
164
|
-
const child = spawn(command, args, { env: options.env ?? process.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
165
|
-
let stdout = "";
|
|
166
|
-
let stderr = "";
|
|
167
|
-
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
|
|
168
|
-
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
|
|
169
|
-
child.on("error", reject);
|
|
170
|
-
child.on("close", (code) => {
|
|
171
|
-
const result = { code: code ?? 1, stderr, stdout };
|
|
172
|
-
if (!options.allowFailure && result.code !== 0) reject(new Error(stderr.trim() || `${command} failed`));
|
|
173
|
-
else resolve(result);
|
|
174
|
-
});
|
|
175
|
-
child.stdin.end(options.stdin ?? "");
|
|
176
|
-
});
|
|
177
|
-
}
|
|
@@ -24,14 +24,20 @@ export async function installWorkBuddyMcp(options = {}) {
|
|
|
24
24
|
args: ["-y", packageSpec, "mcp", "serve"],
|
|
25
25
|
timeout: 60000
|
|
26
26
|
};
|
|
27
|
-
if (platform === "darwin") {
|
|
28
|
-
const credentialDirectory =
|
|
27
|
+
if (platform === "darwin" || platform === "win32") {
|
|
28
|
+
const credentialDirectory = platform === "win32"
|
|
29
|
+
? join(env.APPDATA || homeDirectory, "Fargo AI")
|
|
30
|
+
: join(homeDirectory, ".fargo");
|
|
29
31
|
nextServer.env = {
|
|
30
32
|
FARGO_CREDENTIAL_PATH: join(credentialDirectory, "credentials.json"),
|
|
31
33
|
FARGO_CREDENTIAL_STORE: "file",
|
|
32
|
-
FARGO_REFRESH_LOCK_PATH: join(credentialDirectory, "oauth-refresh.lock")
|
|
33
|
-
PATH: buildMacPath(npxCommand)
|
|
34
|
+
FARGO_REFRESH_LOCK_PATH: join(credentialDirectory, "oauth-refresh.lock")
|
|
34
35
|
};
|
|
36
|
+
if (platform === "darwin") nextServer.env.PATH = buildMacPath(npxCommand);
|
|
37
|
+
if (platform === "win32") {
|
|
38
|
+
const windowsPath = env.PATH ?? env.Path ?? env.path;
|
|
39
|
+
if (windowsPath) nextServer.env.PATH = windowsPath;
|
|
40
|
+
}
|
|
35
41
|
}
|
|
36
42
|
const previousServer = config.mcpServers?.["fargo-ai"];
|
|
37
43
|
const changed = JSON.stringify(previousServer) !== JSON.stringify(nextServer);
|