@phnx-labs/agents-cli 1.22.84 → 1.22.86
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 +96 -0
- package/README.md +80 -53
- package/dist/bootstrap.d.ts +7 -6
- package/dist/bootstrap.js +20 -27
- package/dist/cli/command-registry.js +5 -1
- package/dist/commands/accounts.d.ts +6 -13
- package/dist/commands/accounts.js +330 -143
- package/dist/commands/alias.js +7 -3
- package/dist/commands/apply.js +1 -1
- package/dist/commands/auth-mint.d.ts +7 -3
- package/dist/commands/auth-mint.js +21 -9
- package/dist/commands/auth.js +2 -3
- package/dist/commands/browser.js +10 -10
- package/dist/commands/daemon.d.ts +7 -3
- package/dist/commands/daemon.js +40 -25
- package/dist/commands/doctor.js +28 -2
- package/dist/commands/exec.js +87 -39
- package/dist/commands/fleet-capture.js +21 -2
- package/dist/commands/harness-wizard.js +2 -2
- package/dist/commands/lease.js +7 -12
- package/dist/commands/profiles.d.ts +2 -2
- package/dist/commands/profiles.js +20 -13
- package/dist/commands/repo.js +3 -3
- package/dist/commands/run-account-picker.d.ts +3 -3
- package/dist/commands/run-account-picker.js +29 -30
- package/dist/commands/secrets-passthrough.d.ts +20 -0
- package/dist/commands/secrets-passthrough.js +43 -0
- package/dist/commands/setup-accounts.d.ts +3 -2
- package/dist/commands/setup-accounts.js +4 -4
- package/dist/commands/setup-secrets.d.ts +16 -21
- package/dist/commands/setup-secrets.js +43 -224
- package/dist/commands/ssh.js +2 -2
- package/dist/commands/sync.js +1 -1
- package/dist/commands/update.d.ts +10 -0
- package/dist/commands/update.js +43 -40
- package/dist/commands/versions.d.ts +11 -11
- package/dist/commands/versions.js +131 -83
- package/dist/commands/view.d.ts +1 -9
- package/dist/commands/view.js +41 -55
- package/dist/commands/webhook.js +5 -5
- package/dist/commands/workflows.js +2 -1
- package/dist/index.d.ts +12 -12
- package/dist/index.js +13 -42
- package/dist/lib/account-capabilities.d.ts +1 -1
- package/dist/lib/account-capabilities.js +1 -1
- package/dist/lib/account-catalog.d.ts +121 -6
- package/dist/lib/account-catalog.js +421 -7
- package/dist/lib/account-registry.d.ts +8 -30
- package/dist/lib/account-registry.js +36 -79
- package/dist/lib/account-schema.d.ts +1 -1
- package/dist/lib/account-schema.js +1 -1
- package/dist/lib/accounting/account-pool-collect.js +16 -4
- package/dist/lib/accounting/rotate.d.ts +20 -7
- package/dist/lib/accounting/rotate.js +89 -13
- package/dist/lib/accounting/usage.js +11 -16
- package/dist/lib/accounts/add.d.ts +138 -0
- package/dist/lib/accounts/add.js +651 -0
- package/dist/lib/accounts/migrate.d.ts +117 -0
- package/dist/lib/accounts/migrate.js +536 -0
- package/dist/lib/accounts/slots.d.ts +13 -0
- package/dist/lib/accounts/slots.js +100 -0
- package/dist/lib/agent-spec/agents.d.ts +16 -0
- package/dist/lib/agent-spec/agents.js +38 -4
- package/dist/lib/app-bundle-install.js +5 -4
- package/dist/lib/auth-health.d.ts +3 -0
- package/dist/lib/auth-health.js +11 -0
- package/dist/lib/auth-mint.d.ts +52 -16
- package/dist/lib/auth-mint.js +117 -39
- package/dist/lib/browser/chrome.d.ts +1 -1
- package/dist/lib/browser/chrome.js +5 -5
- package/dist/lib/byok-usage.js +3 -3
- package/dist/lib/claude-account-token.d.ts +47 -3
- package/dist/lib/claude-account-token.js +172 -50
- package/dist/lib/cloud/antigravity.js +4 -4
- package/dist/lib/cloud/cursor.js +4 -4
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +6 -6
- package/dist/lib/crabbox/runtimes.d.ts +3 -3
- package/dist/lib/crabbox/runtimes.js +5 -5
- package/dist/lib/daemon/account-state-daemon-service.d.ts +37 -1
- package/dist/lib/daemon/account-state-daemon-service.js +199 -3
- package/dist/lib/daemon/auth-sync-service.js +32 -1
- package/dist/lib/daemon/daemon.d.ts +6 -16
- package/dist/lib/daemon/daemon.js +52 -97
- package/dist/lib/daemon/daemon.test-fixture.d.ts +7 -9
- package/dist/lib/daemon/daemon.test-fixture.js +22 -40
- package/dist/lib/daemon/harness-update-service.d.ts +1 -1
- package/dist/lib/daemon/harness-update-service.js +1 -1
- package/dist/lib/daemon/runner.d.ts +15 -3
- package/dist/lib/daemon/runner.js +49 -24
- package/dist/lib/daemon-health.d.ts +1 -2
- package/dist/lib/daemon-health.js +7 -6
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +1 -11
- package/dist/lib/daemon-webhooks.d.ts +8 -7
- package/dist/lib/daemon-webhooks.js +10 -9
- package/dist/lib/device-config.js +1 -1
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/harness-inventory.d.ts +39 -0
- package/dist/lib/devices/harness-inventory.js +126 -4
- package/dist/lib/doctor-diff.js +2 -1
- package/dist/lib/exec-account-home.d.ts +38 -0
- package/dist/lib/exec-account-home.js +164 -0
- package/dist/lib/exec.d.ts +30 -0
- package/dist/lib/exec.js +101 -31
- package/dist/lib/fleet/apply.d.ts +2 -2
- package/dist/lib/fleet/apply.js +4 -4
- package/dist/lib/fleet/auth-sync.js +4 -3
- package/dist/lib/fleet-shared-repo-sync.js +6 -9
- package/dist/lib/harness/adapter.d.ts +7 -7
- package/dist/lib/harness/adapter.js +10 -6
- package/dist/lib/harness/adapters/grok.js +8 -3
- package/dist/lib/harness/adapters/muse.js +1 -1
- package/dist/lib/harness/adapters/opencode.js +12 -3
- package/dist/lib/harness-auth-capabilities.d.ts +61 -0
- package/dist/lib/harness-auth-capabilities.js +52 -0
- package/dist/lib/helper-versions.d.ts +6 -4
- package/dist/lib/helper-versions.js +5 -4
- package/dist/lib/hosts/credential-transport.d.ts +10 -0
- package/dist/lib/hosts/credential-transport.js +37 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +13 -6
- package/dist/lib/identity/client.d.ts +3 -3
- package/dist/lib/identity/client.js +3 -3
- package/dist/lib/installations/index.d.ts +1 -1
- package/dist/lib/installations/index.js +1 -1
- package/dist/lib/installations/migrate.js +9 -0
- package/dist/lib/installations/resolve.js +1 -1
- package/dist/lib/installations/shims.d.ts +21 -4
- package/dist/lib/installations/shims.js +86 -5
- package/dist/lib/installations/store.d.ts +43 -0
- package/dist/lib/installations/store.js +75 -7
- package/dist/lib/installations/versions.js +1 -1
- package/dist/lib/menubar/install-menubar.d.ts +3 -3
- package/dist/lib/menubar/install-menubar.js +3 -3
- package/dist/lib/native-accounts.d.ts +35 -0
- package/dist/lib/native-accounts.js +41 -0
- package/dist/lib/net-close.d.ts +11 -0
- package/dist/lib/net-close.js +25 -0
- package/dist/lib/openclaw-keychain.js +27 -1
- package/dist/lib/profiles.js +22 -9
- package/dist/lib/project-resources.js +1 -1
- package/dist/lib/reserved-stores.d.ts +53 -0
- package/dist/lib/reserved-stores.js +120 -0
- package/dist/lib/secrets-client.d.ts +199 -0
- package/dist/lib/secrets-client.js +718 -0
- package/dist/lib/secrets-policy.d.ts +235 -0
- package/dist/lib/secrets-policy.js +505 -0
- package/dist/lib/secrets-types.d.ts +188 -0
- package/dist/lib/secrets-types.js +24 -0
- package/dist/lib/self-heal/checks/shims.js +5 -3
- package/dist/lib/service-manifest.js +3 -4
- package/dist/lib/session/db.d.ts +18 -0
- package/dist/lib/session/db.js +47 -14
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/sha256-asset.d.ts +11 -16
- package/dist/lib/sha256-asset.js +11 -16
- package/dist/lib/share/config.js +14 -5
- package/dist/lib/signin-badge.d.ts +26 -0
- package/dist/lib/signin-badge.js +42 -0
- package/dist/lib/staleness/detectors/workflows.js +3 -105
- package/dist/lib/staleness/writers/workflows.js +2 -1
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +18 -7
- package/dist/lib/sync-umbrella.js +2 -2
- package/dist/lib/teams/agents.js +1 -1
- package/dist/lib/types.d.ts +74 -33
- package/dist/lib/view-types.d.ts +6 -3
- package/dist/lib/workflows-registry.d.ts +71 -0
- package/dist/lib/workflows-registry.js +280 -0
- package/dist/lib/workflows.d.ts +21 -21
- package/dist/lib/workflows.js +12 -327
- package/package.json +2 -3
- package/scripts/postinstall.js +35 -30
- package/dist/commands/secrets-import.d.ts +0 -18
- package/dist/commands/secrets-import.js +0 -74
- package/dist/commands/secrets-migrate.d.ts +0 -25
- package/dist/commands/secrets-migrate.js +0 -334
- package/dist/commands/secrets-rotate-passphrase.d.ts +0 -17
- package/dist/commands/secrets-rotate-passphrase.js +0 -96
- package/dist/commands/secrets-sync.d.ts +0 -11
- package/dist/commands/secrets-sync.js +0 -153
- package/dist/commands/secrets-vault.d.ts +0 -10
- package/dist/commands/secrets-vault.js +0 -130
- package/dist/commands/secrets.d.ts +0 -212
- package/dist/commands/secrets.js +0 -3030
- package/dist/lib/accounts/connect.d.ts +0 -176
- package/dist/lib/accounts/connect.js +0 -453
- package/dist/lib/daemon/keychain-reap-service.d.ts +0 -17
- package/dist/lib/daemon/keychain-reap-service.js +0 -32
- package/dist/lib/daemon/secrets-broker-service.d.ts +0 -21
- package/dist/lib/daemon/secrets-broker-service.js +0 -51
- package/dist/lib/secrets/agent.d.ts +0 -358
- package/dist/lib/secrets/agent.js +0 -1291
- package/dist/lib/secrets/audit.d.ts +0 -46
- package/dist/lib/secrets/audit.js +0 -101
- package/dist/lib/secrets/bundles.d.ts +0 -290
- package/dist/lib/secrets/bundles.js +0 -1547
- package/dist/lib/secrets/download-keychain.d.ts +0 -47
- package/dist/lib/secrets/download-keychain.js +0 -70
- package/dist/lib/secrets/drivers/rush.d.ts +0 -14
- package/dist/lib/secrets/drivers/rush.js +0 -90
- package/dist/lib/secrets/fallback.d.ts +0 -48
- package/dist/lib/secrets/fallback.js +0 -48
- package/dist/lib/secrets/filestore.d.ts +0 -222
- package/dist/lib/secrets/filestore.js +0 -1099
- package/dist/lib/secrets/headless.d.ts +0 -43
- package/dist/lib/secrets/headless.js +0 -56
- package/dist/lib/secrets/icloud-import.d.ts +0 -79
- package/dist/lib/secrets/icloud-import.js +0 -206
- package/dist/lib/secrets/index.d.ts +0 -451
- package/dist/lib/secrets/index.js +0 -1568
- package/dist/lib/secrets/install-helper.d.ts +0 -72
- package/dist/lib/secrets/install-helper.js +0 -245
- package/dist/lib/secrets/lease.d.ts +0 -25
- package/dist/lib/secrets/lease.js +0 -44
- package/dist/lib/secrets/linux.d.ts +0 -77
- package/dist/lib/secrets/linux.js +0 -393
- package/dist/lib/secrets/list-filter.d.ts +0 -109
- package/dist/lib/secrets/list-filter.js +0 -261
- package/dist/lib/secrets/mcp.d.ts +0 -93
- package/dist/lib/secrets/mcp.js +0 -211
- package/dist/lib/secrets/profiles.d.ts +0 -10
- package/dist/lib/secrets/profiles.js +0 -13
- package/dist/lib/secrets/push.d.ts +0 -133
- package/dist/lib/secrets/push.js +0 -273
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -63
- package/dist/lib/secrets/rc-hygiene.js +0 -143
- package/dist/lib/secrets/read-backoff.d.ts +0 -27
- package/dist/lib/secrets/read-backoff.js +0 -64
- package/dist/lib/secrets/reaper.d.ts +0 -90
- package/dist/lib/secrets/reaper.js +0 -243
- package/dist/lib/secrets/remote.d.ts +0 -152
- package/dist/lib/secrets/remote.js +0 -344
- package/dist/lib/secrets/reserved-sync.d.ts +0 -66
- package/dist/lib/secrets/reserved-sync.js +0 -147
- package/dist/lib/secrets/scope.d.ts +0 -26
- package/dist/lib/secrets/scope.js +0 -29
- package/dist/lib/secrets/session-store.d.ts +0 -107
- package/dist/lib/secrets/session-store.js +0 -342
- package/dist/lib/secrets/sync-backend.d.ts +0 -48
- package/dist/lib/secrets/sync-backend.js +0 -13
- package/dist/lib/secrets/sync-commands.d.ts +0 -21
- package/dist/lib/secrets/sync-commands.js +0 -21
- package/dist/lib/secrets/sync.d.ts +0 -48
- package/dist/lib/secrets/sync.js +0 -237
- package/dist/lib/secrets/unlock-hints.d.ts +0 -27
- package/dist/lib/secrets/unlock-hints.js +0 -36
- package/dist/lib/secrets/usage-db.d.ts +0 -46
- package/dist/lib/secrets/usage-db.js +0 -96
- package/dist/lib/secrets/vault-age-helper.d.ts +0 -1
- package/dist/lib/secrets/vault-age-helper.js +0 -34
- package/dist/lib/secrets/vault.d.ts +0 -49
- package/dist/lib/secrets/vault.js +0 -399
- package/dist/lib/secrets/windows.d.ts +0 -81
- package/dist/lib/secrets/windows.js +0 -556
- package/scripts/install-helper.js +0 -97
- /package/dist/lib/{secrets/sync-passphrase.d.ts → sync-passphrase.d.ts} +0 -0
- /package/dist/lib/{secrets/sync-passphrase.js → sync-passphrase.js} +0 -0
|
@@ -1,1568 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cross-platform secure credential storage.
|
|
3
|
-
*
|
|
4
|
-
* macOS: every keychain operation goes through the signed `Agents CLI.app`
|
|
5
|
-
* helper. The helper attaches a biometry-or-passcode access control to every
|
|
6
|
-
* item it writes, so the OS itself gates decryption with Touch ID. A single
|
|
7
|
-
* LAContext lives for the helper's process lifetime, so a batch read pops
|
|
8
|
-
* Touch ID once and reuses the assertion for every item in the same batch.
|
|
9
|
-
* No /usr/bin/security fast path: that path bypasses the helper's ACL,
|
|
10
|
-
* exposes items to the legacy password sheet, and would defeat the model.
|
|
11
|
-
*
|
|
12
|
-
* Linux: libsecret (GNOME Keyring) via the `secret-tool` CLI. No biometry —
|
|
13
|
-
* items are unlocked when the keyring is open.
|
|
14
|
-
*
|
|
15
|
-
* Windows: Windows Credential Manager (CRED_TYPE_GENERIC,
|
|
16
|
-
* CRED_PERSIST_LOCAL_MACHINE) via a PowerShell P/Invoke shim, with the same
|
|
17
|
-
* AES-256-GCM encrypted-file fallback used on Linux when the credential store
|
|
18
|
-
* is unreachable (no logon session / no powershell.exe). No biometry.
|
|
19
|
-
*
|
|
20
|
-
* Items are device-local: the biometry access control requires the OS to
|
|
21
|
-
* treat them as bound to this device, so cross-machine propagation goes
|
|
22
|
-
* through the explicit export/import flow in src/lib/secrets/sync.ts
|
|
23
|
-
* rather than the system's cloud-keychain path.
|
|
24
|
-
*/
|
|
25
|
-
import { execFileSync, spawnSync } from 'child_process';
|
|
26
|
-
import { createHmac, randomBytes } from 'node:crypto';
|
|
27
|
-
import * as fs from 'fs';
|
|
28
|
-
import * as os from 'os';
|
|
29
|
-
import * as path from 'path';
|
|
30
|
-
import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSecretToolItems } from './linux.js';
|
|
31
|
-
import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
|
|
32
|
-
import { isHeadlessSecretsContext } from './headless.js';
|
|
33
|
-
import { KEYCHAIN_READ_BACKOFF_TTL_MS, clearKeychainReadBackoff, isKeychainReadBackedOff, noteKeychainReadFailure, } from './read-backoff.js';
|
|
34
|
-
import { getKeychainHelperPath } from './install-helper.js';
|
|
35
|
-
import { deriveShortId } from '../text/short-id.js';
|
|
36
|
-
const SERVICE_PREFIX = 'agents-cli';
|
|
37
|
-
export const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
|
|
38
|
-
const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
|
|
39
|
-
/** Timeout for keychain-helper verbs that never raise a user prompt. */
|
|
40
|
-
const KEYCHAIN_SILENT_TIMEOUT_MS = 8_000;
|
|
41
|
-
/** Timeout for verbs that may raise Touch ID / password auth UI. */
|
|
42
|
-
const KEYCHAIN_INTERACTIVE_TIMEOUT_MS = 60_000;
|
|
43
|
-
/**
|
|
44
|
-
* Thrown when a keychain helper / security spawnSync is killed because it
|
|
45
|
-
* exceeded its timeout. A wedged coreauthd / LocalAuthentication dialog can hang
|
|
46
|
-
* the parent forever; this makes the failure explicit and arms the read back-off.
|
|
47
|
-
*/
|
|
48
|
-
export class KeychainHelperTimeoutError extends Error {
|
|
49
|
-
bin;
|
|
50
|
-
args;
|
|
51
|
-
constructor(bin, args) {
|
|
52
|
-
super(`keychain helper timed out (${bin} ${args.join(' ')}) — keychain locked / ` +
|
|
53
|
-
`LocalAuthentication unresponsive — retry; if it persists, lock/unlock the screen or reboot`);
|
|
54
|
-
this.bin = bin;
|
|
55
|
-
this.args = args;
|
|
56
|
-
this.name = 'KeychainHelperTimeoutError';
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
let keychainDaemonBootEnabled = true;
|
|
60
|
-
let keychainDaemonBootAttempted = false;
|
|
61
|
-
/** Test seam: suppress the side-effect daemon boot in unit tests. */
|
|
62
|
-
export function setKeychainDaemonBootForTest(enabled) {
|
|
63
|
-
keychainDaemonBootEnabled = enabled;
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Single wrapper for every keychain-helper (and /usr/bin/security) spawnSync.
|
|
67
|
-
* Applies a hard timeout + SIGKILL so a wedged coreauthd can never hang the
|
|
68
|
-
* parent process. Throws {@link KeychainHelperTimeoutError} when the child is
|
|
69
|
-
* killed by the timeout. Also boots the daemon once per process so the reaper
|
|
70
|
-
* can clean up any stuck helpers this or prior invocations left behind.
|
|
71
|
-
*/
|
|
72
|
-
function spawnKeychainHelper(bin, args, opts, timeoutMs) {
|
|
73
|
-
if (keychainDaemonBootEnabled && !keychainDaemonBootAttempted) {
|
|
74
|
-
keychainDaemonBootAttempted = true;
|
|
75
|
-
// Fire-and-forget: daemon start must not block the foreground secrets op.
|
|
76
|
-
import('../daemon/daemon.js')
|
|
77
|
-
.then(({ ensureDaemonStarted }) => ensureDaemonStarted())
|
|
78
|
-
.catch(() => { });
|
|
79
|
-
}
|
|
80
|
-
const result = spawnSync(bin, args, { ...opts, timeout: timeoutMs, killSignal: 'SIGKILL' });
|
|
81
|
-
if (result.signal) {
|
|
82
|
-
throw new KeychainHelperTimeoutError(bin, args);
|
|
83
|
-
}
|
|
84
|
-
return result;
|
|
85
|
-
}
|
|
86
|
-
/** Test seam: exercise the timeout wrapper with an arbitrary binary. */
|
|
87
|
-
export function spawnKeychainHelperForTest(bin, args, opts, timeoutMs) {
|
|
88
|
-
return spawnKeychainHelper(bin, args, opts, timeoutMs);
|
|
89
|
-
}
|
|
90
|
-
const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
|
|
91
|
-
/** Parse a bundle value into either a literal string or a typed secret ref. */
|
|
92
|
-
export function parseBundleValue(raw) {
|
|
93
|
-
if (typeof raw === 'object' && raw !== null && typeof raw.value === 'string') {
|
|
94
|
-
return { literal: raw.value };
|
|
95
|
-
}
|
|
96
|
-
if (typeof raw !== 'string') {
|
|
97
|
-
throw new Error(`Invalid bundle value (expected string or {value: string}): ${JSON.stringify(raw)}`);
|
|
98
|
-
}
|
|
99
|
-
const match = REF_PATTERN.exec(raw);
|
|
100
|
-
if (!match)
|
|
101
|
-
return { literal: raw };
|
|
102
|
-
return { ref: { provider: match[1], value: match[2] } };
|
|
103
|
-
}
|
|
104
|
-
/** Serialize a secret ref back to its `provider:value` string form. */
|
|
105
|
-
export function serializeRef(ref) {
|
|
106
|
-
return `${ref.provider}:${ref.value}`;
|
|
107
|
-
}
|
|
108
|
-
function assertSupportedPlatform() {
|
|
109
|
-
if (process.platform !== 'darwin' && process.platform !== 'linux' && process.platform !== 'win32') {
|
|
110
|
-
throw new Error('agents secrets requires macOS Keychain, Linux libsecret, or Windows Credential Manager.\n' +
|
|
111
|
-
'Use environment variables or a .env file on unsupported platforms.');
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
function isLinux() {
|
|
115
|
-
return process.platform === 'linux';
|
|
116
|
-
}
|
|
117
|
-
function isWindows() {
|
|
118
|
-
return process.platform === 'win32';
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Guard a secret value before it is written to the current platform's primary
|
|
122
|
-
* backend.
|
|
123
|
-
*
|
|
124
|
-
* A value is empty on every platform → always rejected. Embedded newlines are
|
|
125
|
-
* rejected ONLY on darwin: the macOS batch read path (`get-batch`, see
|
|
126
|
-
* getKeychainTokens) is newline-delimited, so a value with a newline would
|
|
127
|
-
* corrupt record framing on read. Linux (secret-tool), Windows (Credential
|
|
128
|
-
* Manager stores the raw UTF-8 blob and emits base64), and the encrypted-file
|
|
129
|
-
* fallback all store raw bytes and round-trip multiline values (PEM / SSH keys)
|
|
130
|
-
* faithfully, so they accept newlines. `platform` is injectable for tests.
|
|
131
|
-
*/
|
|
132
|
-
export function assertValueStorable(value, platform = process.platform) {
|
|
133
|
-
if (!value || !value.trim())
|
|
134
|
-
throw new Error('Secret value is empty.');
|
|
135
|
-
if (platform === 'darwin' && /[\r\n]/.test(value)) {
|
|
136
|
-
throw new Error('Secret value contains newlines, which are not supported.');
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
/** Build the keychain item name for a profile provider token. */
|
|
140
|
-
export function profileKeychainItem(provider) {
|
|
141
|
-
return `${SERVICE_PREFIX}.${provider}.token`;
|
|
142
|
-
}
|
|
143
|
-
/** Build the keychain item name for a secrets-bundle key. */
|
|
144
|
-
export function secretsKeychainItem(bundle, key) {
|
|
145
|
-
return `${SECRETS_ITEM_PREFIX}${bundle}.${key}`;
|
|
146
|
-
}
|
|
147
|
-
function keychainItemRequiresUserPresence(item) {
|
|
148
|
-
return item.startsWith(SECRETS_ITEM_PREFIX) || item.startsWith(BUNDLES_ITEM_PREFIX);
|
|
149
|
-
}
|
|
150
|
-
let backend = null;
|
|
151
|
-
/** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
|
|
152
|
-
export function setKeychainBackendForTest(b) {
|
|
153
|
-
const prev = backend;
|
|
154
|
-
backend = b;
|
|
155
|
-
// The hashing state depends on whether a backend is installed — never let a
|
|
156
|
-
// state resolved against the real keychain leak into a backend-driven test.
|
|
157
|
-
hashStateCache = null;
|
|
158
|
-
autoRekeyAttempted = false;
|
|
159
|
-
return prev;
|
|
160
|
-
}
|
|
161
|
-
/** True when a test backend is installed (real keychain / biometry bypassed).
|
|
162
|
-
* Callers that gate on the live secrets-agent broker use this to stay hermetic —
|
|
163
|
-
* with an in-memory backend there is no real keychain to dedup, so the broker
|
|
164
|
-
* fast-path must not engage. Always false in production (`backend` is null). */
|
|
165
|
-
export function isKeychainBackendOverridden() {
|
|
166
|
-
return backend !== null;
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* Items whose name does NOT start with `agents-cli.` belong to another
|
|
170
|
-
* application (e.g. Anthropic's `Claude Code-credentials-*`). Their ACL
|
|
171
|
-
* trusts THEIR writer, not our signed helper, so routing them through our
|
|
172
|
-
* helper produces a legacy password sheet. `/usr/bin/security` reads them
|
|
173
|
-
* silently because it's in the default trusted-app list on most user-owned
|
|
174
|
-
* keychain items. And we MUST NOT JIT-migrate them — the owning app
|
|
175
|
-
* expects to re-write the item with its own ACL design.
|
|
176
|
-
*/
|
|
177
|
-
function isOurItem(item) {
|
|
178
|
-
return item.startsWith('agents-cli.');
|
|
179
|
-
}
|
|
180
|
-
// ─── Hashed service names (GitHub #316, Finding 1) ──────────────────────────
|
|
181
|
-
//
|
|
182
|
-
// The helper's `list` never decrypts and never prompts (by design), which made
|
|
183
|
-
// service names enumerable metadata: any same-user process could silently read
|
|
184
|
-
// every bundle, key, and provider name (`agents-cli.secrets.<bundle>.<KEY>`,
|
|
185
|
-
// `agents-cli.<provider>.token`) and build a target list before ever popping
|
|
186
|
-
// Touch ID. To close that, on macOS every item in our namespace is stored
|
|
187
|
-
// under an opaque HMAC-SHA256-hashed service name:
|
|
188
|
-
//
|
|
189
|
-
// agents-cli.bundles.<name> → agents-cli.h.<ns>.m
|
|
190
|
-
// agents-cli.secrets.<bundle>.<KEY> → agents-cli.h.<ns>.k.<kh>
|
|
191
|
-
// agents-cli.<anything else> → agents-cli.h.o.<ih>
|
|
192
|
-
//
|
|
193
|
-
// where <ns> = HMAC(key, 'ns\0'+bundle) and <kh>/<ih> are per-item HMACs
|
|
194
|
-
// (first 32 hex chars each). The per-bundle <ns> segment is deliberate: a
|
|
195
|
-
// bundle's value items keep a common silent-enumerable prefix
|
|
196
|
-
// (`agents-cli.h.<ns>.k.`), so readAndResolveBundleEnv still fetches metadata
|
|
197
|
-
// + all values in ONE get-batch behind ONE Touch ID — a flat hash of the full
|
|
198
|
-
// name would have forced a second prompt on every bundle read. Names still
|
|
199
|
-
// start with `agents-cli.`, so the helper's JIT-migration guard and prefix
|
|
200
|
-
// gates keep working. What an enumerator learns shrinks to item grouping and
|
|
201
|
-
// counts — never a bundle, key, or provider name.
|
|
202
|
-
//
|
|
203
|
-
// The HMAC key is 32 random bytes in `agents-cli.hmackey`, written through the
|
|
204
|
-
// helper's no-ACL path (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
205
|
-
// device-local, access-group-pinned). Deliberately NO user-presence ACL: the
|
|
206
|
-
// key protects metadata confidentiality only, and gating it behind Touch ID
|
|
207
|
-
// would make every silent operation (list/has) prompt. Per-machine is fine —
|
|
208
|
-
// sync re-materializes items locally through the same primitives, so hashed
|
|
209
|
-
// names never leave the machine. Deriving the key from machine constants was
|
|
210
|
-
// rejected: that would hand any local process a dictionary-confirmation
|
|
211
|
-
// oracle without even touching the keychain.
|
|
212
|
-
//
|
|
213
|
-
// Hashing activates only after the one-time re-key migration
|
|
214
|
-
// (rekeyServiceNames below / `agents secrets rekey`) has moved every existing
|
|
215
|
-
// cleartext-named item; until then all operations use cleartext names exactly
|
|
216
|
-
// as before. The sentinel lives INSIDE the hmackey record — in the keychain,
|
|
217
|
-
// not on disk — so it can never desync from the items it describes (e.g. a
|
|
218
|
-
// keychain restored from a backup brings its matching state along).
|
|
219
|
-
const HASHED_SERVICE_PREFIX = `${SERVICE_PREFIX}.h.`;
|
|
220
|
-
export const HMAC_KEY_ITEM = `${SERVICE_PREFIX}.hmackey`;
|
|
221
|
-
const HASHED_META_RE = /^agents-cli\.h\.[0-9a-f]{32}\.m$/;
|
|
222
|
-
let hashStateCache = null;
|
|
223
|
-
let forcedTestKey = null;
|
|
224
|
-
let rawScopeDepth = 0;
|
|
225
|
-
let rekeyRunning = false;
|
|
226
|
-
let autoRekeyAttempted = false;
|
|
227
|
-
/** Force hashed service names on with a fixed key (test only). Pass null to
|
|
228
|
-
* restore lazy production resolution. Composes with setKeychainBackendForTest
|
|
229
|
-
* so unit tests exercise the exact transform production uses. */
|
|
230
|
-
export function setKeychainServiceHashingForTest(key) {
|
|
231
|
-
forcedTestKey = key;
|
|
232
|
-
hashStateCache = null;
|
|
233
|
-
autoRekeyAttempted = false;
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Run `fn` with service-name hashing suspended: every primitive uses the
|
|
237
|
-
* literal names it is given. For migration flows ONLY — they enumerate raw
|
|
238
|
-
* names from the helper (which may be pre-re-key cleartext leftovers) and must
|
|
239
|
-
* read/delete those exact items, not their hashed transforms.
|
|
240
|
-
*/
|
|
241
|
-
export function withRawKeychainServiceNames(fn) {
|
|
242
|
-
rawScopeDepth++;
|
|
243
|
-
try {
|
|
244
|
-
return fn();
|
|
245
|
-
}
|
|
246
|
-
finally {
|
|
247
|
-
rawScopeDepth--;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
function hmacHex32(key, input) {
|
|
251
|
-
return createHmac('sha256', key).update(input, 'utf8').digest('hex').slice(0, 32);
|
|
252
|
-
}
|
|
253
|
-
function bundleNamespaceHash(bundle, key) {
|
|
254
|
-
return hmacHex32(key, `ns\0${bundle}`);
|
|
255
|
-
}
|
|
256
|
-
/** The hashed (storage) service name for a cleartext item name. Exported for
|
|
257
|
-
* the re-key migration and tests; runtime callers go through the primitives,
|
|
258
|
-
* which apply this transparently. */
|
|
259
|
-
export function hashedServiceName(item, key) {
|
|
260
|
-
if (item.startsWith(BUNDLES_ITEM_PREFIX)) {
|
|
261
|
-
const name = item.slice(BUNDLES_ITEM_PREFIX.length);
|
|
262
|
-
return `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(name, key)}.m`;
|
|
263
|
-
}
|
|
264
|
-
if (item.startsWith(SECRETS_ITEM_PREFIX)) {
|
|
265
|
-
// Bundle names may contain dots; env keys and wallet ids never do — the
|
|
266
|
-
// LAST dot is the unambiguous bundle/key split.
|
|
267
|
-
const rest = item.slice(SECRETS_ITEM_PREFIX.length);
|
|
268
|
-
const dot = rest.lastIndexOf('.');
|
|
269
|
-
if (dot > 0 && dot < rest.length - 1) {
|
|
270
|
-
const bundle = rest.slice(0, dot);
|
|
271
|
-
const keyName = rest.slice(dot + 1);
|
|
272
|
-
return `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(bundle, key)}.k.${hmacHex32(key, `kv\0${bundle}\0${keyName}`)}`;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
return `${HASHED_SERVICE_PREFIX}o.${hmacHex32(key, `it\0${item}`)}`;
|
|
276
|
-
}
|
|
277
|
-
function parseHmacKeyRecord(raw) {
|
|
278
|
-
try {
|
|
279
|
-
const rec = JSON.parse(raw);
|
|
280
|
-
if (rec && typeof rec === 'object' && rec.v === 1 && typeof rec.k === 'string' && /^[0-9a-f]{64}$/.test(rec.k)) {
|
|
281
|
-
return rec;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
/* malformed — treated as absent */
|
|
286
|
-
}
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
export function readHmacKeyRecord() {
|
|
290
|
-
// HMAC_KEY_ITEM is exempt from the transform, so this routes to the helper
|
|
291
|
-
// (or the test backend) under its literal name. The item is no-ACL, so the
|
|
292
|
-
// read is silent — attest that to the storm guard so a headless hashed-name
|
|
293
|
-
// resolution never trips the fail-fast.
|
|
294
|
-
let raw;
|
|
295
|
-
try {
|
|
296
|
-
raw = getKeychainToken(HMAC_KEY_ITEM, { silentNoAcl: true });
|
|
297
|
-
}
|
|
298
|
-
catch {
|
|
299
|
-
return null;
|
|
300
|
-
}
|
|
301
|
-
const record = parseHmacKeyRecord(raw);
|
|
302
|
-
// Converge a stale-ACL'd hmackey to silent, on the HOT read path (RUSH-2441 /
|
|
303
|
-
// restore of v1.22.7 / 391017461). An old helper re-stamped this
|
|
304
|
-
// contractually-no-ACL item with a biometry ACL, so the read just above pops
|
|
305
|
-
// the generic "Agents CLI needs to authenticate" sheet on EVERY hashed
|
|
306
|
-
// lookup — the `agents devices list` stats probe the SessionStart hook runs,
|
|
307
|
-
// and every other background hashed read. `maybeAutoRekey`'s one-shot heal
|
|
308
|
-
// only fires on a cleartext-bundle resolve and is bypassed for the
|
|
309
|
-
// hmackey/hashed-name path (`prepareServiceName` returns early for
|
|
310
|
-
// HMAC_KEY_ITEM before maybeAutoRekey), so it never converged exactly these
|
|
311
|
-
// reads and the machine prompted forever. Re-store the record no-ACL once
|
|
312
|
-
// per machine here (the read that produced it has already happened — and
|
|
313
|
-
// already prompted if the item was ACL'd); every subsequent read, in this
|
|
314
|
-
// process and all future ones, is silent.
|
|
315
|
-
//
|
|
316
|
-
// v1.22.10 (bf79dc885 / #1995) moved the heal back into maybeAutoRekey and
|
|
317
|
-
// left the changelog claim in 1.22.7 false until this restore.
|
|
318
|
-
if (record && !record.healedNoAcl) {
|
|
319
|
-
try {
|
|
320
|
-
healHmacKeyNoAclOnce(record);
|
|
321
|
-
record.healedNoAcl = true;
|
|
322
|
-
}
|
|
323
|
-
catch {
|
|
324
|
-
// A failed no-ACL re-store leaves the item still ACL'd (a still-prompting
|
|
325
|
-
// read) rather than a silent wrong state; the next process retries.
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
return record;
|
|
329
|
-
}
|
|
330
|
-
function writeHmacKeyRecord(rec) {
|
|
331
|
-
// JSON.stringify drops undefined fields (used to clear pendingDeletes).
|
|
332
|
-
// noAcl: reads of this record must stay prompt-free; an old pinned helper
|
|
333
|
-
// without the set-no-acl path rejects this loudly (see setKeychainToken),
|
|
334
|
-
// which is exactly the "old helper never half-runs the re-key" gate.
|
|
335
|
-
setKeychainToken(HMAC_KEY_ITEM, JSON.stringify(rec), { noAcl: true });
|
|
336
|
-
hashStateCache = null;
|
|
337
|
-
}
|
|
338
|
-
/**
|
|
339
|
-
* Heal a `hmackey` item that an OLD helper (pre the metadata/hmackey no-ACL
|
|
340
|
-
* migration fix) re-stamped with a biometry ACL. Such an item makes EVERY hashed
|
|
341
|
-
* keychain lookup pop the generic "Agents CLI needs to authenticate" sheet,
|
|
342
|
-
* because the HMAC key is read before every hashed name resolves. The migration
|
|
343
|
-
* fix stopped the re-stamping but never un-stamped an already-damaged item, and
|
|
344
|
-
* nothing else re-stores it once hashing is already active — so it prompts forever.
|
|
345
|
-
*
|
|
346
|
-
* This re-stores the record no-ACL exactly once per machine (guarded by
|
|
347
|
-
* `healedNoAcl`), turning every future read silent. The read that produced `rec`
|
|
348
|
-
* has already happened (and already prompted if it was ACL'd); this only writes.
|
|
349
|
-
* Returns true if it healed. Exported for tests. No-op when already healed.
|
|
350
|
-
*/
|
|
351
|
-
export function healHmacKeyNoAclOnce(rec) {
|
|
352
|
-
if (rec.healedNoAcl)
|
|
353
|
-
return false;
|
|
354
|
-
writeHmacKeyRecord({ ...rec, healedNoAcl: true });
|
|
355
|
-
return true;
|
|
356
|
-
}
|
|
357
|
-
function resolveHashState() {
|
|
358
|
-
if (forcedTestKey)
|
|
359
|
-
return { active: true, key: forcedTestKey, record: null };
|
|
360
|
-
if (hashStateCache)
|
|
361
|
-
return hashStateCache;
|
|
362
|
-
if (backend || process.platform !== 'darwin' || process.env.AGENTS_SECRETS_HASH_NAMES === '0') {
|
|
363
|
-
hashStateCache = { active: false, key: null, record: null };
|
|
364
|
-
return hashStateCache;
|
|
365
|
-
}
|
|
366
|
-
const record = readHmacKeyRecord();
|
|
367
|
-
const key = record ? Buffer.from(record.k, 'hex') : null;
|
|
368
|
-
// AGENTS_SECRETS_HASH_NAMES=1 forces hashing on before the machine-wide
|
|
369
|
-
// sentinel flips — used to verify a partial (--prefix) re-key end-to-end.
|
|
370
|
-
const active = !!record && (record.migrated || process.env.AGENTS_SECRETS_HASH_NAMES === '1');
|
|
371
|
-
hashStateCache = { active, key, record };
|
|
372
|
-
return hashStateCache;
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* The storage-layer service name for `item`: hashed when hashing is active,
|
|
376
|
-
* the item itself otherwise. For callers that mix helper-enumerated
|
|
377
|
-
* (already-hashed) names with computed cleartext names in one lookup map —
|
|
378
|
-
* see readAndResolveBundleEnv.
|
|
379
|
-
*/
|
|
380
|
-
export function keychainServiceAlias(item) {
|
|
381
|
-
return prepareServiceName(item);
|
|
382
|
-
}
|
|
383
|
-
function prepareServiceName(item) {
|
|
384
|
-
if (rawScopeDepth > 0)
|
|
385
|
-
return item;
|
|
386
|
-
if (!isOurItem(item))
|
|
387
|
-
return item;
|
|
388
|
-
if (item === HMAC_KEY_ITEM || item.startsWith(HASHED_SERVICE_PREFIX))
|
|
389
|
-
return item;
|
|
390
|
-
const st = resolveHashState();
|
|
391
|
-
if (!st.active || !st.key)
|
|
392
|
-
return item;
|
|
393
|
-
return hashedServiceName(item, st.key);
|
|
394
|
-
}
|
|
395
|
-
/**
|
|
396
|
-
* Map a cleartext enumeration prefix to its hashed-storage equivalent. Only
|
|
397
|
-
* two shapes are ever enumerated at sub-namespace granularity (bundle
|
|
398
|
-
* metadata, and one bundle's value items); both map to a broad `agents-cli.`
|
|
399
|
-
* helper query plus a client-side filter. Every mapped filter is a UNION with
|
|
400
|
-
* the original cleartext prefix so mid-migration leftovers (or items written
|
|
401
|
-
* by an older CLI on this machine) stay visible to migration tooling.
|
|
402
|
-
*/
|
|
403
|
-
function prepareListPrefix(prefix) {
|
|
404
|
-
if (rawScopeDepth > 0)
|
|
405
|
-
return { prefix };
|
|
406
|
-
if (!prefix.startsWith(`${SERVICE_PREFIX}.`))
|
|
407
|
-
return { prefix };
|
|
408
|
-
if (prefix.startsWith(HASHED_SERVICE_PREFIX))
|
|
409
|
-
return { prefix };
|
|
410
|
-
const st = resolveHashState();
|
|
411
|
-
if (!st.active || !st.key)
|
|
412
|
-
return { prefix };
|
|
413
|
-
if (prefix === BUNDLES_ITEM_PREFIX) {
|
|
414
|
-
return {
|
|
415
|
-
prefix: `${SERVICE_PREFIX}.`,
|
|
416
|
-
filter: (s) => HASHED_META_RE.test(s) || s.startsWith(BUNDLES_ITEM_PREFIX),
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
if (prefix.startsWith(SECRETS_ITEM_PREFIX) && prefix.endsWith('.') && prefix.length > SECRETS_ITEM_PREFIX.length + 1) {
|
|
420
|
-
const bundle = prefix.slice(SECRETS_ITEM_PREFIX.length, -1);
|
|
421
|
-
const hashedValuePrefix = `${HASHED_SERVICE_PREFIX}${bundleNamespaceHash(bundle, st.key)}.k.`;
|
|
422
|
-
return {
|
|
423
|
-
prefix: `${SERVICE_PREFIX}.`,
|
|
424
|
-
filter: (s) => s.startsWith(hashedValuePrefix) || s.startsWith(prefix),
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
return { prefix };
|
|
428
|
-
}
|
|
429
|
-
function listCleartextServices(prefixes) {
|
|
430
|
-
const all = withRawKeychainServiceNames(() => listKeychainItems(`${SERVICE_PREFIX}.`));
|
|
431
|
-
return all.filter((s) => s.startsWith(`${SERVICE_PREFIX}.`) &&
|
|
432
|
-
!s.startsWith(HASHED_SERVICE_PREFIX) &&
|
|
433
|
-
s !== HMAC_KEY_ITEM &&
|
|
434
|
-
(!prefixes || prefixes.some((p) => s.startsWith(p))));
|
|
435
|
-
}
|
|
436
|
-
function ensureHmacKeyRecord(markMigratedIfCreating) {
|
|
437
|
-
const existing = readHmacKeyRecord();
|
|
438
|
-
if (existing)
|
|
439
|
-
return existing;
|
|
440
|
-
const fresh = { v: 1, k: randomBytes(32).toString('hex'), migrated: markMigratedIfCreating };
|
|
441
|
-
writeHmacKeyRecord(fresh);
|
|
442
|
-
// If two processes raced the first write, the keychain holds exactly one
|
|
443
|
-
// winner — adopt whatever is stored NOW so both sides converge on a single
|
|
444
|
-
// key before hashing anything under it.
|
|
445
|
-
return readHmacKeyRecord() ?? fresh;
|
|
446
|
-
}
|
|
447
|
-
/**
|
|
448
|
-
* Guard against a silently-degraded enumeration. The helper's `list` skips the
|
|
449
|
-
* data-protection pass wholesale when the DP keybag is locked (screen lock —
|
|
450
|
-
* see keychain-helper.swift, errSecInteractionNotAllowed handling), returning
|
|
451
|
-
* an EMPTY result even though items exist and no-ACL reads/writes still work.
|
|
452
|
-
* Observed live on macOS 26: `set-no-acl` + `get` succeed while `list` of the
|
|
453
|
-
* just-written item returns nothing. Without this probe, a re-key run in that
|
|
454
|
-
* state would see "zero cleartext items" and wrongly activate hashed naming,
|
|
455
|
-
* making every existing cleartext item invisible after unlock.
|
|
456
|
-
*
|
|
457
|
-
* The probe requires the hmackey record (a DP item that provably exists — the
|
|
458
|
-
* caller just ensured it) to appear in a raw enumeration. Trivially true for
|
|
459
|
-
* the in-memory test backend.
|
|
460
|
-
*/
|
|
461
|
-
function assertEnumerationTrustworthy() {
|
|
462
|
-
const all = withRawKeychainServiceNames(() => listKeychainItems(`${SERVICE_PREFIX}.`));
|
|
463
|
-
if (!all.includes(HMAC_KEY_ITEM)) {
|
|
464
|
-
throw new Error('keychain enumeration is unavailable (locked keybag / screen lock?) — refusing to decide the re-key on an empty listing. Retry while unlocked.');
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
function finishPendingDeletes(rec) {
|
|
468
|
-
const pending = rec.pendingDeletes ?? [];
|
|
469
|
-
if (pending.length === 0)
|
|
470
|
-
return;
|
|
471
|
-
withRawKeychainServiceNames(() => {
|
|
472
|
-
for (const service of pending)
|
|
473
|
-
deleteKeychainToken(service);
|
|
474
|
-
});
|
|
475
|
-
writeHmacKeyRecord({ ...rec, pendingDeletes: undefined });
|
|
476
|
-
}
|
|
477
|
-
/**
|
|
478
|
-
* One-shot per process: activate hashing on machines with nothing to move,
|
|
479
|
-
* finish a crash-interrupted delete phase (silent), and run the interactive
|
|
480
|
-
* one-time re-key when cleartext-named items exist and a human is present.
|
|
481
|
-
* Never throws — a failed attempt leaves the process on cleartext names
|
|
482
|
-
* (exact pre-#316 behavior) and the next process retries.
|
|
483
|
-
*/
|
|
484
|
-
export function maybeAutoRekey() {
|
|
485
|
-
if (autoRekeyAttempted || rekeyRunning || rawScopeDepth > 0)
|
|
486
|
-
return;
|
|
487
|
-
autoRekeyAttempted = true;
|
|
488
|
-
if (forcedTestKey || backend)
|
|
489
|
-
return;
|
|
490
|
-
// Never auto-mutate the developer's real keychain from a test runner.
|
|
491
|
-
if (process.env.VITEST)
|
|
492
|
-
return;
|
|
493
|
-
if (process.platform !== 'darwin')
|
|
494
|
-
return;
|
|
495
|
-
if (process.env.AGENTS_SECRETS_NO_AUTO_REKEY === '1')
|
|
496
|
-
return;
|
|
497
|
-
if (process.env.AGENTS_SECRETS_HASH_NAMES === '0')
|
|
498
|
-
return;
|
|
499
|
-
const st = resolveHashState();
|
|
500
|
-
if (st.active) {
|
|
501
|
-
// The stale-ACL'd-hmackey heal runs on the hot read path
|
|
502
|
-
// (readHmacKeyRecord), which resolveHashState() above just went through —
|
|
503
|
-
// so st.record is already healed here regardless of how this machine
|
|
504
|
-
// reached "hashing active". Nothing to do but finish any pending deletes.
|
|
505
|
-
if (st.record?.pendingDeletes?.length) {
|
|
506
|
-
try {
|
|
507
|
-
finishPendingDeletes(st.record);
|
|
508
|
-
}
|
|
509
|
-
catch {
|
|
510
|
-
/* next process retries */
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
let cleartext;
|
|
516
|
-
try {
|
|
517
|
-
cleartext = listCleartextServices();
|
|
518
|
-
}
|
|
519
|
-
catch {
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
// Moving real items pops Touch ID — only auto-run with a human present. An
|
|
523
|
-
// empty listing still goes through rekeyServiceNames (prompt-free): it
|
|
524
|
-
// verifies the enumeration is trustworthy before activating on "nothing to
|
|
525
|
-
// migrate", so a locked keybag can never masquerade as a fresh machine.
|
|
526
|
-
const interactive = process.stdin.isTTY && process.stderr.isTTY;
|
|
527
|
-
if (cleartext.length > 0 && !interactive)
|
|
528
|
-
return;
|
|
529
|
-
try {
|
|
530
|
-
rekeyServiceNames({ announce: cleartext.length > 0, log: (line) => console.error(line) });
|
|
531
|
-
}
|
|
532
|
-
catch (err) {
|
|
533
|
-
// Headless processes stay quiet (e.g. locked-keybag probe failures would
|
|
534
|
-
// otherwise spam every background run); a human gets the pointer.
|
|
535
|
-
if (interactive) {
|
|
536
|
-
console.error(`agents secrets: one-time re-key did not complete (${err.message}). ` +
|
|
537
|
-
`Keychain service names remain enumerable; run 'agents secrets rekey' to retry.`);
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
/**
|
|
542
|
-
* Build the old→new mapping for a set of cleartext services. Bundle metadata
|
|
543
|
-
* is parsed first to (a) recover each bundle's prompt policy — the persisted
|
|
544
|
-
* `tier` token, where `none`/`never` means the item must be re-written through
|
|
545
|
-
* the no-ACL path — and (b) inject the cleartext `name` into the JSON, because
|
|
546
|
-
* after hashing the service name can no longer carry it (listBundles reads it
|
|
547
|
-
* back from the payload). A value item's tier is resolved from its bundle's
|
|
548
|
-
* metadata PAYLOAD in `values` — under the cleartext metadata name or its
|
|
549
|
-
* hashed transform — never from the metadata item being part of the same
|
|
550
|
-
* `services` batch: a --prefix run can scope a bundle's value items alone, and
|
|
551
|
-
* rekeyServiceNames supplies the out-of-scope metadata reads (see the
|
|
552
|
-
* supplemental batch there). Exported for unit tests.
|
|
553
|
-
*/
|
|
554
|
-
export function computeRekeyPlan(services, values, key) {
|
|
555
|
-
const items = [];
|
|
556
|
-
const unreadable = [];
|
|
557
|
-
const bundleNoAcl = (bundle) => {
|
|
558
|
-
const meta = `${BUNDLES_ITEM_PREFIX}${bundle}`;
|
|
559
|
-
const raw = values.get(meta) ?? values.get(hashedServiceName(meta, key));
|
|
560
|
-
if (raw === undefined)
|
|
561
|
-
return false;
|
|
562
|
-
try {
|
|
563
|
-
const parsed = JSON.parse(raw);
|
|
564
|
-
if (parsed && typeof parsed === 'object')
|
|
565
|
-
return parsed.tier === 'none' || parsed.tier === 'never';
|
|
566
|
-
}
|
|
567
|
-
catch {
|
|
568
|
-
/* malformed JSON — ACL'd */
|
|
569
|
-
}
|
|
570
|
-
return false;
|
|
571
|
-
};
|
|
572
|
-
for (const service of services) {
|
|
573
|
-
if (!service.startsWith(BUNDLES_ITEM_PREFIX))
|
|
574
|
-
continue;
|
|
575
|
-
const value = values.get(service);
|
|
576
|
-
if (value === undefined) {
|
|
577
|
-
unreadable.push(service);
|
|
578
|
-
continue;
|
|
579
|
-
}
|
|
580
|
-
const name = service.slice(BUNDLES_ITEM_PREFIX.length);
|
|
581
|
-
let payload;
|
|
582
|
-
let noAcl = false;
|
|
583
|
-
try {
|
|
584
|
-
const parsed = JSON.parse(value);
|
|
585
|
-
if (parsed && typeof parsed === 'object') {
|
|
586
|
-
// Bundle metadata is non-sensitive by contract and stored no-ACL at
|
|
587
|
-
// EVERY tier (matches writeBundle in bundles.ts), so `secrets list` /
|
|
588
|
-
// crabbox's `agents devices list` enumerate bundles with no Touch ID
|
|
589
|
-
// (RUSH-1759). Re-home metadata no-ACL regardless of the bundle's policy;
|
|
590
|
-
// the real secret values (second loop) still carry their per-bundle ACL.
|
|
591
|
-
noAcl = true;
|
|
592
|
-
payload = JSON.stringify({ ...parsed, name });
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
catch {
|
|
596
|
-
/* malformed JSON — copy verbatim, ACL'd */
|
|
597
|
-
}
|
|
598
|
-
items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl, payload });
|
|
599
|
-
}
|
|
600
|
-
for (const service of services) {
|
|
601
|
-
if (service.startsWith(BUNDLES_ITEM_PREFIX))
|
|
602
|
-
continue;
|
|
603
|
-
const value = values.get(service);
|
|
604
|
-
if (value === undefined) {
|
|
605
|
-
unreadable.push(service);
|
|
606
|
-
continue;
|
|
607
|
-
}
|
|
608
|
-
let noAcl = false;
|
|
609
|
-
if (service.startsWith(SECRETS_ITEM_PREFIX)) {
|
|
610
|
-
const rest = service.slice(SECRETS_ITEM_PREFIX.length);
|
|
611
|
-
const dot = rest.lastIndexOf('.');
|
|
612
|
-
if (dot > 0)
|
|
613
|
-
noAcl = bundleNoAcl(rest.slice(0, dot));
|
|
614
|
-
}
|
|
615
|
-
// Durable "unlocked session" items (agents-cli.session.*, see session-store.ts)
|
|
616
|
-
// are always no-ACL — re-wrapping them in a biometry ACL on rekey would break
|
|
617
|
-
// their silent (no-Touch-ID) reads that the rehydrate/fallback paths depend on.
|
|
618
|
-
if (service.startsWith('agents-cli.session.'))
|
|
619
|
-
noAcl = true;
|
|
620
|
-
items.push({ oldService: service, newService: hashedServiceName(service, key), noAcl });
|
|
621
|
-
}
|
|
622
|
-
return { items, unreadable };
|
|
623
|
-
}
|
|
624
|
-
/**
|
|
625
|
-
* The one-time re-key: move every cleartext-named `agents-cli.*` item to its
|
|
626
|
-
* hashed service name. Composed entirely from the existing helper primitives —
|
|
627
|
-
* no new Swift command:
|
|
628
|
-
*
|
|
629
|
-
* 1. Enumerate cleartext services (silent) and batch-read every value behind
|
|
630
|
-
* ONE Touch ID (`get-batch`; readItem also sweeps legacy/orphaned copies).
|
|
631
|
-
* 2. Write each hashed copy (`set`/`set-no-acl` never prompt), preserving
|
|
632
|
-
* the no-ACL tier for `never`-policy bundles.
|
|
633
|
-
* 3. Batch-verify every copy round-trips (second Touch ID).
|
|
634
|
-
* 4. Only then activate hashing (sentinel + pendingDeletes) and delete the
|
|
635
|
-
* old items (silent).
|
|
636
|
-
*
|
|
637
|
-
* Add-before-delete throughout: a cancel/crash/failure anywhere before step 4
|
|
638
|
-
* leaves every old item intact and hashing OFF — same rationale as the
|
|
639
|
-
* helper's migrate-orphans, which is also why no pre-write backup is taken.
|
|
640
|
-
* On ANY per-item failure nothing is deleted and the sentinel stays off
|
|
641
|
-
* (all-or-nothing activation); the report names every failed item. A crash
|
|
642
|
-
* between the sentinel write and the deletes is resumed silently by the next
|
|
643
|
-
* process (pendingDeletes). Idempotent: re-running converges.
|
|
644
|
-
*/
|
|
645
|
-
export function rekeyServiceNames(opts = {}) {
|
|
646
|
-
const log = opts.log ?? (() => { });
|
|
647
|
-
if (!backend && process.platform !== 'darwin') {
|
|
648
|
-
throw new Error('secrets rekey is macOS-only — service names are enumerable only via the macOS keychain helper.');
|
|
649
|
-
}
|
|
650
|
-
if (rekeyRunning)
|
|
651
|
-
throw new Error('re-key already running in this process.');
|
|
652
|
-
rekeyRunning = true;
|
|
653
|
-
try {
|
|
654
|
-
const partial = !!opts.prefixes?.length;
|
|
655
|
-
let record = ensureHmacKeyRecord(false);
|
|
656
|
-
const key = Buffer.from(record.k, 'hex');
|
|
657
|
-
// The record we just ensured is a DP item — if enumeration can't see it,
|
|
658
|
-
// every listing below is lying (locked keybag) and no decision — least of
|
|
659
|
-
// all "nothing to migrate, activate" — can be made on it.
|
|
660
|
-
assertEnumerationTrustworthy();
|
|
661
|
-
if (record.pendingDeletes?.length) {
|
|
662
|
-
log(`Finishing interrupted re-key: removing ${record.pendingDeletes.length} already-copied cleartext item(s)…`);
|
|
663
|
-
finishPendingDeletes(record);
|
|
664
|
-
record = readHmacKeyRecord() ?? record;
|
|
665
|
-
}
|
|
666
|
-
const cleartext = listCleartextServices(opts.prefixes);
|
|
667
|
-
if (cleartext.length === 0) {
|
|
668
|
-
if (!record.migrated && !partial) {
|
|
669
|
-
writeHmacKeyRecord({ ...record, migrated: true });
|
|
670
|
-
log('No cleartext-named keychain items found — hashed service names are now active.');
|
|
671
|
-
return { migrated: [], failed: [], activated: true, nothingToDo: true };
|
|
672
|
-
}
|
|
673
|
-
return { migrated: [], failed: [], activated: record.migrated, nothingToDo: true };
|
|
674
|
-
}
|
|
675
|
-
if (opts.announce) {
|
|
676
|
-
log(`One-time secrets re-key: replacing ${cleartext.length} enumerable keychain service name(s) with opaque hashed names (GitHub #316).`);
|
|
677
|
-
log('Touch ID will prompt twice (read + verify). Cancelling is safe — the re-key resumes on a later run.');
|
|
678
|
-
}
|
|
679
|
-
// A --prefix run can scope a bundle's value items WITHOUT its metadata
|
|
680
|
-
// item (`agents-cli.bundles.<bundle>`); the tier decision must still come
|
|
681
|
-
// from the keychain, not from batch membership — otherwise a partial
|
|
682
|
-
// re-key of a `never`-policy bundle would silently re-attach a biometry
|
|
683
|
-
// ACL to its values (and delete the cleartext originals, one-way). Read
|
|
684
|
-
// every such bundle's metadata alongside the values in the same batch:
|
|
685
|
-
// the cleartext name for a not-yet-moved metadata item, its hashed
|
|
686
|
-
// transform for one an earlier partial run already moved. Absent both (a
|
|
687
|
-
// standalone item with no backing bundle), the value stays ACL'd. A full
|
|
688
|
-
// run adds nothing here — every metadata item is already in scope.
|
|
689
|
-
const inScope = new Set(cleartext);
|
|
690
|
-
const supplementalMetaReads = new Set();
|
|
691
|
-
for (const service of cleartext) {
|
|
692
|
-
if (!service.startsWith(SECRETS_ITEM_PREFIX))
|
|
693
|
-
continue;
|
|
694
|
-
const rest = service.slice(SECRETS_ITEM_PREFIX.length);
|
|
695
|
-
const dot = rest.lastIndexOf('.');
|
|
696
|
-
if (dot <= 0)
|
|
697
|
-
continue;
|
|
698
|
-
const meta = `${BUNDLES_ITEM_PREFIX}${rest.slice(0, dot)}`;
|
|
699
|
-
if (inScope.has(meta))
|
|
700
|
-
continue;
|
|
701
|
-
supplementalMetaReads.add(meta);
|
|
702
|
-
supplementalMetaReads.add(hashedServiceName(meta, key));
|
|
703
|
-
}
|
|
704
|
-
const values = withRawKeychainServiceNames(() => getKeychainTokens([...cleartext, ...supplementalMetaReads]));
|
|
705
|
-
const { items, unreadable } = computeRekeyPlan(cleartext, values, key);
|
|
706
|
-
const failed = unreadable.map((item) => ({
|
|
707
|
-
item,
|
|
708
|
-
detail: 'read failed or item absent',
|
|
709
|
-
}));
|
|
710
|
-
const added = [];
|
|
711
|
-
for (const plan of items) {
|
|
712
|
-
try {
|
|
713
|
-
setKeychainToken(plan.newService, plan.payload ?? values.get(plan.oldService), { noAcl: plan.noAcl });
|
|
714
|
-
added.push(plan);
|
|
715
|
-
}
|
|
716
|
-
catch (err) {
|
|
717
|
-
failed.push({ item: plan.oldService, detail: `write: ${err.message}` });
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
const verified = [];
|
|
721
|
-
if (added.length > 0) {
|
|
722
|
-
const readBack = getKeychainTokens(added.map((p) => p.newService));
|
|
723
|
-
for (const plan of added) {
|
|
724
|
-
const expected = plan.payload ?? values.get(plan.oldService);
|
|
725
|
-
if (readBack.get(plan.newService) === expected)
|
|
726
|
-
verified.push(plan);
|
|
727
|
-
else
|
|
728
|
-
failed.push({ item: plan.oldService, detail: 'verify: value mismatch after rewrite' });
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
if (failed.length > 0) {
|
|
732
|
-
log(`Re-key INCOMPLETE — ${failed.length} of ${cleartext.length} item(s) could not be moved; nothing was deleted and hashed naming stays OFF:`);
|
|
733
|
-
for (const f of failed)
|
|
734
|
-
log(` ${f.item}: ${f.detail}`);
|
|
735
|
-
return { migrated: [], failed, activated: false, nothingToDo: false };
|
|
736
|
-
}
|
|
737
|
-
if (partial) {
|
|
738
|
-
withRawKeychainServiceNames(() => {
|
|
739
|
-
for (const plan of verified)
|
|
740
|
-
deleteKeychainToken(plan.oldService);
|
|
741
|
-
});
|
|
742
|
-
log(`Re-keyed ${verified.length} item(s) (partial run — hashed naming NOT activated).`);
|
|
743
|
-
return { migrated: verified.map((p) => p.oldService), failed: [], activated: record.migrated, nothingToDo: false };
|
|
744
|
-
}
|
|
745
|
-
writeHmacKeyRecord({ ...record, migrated: true, pendingDeletes: verified.map((p) => p.oldService) });
|
|
746
|
-
withRawKeychainServiceNames(() => {
|
|
747
|
-
for (const plan of verified)
|
|
748
|
-
deleteKeychainToken(plan.oldService);
|
|
749
|
-
});
|
|
750
|
-
writeHmacKeyRecord({ ...record, migrated: true, pendingDeletes: undefined });
|
|
751
|
-
log(`Re-keyed ${verified.length} keychain item(s); service names are now opaque (agents-cli.h.*).`);
|
|
752
|
-
return { migrated: verified.map((p) => p.oldService), failed: [], activated: true, nothingToDo: false };
|
|
753
|
-
}
|
|
754
|
-
finally {
|
|
755
|
-
rekeyRunning = false;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
/** Re-key state snapshot for `agents secrets rekey --status`. */
|
|
759
|
-
export function rekeyStatus() {
|
|
760
|
-
const rec = readHmacKeyRecord();
|
|
761
|
-
let enumerationOk = true;
|
|
762
|
-
if (rec) {
|
|
763
|
-
try {
|
|
764
|
-
assertEnumerationTrustworthy();
|
|
765
|
-
}
|
|
766
|
-
catch {
|
|
767
|
-
enumerationOk = false;
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
return {
|
|
771
|
-
migrated: !!rec?.migrated,
|
|
772
|
-
hasKey: !!rec,
|
|
773
|
-
pendingDeletes: rec?.pendingDeletes?.length ?? 0,
|
|
774
|
-
cleartext: listCleartextServices(),
|
|
775
|
-
enumerationOk,
|
|
776
|
-
};
|
|
777
|
-
}
|
|
778
|
-
/**
|
|
779
|
-
* Check if a keychain/keyring item exists. Never prompts for biometry.
|
|
780
|
-
*
|
|
781
|
-
* Throws when the item cannot be reached — on macOS, when the signed helper is
|
|
782
|
-
* unavailable. That is deliberate: this primitive gates destructive writes as
|
|
783
|
-
* well as reads. Through `bundleExists()` it guards the `--force` overwrite
|
|
784
|
-
* checks in `agents secrets create` (`../../commands/secrets.ts`) and the
|
|
785
|
-
* bundle-rename purge (`./bundles.ts`), and the pull-rollback bookkeeping
|
|
786
|
-
* (`./sync.ts`). A false "absent" silently disarms every one of them, so an
|
|
787
|
-
* unreachable keychain must fail loudly rather than answer "no".
|
|
788
|
-
*
|
|
789
|
-
* Tests needing this path without a helper install a backend via
|
|
790
|
-
* `setKeychainBackendForTest()`, which short-circuits on the next line.
|
|
791
|
-
*/
|
|
792
|
-
export function hasKeychainToken(item) {
|
|
793
|
-
item = prepareServiceName(item);
|
|
794
|
-
if (backend)
|
|
795
|
-
return backend.has(item);
|
|
796
|
-
assertSupportedPlatform();
|
|
797
|
-
if (isLinux())
|
|
798
|
-
return linuxBackend.has(item);
|
|
799
|
-
if (isWindows())
|
|
800
|
-
return windowsBackend.has(item);
|
|
801
|
-
if (!isOurItem(item)) {
|
|
802
|
-
return spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
|
|
803
|
-
stdio: ['ignore', 'ignore', 'ignore'],
|
|
804
|
-
}, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
|
|
805
|
-
}
|
|
806
|
-
const bin = getKeychainHelperPath();
|
|
807
|
-
const r = spawnKeychainHelper(bin, ['has', item, os.userInfo().username], {
|
|
808
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
809
|
-
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
810
|
-
// The helper exits 0 = present (incl. errSecInteractionNotAllowed — a locked or
|
|
811
|
-
// biometry-ACL'd item still EXISTS), 1 = genuinely absent. Any other outcome
|
|
812
|
-
// (bad args, helper failure, spawn error) means the keychain could not be
|
|
813
|
-
// reached — and a false "absent" silently disarms every destructive-write guard
|
|
814
|
-
// that calls this (see the docblock), so it MUST fail loud rather than answer
|
|
815
|
-
// "no" (RUSH-2235). Timeouts already throw inside spawnKeychainHelper.
|
|
816
|
-
if (r.status === 0)
|
|
817
|
-
return true;
|
|
818
|
-
if (r.status === 1)
|
|
819
|
-
return false;
|
|
820
|
-
const stderr = r.stderr?.toString().trim();
|
|
821
|
-
throw new Error(stderr ||
|
|
822
|
-
`keychain existence check for '${item}' failed (helper exit ${r.status ?? 'null'}` +
|
|
823
|
-
`${r.error ? `: ${r.error.message}` : ''}) — keychain unreachable, not a proven absence.`);
|
|
824
|
-
}
|
|
825
|
-
/**
|
|
826
|
-
* The detector the raw-read storm guard consults, overridable so tests on any
|
|
827
|
-
* platform exercise the fail-fast path — the real detector self-gates to
|
|
828
|
-
* darwin (the only platform with a Touch ID sheet to suppress), which would
|
|
829
|
-
* make the throw unreachable from a Linux CI run. Same parameterization
|
|
830
|
-
* argument as the injected env/platform/tty on isHeadlessSecretsContext.
|
|
831
|
-
*/
|
|
832
|
-
let rawReadHeadlessDetector = () => isHeadlessSecretsContext();
|
|
833
|
-
export function setKeychainHeadlessDetectorForTest(detector) {
|
|
834
|
-
rawReadHeadlessDetector = detector ?? (() => isHeadlessSecretsContext());
|
|
835
|
-
}
|
|
836
|
-
/**
|
|
837
|
-
* The raw-read storm guard, consulted by every getKeychainToken /
|
|
838
|
-
* getKeychainTokens read that could reach a prompting keychain query. Two
|
|
839
|
-
* fail-fast gates, both skipped for `silentNoAcl` (provably prompt-free)
|
|
840
|
-
* reads:
|
|
841
|
-
*
|
|
842
|
-
* 1. Headless fail-fast. A non-interactive process (AGENTS_RUNTIME set, or
|
|
843
|
-
* no TTY — see isHeadlessSecretsContext) must NEVER raise a Touch ID sheet
|
|
844
|
-
* on the interactive user's screen: the sheet has no one to answer it, a
|
|
845
|
-
* polling caller re-raises it every few seconds, and a cancel just feeds
|
|
846
|
-
* the next poll. Throw an actionable error naming the item instead.
|
|
847
|
-
* 2. Back-off. A read whose prompt recently failed or was cancelled is
|
|
848
|
-
* suppressed for KEYCHAIN_READ_BACKOFF_TTL_MS so an interactive-context
|
|
849
|
-
* poller (TTY but unwatched — a tmux pane, a VS Code task terminal) can't
|
|
850
|
-
* storm sheets either. A successful read or write clears the memo.
|
|
851
|
-
*
|
|
852
|
-
* Placement note: this runs BEFORE the platform branches so the back-off memo
|
|
853
|
-
* is honored identically everywhere, and the headless gate is a no-op off
|
|
854
|
-
* darwin (the detector returns false there) — Linux/Windows reads never
|
|
855
|
-
* prompt, so there is nothing to guard.
|
|
856
|
-
*/
|
|
857
|
-
function assertRawKeychainReadAllowed(key, context, label) {
|
|
858
|
-
if (context.silentNoAcl)
|
|
859
|
-
return;
|
|
860
|
-
const what = label ?? `Keychain item '${key}'`;
|
|
861
|
-
if (rawReadHeadlessDetector()) {
|
|
862
|
-
const hint = context.bundle
|
|
863
|
-
? `Run 'agents secrets unlock ${context.bundle}' in a terminal first`
|
|
864
|
-
: `Provision a prompt-free credential for headless use (a file-based setup token, or an item stored without the biometry ACL), ` +
|
|
865
|
-
`or read it once from an interactive terminal`;
|
|
866
|
-
throw new Error(`${what} requires Touch ID, but this process is non-interactive — ` +
|
|
867
|
-
`a prompt would appear on screen with no one to answer it. ${hint}.`);
|
|
868
|
-
}
|
|
869
|
-
if (isKeychainReadBackedOff(key)) {
|
|
870
|
-
throw new Error(`${what} is in read back-off: a Touch ID prompt for it failed or was cancelled within the last ` +
|
|
871
|
-
`${Math.round(KEYCHAIN_READ_BACKOFF_TTL_MS / 60000)} minutes, and retrying is suppressed so a polling caller can't storm prompts. ` +
|
|
872
|
-
`Read it once interactively or wait out the back-off.`);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
export function keychainOperationPrompt(context = {}) {
|
|
876
|
-
const agent = context.agent || 'Agents CLI';
|
|
877
|
-
const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
|
|
878
|
-
// Which session triggered the read — the short-id disambiguates an unexpected
|
|
879
|
-
// prompt when several agents run at once (interactive + headless + exec).
|
|
880
|
-
const session = context.sessionId ? ` (session ${deriveShortId(context.sessionId)})` : '';
|
|
881
|
-
const duration = context.duration ? ` for ${context.duration}` : '';
|
|
882
|
-
const reason = context.reason ? ` ${context.reason}` : '';
|
|
883
|
-
return `${agent} is requesting to unlock${bundle}${session}${duration}${reason}.`;
|
|
884
|
-
}
|
|
885
|
-
export function getKeychainToken(item, context = {}) {
|
|
886
|
-
// Errors keep the requested (human-readable) name; the storage name may be
|
|
887
|
-
// an opaque hash.
|
|
888
|
-
const requested = item;
|
|
889
|
-
item = prepareServiceName(item);
|
|
890
|
-
if (backend)
|
|
891
|
-
return backend.get(item);
|
|
892
|
-
assertRawKeychainReadAllowed(requested, context);
|
|
893
|
-
assertSupportedPlatform();
|
|
894
|
-
if (isLinux())
|
|
895
|
-
return linuxBackend.get(item);
|
|
896
|
-
if (isWindows())
|
|
897
|
-
return windowsBackend.get(item);
|
|
898
|
-
if (!isOurItem(item)) {
|
|
899
|
-
const sec = spawnKeychainHelper('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
|
|
900
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
901
|
-
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
902
|
-
if (sec.status === 0) {
|
|
903
|
-
const token = sec.stdout?.toString().trim();
|
|
904
|
-
if (token) {
|
|
905
|
-
clearKeychainReadBackoff(requested);
|
|
906
|
-
return token;
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
throw new Error(`Keychain item '${requested}' not found.`);
|
|
910
|
-
}
|
|
911
|
-
const bin = getKeychainHelperPath();
|
|
912
|
-
let result;
|
|
913
|
-
try {
|
|
914
|
-
result = spawnKeychainHelper(bin, ['get', item, os.userInfo().username], {
|
|
915
|
-
env: {
|
|
916
|
-
...process.env,
|
|
917
|
-
AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
|
|
918
|
-
AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
|
|
919
|
-
AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
|
|
920
|
-
},
|
|
921
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
922
|
-
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
923
|
-
}
|
|
924
|
-
catch (err) {
|
|
925
|
-
if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
|
|
926
|
-
noteKeychainReadFailure(requested);
|
|
927
|
-
}
|
|
928
|
-
throw err;
|
|
929
|
-
}
|
|
930
|
-
// Exit 1 is a plain miss — no prompt was raised, so it opens no back-off.
|
|
931
|
-
if (result.status === 1)
|
|
932
|
-
throw new Error(`Keychain item '${requested}' not found.`);
|
|
933
|
-
if (result.status !== 0) {
|
|
934
|
-
// A cancel (4) or helper failure after a prompted read: open the back-off
|
|
935
|
-
// window BEFORE throwing, so the next poll of the same item fails fast
|
|
936
|
-
// instead of re-raising the sheet.
|
|
937
|
-
if (!context.silentNoAcl)
|
|
938
|
-
noteKeychainReadFailure(requested);
|
|
939
|
-
if (result.status === 4)
|
|
940
|
-
throw new Error(`Touch ID cancelled while reading '${requested}'.`);
|
|
941
|
-
const msg = result.stderr?.toString().trim();
|
|
942
|
-
throw new Error(msg || `Failed to read keychain item '${requested}'.`);
|
|
943
|
-
}
|
|
944
|
-
const token = result.stdout?.toString();
|
|
945
|
-
if (!token)
|
|
946
|
-
throw new Error(`Keychain item '${requested}' exists but is empty.`);
|
|
947
|
-
clearKeychainReadBackoff(requested);
|
|
948
|
-
return token;
|
|
949
|
-
}
|
|
950
|
-
/**
|
|
951
|
-
* Batch-read multiple keychain items behind a single Touch ID prompt. The
|
|
952
|
-
* macOS helper holds one LAContext for its whole process: the first protected
|
|
953
|
-
* item triggers Touch ID, every later item in the same invocation reuses the
|
|
954
|
-
* assertion. Missing items are absent from the returned map (caller decides
|
|
955
|
-
* whether that's an error).
|
|
956
|
-
*
|
|
957
|
-
* On Linux or when a test backend is installed, falls back to individual
|
|
958
|
-
* lookups — no biometric prompt path on those platforms.
|
|
959
|
-
*/
|
|
960
|
-
export function getKeychainTokens(items, context = {}) {
|
|
961
|
-
const result = new Map();
|
|
962
|
-
if (items.length === 0)
|
|
963
|
-
return result;
|
|
964
|
-
// Resolve storage names up front, remembering which requested name each one
|
|
965
|
-
// answers for — the returned map is keyed by the names the CALLER passed,
|
|
966
|
-
// whether those were cleartext (hashed here) or already-hashed (enumerated).
|
|
967
|
-
const requestedByStorage = new Map();
|
|
968
|
-
const storageItems = items.map((item) => {
|
|
969
|
-
const storage = prepareServiceName(item);
|
|
970
|
-
if (!requestedByStorage.has(storage))
|
|
971
|
-
requestedByStorage.set(storage, item);
|
|
972
|
-
return storage;
|
|
973
|
-
});
|
|
974
|
-
const record = (storage, value) => {
|
|
975
|
-
result.set(requestedByStorage.get(storage) ?? storage, value);
|
|
976
|
-
};
|
|
977
|
-
if (backend) {
|
|
978
|
-
for (const storage of storageItems) {
|
|
979
|
-
try {
|
|
980
|
-
record(storage, backend.get(storage));
|
|
981
|
-
}
|
|
982
|
-
catch { /* missing — skip */ }
|
|
983
|
-
}
|
|
984
|
-
return result;
|
|
985
|
-
}
|
|
986
|
-
// One back-off memo covers the whole batch: the batch raises at most one
|
|
987
|
-
// prompt, so a cancel/failure suppresses retrying the same batch, keyed by
|
|
988
|
-
// the requested names (never the storage hashes) for a stable identity. The
|
|
989
|
-
// guard runs before the platform branches so the headless fail-fast is
|
|
990
|
-
// exercisable on any platform (the real detector self-gates to darwin).
|
|
991
|
-
const backoffKey = `batch:${items.join('\n')}`;
|
|
992
|
-
assertRawKeychainReadAllowed(backoffKey, context, `Batch read of ${items.length} keychain item(s)`);
|
|
993
|
-
assertSupportedPlatform();
|
|
994
|
-
if (isLinux()) {
|
|
995
|
-
for (const storage of storageItems) {
|
|
996
|
-
try {
|
|
997
|
-
record(storage, linuxBackend.get(storage));
|
|
998
|
-
}
|
|
999
|
-
catch { /* missing — skip */ }
|
|
1000
|
-
}
|
|
1001
|
-
return result;
|
|
1002
|
-
}
|
|
1003
|
-
if (isWindows()) {
|
|
1004
|
-
for (const storage of storageItems) {
|
|
1005
|
-
try {
|
|
1006
|
-
record(storage, windowsBackend.get(storage));
|
|
1007
|
-
}
|
|
1008
|
-
catch { /* missing — skip */ }
|
|
1009
|
-
}
|
|
1010
|
-
return result;
|
|
1011
|
-
}
|
|
1012
|
-
const bin = getKeychainHelperPath();
|
|
1013
|
-
let child;
|
|
1014
|
-
try {
|
|
1015
|
-
child = spawnKeychainHelper(bin, ['get-batch', os.userInfo().username, ...storageItems], {
|
|
1016
|
-
env: {
|
|
1017
|
-
...process.env,
|
|
1018
|
-
AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
|
|
1019
|
-
AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
|
|
1020
|
-
AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
|
|
1021
|
-
// The signed helper's own vocabulary is unchanged (it predates the rename
|
|
1022
|
-
// and ships as a separately-versioned binary), so map to its legacy token.
|
|
1023
|
-
AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
|
|
1024
|
-
AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
|
|
1025
|
-
},
|
|
1026
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1027
|
-
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1028
|
-
}
|
|
1029
|
-
catch (err) {
|
|
1030
|
-
if (err instanceof KeychainHelperTimeoutError && !context.silentNoAcl) {
|
|
1031
|
-
noteKeychainReadFailure(backoffKey);
|
|
1032
|
-
}
|
|
1033
|
-
throw err;
|
|
1034
|
-
}
|
|
1035
|
-
if (child.status !== 0 && !context.silentNoAcl)
|
|
1036
|
-
noteKeychainReadFailure(backoffKey);
|
|
1037
|
-
if (child.status === 4) {
|
|
1038
|
-
throw new Error(`Touch ID cancelled while reading ${items.length} keychain item(s).`);
|
|
1039
|
-
}
|
|
1040
|
-
if (child.status !== 0) {
|
|
1041
|
-
const msg = child.stderr?.toString().trim();
|
|
1042
|
-
throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
|
|
1043
|
-
}
|
|
1044
|
-
clearKeychainReadBackoff(backoffKey);
|
|
1045
|
-
const out = child.stdout?.toString() ?? '';
|
|
1046
|
-
parseBatchRecords(out, record);
|
|
1047
|
-
return result;
|
|
1048
|
-
}
|
|
1049
|
-
/**
|
|
1050
|
-
* Parse the helper's batch-read output, routing each present record through
|
|
1051
|
-
* `record(service, value)` — getKeychainTokens uses that to reverse-map hashed
|
|
1052
|
-
* storage names back to the names the caller asked with. The format is shared
|
|
1053
|
-
* by `get-batch` and `get-batch-synced` — a sequence of records, one per
|
|
1054
|
-
* service in input order:
|
|
1055
|
-
* "V <service>\n<value>\n" (present)
|
|
1056
|
-
* "M <service>\n" (missing)
|
|
1057
|
-
* Service names are validated newline/'='-free by setKeychainToken below
|
|
1058
|
-
* and values are rejected if they contain newlines — so splitting on '\n'
|
|
1059
|
-
* and walking line-by-line is unambiguous.
|
|
1060
|
-
*/
|
|
1061
|
-
function parseBatchRecords(out, record) {
|
|
1062
|
-
const lines = out.split('\n');
|
|
1063
|
-
let i = 0;
|
|
1064
|
-
while (i < lines.length) {
|
|
1065
|
-
const line = lines[i];
|
|
1066
|
-
if (line === '' && i === lines.length - 1)
|
|
1067
|
-
break;
|
|
1068
|
-
if (line.startsWith('V ')) {
|
|
1069
|
-
const service = line.slice(2);
|
|
1070
|
-
const value = lines[i + 1] ?? '';
|
|
1071
|
-
record(service, value);
|
|
1072
|
-
i += 2;
|
|
1073
|
-
}
|
|
1074
|
-
else if (line.startsWith('M ')) {
|
|
1075
|
-
i += 1;
|
|
1076
|
-
}
|
|
1077
|
-
else if (line === '') {
|
|
1078
|
-
i += 1;
|
|
1079
|
-
}
|
|
1080
|
-
else {
|
|
1081
|
-
throw new Error(`Malformed get-batch output line: ${JSON.stringify(line)}`);
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
/** Store or update a secret value in the keychain/keyring. Device-local;
|
|
1086
|
-
* biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
|
|
1087
|
-
* item WITHOUT the biometry access control so later reads are fully silent — it
|
|
1088
|
-
* routes through the signed helper's `set-no-acl` path. A pinned helper that
|
|
1089
|
-
* predates that path rejects the unknown command (exit 2) and this throws,
|
|
1090
|
-
* rather than silently falling back to an ACL'd `set` (which would behave like
|
|
1091
|
-
* `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
|
|
1092
|
-
/**
|
|
1093
|
-
* argv for writing a bare (non-`agents-cli.`) keychain item via
|
|
1094
|
-
* `/usr/bin/security add-generic-password`, deliberately WITHOUT the value: the
|
|
1095
|
-
* secret travels over stdin (see setKeychainToken) so it never lands in argv or
|
|
1096
|
-
* a `ps` snapshot. Exported so a test can assert the value is absent from argv.
|
|
1097
|
-
*/
|
|
1098
|
-
export function buildAddGenericPasswordArgs(account, item) {
|
|
1099
|
-
return ['add-generic-password', '-U', '-a', account, '-s', item, '-w'];
|
|
1100
|
-
}
|
|
1101
|
-
/**
|
|
1102
|
-
* spawnSync options for the bare `-w` keychain write. Pure so the two
|
|
1103
|
-
* load-bearing properties are unit-testable without touching the real keychain:
|
|
1104
|
-
* - `input` pipes the value TWICE (bare `-w` prompts enter+confirm; one line
|
|
1105
|
-
* fails the confirm and stores an empty secret).
|
|
1106
|
-
* - `detached: true` runs `security` in a new session with no controlling
|
|
1107
|
-
* terminal, so readpassphrase(3) falls back to our piped stdin instead of
|
|
1108
|
-
* prompting the user's `/dev/tty` in an interactive shell (see setKeychainToken).
|
|
1109
|
-
*/
|
|
1110
|
-
export function buildAddGenericPasswordSpawnOptions(value) {
|
|
1111
|
-
// `detached` is honored by spawnSync at runtime (libuv setsid) but is not
|
|
1112
|
-
// declared on Node's SpawnSyncOptions type, so widen the return explicitly.
|
|
1113
|
-
return {
|
|
1114
|
-
input: `${value}\n${value}\n`,
|
|
1115
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1116
|
-
timeout: 10_000,
|
|
1117
|
-
detached: true,
|
|
1118
|
-
};
|
|
1119
|
-
}
|
|
1120
|
-
export function setKeychainToken(item, value, opts) {
|
|
1121
|
-
// Validate the CLEARTEXT name (a hashed storage name is always clean), then
|
|
1122
|
-
// resolve the storage name.
|
|
1123
|
-
if (/[\x00=\r\n]/.test(item))
|
|
1124
|
-
throw new Error('Secret item name contains invalid characters.');
|
|
1125
|
-
const requested = item;
|
|
1126
|
-
item = prepareServiceName(item);
|
|
1127
|
-
if (backend) {
|
|
1128
|
-
backend.set(item, value, opts);
|
|
1129
|
-
return;
|
|
1130
|
-
}
|
|
1131
|
-
assertSupportedPlatform();
|
|
1132
|
-
assertValueStorable(value);
|
|
1133
|
-
if (isLinux()) {
|
|
1134
|
-
linuxBackend.set(item, value);
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1137
|
-
if (isWindows()) {
|
|
1138
|
-
windowsBackend.set(item, value);
|
|
1139
|
-
return;
|
|
1140
|
-
}
|
|
1141
|
-
// Bare (non-`agents-cli.`) items are written WITHOUT the biometry ACL so
|
|
1142
|
-
// they round-trip with the no-prompt read path in getKeychainToken (which
|
|
1143
|
-
// also uses /usr/bin/security for non-our items). This is what lets a
|
|
1144
|
-
// SessionStart hook read e.g. `linear-api-key` silently on every launch.
|
|
1145
|
-
// Routing these through the helper would attach a Touch ID ACL that the
|
|
1146
|
-
// /usr/bin/security read can't satisfy without popping the legacy password
|
|
1147
|
-
// sheet. -U upserts so repeated sets overwrite in place.
|
|
1148
|
-
if (!isOurItem(item)) {
|
|
1149
|
-
// The secret VALUE must never appear in argv — a `ps` snapshot on a shared
|
|
1150
|
-
// host would leak it (RUSH-1764). `security add-generic-password` has no
|
|
1151
|
-
// stdin-password flag; instead a BARE `-w` as the LAST option makes it prompt
|
|
1152
|
-
// ("password data for new item:" / "retype...") and read the secret from fd 0
|
|
1153
|
-
// via readpassphrase(3) -- so the value travels over stdin, never on the
|
|
1154
|
-
// command line. The prompt asks twice (enter + confirm), so we pipe the value
|
|
1155
|
-
// TWICE; a single line fails the confirm and would store an empty secret. The
|
|
1156
|
-
// item stays ACL-free (no biometry gate), so the no-prompt /usr/bin/security
|
|
1157
|
-
// read path in getKeychainToken still works. Values are newline-free on darwin
|
|
1158
|
-
// (assertValueStorable above), so each line carries the whole secret verbatim
|
|
1159
|
-
// (no shell/quoting layer). `timeout` bounds the call so a context that cannot
|
|
1160
|
-
// read the prompt fails loudly instead of hanging.
|
|
1161
|
-
//
|
|
1162
|
-
// `detached: true` is load-bearing, not an afterthought: readpassphrase(3)
|
|
1163
|
-
// reads from the *controlling terminal* (`/dev/tty`) when one exists, and only
|
|
1164
|
-
// falls back to fd 0 when it cannot open one. So piping over stdin works in a
|
|
1165
|
-
// headless/CI context (no controlling tty) but is IGNORED in an interactive
|
|
1166
|
-
// shell — there `security` prompts the real user ("password data for new
|
|
1167
|
-
// item:") and hangs to the timeout, and any keystroke would be stored AS the
|
|
1168
|
-
// secret. `detached` runs the child in a new session (setsid) with no
|
|
1169
|
-
// controlling terminal, so readpassphrase always falls back to our piped
|
|
1170
|
-
// stdin. Without it, an interactive `agents view` (refreshing+saving a Claude
|
|
1171
|
-
// OAuth token) or `agents secrets add` pops a keychain password sheet.
|
|
1172
|
-
const sec = spawnKeychainHelper('/usr/bin/security', buildAddGenericPasswordArgs(os.userInfo().username, item), buildAddGenericPasswordSpawnOptions(value), KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1173
|
-
if (sec.status !== 0) {
|
|
1174
|
-
const msg = sec.stderr?.toString().trim();
|
|
1175
|
-
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
1176
|
-
}
|
|
1177
|
-
clearKeychainReadBackoff(requested);
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
const bin = getKeychainHelperPath();
|
|
1181
|
-
// `never` policy → no-ACL write. The `set-no-acl` subcommand exists only in a
|
|
1182
|
-
// re-notarized helper; an older pinned helper dies with "Unknown command:
|
|
1183
|
-
// set-no-acl" (exit 2), surfaced below — never a silent ACL'd downgrade.
|
|
1184
|
-
const helperCmd = opts?.noAcl ? 'set-no-acl' : 'set';
|
|
1185
|
-
const result = spawnKeychainHelper(bin, [helperCmd, item, os.userInfo().username], {
|
|
1186
|
-
input: value,
|
|
1187
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1188
|
-
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1189
|
-
if (result.status !== 0) {
|
|
1190
|
-
const msg = result.stderr?.toString().trim();
|
|
1191
|
-
if (opts?.noAcl && /unknown command/i.test(msg ?? '')) {
|
|
1192
|
-
throw new Error(`The 'never' prompt-policy needs a Keychain helper with the no-ACL write path, ` +
|
|
1193
|
-
`but the installed helper does not support it. Rebuild + re-notarize the signed ` +
|
|
1194
|
-
`helper (scripts/build-keychain-helper.sh) and re-pin its sha, then retry. ` +
|
|
1195
|
-
`(helper said: ${msg})`);
|
|
1196
|
-
}
|
|
1197
|
-
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
1198
|
-
}
|
|
1199
|
-
// A successful write supersedes any open back-off: the item is known-good
|
|
1200
|
-
// now, so the next read must not be suppressed by a stale failure memo.
|
|
1201
|
-
clearKeychainReadBackoff(requested);
|
|
1202
|
-
}
|
|
1203
|
-
/** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
|
|
1204
|
-
export function deleteKeychainToken(item) {
|
|
1205
|
-
const requested = item;
|
|
1206
|
-
item = prepareServiceName(item);
|
|
1207
|
-
if (backend)
|
|
1208
|
-
return backend.delete(item);
|
|
1209
|
-
assertSupportedPlatform();
|
|
1210
|
-
if (isLinux())
|
|
1211
|
-
return linuxBackend.delete(item);
|
|
1212
|
-
if (isWindows())
|
|
1213
|
-
return windowsBackend.delete(item);
|
|
1214
|
-
const bin = getKeychainHelperPath();
|
|
1215
|
-
const r = spawnKeychainHelper(bin, ['delete', item, os.userInfo().username], {
|
|
1216
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1217
|
-
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1218
|
-
// The helper exits 0 = an item was removed from at least one keychain, 1 =
|
|
1219
|
-
// nothing to delete (genuinely absent). Any other outcome means the keychain
|
|
1220
|
-
// could not be reached; like hasKeychainToken this must fail loud rather than
|
|
1221
|
-
// report a false "nothing was there" (RUSH-2235) — a swallowed failure lets a
|
|
1222
|
-
// rename/purge believe it cleared a name it did not.
|
|
1223
|
-
if (r.status === 0) {
|
|
1224
|
-
// A deleted item must fail its next read as plain "not found", not with a
|
|
1225
|
-
// stale back-off error left over from a pre-delete cancel.
|
|
1226
|
-
clearKeychainReadBackoff(requested);
|
|
1227
|
-
return true;
|
|
1228
|
-
}
|
|
1229
|
-
if (r.status === 1)
|
|
1230
|
-
return false;
|
|
1231
|
-
const stderr = r.stderr?.toString().trim();
|
|
1232
|
-
throw new Error(stderr ||
|
|
1233
|
-
`keychain delete for '${item}' failed (helper exit ${r.status ?? 'null'}` +
|
|
1234
|
-
`${r.error ? `: ${r.error.message}` : ''}) — keychain unreachable, deletion unproven.`);
|
|
1235
|
-
}
|
|
1236
|
-
/**
|
|
1237
|
-
* True when the active keychain backend transparently routes reads/writes to
|
|
1238
|
-
* the encrypted-file store instead of the OS credential store. This only
|
|
1239
|
-
* happens on Linux under the headless / locked-collection fallback
|
|
1240
|
-
* (src/lib/secrets/linux.ts); macOS and the test backend always return false.
|
|
1241
|
-
*
|
|
1242
|
-
* Callers that ALSO enumerate the file store directly (e.g. `listBundles`)
|
|
1243
|
-
* use this to avoid double-counting: under the fallback `listKeychainItems`
|
|
1244
|
-
* and the direct file enumeration return the same items.
|
|
1245
|
-
*/
|
|
1246
|
-
export function keychainUsesFileFallback() {
|
|
1247
|
-
if (backend)
|
|
1248
|
-
return false;
|
|
1249
|
-
if (isLinux())
|
|
1250
|
-
return linuxUsesFileFallback();
|
|
1251
|
-
if (isWindows())
|
|
1252
|
-
return windowsUsesFileFallback();
|
|
1253
|
-
return false;
|
|
1254
|
-
}
|
|
1255
|
-
/** Enumerate keychain/keyring item names starting with the given prefix.
|
|
1256
|
-
* With hashed service names active, the two sub-namespace prefixes callers
|
|
1257
|
-
* use (bundle metadata; one bundle's value items) are mapped to their hashed
|
|
1258
|
-
* shapes — the returned names are then storage (opaque) names. */
|
|
1259
|
-
export function listKeychainItems(prefix) {
|
|
1260
|
-
const mapped = prepareListPrefix(prefix);
|
|
1261
|
-
const apply = (names) => (mapped.filter ? names.filter(mapped.filter) : names);
|
|
1262
|
-
if (backend)
|
|
1263
|
-
return apply(backend.list(mapped.prefix));
|
|
1264
|
-
assertSupportedPlatform();
|
|
1265
|
-
if (isLinux())
|
|
1266
|
-
return apply(linuxBackend.list(mapped.prefix));
|
|
1267
|
-
if (isWindows())
|
|
1268
|
-
return apply(windowsBackend.list(mapped.prefix));
|
|
1269
|
-
const bin = getKeychainHelperPath();
|
|
1270
|
-
const result = spawnKeychainHelper(bin, ['list', mapped.prefix], {
|
|
1271
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1272
|
-
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1273
|
-
if (result.status !== 0) {
|
|
1274
|
-
const msg = result.stderr?.toString().trim();
|
|
1275
|
-
throw new Error(msg || `Failed to enumerate keychain items with prefix '${prefix}'.`);
|
|
1276
|
-
}
|
|
1277
|
-
const out = result.stdout?.toString() || '';
|
|
1278
|
-
return apply(out.split('\n').map((s) => s.trim()).filter(Boolean));
|
|
1279
|
-
}
|
|
1280
|
-
/**
|
|
1281
|
-
* Enumerate ONLY legacy file-based-keychain item names with the given prefix —
|
|
1282
|
-
* the items that still carry a pre-migration (trusted-app) ACL and pop a
|
|
1283
|
-
* separate auth sheet on read. Items already in the data-protection keychain are
|
|
1284
|
-
* excluded (they need no migration). Silent (attributes only, never decrypts).
|
|
1285
|
-
*
|
|
1286
|
-
* macOS only: on Linux / the test backend there is no separate legacy keychain,
|
|
1287
|
-
* so this returns []. Used by `agents secrets migrate-acl` to rewrite only the
|
|
1288
|
-
* stragglers instead of every item (which would be a Touch ID storm).
|
|
1289
|
-
*/
|
|
1290
|
-
export function listLegacyKeychainItems(prefix) {
|
|
1291
|
-
if (backend)
|
|
1292
|
-
return [];
|
|
1293
|
-
assertSupportedPlatform();
|
|
1294
|
-
if (isLinux())
|
|
1295
|
-
return [];
|
|
1296
|
-
const bin = getKeychainHelperPath();
|
|
1297
|
-
const result = spawnKeychainHelper(bin, ['list-legacy', prefix], {
|
|
1298
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1299
|
-
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1300
|
-
if (result.status !== 0) {
|
|
1301
|
-
const msg = result.stderr?.toString().trim();
|
|
1302
|
-
throw new Error(msg || `Failed to enumerate legacy keychain items with prefix '${prefix}'.`);
|
|
1303
|
-
}
|
|
1304
|
-
const out = result.stdout?.toString() || '';
|
|
1305
|
-
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1306
|
-
}
|
|
1307
|
-
let syncedBackend = null;
|
|
1308
|
-
export function setSyncedKeychainBackendForTest(b) {
|
|
1309
|
-
const prev = syncedBackend;
|
|
1310
|
-
syncedBackend = b;
|
|
1311
|
-
return prev;
|
|
1312
|
-
}
|
|
1313
|
-
/**
|
|
1314
|
-
* Enumerate LEGACY SYNCHRONIZABLE (iCloud Keychain) item names with the given
|
|
1315
|
-
* prefix — bundles written by the pre-biometry helper era, which defaulted
|
|
1316
|
-
* secrets to iCloud Keychain sync. The device-local cutover orphaned them:
|
|
1317
|
-
* every modern query pins synchronizable=false, so only the helper's
|
|
1318
|
-
* `list-synced` verb can see them. Silent (attributes only, never decrypts).
|
|
1319
|
-
* macOS only — Linux/Windows never had iCloud Keychain sync, so this returns [].
|
|
1320
|
-
*/
|
|
1321
|
-
export function listSyncedKeychainItems(prefix) {
|
|
1322
|
-
if (syncedBackend)
|
|
1323
|
-
return syncedBackend.list(prefix);
|
|
1324
|
-
if (backend)
|
|
1325
|
-
return [];
|
|
1326
|
-
assertSupportedPlatform();
|
|
1327
|
-
if (isLinux() || isWindows())
|
|
1328
|
-
return [];
|
|
1329
|
-
const bin = getKeychainHelperPath();
|
|
1330
|
-
const result = spawnKeychainHelper(bin, ['list-synced', prefix], {
|
|
1331
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1332
|
-
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1333
|
-
if (result.status !== 0) {
|
|
1334
|
-
const msg = result.stderr?.toString().trim();
|
|
1335
|
-
throw new Error(msg || `Failed to enumerate iCloud keychain items with prefix '${prefix}'.`);
|
|
1336
|
-
}
|
|
1337
|
-
const out = result.stdout?.toString() || '';
|
|
1338
|
-
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1339
|
-
}
|
|
1340
|
-
/**
|
|
1341
|
-
* Batch-read LEGACY SYNCHRONIZABLE (iCloud Keychain) items. Returns a map of
|
|
1342
|
-
* item name → value; missing items are simply absent. Pre-biometry items carry
|
|
1343
|
-
* no biometry ACL, so this does not normally prompt. macOS only — returns an
|
|
1344
|
-
* empty map on Linux/Windows.
|
|
1345
|
-
*/
|
|
1346
|
-
export function getSyncedKeychainTokens(items) {
|
|
1347
|
-
const result = new Map();
|
|
1348
|
-
if (items.length === 0)
|
|
1349
|
-
return result;
|
|
1350
|
-
if (syncedBackend)
|
|
1351
|
-
return syncedBackend.getBatch(items);
|
|
1352
|
-
if (backend)
|
|
1353
|
-
return result;
|
|
1354
|
-
assertSupportedPlatform();
|
|
1355
|
-
if (isLinux() || isWindows())
|
|
1356
|
-
return result;
|
|
1357
|
-
const bin = getKeychainHelperPath();
|
|
1358
|
-
const child = spawnKeychainHelper(bin, ['get-batch-synced', os.userInfo().username, ...items], {
|
|
1359
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1360
|
-
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1361
|
-
if (child.status === 4) {
|
|
1362
|
-
throw new Error(`Auth cancelled while reading ${items.length} iCloud keychain item(s).`);
|
|
1363
|
-
}
|
|
1364
|
-
if (child.status !== 0) {
|
|
1365
|
-
const msg = child.stderr?.toString().trim();
|
|
1366
|
-
throw new Error(msg || `Failed to batch-read ${items.length} iCloud keychain items.`);
|
|
1367
|
-
}
|
|
1368
|
-
parseBatchRecords(child.stdout?.toString() ?? '', (service, value) => { result.set(service, value); });
|
|
1369
|
-
return result;
|
|
1370
|
-
}
|
|
1371
|
-
/**
|
|
1372
|
-
* Delete a LEGACY SYNCHRONIZABLE (iCloud Keychain) item after a successful
|
|
1373
|
-
* import (`--purge`). Matches synchronizable items only — the device-local
|
|
1374
|
-
* copy the import wrote is untouched. iCloud propagates the deletion to the
|
|
1375
|
-
* user's other devices. Returns true if a copy was removed.
|
|
1376
|
-
*/
|
|
1377
|
-
export function deleteSyncedKeychainItem(item) {
|
|
1378
|
-
if (syncedBackend)
|
|
1379
|
-
return syncedBackend.delete(item);
|
|
1380
|
-
if (backend)
|
|
1381
|
-
return false;
|
|
1382
|
-
assertSupportedPlatform();
|
|
1383
|
-
if (isLinux() || isWindows())
|
|
1384
|
-
return false;
|
|
1385
|
-
const bin = getKeychainHelperPath();
|
|
1386
|
-
return spawnKeychainHelper(bin, ['delete-synced', item, os.userInfo().username], {
|
|
1387
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1388
|
-
}, KEYCHAIN_SILENT_TIMEOUT_MS).status === 0;
|
|
1389
|
-
}
|
|
1390
|
-
/**
|
|
1391
|
-
* One-time upgrade for a keychain item that was written by a previous helper
|
|
1392
|
-
* generation with a trusted-app ACL. The helper reads the legacy item
|
|
1393
|
-
* (which may pop the password sheet once), then deletes and re-adds it with
|
|
1394
|
-
* the biometry access control. Returns true if the item was rewritten, false
|
|
1395
|
-
* if no item by that name exists. macOS only — Linux backends have no ACL
|
|
1396
|
-
* concept, so the call is a no-op there.
|
|
1397
|
-
*/
|
|
1398
|
-
export function migrateKeychainItem(item) {
|
|
1399
|
-
if (backend)
|
|
1400
|
-
return backend.has(item);
|
|
1401
|
-
assertSupportedPlatform();
|
|
1402
|
-
if (isLinux())
|
|
1403
|
-
return linuxBackend.has(item);
|
|
1404
|
-
if (isWindows())
|
|
1405
|
-
return windowsBackend.has(item);
|
|
1406
|
-
const bin = getKeychainHelperPath();
|
|
1407
|
-
const result = spawnKeychainHelper(bin, ['migrate-acl', item, os.userInfo().username], {
|
|
1408
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1409
|
-
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1410
|
-
if (result.status === 0)
|
|
1411
|
-
return true;
|
|
1412
|
-
if (result.status === 1)
|
|
1413
|
-
return false;
|
|
1414
|
-
const msg = result.stderr?.toString().trim();
|
|
1415
|
-
throw new Error(msg || `Failed to migrate keychain item '${item}'.`);
|
|
1416
|
-
}
|
|
1417
|
-
/**
|
|
1418
|
-
* Enumerate data-protection items whose service starts with `prefix` that live
|
|
1419
|
-
* under a NON-concrete access group — pre-#279 "orphans" filed under the implicit
|
|
1420
|
-
* default group (the literal `2HTP252L87.*`) that the pinned-group queries can't
|
|
1421
|
-
* see. Attributes only: never decrypts, never prompts. macOS only — Linux/Windows
|
|
1422
|
-
* and the test backend have no access-group concept, so this returns [].
|
|
1423
|
-
*/
|
|
1424
|
-
export function listOrphanedKeychainItems(prefix) {
|
|
1425
|
-
if (backend)
|
|
1426
|
-
return [];
|
|
1427
|
-
assertSupportedPlatform();
|
|
1428
|
-
if (isLinux() || isWindows())
|
|
1429
|
-
return [];
|
|
1430
|
-
const bin = getKeychainHelperPath();
|
|
1431
|
-
const result = spawnKeychainHelper(bin, ['list-orphans', prefix, os.userInfo().username], {
|
|
1432
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1433
|
-
}, KEYCHAIN_SILENT_TIMEOUT_MS);
|
|
1434
|
-
if (result.status !== 0) {
|
|
1435
|
-
const msg = result.stderr?.toString().trim();
|
|
1436
|
-
throw new Error(msg || `Failed to enumerate orphaned keychain items with prefix '${prefix}'.`);
|
|
1437
|
-
}
|
|
1438
|
-
const out = result.stdout?.toString() || '';
|
|
1439
|
-
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1440
|
-
}
|
|
1441
|
-
/**
|
|
1442
|
-
* Parse the `migrate-orphans` helper summary (one record per line):
|
|
1443
|
-
* OK <service> re-homed
|
|
1444
|
-
* WARN <service> <detail> pinned copy written but orphan not removed
|
|
1445
|
-
* FAIL <service> <detail> could not re-home (orphan left intact)
|
|
1446
|
-
* Unknown lines are ignored. Exported for unit testing without a keychain.
|
|
1447
|
-
*/
|
|
1448
|
-
export function parseOrphanMigrationOutput(stdout) {
|
|
1449
|
-
const results = [];
|
|
1450
|
-
for (const line of stdout.split('\n')) {
|
|
1451
|
-
const trimmed = line.trim();
|
|
1452
|
-
if (!trimmed)
|
|
1453
|
-
continue;
|
|
1454
|
-
const sep = trimmed.indexOf(' ');
|
|
1455
|
-
const tag = sep === -1 ? trimmed : trimmed.slice(0, sep);
|
|
1456
|
-
const rest = sep === -1 ? '' : trimmed.slice(sep + 1);
|
|
1457
|
-
if (tag === 'OK') {
|
|
1458
|
-
// OK carries only the service name (no trailing detail).
|
|
1459
|
-
results.push({ item: rest, status: 'ok' });
|
|
1460
|
-
}
|
|
1461
|
-
else if (tag === 'WARN' || tag === 'FAIL') {
|
|
1462
|
-
// WARN/FAIL are 'TAG <service> <detail>'. Service names are space-free
|
|
1463
|
-
// (validateBundleName / validateEnvKey), so the first token IS the exact
|
|
1464
|
-
// service — this stays consistent with listOrphanedKeychainItems for the
|
|
1465
|
-
// healed-set reconciliation in migrate-acl.
|
|
1466
|
-
const item = rest.split(' ')[0] ?? rest;
|
|
1467
|
-
results.push({ item, status: tag === 'WARN' ? 'warn' : 'fail', detail: rest });
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
return results;
|
|
1471
|
-
}
|
|
1472
|
-
/**
|
|
1473
|
-
* Re-home every pre-#279 orphaned data-protection item under `prefix` into the
|
|
1474
|
-
* concrete access group, behind a SINGLE Touch ID prompt for the whole batch.
|
|
1475
|
-
* The helper reads each orphan by its exact persistent ref, adds the pinned copy
|
|
1476
|
-
* (add-before-delete: a failed add leaves the orphan intact), then deletes the
|
|
1477
|
-
* orphan by ref. Returns one result per item. macOS only — no-op elsewhere.
|
|
1478
|
-
*
|
|
1479
|
-
* Throws on Touch ID cancellation (exit 4) so callers can distinguish "user
|
|
1480
|
-
* aborted" from "nothing to do" (empty array).
|
|
1481
|
-
*/
|
|
1482
|
-
export function migrateOrphanedKeychainItems(prefix) {
|
|
1483
|
-
if (backend)
|
|
1484
|
-
return [];
|
|
1485
|
-
assertSupportedPlatform();
|
|
1486
|
-
if (isLinux() || isWindows())
|
|
1487
|
-
return [];
|
|
1488
|
-
const bin = getKeychainHelperPath();
|
|
1489
|
-
const result = spawnKeychainHelper(bin, ['migrate-orphans', prefix, os.userInfo().username], {
|
|
1490
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1491
|
-
}, KEYCHAIN_INTERACTIVE_TIMEOUT_MS);
|
|
1492
|
-
if (result.status === 4)
|
|
1493
|
-
throw new Error('Touch ID cancelled during orphan migration.');
|
|
1494
|
-
if (result.status !== 0) {
|
|
1495
|
-
const msg = result.stderr?.toString().trim();
|
|
1496
|
-
throw new Error(msg || `Failed to migrate orphaned keychain items with prefix '${prefix}'.`);
|
|
1497
|
-
}
|
|
1498
|
-
return parseOrphanMigrationOutput(result.stdout?.toString() || '');
|
|
1499
|
-
}
|
|
1500
|
-
/**
|
|
1501
|
-
* Import agents-cli secrets from the native store (GNOME Keyring / Windows
|
|
1502
|
-
* Credential Manager) into the encrypted file store — the Linux/Windows
|
|
1503
|
-
* analogue of the macOS orphan/legacy migration, exposed as
|
|
1504
|
-
* `agents secrets import-keyring`. Requires the native store to be
|
|
1505
|
-
* reachable/unlocked; `commit=false` is a dry-run. macOS returns an empty
|
|
1506
|
-
* report (it has no file fallback and uses `migrate-acl` instead).
|
|
1507
|
-
*/
|
|
1508
|
-
export function importNativeItems(prefix, commit) {
|
|
1509
|
-
if (backend)
|
|
1510
|
-
return { available: false, locked: false, results: [] };
|
|
1511
|
-
assertSupportedPlatform();
|
|
1512
|
-
if (isLinux())
|
|
1513
|
-
return importNativeSecretToolItems(prefix, commit);
|
|
1514
|
-
if (isWindows())
|
|
1515
|
-
return importNativeCredManItems(prefix, commit);
|
|
1516
|
-
return { available: false, locked: false, results: [] };
|
|
1517
|
-
}
|
|
1518
|
-
function expandHome(p) {
|
|
1519
|
-
if (p.startsWith('~/') || p === '~') {
|
|
1520
|
-
return path.join(os.homedir(), p.slice(1));
|
|
1521
|
-
}
|
|
1522
|
-
return p;
|
|
1523
|
-
}
|
|
1524
|
-
/** Resolve a secret ref to its plaintext value using the appropriate provider. */
|
|
1525
|
-
export function resolveRef(ref, opts = {}) {
|
|
1526
|
-
switch (ref.provider) {
|
|
1527
|
-
case 'keychain': {
|
|
1528
|
-
const item = opts.keychainItemFor ? opts.keychainItemFor(ref.value) : ref.value;
|
|
1529
|
-
return getKeychainToken(item);
|
|
1530
|
-
}
|
|
1531
|
-
case 'env': {
|
|
1532
|
-
const name = ref.value;
|
|
1533
|
-
if (opts.envAllowlist && !opts.envAllowlist.includes(name)) {
|
|
1534
|
-
throw new Error(`env: ref '${name}' not in allowlist.`);
|
|
1535
|
-
}
|
|
1536
|
-
const val = process.env[name];
|
|
1537
|
-
if (val === undefined) {
|
|
1538
|
-
throw new Error(`env: ref '${name}' not set in parent environment.`);
|
|
1539
|
-
}
|
|
1540
|
-
return val;
|
|
1541
|
-
}
|
|
1542
|
-
case 'file': {
|
|
1543
|
-
const target = expandHome(ref.value);
|
|
1544
|
-
if (!fs.existsSync(target)) {
|
|
1545
|
-
throw new Error(`file: ref '${ref.value}' does not exist.`);
|
|
1546
|
-
}
|
|
1547
|
-
return fs.readFileSync(target, 'utf-8').trim();
|
|
1548
|
-
}
|
|
1549
|
-
case 'exec': {
|
|
1550
|
-
if (!opts.allowExec) {
|
|
1551
|
-
throw new Error(`exec: ref '${ref.value}' blocked. Set 'allow_exec: true' in the bundle to enable.`);
|
|
1552
|
-
}
|
|
1553
|
-
// shell: false — the bundle author controls the command; no injection
|
|
1554
|
-
// from secret identifiers. Parse a simple space-separated command.
|
|
1555
|
-
const parts = ref.value.match(/(?:[^\s"]+|"[^"]*")+/g)?.map((p) => p.replace(/^"|"$/g, '')) || [];
|
|
1556
|
-
if (parts.length === 0) {
|
|
1557
|
-
throw new Error(`exec: ref '${ref.value}' is empty.`);
|
|
1558
|
-
}
|
|
1559
|
-
const [cmd, ...args] = parts;
|
|
1560
|
-
try {
|
|
1561
|
-
return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
1562
|
-
}
|
|
1563
|
-
catch (err) {
|
|
1564
|
-
throw new Error(`exec: ref '${ref.value}' failed: ${err.message}`);
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
}
|