@bjesuiter/codex-switcher 1.4.0 → 1.5.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 +52 -10
- package/cdx.mjs +599 -92
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -6,25 +6,27 @@ Switch the coding-agents [pi](https://pi.dev/), [codex](https://developers.opena
|
|
|
6
6
|
|
|
7
7
|
## Latest Changes
|
|
8
8
|
|
|
9
|
-
### 1.
|
|
9
|
+
### 1.5.0
|
|
10
10
|
|
|
11
11
|
#### Features
|
|
12
12
|
|
|
13
|
-
- Add
|
|
14
|
-
- Add
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
13
|
+
- Add shell completion support via `cdx complete <shell>` (with parse-completion handling for shell integrations).
|
|
14
|
+
- Add configurable secret-store selection with `--secret-store <mode>` (`auto` or `legacy-keychain`) plus persisted config support.
|
|
15
|
+
- Switch macOS `auto` secret storage to cross-keychain backend selection (prefers native backend, falls back when needed).
|
|
16
|
+
- Add `cdx migrate-secrets` to migrate legacy macOS keychain entries to cross-keychain and update config.
|
|
17
|
+
- Add optional macOS keychain ACL diagnostics in `cdx doctor --check-keychain-acl` to verify trusted runtime access.
|
|
18
|
+
- Add doctor/runtime warnings when macOS keychain access is using legacy/CLI fallback paths where Touch ID prompts may not be offered.
|
|
19
|
+
- Increase cross-keychain max password length handling (default `16384`) to support larger stored credential payloads.
|
|
18
20
|
|
|
19
21
|
#### Fixes
|
|
20
22
|
|
|
21
|
-
-
|
|
23
|
+
- Keep `cdx doctor` fast by making keychain ACL checks opt-in and improving output with clearer guidance and progress feedback.
|
|
24
|
+
- Remove Windows credential payload chunking now that larger payloads are supported directly in the secure store backend.
|
|
22
25
|
|
|
23
26
|
#### Internal
|
|
24
27
|
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
- Update dependencies and lockfile for `cross-keychain`.
|
|
28
|
+
- Temporarily switch keyring dependency from `cross-keychain` to `@bjesuiter/cross-keychain@1.1.0-jb.0` until upstream support is available.
|
|
29
|
+
- Add Windows CI coverage including shell smoke checks and expanded secure-store integration tests (including Windows CRUD coverage).
|
|
28
30
|
|
|
29
31
|
see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
|
|
30
32
|
|
|
@@ -148,6 +150,19 @@ Interactive mode:
|
|
|
148
150
|
cdx
|
|
149
151
|
```
|
|
150
152
|
|
|
153
|
+
Use the legacy macOS keychain implementation (if needed):
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
cdx --secret-store legacy-keychain switch
|
|
157
|
+
cdx --secret-store legacy-keychain status
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Migrate legacy macOS keychain entries to cross-keychain (`auto`) and update config:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
cdx migrate-secrets
|
|
164
|
+
```
|
|
165
|
+
|
|
151
166
|
## Commands
|
|
152
167
|
|
|
153
168
|
| Command | Description |
|
|
@@ -162,13 +177,31 @@ cdx
|
|
|
162
177
|
| `cdx label` | Label an account (interactive) |
|
|
163
178
|
| `cdx label <account> <label>` | Assign label directly |
|
|
164
179
|
| `cdx status` | Show account status, token expiry, and usage |
|
|
180
|
+
| `cdx migrate-secrets` | Migrate macOS legacy keychain entries to cross-keychain and switch config to `auto` |
|
|
165
181
|
| `cdx doctor` | Show auth file paths/state and runtime capabilities |
|
|
182
|
+
| `cdx doctor --check-keychain-acl` | Run additional macOS keychain trusted-app/ACL checks (slow) |
|
|
166
183
|
| `cdx usage` | Show usage overview for all accounts |
|
|
167
184
|
| `cdx usage <account>` | Show detailed usage for a specific account |
|
|
168
185
|
| `cdx help [command]` | Show help for all commands or one command |
|
|
186
|
+
| `cdx complete <shell>` | Generate shell completion script (`zsh`, `bash`, `fish`, `powershell`) |
|
|
169
187
|
| `cdx version` | Show CLI version |
|
|
170
188
|
| `cdx --help` | Show help |
|
|
171
189
|
| `cdx --version` | Show version |
|
|
190
|
+
| `cdx --secret-store legacy-keychain <command>` | Override configured backend for this run (macOS legacy keychain) |
|
|
191
|
+
|
|
192
|
+
### Shell completion
|
|
193
|
+
|
|
194
|
+
Generate and source completion scripts:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# zsh
|
|
198
|
+
source <(cdx complete zsh)
|
|
199
|
+
|
|
200
|
+
# bash
|
|
201
|
+
source <(cdx complete bash)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`cdx` also supports shell parse completion requests via `cdx complete -- ...`.
|
|
172
205
|
|
|
173
206
|
## How It Works
|
|
174
207
|
|
|
@@ -177,8 +210,16 @@ cdx
|
|
|
177
210
|
- **macOS:** macOS Keychain
|
|
178
211
|
- **Windows:** Windows Credential Manager
|
|
179
212
|
- **Linux:** Secret Service/keyring
|
|
213
|
+
- Default backend selection is automatic (`auto`).
|
|
214
|
+
- You can persist a preferred backend in `accounts.json` via optional `"secretStore"` (`"auto"` or `"legacy-keychain"`).
|
|
215
|
+
- `--secret-store <mode>` always overrides config for the current run.
|
|
180
216
|
- If only a fallback secure-store backend is available on your platform, `cdx` asks for one-time explicit consent before the first credential write and explains the security trade-off.
|
|
181
217
|
- Non-interactive override (if you accept the risk): set `CDX_ALLOW_SECURE_STORE_FALLBACK=1`
|
|
218
|
+
- On macOS, `cdx doctor --check-keychain-acl` performs an additional trusted-app/ACL check for configured account secrets. This check can be slow.
|
|
219
|
+
- Cross-keychain payload size policy:
|
|
220
|
+
- Default max password length override is `16384`.
|
|
221
|
+
- Optional override: set `CDX_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH=<integer-above-4096>`.
|
|
222
|
+
- This currently relies on `@bjesuiter/cross-keychain@1.1.0-jb.0` until upstream support is released.
|
|
182
223
|
|
|
183
224
|
### Account list path
|
|
184
225
|
|
|
@@ -233,6 +274,7 @@ And create the accounts list manually:
|
|
|
233
274
|
```json
|
|
234
275
|
{
|
|
235
276
|
"current": 0,
|
|
277
|
+
"secretStore": "auto",
|
|
236
278
|
"accounts": [
|
|
237
279
|
{ "accountId": "ACCOUNT_ID", "keychainService": "cdx-openai-ACCOUNT_ID" }
|
|
238
280
|
]
|
package/cdx.mjs
CHANGED
|
@@ -1,27 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import tab from "@bomb.sh/tab/commander";
|
|
3
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
-
import path from "node:path";
|
|
7
6
|
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import * as p from "@clack/prompts";
|
|
8
9
|
import { spawn } from "node:child_process";
|
|
9
|
-
import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "cross-keychain";
|
|
10
|
+
import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "@bjesuiter/cross-keychain";
|
|
10
11
|
import { createInterface } from "node:readline/promises";
|
|
11
12
|
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
12
13
|
import { randomBytes } from "node:crypto";
|
|
13
14
|
import http from "node:http";
|
|
14
15
|
|
|
15
16
|
//#region package.json
|
|
16
|
-
var version = "1.
|
|
17
|
-
|
|
18
|
-
//#endregion
|
|
19
|
-
//#region lib/commands/errors.ts
|
|
20
|
-
const exitWithCommandError = (error) => {
|
|
21
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
-
process.stderr.write(`${message}\n`);
|
|
23
|
-
process.exit(1);
|
|
24
|
-
};
|
|
17
|
+
var version = "1.5.0";
|
|
25
18
|
|
|
26
19
|
//#endregion
|
|
27
20
|
//#region lib/platform/path-resolver.ts
|
|
@@ -116,6 +109,48 @@ const createTestPaths = (testDir) => ({
|
|
|
116
109
|
piAuthPath: path.join(testDir, "pi", "auth.json")
|
|
117
110
|
});
|
|
118
111
|
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region lib/config.ts
|
|
114
|
+
const isSecretStoreSelection = (value) => value === "auto" || value === "legacy-keychain";
|
|
115
|
+
const loadConfiguredSecretStoreSelection = async () => {
|
|
116
|
+
const { configPath } = getPaths();
|
|
117
|
+
if (!existsSync(configPath)) return;
|
|
118
|
+
try {
|
|
119
|
+
const raw = await readFile(configPath, "utf8");
|
|
120
|
+
const parsed = JSON.parse(raw);
|
|
121
|
+
return isSecretStoreSelection(parsed.secretStore) ? parsed.secretStore : void 0;
|
|
122
|
+
} catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const loadConfig = async () => {
|
|
127
|
+
const { configPath } = getPaths();
|
|
128
|
+
if (!existsSync(configPath)) throw new Error(`Missing config at ${configPath}. Create accounts.json to list Keychain services.`);
|
|
129
|
+
const raw = await readFile(configPath, "utf8");
|
|
130
|
+
const parsed = JSON.parse(raw);
|
|
131
|
+
if (!Array.isArray(parsed.accounts) || parsed.accounts.length === 0) throw new Error("accounts.json must include a non-empty accounts array.");
|
|
132
|
+
if (typeof parsed.current !== "number" || Number.isNaN(parsed.current)) parsed.current = 0;
|
|
133
|
+
if (!isSecretStoreSelection(parsed.secretStore)) delete parsed.secretStore;
|
|
134
|
+
return parsed;
|
|
135
|
+
};
|
|
136
|
+
const saveConfig = async (config) => {
|
|
137
|
+
const { configDir, configPath } = getPaths();
|
|
138
|
+
await mkdir(configDir, { recursive: true });
|
|
139
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
140
|
+
};
|
|
141
|
+
const configExists = () => {
|
|
142
|
+
const { configPath } = getPaths();
|
|
143
|
+
return existsSync(configPath);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region lib/commands/errors.ts
|
|
148
|
+
const exitWithCommandError = (error) => {
|
|
149
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
150
|
+
process.stderr.write(`${message}\n`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
};
|
|
153
|
+
|
|
119
154
|
//#endregion
|
|
120
155
|
//#region lib/auth.ts
|
|
121
156
|
const readExistingJson = async (filePath) => {
|
|
@@ -196,27 +231,6 @@ const writeAllAuthFiles = async (payload) => {
|
|
|
196
231
|
};
|
|
197
232
|
};
|
|
198
233
|
|
|
199
|
-
//#endregion
|
|
200
|
-
//#region lib/config.ts
|
|
201
|
-
const loadConfig = async () => {
|
|
202
|
-
const { configPath } = getPaths();
|
|
203
|
-
if (!existsSync(configPath)) throw new Error(`Missing config at ${configPath}. Create accounts.json to list Keychain services.`);
|
|
204
|
-
const raw = await readFile(configPath, "utf8");
|
|
205
|
-
const parsed = JSON.parse(raw);
|
|
206
|
-
if (!Array.isArray(parsed.accounts) || parsed.accounts.length === 0) throw new Error("accounts.json must include a non-empty accounts array.");
|
|
207
|
-
if (typeof parsed.current !== "number" || Number.isNaN(parsed.current)) parsed.current = 0;
|
|
208
|
-
return parsed;
|
|
209
|
-
};
|
|
210
|
-
const saveConfig = async (config) => {
|
|
211
|
-
const { configDir, configPath } = getPaths();
|
|
212
|
-
await mkdir(configDir, { recursive: true });
|
|
213
|
-
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
214
|
-
};
|
|
215
|
-
const configExists = () => {
|
|
216
|
-
const { configPath } = getPaths();
|
|
217
|
-
return existsSync(configPath);
|
|
218
|
-
};
|
|
219
|
-
|
|
220
234
|
//#endregion
|
|
221
235
|
//#region lib/platform/browser.ts
|
|
222
236
|
const getBrowserLauncher = (platform = process.platform, url) => {
|
|
@@ -279,9 +293,9 @@ const openBrowserUrl = (url, spawnImpl = spawn) => {
|
|
|
279
293
|
|
|
280
294
|
//#endregion
|
|
281
295
|
//#region lib/keychain.ts
|
|
282
|
-
const SERVICE_PREFIX$
|
|
296
|
+
const SERVICE_PREFIX$3 = "cdx-openai-";
|
|
283
297
|
const getKeychainService = (accountId) => {
|
|
284
|
-
return `${SERVICE_PREFIX$
|
|
298
|
+
return `${SERVICE_PREFIX$3}${accountId}`;
|
|
285
299
|
};
|
|
286
300
|
const runSecurity = (args) => {
|
|
287
301
|
const result = Bun.spawnSync({
|
|
@@ -306,6 +320,23 @@ const runSecuritySafe = (args) => {
|
|
|
306
320
|
output: result.exitCode === 0 ? result.stdout.toString() : result.stderr.toString()
|
|
307
321
|
};
|
|
308
322
|
};
|
|
323
|
+
const runSecuritySafeAsync = async (args) => {
|
|
324
|
+
const childProcess = Bun.spawn(["security", ...args], {
|
|
325
|
+
stderr: "pipe",
|
|
326
|
+
stdout: "pipe"
|
|
327
|
+
});
|
|
328
|
+
const stdoutPromise = childProcess.stdout ? new Response(childProcess.stdout).text() : Promise.resolve("");
|
|
329
|
+
const stderrPromise = childProcess.stderr ? new Response(childProcess.stderr).text() : Promise.resolve("");
|
|
330
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
331
|
+
childProcess.exited,
|
|
332
|
+
stdoutPromise,
|
|
333
|
+
stderrPromise
|
|
334
|
+
]);
|
|
335
|
+
return {
|
|
336
|
+
success: exitCode === 0,
|
|
337
|
+
output: exitCode === 0 ? stdout : stderr
|
|
338
|
+
};
|
|
339
|
+
};
|
|
309
340
|
const saveKeychainPayload = (accountId, payload) => {
|
|
310
341
|
runSecurity([
|
|
311
342
|
"add-generic-password",
|
|
@@ -353,12 +384,26 @@ const listKeychainAccounts = () => {
|
|
|
353
384
|
if (result.exitCode !== 0) return [];
|
|
354
385
|
const output = result.stdout.toString();
|
|
355
386
|
const accounts = [];
|
|
356
|
-
const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX$
|
|
387
|
+
const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX$3}([^"]+)"`, "g");
|
|
357
388
|
let match;
|
|
358
389
|
while ((match = serviceRegex.exec(output)) !== null) if (match[1]) accounts.push(match[1]);
|
|
359
390
|
return [...new Set(accounts)];
|
|
360
391
|
};
|
|
361
392
|
|
|
393
|
+
//#endregion
|
|
394
|
+
//#region lib/secrets/cross-keychain-overrides.ts
|
|
395
|
+
const LEGACY_MAX_PASSWORD_LENGTH = 4096;
|
|
396
|
+
const DEFAULT_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH = 16384;
|
|
397
|
+
const parseMaxPasswordLength = (value) => {
|
|
398
|
+
if (!value) return null;
|
|
399
|
+
const parsed = Number.parseInt(value, 10);
|
|
400
|
+
if (!Number.isInteger(parsed) || parsed <= LEGACY_MAX_PASSWORD_LENGTH) return null;
|
|
401
|
+
return parsed;
|
|
402
|
+
};
|
|
403
|
+
const getCrossKeychainBackendOverrides = () => {
|
|
404
|
+
return { max_password_length: parseMaxPasswordLength(process.env.CDX_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH) ?? DEFAULT_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH };
|
|
405
|
+
};
|
|
406
|
+
|
|
362
407
|
//#endregion
|
|
363
408
|
//#region lib/secrets/fallback-consent.ts
|
|
364
409
|
const CONSENT_FILE = "secure-store-fallback-consent.json";
|
|
@@ -413,13 +458,73 @@ const ensureFallbackConsent = async (scope, warningMessage) => {
|
|
|
413
458
|
|
|
414
459
|
//#endregion
|
|
415
460
|
//#region lib/secrets/linux-cross-keychain.ts
|
|
416
|
-
const SERVICE_PREFIX$
|
|
461
|
+
const SERVICE_PREFIX$2 = "cdx-openai-";
|
|
417
462
|
const LINUX_FALLBACK_SCOPE = "linux:cross-keychain:secret-service";
|
|
463
|
+
let backendInitPromise$2 = null;
|
|
464
|
+
let selectedBackend$2 = null;
|
|
465
|
+
const tryUseBackend$2 = async (backendId) => {
|
|
466
|
+
try {
|
|
467
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
468
|
+
return true;
|
|
469
|
+
} catch {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const selectBackend$2 = async () => {
|
|
474
|
+
const backends = await listBackends();
|
|
475
|
+
const available = new Set(backends.map((backend) => backend.id));
|
|
476
|
+
if (available.has("native-linux") && await tryUseBackend$2("native-linux")) return "native-linux";
|
|
477
|
+
if (available.has("secret-service") && await tryUseBackend$2("secret-service")) return "secret-service";
|
|
478
|
+
if (await tryUseBackend$2("native-linux")) return "native-linux";
|
|
479
|
+
if (await tryUseBackend$2("secret-service")) return "secret-service";
|
|
480
|
+
throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
|
|
481
|
+
};
|
|
482
|
+
const ensureLinuxBackend = async (options = {}) => {
|
|
483
|
+
if (!backendInitPromise$2) backendInitPromise$2 = (async () => {
|
|
484
|
+
selectedBackend$2 = await selectBackend$2();
|
|
485
|
+
})();
|
|
486
|
+
try {
|
|
487
|
+
await backendInitPromise$2;
|
|
488
|
+
} catch {
|
|
489
|
+
backendInitPromise$2 = null;
|
|
490
|
+
selectedBackend$2 = null;
|
|
491
|
+
throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
|
|
492
|
+
}
|
|
493
|
+
if (options.forWrite && selectedBackend$2 === "secret-service") await ensureFallbackConsent(LINUX_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Linux fallback backend is available.\nThis path relies on shell-based `secret-tool` operations for Secret Service access.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while helper commands run.");
|
|
494
|
+
};
|
|
495
|
+
const getLinuxCrossKeychainService = (accountId) => `${SERVICE_PREFIX$2}${accountId}`;
|
|
496
|
+
const parsePayload$2 = (accountId, raw) => {
|
|
497
|
+
let parsed;
|
|
498
|
+
try {
|
|
499
|
+
parsed = JSON.parse(raw);
|
|
500
|
+
} catch {
|
|
501
|
+
throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
502
|
+
}
|
|
503
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
504
|
+
return parsed;
|
|
505
|
+
};
|
|
506
|
+
const withService$2 = async (accountId, run, options = {}) => {
|
|
507
|
+
await ensureLinuxBackend(options);
|
|
508
|
+
return run(getLinuxCrossKeychainService(accountId));
|
|
509
|
+
};
|
|
510
|
+
const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$2(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
|
|
511
|
+
const loadLinuxCrossKeychainPayload = async (accountId) => {
|
|
512
|
+
const raw = await withService$2(accountId, (service) => getPassword(service, accountId));
|
|
513
|
+
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
514
|
+
return parsePayload$2(accountId, raw);
|
|
515
|
+
};
|
|
516
|
+
const deleteLinuxCrossKeychainPayload = async (accountId) => withService$2(accountId, (service) => deletePassword(service, accountId));
|
|
517
|
+
const linuxCrossKeychainPayloadExists = async (accountId) => withService$2(accountId, async (service) => await getPassword(service, accountId) !== null);
|
|
518
|
+
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region lib/secrets/macos-cross-keychain.ts
|
|
521
|
+
const SERVICE_PREFIX$1 = "cdx-openai-";
|
|
522
|
+
const MACOS_FALLBACK_SCOPE = "darwin:cross-keychain:macos";
|
|
418
523
|
let backendInitPromise$1 = null;
|
|
419
524
|
let selectedBackend$1 = null;
|
|
420
525
|
const tryUseBackend$1 = async (backendId) => {
|
|
421
526
|
try {
|
|
422
|
-
await useBackend(backendId);
|
|
527
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
423
528
|
return true;
|
|
424
529
|
} catch {
|
|
425
530
|
return false;
|
|
@@ -428,13 +533,13 @@ const tryUseBackend$1 = async (backendId) => {
|
|
|
428
533
|
const selectBackend$1 = async () => {
|
|
429
534
|
const backends = await listBackends();
|
|
430
535
|
const available = new Set(backends.map((backend) => backend.id));
|
|
431
|
-
if (available.has("native-
|
|
432
|
-
if (available.has("
|
|
433
|
-
if (await tryUseBackend$1("native-
|
|
434
|
-
if (await tryUseBackend$1("
|
|
435
|
-
throw new Error("Unable to initialize
|
|
536
|
+
if (available.has("native-macos") && await tryUseBackend$1("native-macos")) return "native-macos";
|
|
537
|
+
if (available.has("macos") && await tryUseBackend$1("macos")) return "macos";
|
|
538
|
+
if (await tryUseBackend$1("native-macos")) return "native-macos";
|
|
539
|
+
if (await tryUseBackend$1("macos")) return "macos";
|
|
540
|
+
throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
|
|
436
541
|
};
|
|
437
|
-
const
|
|
542
|
+
const ensureMacOSBackend = async (options = {}) => {
|
|
438
543
|
if (!backendInitPromise$1) backendInitPromise$1 = (async () => {
|
|
439
544
|
selectedBackend$1 = await selectBackend$1();
|
|
440
545
|
})();
|
|
@@ -443,11 +548,16 @@ const ensureLinuxBackend = async (options = {}) => {
|
|
|
443
548
|
} catch {
|
|
444
549
|
backendInitPromise$1 = null;
|
|
445
550
|
selectedBackend$1 = null;
|
|
446
|
-
throw new Error("Unable to initialize
|
|
551
|
+
throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
|
|
447
552
|
}
|
|
448
|
-
if (options.forWrite && selectedBackend$1 === "
|
|
553
|
+
if (options.forWrite && selectedBackend$1 === "macos") await ensureFallbackConsent(MACOS_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain macOS fallback backend is available.\nThis path uses the `security` command to access Keychain.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while helper commands run.");
|
|
449
554
|
};
|
|
450
|
-
const
|
|
555
|
+
const resolveMacOSCrossKeychainBackendId$1 = async () => {
|
|
556
|
+
await ensureMacOSBackend();
|
|
557
|
+
if (!selectedBackend$1) throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
|
|
558
|
+
return selectedBackend$1;
|
|
559
|
+
};
|
|
560
|
+
const getMacOSCrossKeychainService = (accountId) => `${SERVICE_PREFIX$1}${accountId}`;
|
|
451
561
|
const parsePayload$1 = (accountId, raw) => {
|
|
452
562
|
let parsed;
|
|
453
563
|
try {
|
|
@@ -459,17 +569,17 @@ const parsePayload$1 = (accountId, raw) => {
|
|
|
459
569
|
return parsed;
|
|
460
570
|
};
|
|
461
571
|
const withService$1 = async (accountId, run, options = {}) => {
|
|
462
|
-
await
|
|
463
|
-
return run(
|
|
572
|
+
await ensureMacOSBackend(options);
|
|
573
|
+
return run(getMacOSCrossKeychainService(accountId));
|
|
464
574
|
};
|
|
465
|
-
const
|
|
466
|
-
const
|
|
575
|
+
const saveMacOSCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
|
|
576
|
+
const loadMacOSCrossKeychainPayload = async (accountId) => {
|
|
467
577
|
const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
|
|
468
578
|
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
469
579
|
return parsePayload$1(accountId, raw);
|
|
470
580
|
};
|
|
471
|
-
const
|
|
472
|
-
const
|
|
581
|
+
const deleteMacOSCrossKeychainPayload = async (accountId) => withService$1(accountId, (service) => deletePassword(service, accountId));
|
|
582
|
+
const macosCrossKeychainPayloadExists = async (accountId) => withService$1(accountId, async (service) => await getPassword(service, accountId) !== null);
|
|
473
583
|
|
|
474
584
|
//#endregion
|
|
475
585
|
//#region lib/secrets/windows-cross-keychain.ts
|
|
@@ -479,7 +589,7 @@ let backendInitPromise = null;
|
|
|
479
589
|
let selectedBackend = null;
|
|
480
590
|
const tryUseBackend = async (backendId) => {
|
|
481
591
|
try {
|
|
482
|
-
await useBackend(backendId);
|
|
592
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
483
593
|
return true;
|
|
484
594
|
} catch {
|
|
485
595
|
return false;
|
|
@@ -522,52 +632,154 @@ const withService = async (accountId, run, options = {}) => {
|
|
|
522
632
|
await ensureWindowsBackend(options);
|
|
523
633
|
return run(getWindowsCrossKeychainService(accountId));
|
|
524
634
|
};
|
|
525
|
-
const saveWindowsCrossKeychainPayload = async (accountId, payload) => withService(accountId, (service) =>
|
|
635
|
+
const saveWindowsCrossKeychainPayload = async (accountId, payload) => withService(accountId, async (service) => {
|
|
636
|
+
await setPassword(service, accountId, JSON.stringify(payload));
|
|
637
|
+
}, { forWrite: true });
|
|
526
638
|
const loadWindowsCrossKeychainPayload = async (accountId) => {
|
|
527
639
|
const raw = await withService(accountId, (service) => getPassword(service, accountId));
|
|
528
640
|
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
529
641
|
return parsePayload(accountId, raw);
|
|
530
642
|
};
|
|
531
|
-
const deleteWindowsCrossKeychainPayload = async (accountId) => withService(accountId, (service) =>
|
|
643
|
+
const deleteWindowsCrossKeychainPayload = async (accountId) => withService(accountId, async (service) => {
|
|
644
|
+
await deletePassword(service, accountId);
|
|
645
|
+
});
|
|
532
646
|
const windowsCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
|
|
533
647
|
|
|
534
648
|
//#endregion
|
|
535
649
|
//#region lib/secrets/store.ts
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
650
|
+
const MISSING_SECRET_STORE_ERROR_MARKERS = [
|
|
651
|
+
"No stored credentials found",
|
|
652
|
+
"No Keychain payload found",
|
|
653
|
+
"Password not found"
|
|
654
|
+
];
|
|
655
|
+
const isMissingSecretStoreEntryError = (error) => {
|
|
656
|
+
if (!(error instanceof Error)) return false;
|
|
657
|
+
return MISSING_SECRET_STORE_ERROR_MARKERS.some((marker) => error.message.includes(marker));
|
|
658
|
+
};
|
|
659
|
+
const createMissingSecretStoreEntryError = (accountId) => /* @__PURE__ */ new Error(`No stored credentials found for account ${accountId}.`);
|
|
660
|
+
const CACHED_ADAPTER_SYMBOL = Symbol.for("cdx.secretStore.cachedAdapter");
|
|
661
|
+
const withSecretStoreCache = (adapter) => {
|
|
662
|
+
if (adapter[CACHED_ADAPTER_SYMBOL]) return adapter;
|
|
663
|
+
const payloadCache = /* @__PURE__ */ new Map();
|
|
664
|
+
const existsCache = /* @__PURE__ */ new Map();
|
|
665
|
+
const missingAccounts = /* @__PURE__ */ new Set();
|
|
666
|
+
const inFlightLoads = /* @__PURE__ */ new Map();
|
|
667
|
+
const markPresent = (accountId, payload) => {
|
|
668
|
+
payloadCache.set(accountId, payload);
|
|
669
|
+
existsCache.set(accountId, true);
|
|
670
|
+
missingAccounts.delete(accountId);
|
|
671
|
+
};
|
|
672
|
+
const markMissing = (accountId) => {
|
|
673
|
+
payloadCache.delete(accountId);
|
|
674
|
+
existsCache.set(accountId, false);
|
|
675
|
+
missingAccounts.add(accountId);
|
|
676
|
+
};
|
|
677
|
+
const loadAndCache = async (accountId) => {
|
|
678
|
+
const existingPromise = inFlightLoads.get(accountId);
|
|
679
|
+
if (existingPromise) return existingPromise;
|
|
680
|
+
const promise = (async () => {
|
|
681
|
+
try {
|
|
682
|
+
const payload = await adapter.load(accountId);
|
|
683
|
+
markPresent(accountId, payload);
|
|
684
|
+
return payload;
|
|
685
|
+
} catch (error) {
|
|
686
|
+
if (isMissingSecretStoreEntryError(error)) markMissing(accountId);
|
|
687
|
+
throw error;
|
|
688
|
+
} finally {
|
|
689
|
+
inFlightLoads.delete(accountId);
|
|
690
|
+
}
|
|
691
|
+
})();
|
|
692
|
+
inFlightLoads.set(accountId, promise);
|
|
693
|
+
return promise;
|
|
694
|
+
};
|
|
695
|
+
return {
|
|
696
|
+
id: adapter.id,
|
|
697
|
+
label: adapter.label,
|
|
698
|
+
getServiceName: (accountId) => adapter.getServiceName(accountId),
|
|
699
|
+
save: async (accountId, payload) => {
|
|
700
|
+
await adapter.save(accountId, payload);
|
|
701
|
+
markPresent(accountId, payload);
|
|
702
|
+
},
|
|
703
|
+
load: async (accountId) => {
|
|
704
|
+
const cachedPayload = payloadCache.get(accountId);
|
|
705
|
+
if (cachedPayload) return cachedPayload;
|
|
706
|
+
if (missingAccounts.has(accountId)) throw createMissingSecretStoreEntryError(accountId);
|
|
707
|
+
return loadAndCache(accountId);
|
|
708
|
+
},
|
|
709
|
+
delete: async (accountId) => {
|
|
710
|
+
try {
|
|
711
|
+
await adapter.delete(accountId);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
if (isMissingSecretStoreEntryError(error)) markMissing(accountId);
|
|
714
|
+
throw error;
|
|
715
|
+
}
|
|
716
|
+
markMissing(accountId);
|
|
717
|
+
},
|
|
718
|
+
exists: async (accountId) => {
|
|
719
|
+
if (payloadCache.has(accountId)) return true;
|
|
720
|
+
if (missingAccounts.has(accountId)) return false;
|
|
721
|
+
const cachedExists = existsCache.get(accountId);
|
|
722
|
+
if (cachedExists !== void 0) return cachedExists;
|
|
723
|
+
try {
|
|
724
|
+
await loadAndCache(accountId);
|
|
725
|
+
return true;
|
|
726
|
+
} catch (error) {
|
|
727
|
+
if (isMissingSecretStoreEntryError(error)) return false;
|
|
728
|
+
}
|
|
729
|
+
const exists = await adapter.exists(accountId);
|
|
730
|
+
existsCache.set(accountId, exists);
|
|
731
|
+
if (!exists) missingAccounts.add(accountId);
|
|
732
|
+
return exists;
|
|
733
|
+
},
|
|
734
|
+
listAccountIds: async () => {
|
|
735
|
+
const accountIds = await adapter.listAccountIds();
|
|
736
|
+
for (const accountId of accountIds) {
|
|
737
|
+
existsCache.set(accountId, true);
|
|
738
|
+
missingAccounts.delete(accountId);
|
|
739
|
+
}
|
|
740
|
+
return accountIds;
|
|
741
|
+
},
|
|
742
|
+
getCapability: () => adapter.getCapability(),
|
|
743
|
+
[CACHED_ADAPTER_SYMBOL]: true
|
|
744
|
+
};
|
|
548
745
|
};
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
746
|
+
const unsupportedError = (platform) => /* @__PURE__ */ new Error(`No default secret store adapter configured for platform '${platform}'. Only macOS, Windows, and Linux adapters are wired by default right now.`);
|
|
747
|
+
const loadConfiguredAccountIds = async () => {
|
|
748
|
+
if (!configExists()) return [];
|
|
749
|
+
return (await loadConfig()).accounts.map((account) => account.accountId);
|
|
552
750
|
};
|
|
553
|
-
const
|
|
554
|
-
id: "macos-keychain",
|
|
555
|
-
label: "macOS Keychain",
|
|
751
|
+
const createMacOSCrossKeychainAdapter = () => ({
|
|
752
|
+
id: "macos-cross-keychain",
|
|
753
|
+
label: "macOS Keychain (cross-keychain)",
|
|
754
|
+
getServiceName: getMacOSCrossKeychainService,
|
|
755
|
+
save: saveMacOSCrossKeychainPayload,
|
|
756
|
+
load: loadMacOSCrossKeychainPayload,
|
|
757
|
+
delete: deleteMacOSCrossKeychainPayload,
|
|
758
|
+
exists: macosCrossKeychainPayloadExists,
|
|
759
|
+
listAccountIds: async () => {
|
|
760
|
+
const accountIds = await loadConfiguredAccountIds();
|
|
761
|
+
return (await Promise.all(accountIds.map(async (accountId) => ({
|
|
762
|
+
accountId,
|
|
763
|
+
exists: await macosCrossKeychainPayloadExists(accountId)
|
|
764
|
+
})))).filter((item) => item.exists).map((item) => item.accountId);
|
|
765
|
+
},
|
|
766
|
+
getCapability: () => ({ available: true })
|
|
767
|
+
});
|
|
768
|
+
const createMacOSLegacyKeychainAdapter = () => ({
|
|
769
|
+
id: "macos-legacy-keychain",
|
|
770
|
+
label: "macOS Keychain (legacy security CLI)",
|
|
556
771
|
getServiceName: getKeychainService,
|
|
557
772
|
save: async (accountId, payload) => {
|
|
558
|
-
await ensureMacFallbackConsentIfNeeded();
|
|
559
773
|
saveKeychainPayload(accountId, payload);
|
|
560
774
|
},
|
|
561
775
|
load: async (accountId) => loadKeychainPayload(accountId),
|
|
562
|
-
delete: async (accountId) =>
|
|
776
|
+
delete: async (accountId) => {
|
|
777
|
+
deleteKeychainPayload(accountId);
|
|
778
|
+
},
|
|
563
779
|
exists: async (accountId) => keychainPayloadExists(accountId),
|
|
564
780
|
listAccountIds: async () => listKeychainAccounts(),
|
|
565
781
|
getCapability: () => ({ available: true })
|
|
566
782
|
});
|
|
567
|
-
const loadConfiguredAccountIds = async () => {
|
|
568
|
-
if (!configExists()) return [];
|
|
569
|
-
return (await loadConfig()).accounts.map((account) => account.accountId);
|
|
570
|
-
};
|
|
571
783
|
const createWindowsCrossKeychainAdapter = () => ({
|
|
572
784
|
id: "windows-cross-keychain",
|
|
573
785
|
label: "Windows Credential Manager (cross-keychain)",
|
|
@@ -623,18 +835,29 @@ const createUnsupportedAdapter = (platform) => ({
|
|
|
623
835
|
})
|
|
624
836
|
});
|
|
625
837
|
const createRuntimeSecretStoreAdapter = (platform = process.platform) => {
|
|
626
|
-
if (platform === "darwin") return
|
|
838
|
+
if (platform === "darwin") return createMacOSCrossKeychainAdapter();
|
|
627
839
|
if (platform === "win32") return createWindowsCrossKeychainAdapter();
|
|
628
840
|
if (platform === "linux") return createLinuxCrossKeychainAdapter();
|
|
629
841
|
return createUnsupportedAdapter(platform);
|
|
630
842
|
};
|
|
631
|
-
|
|
843
|
+
const createSecretStoreAdapterFromSelection = (selection = "auto", platform = process.platform) => {
|
|
844
|
+
if (selection === "legacy-keychain") {
|
|
845
|
+
if (platform !== "darwin") throw new Error("The legacy keychain adapter is only available on macOS (darwin).");
|
|
846
|
+
return createMacOSLegacyKeychainAdapter();
|
|
847
|
+
}
|
|
848
|
+
return createRuntimeSecretStoreAdapter(platform);
|
|
849
|
+
};
|
|
850
|
+
const resolveMacOSCrossKeychainBackendId = async (platform = process.platform) => {
|
|
851
|
+
if (platform !== "darwin") return null;
|
|
852
|
+
return resolveMacOSCrossKeychainBackendId$1();
|
|
853
|
+
};
|
|
854
|
+
let currentSecretStoreAdapter = withSecretStoreCache(createRuntimeSecretStoreAdapter());
|
|
632
855
|
const getSecretStoreAdapter = () => currentSecretStoreAdapter;
|
|
633
856
|
const setSecretStoreAdapter = (adapter) => {
|
|
634
|
-
currentSecretStoreAdapter = adapter;
|
|
857
|
+
currentSecretStoreAdapter = withSecretStoreCache(adapter);
|
|
635
858
|
};
|
|
636
859
|
const resetSecretStoreAdapter = () => {
|
|
637
|
-
currentSecretStoreAdapter = createRuntimeSecretStoreAdapter();
|
|
860
|
+
currentSecretStoreAdapter = withSecretStoreCache(createRuntimeSecretStoreAdapter());
|
|
638
861
|
};
|
|
639
862
|
const getSecretStoreCapability = () => {
|
|
640
863
|
const adapter = getSecretStoreAdapter();
|
|
@@ -1100,14 +1323,17 @@ const readPiAuthAccount = async () => {
|
|
|
1100
1323
|
};
|
|
1101
1324
|
const getAccountStatus = async (accountId, isCurrent, label) => {
|
|
1102
1325
|
const secretStore = getSecretStoreAdapter();
|
|
1103
|
-
|
|
1326
|
+
let secureStoreExists = false;
|
|
1104
1327
|
let expiresAt = null;
|
|
1105
1328
|
let hasIdToken = false;
|
|
1106
|
-
|
|
1329
|
+
try {
|
|
1107
1330
|
const payload = await secretStore.load(accountId);
|
|
1331
|
+
secureStoreExists = true;
|
|
1108
1332
|
expiresAt = payload.expires;
|
|
1109
1333
|
hasIdToken = !!payload.idToken;
|
|
1110
|
-
} catch {
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
secureStoreExists = !isMissingSecretStoreEntryError(error);
|
|
1336
|
+
}
|
|
1111
1337
|
return {
|
|
1112
1338
|
accountId,
|
|
1113
1339
|
label,
|
|
@@ -1481,10 +1707,89 @@ const registerDefaultInteractiveAction = (program) => {
|
|
|
1481
1707
|
});
|
|
1482
1708
|
};
|
|
1483
1709
|
|
|
1710
|
+
//#endregion
|
|
1711
|
+
//#region lib/keychain-acl.ts
|
|
1712
|
+
const getDefaultMap = (services) => {
|
|
1713
|
+
const map = /* @__PURE__ */ new Map();
|
|
1714
|
+
for (const service of services) map.set(service, {
|
|
1715
|
+
service,
|
|
1716
|
+
mode: "missing",
|
|
1717
|
+
applications: []
|
|
1718
|
+
});
|
|
1719
|
+
return map;
|
|
1720
|
+
};
|
|
1721
|
+
const parseItemEntries = (block) => {
|
|
1722
|
+
const entries = [];
|
|
1723
|
+
const entryRegex = /entry\s+\d+:\n([\s\S]*?)(?=\n\s*entry\s+\d+:|$)/g;
|
|
1724
|
+
let match;
|
|
1725
|
+
while ((match = entryRegex.exec(block)) !== null) if (match[1]) entries.push(match[1]);
|
|
1726
|
+
return entries;
|
|
1727
|
+
};
|
|
1728
|
+
const parseApplicationsFromEntry = (entry) => {
|
|
1729
|
+
const applications = [];
|
|
1730
|
+
const appRegex = /^\s*\d+:\s+(.+?)(?:\s+\([^\n]*\))?\s*$/gm;
|
|
1731
|
+
let match;
|
|
1732
|
+
while ((match = appRegex.exec(entry)) !== null) {
|
|
1733
|
+
const app = match[1]?.trim();
|
|
1734
|
+
if (app) applications.push(app);
|
|
1735
|
+
}
|
|
1736
|
+
return applications;
|
|
1737
|
+
};
|
|
1738
|
+
const parseKeychainDecryptAccessFromDump = (dumpOutput, services) => {
|
|
1739
|
+
const dedupedServices = [...new Set(services.filter((service) => service.length > 0))];
|
|
1740
|
+
const result = getDefaultMap(dedupedServices);
|
|
1741
|
+
if (dedupedServices.length === 0 || !dumpOutput.trim()) return result;
|
|
1742
|
+
const targetServices = new Set(dedupedServices);
|
|
1743
|
+
const blocks = dumpOutput.split(/\n(?=keychain:\s+")/g);
|
|
1744
|
+
for (const block of blocks) {
|
|
1745
|
+
if (!block.startsWith("keychain:")) continue;
|
|
1746
|
+
const service = block.match(/"svce"<blob>="([^"]+)"/)?.[1];
|
|
1747
|
+
if (!service || !targetServices.has(service)) continue;
|
|
1748
|
+
const entries = parseItemEntries(block);
|
|
1749
|
+
let mode = "missing";
|
|
1750
|
+
const applications = [];
|
|
1751
|
+
for (const entry of entries) {
|
|
1752
|
+
const authorizationsLine = entry.match(/authorizations\s*\(\d+\):\s*([^\n]+)/)?.[1] ?? "";
|
|
1753
|
+
if (!/\bdecrypt\b/.test(authorizationsLine)) continue;
|
|
1754
|
+
if (/applications:\s*<null>/.test(entry)) {
|
|
1755
|
+
mode = "all-apps";
|
|
1756
|
+
applications.length = 0;
|
|
1757
|
+
break;
|
|
1758
|
+
}
|
|
1759
|
+
const entryApplications = parseApplicationsFromEntry(entry);
|
|
1760
|
+
if (entryApplications.length > 0) {
|
|
1761
|
+
mode = "explicit-list";
|
|
1762
|
+
for (const app of entryApplications) if (!applications.includes(app)) applications.push(app);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
result.set(service, {
|
|
1766
|
+
service,
|
|
1767
|
+
mode,
|
|
1768
|
+
applications
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
return result;
|
|
1772
|
+
};
|
|
1773
|
+
const getKeychainDecryptAccessByServiceAsync = async (services) => {
|
|
1774
|
+
const dedupedServices = [...new Set(services.filter((service) => service.length > 0))];
|
|
1775
|
+
const defaultResult = getDefaultMap(dedupedServices);
|
|
1776
|
+
if (process.platform !== "darwin" || dedupedServices.length === 0) return defaultResult;
|
|
1777
|
+
const dumpResult = await runSecuritySafeAsync(["dump-keychain", "-a"]);
|
|
1778
|
+
if (!dumpResult.success) return defaultResult;
|
|
1779
|
+
return parseKeychainDecryptAccessFromDump(dumpResult.output, dedupedServices);
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1484
1782
|
//#endregion
|
|
1485
1783
|
//#region lib/commands/doctor.ts
|
|
1784
|
+
const hasRuntimeTrustedApp = (trustedApplications, runtimeExecutablePath) => {
|
|
1785
|
+
const runtimeBaseName = path.basename(runtimeExecutablePath).toLowerCase();
|
|
1786
|
+
return trustedApplications.some((trustedApp) => {
|
|
1787
|
+
if (trustedApp === runtimeExecutablePath) return true;
|
|
1788
|
+
return path.basename(trustedApp).toLowerCase() === runtimeBaseName;
|
|
1789
|
+
});
|
|
1790
|
+
};
|
|
1486
1791
|
const registerDoctorCommand = (program) => {
|
|
1487
|
-
program.command("doctor").description("Show auth file paths and runtime capabilities").action(async () => {
|
|
1792
|
+
program.command("doctor").description("Show auth file paths and runtime capabilities").option("--check-keychain-acl", "Run keychain trusted-app/ACL checks on macOS (can be slow)").action(async (options) => {
|
|
1488
1793
|
try {
|
|
1489
1794
|
const status = await getStatus();
|
|
1490
1795
|
const paths = getPaths();
|
|
@@ -1509,6 +1814,50 @@ const registerDoctorCommand = (program) => {
|
|
|
1509
1814
|
process.stdout.write(` Secret store: ${status.capabilities.secretStore.label} — ${secretStoreState}\n`);
|
|
1510
1815
|
const browserState = status.capabilities.browserLauncher.available ? "available" : "not found";
|
|
1511
1816
|
process.stdout.write(` Browser launcher: ${status.capabilities.browserLauncher.label} — ${browserState}\n`);
|
|
1817
|
+
if (process.platform === "darwin" && !options.checkKeychainAcl) {
|
|
1818
|
+
process.stdout.write(" ┌─ Optional keychain ACL check\n");
|
|
1819
|
+
process.stdout.write(" │ Run: cdx doctor --check-keychain-acl\n");
|
|
1820
|
+
process.stdout.write(" │ Verifies whether your current runtime is trusted by Keychain.\n");
|
|
1821
|
+
process.stdout.write(" └─ Expected duration: ~30-60 seconds\n");
|
|
1822
|
+
}
|
|
1823
|
+
if (process.platform === "darwin" && options.checkKeychainAcl) {
|
|
1824
|
+
const secretStore = getSecretStoreAdapter();
|
|
1825
|
+
const accountsWithSecrets = status.accounts.filter((account) => account.secureStoreExists);
|
|
1826
|
+
if (accountsWithSecrets.length > 0) {
|
|
1827
|
+
const runtimeExecutablePath = process.execPath;
|
|
1828
|
+
const services = accountsWithSecrets.map((account) => secretStore.getServiceName(account.accountId));
|
|
1829
|
+
process.stdout.write("\nKeychain ACL checks:\n");
|
|
1830
|
+
process.stdout.write(` Runtime executable: ${runtimeExecutablePath}\n`);
|
|
1831
|
+
const aclSpinner = p.spinner();
|
|
1832
|
+
const accountWord = accountsWithSecrets.length === 1 ? "account" : "accounts";
|
|
1833
|
+
aclSpinner.start(`Checking keychain ACLs for ${accountsWithSecrets.length} ${accountWord}...`);
|
|
1834
|
+
const decryptAccessByService = await getKeychainDecryptAccessByServiceAsync(services);
|
|
1835
|
+
aclSpinner.stop("Keychain ACL checks complete.");
|
|
1836
|
+
for (const account of accountsWithSecrets) {
|
|
1837
|
+
const service = secretStore.getServiceName(account.accountId);
|
|
1838
|
+
const decryptAccess = decryptAccessByService.get(service);
|
|
1839
|
+
const accountLabel = resolveLabel(account.accountId);
|
|
1840
|
+
if (!decryptAccess || decryptAccess.mode === "missing") {
|
|
1841
|
+
process.stdout.write(` ${accountLabel}: unable to read decrypt trusted apps (service: ${service})\n`);
|
|
1842
|
+
continue;
|
|
1843
|
+
}
|
|
1844
|
+
if (decryptAccess.mode === "all-apps") {
|
|
1845
|
+
process.stdout.write(` ${accountLabel}: decrypt access allows all apps (<null>)\n`);
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1848
|
+
const runtimeTrusted = hasRuntimeTrustedApp(decryptAccess.applications, runtimeExecutablePath);
|
|
1849
|
+
const trustedAppsList = decryptAccess.applications.join(", ");
|
|
1850
|
+
if (runtimeTrusted) {
|
|
1851
|
+
process.stdout.write(` ${accountLabel}: runtime is in trusted apps\n`);
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
process.stdout.write(` ⚠ ${accountLabel}: runtime not found in trusted apps\n`);
|
|
1855
|
+
process.stdout.write(` Service: ${service}\n`);
|
|
1856
|
+
process.stdout.write(` Trusted apps: ${trustedAppsList || "(none)"}\n`);
|
|
1857
|
+
process.stdout.write(" This secret may have been created with a different runtime/toolchain (for example node vs bun).\n");
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1512
1861
|
process.stdout.write("\n");
|
|
1513
1862
|
} catch (error) {
|
|
1514
1863
|
exitWithCommandError(error);
|
|
@@ -1571,6 +1920,119 @@ const registerLoginCommand = (program, deps = {}) => {
|
|
|
1571
1920
|
});
|
|
1572
1921
|
};
|
|
1573
1922
|
|
|
1923
|
+
//#endregion
|
|
1924
|
+
//#region lib/secrets/migrate.ts
|
|
1925
|
+
const asErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
1926
|
+
const migrateLegacyMacOSSecrets = async (options = {}) => {
|
|
1927
|
+
if ((options.platform ?? process.platform) !== "darwin") throw new Error("'migrate-secrets' is only available on macOS (darwin).");
|
|
1928
|
+
const loadConfigFn = options.loadConfigFn ?? loadConfig;
|
|
1929
|
+
const saveConfigFn = options.saveConfigFn ?? saveConfig;
|
|
1930
|
+
const sourceAdapter = options.sourceAdapter ?? createMacOSLegacyKeychainAdapter();
|
|
1931
|
+
const targetAdapter = options.targetAdapter ?? createRuntimeSecretStoreAdapter("darwin");
|
|
1932
|
+
const config = await loadConfigFn();
|
|
1933
|
+
const accountResults = [];
|
|
1934
|
+
for (const account of config.accounts) {
|
|
1935
|
+
const accountId = account.accountId;
|
|
1936
|
+
try {
|
|
1937
|
+
if (!await sourceAdapter.exists(accountId)) {
|
|
1938
|
+
accountResults.push({
|
|
1939
|
+
accountId,
|
|
1940
|
+
label: account.label,
|
|
1941
|
+
status: "skipped",
|
|
1942
|
+
message: "No legacy keychain entry found."
|
|
1943
|
+
});
|
|
1944
|
+
continue;
|
|
1945
|
+
}
|
|
1946
|
+
const loaded = await sourceAdapter.load(accountId);
|
|
1947
|
+
const payload = loaded.accountId === accountId ? loaded : {
|
|
1948
|
+
...loaded,
|
|
1949
|
+
accountId
|
|
1950
|
+
};
|
|
1951
|
+
await sourceAdapter.delete(accountId);
|
|
1952
|
+
try {
|
|
1953
|
+
await targetAdapter.save(accountId, payload);
|
|
1954
|
+
} catch (error) {
|
|
1955
|
+
try {
|
|
1956
|
+
await sourceAdapter.save(accountId, payload);
|
|
1957
|
+
} catch {}
|
|
1958
|
+
throw error;
|
|
1959
|
+
}
|
|
1960
|
+
accountResults.push({
|
|
1961
|
+
accountId,
|
|
1962
|
+
label: account.label,
|
|
1963
|
+
status: "migrated",
|
|
1964
|
+
message: "Legacy entry migrated to cross-keychain backend."
|
|
1965
|
+
});
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
accountResults.push({
|
|
1968
|
+
accountId,
|
|
1969
|
+
label: account.label,
|
|
1970
|
+
status: "failed",
|
|
1971
|
+
message: asErrorMessage(error)
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
const failed = accountResults.filter((entry) => entry.status === "failed").length;
|
|
1976
|
+
const skipped = accountResults.filter((entry) => entry.status === "skipped").length;
|
|
1977
|
+
const migrated = accountResults.filter((entry) => entry.status === "migrated").length;
|
|
1978
|
+
let configUpdated = false;
|
|
1979
|
+
if (failed === 0) {
|
|
1980
|
+
let changed = false;
|
|
1981
|
+
for (const account of config.accounts) {
|
|
1982
|
+
const expectedService = targetAdapter.getServiceName(account.accountId);
|
|
1983
|
+
if (account.keychainService !== expectedService) {
|
|
1984
|
+
account.keychainService = expectedService;
|
|
1985
|
+
changed = true;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
if (config.secretStore !== "auto") {
|
|
1989
|
+
config.secretStore = "auto";
|
|
1990
|
+
changed = true;
|
|
1991
|
+
}
|
|
1992
|
+
if (changed) {
|
|
1993
|
+
await saveConfigFn(config);
|
|
1994
|
+
configUpdated = true;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
return {
|
|
1998
|
+
migrated,
|
|
1999
|
+
skipped,
|
|
2000
|
+
failed,
|
|
2001
|
+
configUpdated,
|
|
2002
|
+
accountResults
|
|
2003
|
+
};
|
|
2004
|
+
};
|
|
2005
|
+
|
|
2006
|
+
//#endregion
|
|
2007
|
+
//#region lib/commands/migrate-secrets.ts
|
|
2008
|
+
const statusPrefix = (result) => {
|
|
2009
|
+
if (result.status === "migrated") return "✓";
|
|
2010
|
+
if (result.status === "skipped") return "-";
|
|
2011
|
+
return "✗";
|
|
2012
|
+
};
|
|
2013
|
+
const formatName = (result) => result.label ? `${result.label} (${result.accountId})` : result.accountId;
|
|
2014
|
+
const registerMigrateSecretsCommand = (program) => {
|
|
2015
|
+
program.command("migrate-secrets").description("Migrate macOS legacy keychain entries to cross-keychain and update config").action(async () => {
|
|
2016
|
+
try {
|
|
2017
|
+
const result = await migrateLegacyMacOSSecrets();
|
|
2018
|
+
process.stdout.write("\nSecret migration results:\n");
|
|
2019
|
+
for (const accountResult of result.accountResults) process.stdout.write(` ${statusPrefix(accountResult)} ${formatName(accountResult)}: ${accountResult.message}\n`);
|
|
2020
|
+
process.stdout.write("\nSummary:\n");
|
|
2021
|
+
process.stdout.write(` Migrated: ${result.migrated}\n`);
|
|
2022
|
+
process.stdout.write(` Skipped: ${result.skipped}\n`);
|
|
2023
|
+
process.stdout.write(` Failed: ${result.failed}\n`);
|
|
2024
|
+
if (result.failed === 0) {
|
|
2025
|
+
process.stdout.write(result.configUpdated ? " Config: updated (secretStore=auto, service names normalized)\n\n" : " Config: already up to date\n\n");
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
process.stdout.write(" Config: not updated because at least one account failed\n\n");
|
|
2029
|
+
throw new Error(`Migration finished with ${result.failed} failed account(s). Resolve them and run 'cdx migrate-secrets' again.`);
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
exitWithCommandError(error);
|
|
2032
|
+
}
|
|
2033
|
+
});
|
|
2034
|
+
};
|
|
2035
|
+
|
|
1574
2036
|
//#endregion
|
|
1575
2037
|
//#region lib/commands/output.ts
|
|
1576
2038
|
const formatCodexMark = (result) => {
|
|
@@ -1932,13 +2394,51 @@ const registerVersionCommand = (program, version) => {
|
|
|
1932
2394
|
//#endregion
|
|
1933
2395
|
//#region cdx.ts
|
|
1934
2396
|
const interactiveMode = runInteractiveMode;
|
|
2397
|
+
const parseSecretStoreSelection = (value) => {
|
|
2398
|
+
if (value === "auto" || value === "legacy-keychain") return value;
|
|
2399
|
+
throw new InvalidArgumentError(`Invalid value '${value}' for --secret-store. Allowed values: auto, legacy-keychain.`);
|
|
2400
|
+
};
|
|
2401
|
+
const getCompletionParseArgs = (argv) => {
|
|
2402
|
+
const completeIndex = argv.findIndex((arg) => arg === "complete");
|
|
2403
|
+
if (completeIndex === -1) return null;
|
|
2404
|
+
const separatorIndex = argv.findIndex((arg) => arg === "--");
|
|
2405
|
+
if (separatorIndex === -1 || separatorIndex <= completeIndex) return null;
|
|
2406
|
+
return argv.slice(separatorIndex + 1);
|
|
2407
|
+
};
|
|
2408
|
+
const getMacOSKeychainPromptWarning = (selection, platform = process.platform, backendId = null) => {
|
|
2409
|
+
if (platform !== "darwin") return null;
|
|
2410
|
+
if (selection === "legacy-keychain") return "⚠ macOS keychain is using the legacy security CLI backend. Touch ID may not be offered for keychain prompts.";
|
|
2411
|
+
if (selection === "auto" && backendId === "macos") return "⚠ macOS keychain is using the cross-keychain CLI fallback (`security`). Touch ID may not be offered for keychain prompts.";
|
|
2412
|
+
return null;
|
|
2413
|
+
};
|
|
2414
|
+
const maybeWarnAboutMacOSKeychainPromptMode = async (selection) => {
|
|
2415
|
+
let backendId = null;
|
|
2416
|
+
if (selection === "auto" && process.platform === "darwin") try {
|
|
2417
|
+
backendId = await resolveMacOSCrossKeychainBackendId();
|
|
2418
|
+
} catch {
|
|
2419
|
+
backendId = null;
|
|
2420
|
+
}
|
|
2421
|
+
const warning = getMacOSKeychainPromptWarning(selection, process.platform, backendId);
|
|
2422
|
+
if (warning) process.stderr.write(`${warning}\n`);
|
|
2423
|
+
};
|
|
1935
2424
|
const createProgram = (deps = {}) => {
|
|
1936
2425
|
const program = new Command();
|
|
1937
|
-
program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version");
|
|
2426
|
+
program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version").option("--secret-store <mode>", "Select secret-store backend (auto|legacy-keychain)", parseSecretStoreSelection);
|
|
2427
|
+
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
2428
|
+
const options = actionCommand.optsWithGlobals();
|
|
2429
|
+
const configuredSelection = options.secretStore ? void 0 : await loadConfiguredSecretStoreSelection();
|
|
2430
|
+
const selection = options.secretStore ?? configuredSelection ?? "auto";
|
|
2431
|
+
setSecretStoreAdapter(createSecretStoreAdapterFromSelection(selection));
|
|
2432
|
+
await maybeWarnAboutMacOSKeychainPromptMode(selection);
|
|
2433
|
+
});
|
|
2434
|
+
program.hook("postAction", () => {
|
|
2435
|
+
resetSecretStoreAdapter();
|
|
2436
|
+
});
|
|
1938
2437
|
registerLoginCommand(program, deps);
|
|
1939
2438
|
registerReloginCommand(program);
|
|
1940
2439
|
registerSwitchCommand(program);
|
|
1941
2440
|
registerLabelCommand(program);
|
|
2441
|
+
registerMigrateSecretsCommand(program);
|
|
1942
2442
|
registerStatusCommand(program);
|
|
1943
2443
|
registerDoctorCommand(program);
|
|
1944
2444
|
registerUsageCommand(program);
|
|
@@ -1948,11 +2448,18 @@ const createProgram = (deps = {}) => {
|
|
|
1948
2448
|
return program;
|
|
1949
2449
|
};
|
|
1950
2450
|
const main = async () => {
|
|
1951
|
-
|
|
2451
|
+
const program = createProgram();
|
|
2452
|
+
const completion = tab(program);
|
|
2453
|
+
const completionArgs = getCompletionParseArgs(process.argv);
|
|
2454
|
+
if (completionArgs) {
|
|
2455
|
+
completion.parse(completionArgs);
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
await program.parseAsync(process.argv);
|
|
1952
2459
|
};
|
|
1953
2460
|
if (import.meta.main) main().catch((error) => {
|
|
1954
2461
|
exitWithCommandError(error);
|
|
1955
2462
|
});
|
|
1956
2463
|
|
|
1957
2464
|
//#endregion
|
|
1958
|
-
export { createProgram, createRuntimeSecretStoreAdapter, createTestPaths, getPaths, getSecretStoreAdapter, interactiveMode, loadConfig, resetPaths, resetSecretStoreAdapter, runInteractiveMode, saveConfig, setPaths, setSecretStoreAdapter, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };
|
|
2465
|
+
export { createProgram, createRuntimeSecretStoreAdapter, createSecretStoreAdapterFromSelection, createTestPaths, getMacOSKeychainPromptWarning, getPaths, getSecretStoreAdapter, interactiveMode, loadConfig, resetPaths, resetSecretStoreAdapter, resolveMacOSCrossKeychainBackendId, runInteractiveMode, saveConfig, setPaths, setSecretStoreAdapter, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bjesuiter/codex-switcher",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
|
|
6
6
|
"bin": {
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"author": "bjesuiter",
|
|
22
22
|
"dependencies": {
|
|
23
|
+
"@bjesuiter/cross-keychain": "1.1.0-jb.0",
|
|
24
|
+
"@bomb.sh/tab": "^0.0.13",
|
|
23
25
|
"@clack/prompts": "^1.0.0",
|
|
24
26
|
"@openauthjs/openauth": "^0.4.3",
|
|
25
|
-
"commander": "^14.0.3"
|
|
26
|
-
"cross-keychain": "^1.1.0"
|
|
27
|
+
"commander": "^14.0.3"
|
|
27
28
|
}
|
|
28
29
|
}
|