@phnx-labs/agents-cli 1.20.0 → 1.20.3
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 +73 -0
- package/README.md +4 -4
- package/dist/commands/cli.js +3 -3
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/commands.js +24 -7
- package/dist/commands/exec.js +36 -16
- package/dist/commands/feedback.d.ts +7 -0
- package/dist/commands/feedback.js +89 -0
- package/dist/commands/helper.d.ts +12 -0
- package/dist/commands/helper.js +87 -0
- package/dist/commands/hooks.js +86 -7
- package/dist/commands/mcp.js +166 -10
- package/dist/commands/packages.js +196 -27
- package/dist/commands/permissions.js +21 -6
- package/dist/commands/profiles.d.ts +8 -0
- package/dist/commands/profiles.js +117 -4
- package/dist/commands/pull.js +4 -4
- package/dist/commands/routines.js +6 -6
- package/dist/commands/rules.js +8 -4
- package/dist/commands/secrets-migrate.d.ts +24 -0
- package/dist/commands/secrets-migrate.js +198 -0
- package/dist/commands/secrets-sync.d.ts +11 -0
- package/dist/commands/secrets-sync.js +155 -0
- package/dist/commands/secrets.js +74 -39
- package/dist/commands/skills.js +22 -5
- package/dist/commands/subagents.js +69 -49
- package/dist/commands/teams.js +48 -10
- package/dist/commands/utils.d.ts +33 -0
- package/dist/commands/utils.js +139 -0
- package/dist/commands/versions.js +4 -4
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +164 -8
- package/dist/commands/workflows.js +29 -6
- package/dist/index.js +4 -0
- package/dist/lib/acp/client.js +6 -1
- package/dist/lib/agents.d.ts +4 -0
- package/dist/lib/agents.js +18 -14
- package/dist/lib/auto-pull-worker.js +18 -1
- package/dist/lib/browser/chrome.js +4 -0
- package/dist/lib/browser/drivers/ssh.js +1 -1
- package/dist/lib/browser/profiles.d.ts +3 -3
- package/dist/lib/browser/profiles.js +3 -3
- package/dist/lib/browser/service.js +19 -0
- package/dist/lib/browser/types.d.ts +4 -4
- package/dist/lib/cli-resources.d.ts +36 -8
- package/dist/lib/cli-resources.js +268 -46
- package/dist/lib/cloud/factory.d.ts +1 -1
- package/dist/lib/cloud/factory.js +1 -1
- package/dist/lib/events.d.ts +16 -2
- package/dist/lib/events.js +33 -2
- package/dist/lib/exec.d.ts +39 -11
- package/dist/lib/exec.js +90 -31
- package/dist/lib/help.js +11 -5
- package/dist/lib/hooks/cache.d.ts +38 -0
- package/dist/lib/hooks/cache.js +242 -0
- package/dist/lib/hooks/profile.d.ts +33 -0
- package/dist/lib/hooks/profile.js +129 -0
- package/dist/lib/hooks.d.ts +0 -10
- package/dist/lib/hooks.js +68 -15
- package/dist/lib/mcp.d.ts +15 -0
- package/dist/lib/mcp.js +40 -0
- package/dist/lib/permissions.d.ts +13 -0
- package/dist/lib/permissions.js +51 -1
- package/dist/lib/plugins.js +15 -1
- package/dist/lib/profiles-presets.d.ts +26 -0
- package/dist/lib/profiles-presets.js +187 -8
- package/dist/lib/profiles.d.ts +34 -0
- package/dist/lib/profiles.js +112 -1
- package/dist/lib/routines-format.d.ts +17 -5
- package/dist/lib/routines-format.js +37 -16
- package/dist/lib/routines.d.ts +1 -1
- package/dist/lib/routines.js +2 -2
- package/dist/lib/runner.js +64 -10
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
- package/dist/lib/secrets/bundles.d.ts +18 -22
- package/dist/lib/secrets/bundles.js +75 -99
- package/dist/lib/secrets/index.d.ts +51 -27
- package/dist/lib/secrets/index.js +147 -156
- package/dist/lib/secrets/install-helper.d.ts +45 -0
- package/dist/lib/secrets/install-helper.js +165 -0
- package/dist/lib/secrets/linux.js +4 -4
- package/dist/lib/secrets/sync.d.ts +56 -0
- package/dist/lib/secrets/sync.js +180 -0
- package/dist/lib/session/render.js +4 -4
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/shims.d.ts +4 -1
- package/dist/lib/shims.js +5 -35
- package/dist/lib/state.d.ts +14 -1
- package/dist/lib/state.js +49 -5
- package/dist/lib/teams/agents.d.ts +5 -4
- package/dist/lib/teams/agents.js +47 -21
- package/dist/lib/teams/api.d.ts +2 -1
- package/dist/lib/teams/api.js +4 -3
- package/dist/lib/types.d.ts +57 -1
- package/dist/lib/types.js +2 -0
- package/dist/lib/usage.d.ts +27 -2
- package/dist/lib/usage.js +100 -17
- package/dist/lib/versions.d.ts +35 -1
- package/dist/lib/versions.js +267 -64
- package/package.json +9 -8
- package/scripts/install-helper.js +97 -0
- package/scripts/postinstall.js +16 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-platform secure credential storage.
|
|
3
3
|
*
|
|
4
|
-
* macOS:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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: not supported.
|
|
16
|
+
*
|
|
17
|
+
* Items are device-local: the biometry access control requires the OS to
|
|
18
|
+
* treat them as bound to this device, so cross-machine propagation goes
|
|
19
|
+
* through the explicit export/import flow in src/lib/secrets/sync.ts
|
|
20
|
+
* rather than the system's cloud-keychain path.
|
|
12
21
|
*/
|
|
13
22
|
/** Supported secret resolution backends. */
|
|
14
23
|
export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
|
|
@@ -44,33 +53,50 @@ export declare function secretsKeychainItem(bundle: string, key: string): string
|
|
|
44
53
|
* tests) is destructive and would require an interactive Keychain unlock.
|
|
45
54
|
*/
|
|
46
55
|
export interface KeychainBackend {
|
|
47
|
-
has(item: string
|
|
48
|
-
get(item: string
|
|
49
|
-
set(item: string, value: string
|
|
50
|
-
delete(item: string
|
|
56
|
+
has(item: string): boolean;
|
|
57
|
+
get(item: string): string;
|
|
58
|
+
set(item: string, value: string): void;
|
|
59
|
+
delete(item: string): boolean;
|
|
51
60
|
list(prefix: string): string[];
|
|
52
61
|
}
|
|
53
62
|
/** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
|
|
54
63
|
export declare function setKeychainBackendForTest(b: KeychainBackend | null): KeychainBackend | null;
|
|
55
|
-
/** Check if a keychain/keyring item exists. */
|
|
56
|
-
export declare function hasKeychainToken(item: string
|
|
57
|
-
/** Retrieve a secret value from the keychain/keyring. Throws if not found. */
|
|
58
|
-
export declare function getKeychainToken(item: string, sync?: boolean): string;
|
|
64
|
+
/** Check if a keychain/keyring item exists. Never prompts for biometry. */
|
|
65
|
+
export declare function hasKeychainToken(item: string): boolean;
|
|
59
66
|
/**
|
|
60
|
-
*
|
|
61
|
-
* unreadable items are simply absent from the map (the caller decides whether a
|
|
62
|
-
* given key was required).
|
|
67
|
+
* Retrieve a secret value from the keychain/keyring. Throws if not found.
|
|
63
68
|
*
|
|
64
|
-
* On macOS this
|
|
65
|
-
*
|
|
69
|
+
* On macOS this triggers Touch ID (or reuses an assertion held by an earlier
|
|
70
|
+
* call in the same process). For bundles, prefer getKeychainTokens() so a
|
|
71
|
+
* single biometric prompt covers every key in the batch.
|
|
66
72
|
*/
|
|
67
|
-
export declare function
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
export declare function getKeychainToken(item: string): string;
|
|
74
|
+
/**
|
|
75
|
+
* Batch-read multiple keychain items behind a single Touch ID prompt. The
|
|
76
|
+
* macOS helper holds one LAContext for its whole process: the first protected
|
|
77
|
+
* item triggers Touch ID, every later item in the same invocation reuses the
|
|
78
|
+
* assertion. Missing items are absent from the returned map (caller decides
|
|
79
|
+
* whether that's an error).
|
|
80
|
+
*
|
|
81
|
+
* On Linux or when a test backend is installed, falls back to individual
|
|
82
|
+
* lookups — no biometric prompt path on those platforms.
|
|
83
|
+
*/
|
|
84
|
+
export declare function getKeychainTokens(items: string[]): Map<string, string>;
|
|
85
|
+
/** Store or update a secret value in the keychain/keyring. Device-local; biometry-gated on macOS. */
|
|
86
|
+
export declare function setKeychainToken(item: string, value: string): void;
|
|
87
|
+
/** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
|
|
88
|
+
export declare function deleteKeychainToken(item: string): boolean;
|
|
72
89
|
/** Enumerate keychain/keyring item names starting with the given prefix. */
|
|
73
90
|
export declare function listKeychainItems(prefix: string): string[];
|
|
91
|
+
/**
|
|
92
|
+
* One-time upgrade for a keychain item that was written by a previous helper
|
|
93
|
+
* generation with a trusted-app ACL. The helper reads the legacy item
|
|
94
|
+
* (which may pop the password sheet once), then deletes and re-adds it with
|
|
95
|
+
* the biometry access control. Returns true if the item was rewritten, false
|
|
96
|
+
* if no item by that name exists. macOS only — Linux backends have no ACL
|
|
97
|
+
* concept, so the call is a no-op there.
|
|
98
|
+
*/
|
|
99
|
+
export declare function migrateKeychainItem(item: string): boolean;
|
|
74
100
|
/** Options controlling how secret refs are resolved. */
|
|
75
101
|
export interface ResolveOptions {
|
|
76
102
|
/** Translate a short keychain ID to a fully namespaced item name. */
|
|
@@ -79,8 +105,6 @@ export interface ResolveOptions {
|
|
|
79
105
|
allowExec?: boolean;
|
|
80
106
|
/** Restrict env: refs to this allowlist. When undefined, any env var may be read. */
|
|
81
107
|
envAllowlist?: string[];
|
|
82
|
-
/** Read keychain refs from the iCloud-synced keychain backend. */
|
|
83
|
-
iCloudSync?: boolean;
|
|
84
108
|
}
|
|
85
109
|
/** Resolve a secret ref to its plaintext value using the appropriate provider. */
|
|
86
110
|
export declare function resolveRef(ref: SecretRef, opts?: ResolveOptions): string;
|
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-platform secure credential storage.
|
|
3
3
|
*
|
|
4
|
-
* macOS:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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: not supported.
|
|
16
|
+
*
|
|
17
|
+
* Items are device-local: the biometry access control requires the OS to
|
|
18
|
+
* treat them as bound to this device, so cross-machine propagation goes
|
|
19
|
+
* through the explicit export/import flow in src/lib/secrets/sync.ts
|
|
20
|
+
* rather than the system's cloud-keychain path.
|
|
12
21
|
*/
|
|
13
|
-
import { fileURLToPath } from 'url';
|
|
14
22
|
import { execFileSync, spawnSync } from 'child_process';
|
|
15
23
|
import * as fs from 'fs';
|
|
16
24
|
import * as os from 'os';
|
|
17
25
|
import * as path from 'path';
|
|
18
26
|
import { linuxBackend } from './linux.js';
|
|
27
|
+
import { getKeychainHelperPath } from './install-helper.js';
|
|
19
28
|
const SERVICE_PREFIX = 'agents-cli';
|
|
20
29
|
const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
|
|
21
30
|
const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
|
|
@@ -47,9 +56,6 @@ function assertSupportedPlatform() {
|
|
|
47
56
|
function isLinux() {
|
|
48
57
|
return process.platform === 'linux';
|
|
49
58
|
}
|
|
50
|
-
function isMacOS() {
|
|
51
|
-
return process.platform === 'darwin';
|
|
52
|
-
}
|
|
53
59
|
/** Build the keychain item name for a profile provider token. */
|
|
54
60
|
export function profileKeychainItem(provider) {
|
|
55
61
|
return `${SERVICE_PREFIX}.${provider}.token`;
|
|
@@ -61,22 +67,6 @@ export function secretsKeychainItem(bundle, key) {
|
|
|
61
67
|
function keychainItemRequiresUserPresence(item) {
|
|
62
68
|
return item.startsWith(SECRETS_ITEM_PREFIX) || item.startsWith(BUNDLES_ITEM_PREFIX);
|
|
63
69
|
}
|
|
64
|
-
// Resolve the bundled, signed-and-notarized Agents CLI.app shipped
|
|
65
|
-
// alongside the compiled JS. The .app embeds a provisioning profile that
|
|
66
|
-
// grants the application-identifier + keychain-access-groups entitlements
|
|
67
|
-
// macOS requires for kSecAttrSynchronizable writes. Bare CLI binaries
|
|
68
|
-
// (ad-hoc or Developer ID) cannot do this; only an .app with an embedded
|
|
69
|
-
// profile can. So compile-on-first-use is not possible — the binary must
|
|
70
|
-
// be prebuilt by `scripts/build-keychain-helper.sh` and shipped.
|
|
71
|
-
function ensureKeychainHelper() {
|
|
72
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
73
|
-
const binPath = path.join(here, 'Agents CLI.app', 'Contents', 'MacOS', 'Agents CLI');
|
|
74
|
-
if (!fs.existsSync(binPath)) {
|
|
75
|
-
throw new Error(`Keychain helper missing at ${binPath}. ` +
|
|
76
|
-
'This npm package was built without the signed helper bundle. Reinstall agents-cli.');
|
|
77
|
-
}
|
|
78
|
-
return binPath;
|
|
79
|
-
}
|
|
80
70
|
let backend = null;
|
|
81
71
|
/** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
|
|
82
72
|
export function setKeychainBackendForTest(b) {
|
|
@@ -84,104 +74,96 @@ export function setKeychainBackendForTest(b) {
|
|
|
84
74
|
backend = b;
|
|
85
75
|
return prev;
|
|
86
76
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Items whose name does NOT start with `agents-cli.` belong to another
|
|
79
|
+
* application (e.g. Anthropic's `Claude Code-credentials-*`). Their ACL
|
|
80
|
+
* trusts THEIR writer, not our signed helper, so routing them through our
|
|
81
|
+
* helper produces a legacy password sheet. `/usr/bin/security` reads them
|
|
82
|
+
* silently because it's in the default trusted-app list on most user-owned
|
|
83
|
+
* keychain items. And we MUST NOT JIT-migrate them — the owning app
|
|
84
|
+
* expects to re-write the item with its own ACL design.
|
|
85
|
+
*/
|
|
86
|
+
function isOurItem(item) {
|
|
87
|
+
return item.startsWith('agents-cli.');
|
|
88
|
+
}
|
|
89
|
+
/** Check if a keychain/keyring item exists. Never prompts for biometry. */
|
|
90
|
+
export function hasKeychainToken(item) {
|
|
95
91
|
if (backend)
|
|
96
|
-
return backend.has(item
|
|
92
|
+
return backend.has(item);
|
|
97
93
|
assertSupportedPlatform();
|
|
98
94
|
if (isLinux())
|
|
99
|
-
return linuxBackend.has(item
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
107
|
-
}).status === 0)
|
|
108
|
-
return true;
|
|
109
|
-
// Fallback: binary searches both synced and non-synced via kSecAttrSynchronizableAny
|
|
110
|
-
const bin = ensureKeychainHelper();
|
|
95
|
+
return linuxBackend.has(item);
|
|
96
|
+
if (!isOurItem(item)) {
|
|
97
|
+
return spawnSync('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
|
|
98
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
99
|
+
}).status === 0;
|
|
100
|
+
}
|
|
101
|
+
const bin = getKeychainHelperPath();
|
|
111
102
|
return spawnSync(bin, ['has', item, os.userInfo().username], {
|
|
112
103
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
113
104
|
}).status === 0;
|
|
114
105
|
}
|
|
115
|
-
/**
|
|
116
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Retrieve a secret value from the keychain/keyring. Throws if not found.
|
|
108
|
+
*
|
|
109
|
+
* On macOS this triggers Touch ID (or reuses an assertion held by an earlier
|
|
110
|
+
* call in the same process). For bundles, prefer getKeychainTokens() so a
|
|
111
|
+
* single biometric prompt covers every key in the batch.
|
|
112
|
+
*/
|
|
113
|
+
export function getKeychainToken(item) {
|
|
117
114
|
if (backend)
|
|
118
|
-
return backend.get(item
|
|
115
|
+
return backend.get(item);
|
|
119
116
|
assertSupportedPlatform();
|
|
120
117
|
if (isLinux())
|
|
121
|
-
return linuxBackend.get(item
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// supplies an LAContext for items protected by kSecAttrAccessControl.
|
|
125
|
-
//
|
|
126
|
-
// Bare `security` is only a fallback for when the helper bundle is absent
|
|
127
|
-
// (e.g. a dev build without the .app). It must NOT be tried first: macOS
|
|
128
|
-
// shows the "security wants to access … enter keychain password" sheet on any
|
|
129
|
-
// item whose ACL doesn't list `security`, which is every item we write. That
|
|
130
|
-
// security-first ordering is exactly what made bundle reads prompt on every
|
|
131
|
-
// `secrets exec`.
|
|
132
|
-
let bin;
|
|
133
|
-
try {
|
|
134
|
-
bin = ensureKeychainHelper();
|
|
135
|
-
}
|
|
136
|
-
catch {
|
|
137
|
-
// Helper bundle missing — degrade to security. Reads items security created
|
|
138
|
-
// without a prompt; restrictive items may still prompt (dev-build only).
|
|
139
|
-
const secResult = spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
|
|
118
|
+
return linuxBackend.get(item);
|
|
119
|
+
if (!isOurItem(item)) {
|
|
120
|
+
const sec = spawnSync('/usr/bin/security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
|
|
140
121
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
141
122
|
});
|
|
142
|
-
if (
|
|
143
|
-
const token =
|
|
123
|
+
if (sec.status === 0) {
|
|
124
|
+
const token = sec.stdout?.toString().trim();
|
|
144
125
|
if (token)
|
|
145
126
|
return token;
|
|
146
127
|
}
|
|
147
128
|
throw new Error(`Keychain item '${item}' not found.`);
|
|
148
129
|
}
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
? ['get-auth', item, os.userInfo().username, '--reason', 'read agents-cli secrets']
|
|
152
|
-
: ['get', item, os.userInfo().username];
|
|
153
|
-
const result = spawnSync(bin, args, {
|
|
130
|
+
const bin = getKeychainHelperPath();
|
|
131
|
+
const result = spawnSync(bin, ['get', item, os.userInfo().username], {
|
|
154
132
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
155
133
|
});
|
|
156
134
|
if (result.status === 1)
|
|
157
135
|
throw new Error(`Keychain item '${item}' not found.`);
|
|
136
|
+
if (result.status === 4)
|
|
137
|
+
throw new Error(`Touch ID cancelled while reading '${item}'.`);
|
|
158
138
|
if (result.status !== 0) {
|
|
159
139
|
const msg = result.stderr?.toString().trim();
|
|
160
140
|
throw new Error(msg || `Failed to read keychain item '${item}'.`);
|
|
161
141
|
}
|
|
162
|
-
const token = result.stdout?.toString()
|
|
142
|
+
const token = result.stdout?.toString();
|
|
163
143
|
if (!token)
|
|
164
144
|
throw new Error(`Keychain item '${item}' exists but is empty.`);
|
|
165
145
|
return token;
|
|
166
146
|
}
|
|
167
147
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
148
|
+
* Batch-read multiple keychain items behind a single Touch ID prompt. The
|
|
149
|
+
* macOS helper holds one LAContext for its whole process: the first protected
|
|
150
|
+
* item triggers Touch ID, every later item in the same invocation reuses the
|
|
151
|
+
* assertion. Missing items are absent from the returned map (caller decides
|
|
152
|
+
* whether that's an error).
|
|
171
153
|
*
|
|
172
|
-
* On
|
|
173
|
-
*
|
|
154
|
+
* On Linux or when a test backend is installed, falls back to individual
|
|
155
|
+
* lookups — no biometric prompt path on those platforms.
|
|
174
156
|
*/
|
|
175
|
-
export function
|
|
157
|
+
export function getKeychainTokens(items) {
|
|
176
158
|
const result = new Map();
|
|
159
|
+
if (items.length === 0)
|
|
160
|
+
return result;
|
|
177
161
|
if (backend) {
|
|
178
162
|
for (const item of items) {
|
|
179
163
|
try {
|
|
180
|
-
result.set(item, backend.get(item
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
// Missing or unreadable — skip; the caller reports which key is missing.
|
|
164
|
+
result.set(item, backend.get(item));
|
|
184
165
|
}
|
|
166
|
+
catch { /* missing — skip */ }
|
|
185
167
|
}
|
|
186
168
|
return result;
|
|
187
169
|
}
|
|
@@ -189,51 +171,58 @@ export function getKeychainTokensBatch(items, sync = false, reason = 'read agent
|
|
|
189
171
|
if (isLinux()) {
|
|
190
172
|
for (const item of items) {
|
|
191
173
|
try {
|
|
192
|
-
result.set(item, linuxBackend.get(item
|
|
193
|
-
}
|
|
194
|
-
catch {
|
|
195
|
-
// Missing or unreadable — skip; the caller reports which key is missing.
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return result;
|
|
199
|
-
}
|
|
200
|
-
let bin;
|
|
201
|
-
try {
|
|
202
|
-
bin = ensureKeychainHelper();
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
for (const item of items) {
|
|
206
|
-
try {
|
|
207
|
-
result.set(item, getKeychainToken(item, sync));
|
|
208
|
-
}
|
|
209
|
-
catch {
|
|
210
|
-
// Missing or unreadable — skip; the caller reports which key is missing.
|
|
174
|
+
result.set(item, linuxBackend.get(item));
|
|
211
175
|
}
|
|
176
|
+
catch { /* missing — skip */ }
|
|
212
177
|
}
|
|
213
178
|
return result;
|
|
214
179
|
}
|
|
215
|
-
const
|
|
180
|
+
const bin = getKeychainHelperPath();
|
|
181
|
+
const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...items], {
|
|
216
182
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
217
183
|
});
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
184
|
+
if (child.status === 4) {
|
|
185
|
+
throw new Error(`Touch ID cancelled while reading ${items.length} keychain item(s).`);
|
|
186
|
+
}
|
|
187
|
+
if (child.status !== 0) {
|
|
188
|
+
const msg = child.stderr?.toString().trim();
|
|
189
|
+
throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
|
|
190
|
+
}
|
|
191
|
+
const out = child.stdout?.toString() ?? '';
|
|
192
|
+
// Output is a sequence of records, one per service in input order:
|
|
193
|
+
// "V <service>\n<value>\n" (present)
|
|
194
|
+
// "M <service>\n" (missing)
|
|
195
|
+
// Service names are validated newline/'='-free by setKeychainToken below
|
|
196
|
+
// and values are rejected if they contain newlines — so splitting on '\n'
|
|
197
|
+
// and walking line-by-line is unambiguous.
|
|
198
|
+
const lines = out.split('\n');
|
|
199
|
+
let i = 0;
|
|
200
|
+
while (i < lines.length) {
|
|
201
|
+
const line = lines[i];
|
|
202
|
+
if (line === '' && i === lines.length - 1)
|
|
203
|
+
break;
|
|
204
|
+
if (line.startsWith('V ')) {
|
|
205
|
+
const service = line.slice(2);
|
|
206
|
+
const value = lines[i + 1] ?? '';
|
|
207
|
+
result.set(service, value);
|
|
208
|
+
i += 2;
|
|
209
|
+
}
|
|
210
|
+
else if (line.startsWith('M ')) {
|
|
211
|
+
i += 1;
|
|
212
|
+
}
|
|
213
|
+
else if (line === '') {
|
|
214
|
+
i += 1;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
throw new Error(`Malformed get-batch output line: ${JSON.stringify(line)}`);
|
|
218
|
+
}
|
|
230
219
|
}
|
|
231
220
|
return result;
|
|
232
221
|
}
|
|
233
|
-
/** Store or update a secret value in the keychain/keyring.
|
|
234
|
-
export function setKeychainToken(item, value
|
|
222
|
+
/** Store or update a secret value in the keychain/keyring. Device-local; biometry-gated on macOS. */
|
|
223
|
+
export function setKeychainToken(item, value) {
|
|
235
224
|
if (backend) {
|
|
236
|
-
backend.set(item, value
|
|
225
|
+
backend.set(item, value);
|
|
237
226
|
return;
|
|
238
227
|
}
|
|
239
228
|
assertSupportedPlatform();
|
|
@@ -244,18 +233,11 @@ export function setKeychainToken(item, value, sync = false) {
|
|
|
244
233
|
if (/[\x00=\r\n]/.test(item))
|
|
245
234
|
throw new Error('Secret item name contains invalid characters.');
|
|
246
235
|
if (isLinux()) {
|
|
247
|
-
linuxBackend.set(item, value
|
|
236
|
+
linuxBackend.set(item, value);
|
|
248
237
|
return;
|
|
249
238
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// helper takes an optional `nosync` arg for device-local writes; sync writes
|
|
253
|
-
// get kSecAttrSynchronizable=true by default.
|
|
254
|
-
const bin = ensureKeychainHelper();
|
|
255
|
-
const args = ['set', item, os.userInfo().username];
|
|
256
|
-
if (!sync)
|
|
257
|
-
args.push('nosync');
|
|
258
|
-
const result = spawnSync(bin, args, {
|
|
239
|
+
const bin = getKeychainHelperPath();
|
|
240
|
+
const result = spawnSync(bin, ['set', item, os.userInfo().username], {
|
|
259
241
|
input: value,
|
|
260
242
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
261
243
|
});
|
|
@@ -264,29 +246,14 @@ export function setKeychainToken(item, value, sync = false) {
|
|
|
264
246
|
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
265
247
|
}
|
|
266
248
|
}
|
|
267
|
-
/** Delete a keychain/keyring item. Returns true if it existed. */
|
|
268
|
-
export function deleteKeychainToken(item
|
|
249
|
+
/** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
|
|
250
|
+
export function deleteKeychainToken(item) {
|
|
269
251
|
if (backend)
|
|
270
|
-
return backend.delete(item
|
|
252
|
+
return backend.delete(item);
|
|
271
253
|
assertSupportedPlatform();
|
|
272
254
|
if (isLinux())
|
|
273
|
-
return linuxBackend.delete(item
|
|
274
|
-
|
|
275
|
-
// prompts for keychain-password authorization on any item whose ACL doesn't list
|
|
276
|
-
// `security` — which is every item the helper writes. Same reasoning as
|
|
277
|
-
// getKeychainToken's helper-first ordering above. The helper also handles the
|
|
278
|
-
// synced keychain via kSecAttrSynchronizableAny in one call.
|
|
279
|
-
let bin;
|
|
280
|
-
try {
|
|
281
|
-
bin = ensureKeychainHelper();
|
|
282
|
-
}
|
|
283
|
-
catch {
|
|
284
|
-
// Helper bundle missing (dev build). Fall back to security; it can only
|
|
285
|
-
// touch non-synced items and may prompt for items it didn't write.
|
|
286
|
-
return spawnSync('security', ['delete-generic-password', '-a', os.userInfo().username, '-s', item], {
|
|
287
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
288
|
-
}).status === 0;
|
|
289
|
-
}
|
|
255
|
+
return linuxBackend.delete(item);
|
|
256
|
+
const bin = getKeychainHelperPath();
|
|
290
257
|
return spawnSync(bin, ['delete', item, os.userInfo().username], {
|
|
291
258
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
292
259
|
}).status === 0;
|
|
@@ -298,8 +265,7 @@ export function listKeychainItems(prefix) {
|
|
|
298
265
|
assertSupportedPlatform();
|
|
299
266
|
if (isLinux())
|
|
300
267
|
return linuxBackend.list(prefix);
|
|
301
|
-
|
|
302
|
-
const bin = ensureKeychainHelper();
|
|
268
|
+
const bin = getKeychainHelperPath();
|
|
303
269
|
const result = spawnSync(bin, ['list', prefix], {
|
|
304
270
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
305
271
|
});
|
|
@@ -310,6 +276,31 @@ export function listKeychainItems(prefix) {
|
|
|
310
276
|
const out = result.stdout?.toString() || '';
|
|
311
277
|
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
312
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* One-time upgrade for a keychain item that was written by a previous helper
|
|
281
|
+
* generation with a trusted-app ACL. The helper reads the legacy item
|
|
282
|
+
* (which may pop the password sheet once), then deletes and re-adds it with
|
|
283
|
+
* the biometry access control. Returns true if the item was rewritten, false
|
|
284
|
+
* if no item by that name exists. macOS only — Linux backends have no ACL
|
|
285
|
+
* concept, so the call is a no-op there.
|
|
286
|
+
*/
|
|
287
|
+
export function migrateKeychainItem(item) {
|
|
288
|
+
if (backend)
|
|
289
|
+
return backend.has(item);
|
|
290
|
+
assertSupportedPlatform();
|
|
291
|
+
if (isLinux())
|
|
292
|
+
return linuxBackend.has(item);
|
|
293
|
+
const bin = getKeychainHelperPath();
|
|
294
|
+
const result = spawnSync(bin, ['migrate-acl', item, os.userInfo().username], {
|
|
295
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
296
|
+
});
|
|
297
|
+
if (result.status === 0)
|
|
298
|
+
return true;
|
|
299
|
+
if (result.status === 1)
|
|
300
|
+
return false;
|
|
301
|
+
const msg = result.stderr?.toString().trim();
|
|
302
|
+
throw new Error(msg || `Failed to migrate keychain item '${item}'.`);
|
|
303
|
+
}
|
|
313
304
|
function expandHome(p) {
|
|
314
305
|
if (p.startsWith('~/') || p === '~') {
|
|
315
306
|
return path.join(os.homedir(), p.slice(1));
|
|
@@ -321,7 +312,7 @@ export function resolveRef(ref, opts = {}) {
|
|
|
321
312
|
switch (ref.provider) {
|
|
322
313
|
case 'keychain': {
|
|
323
314
|
const item = opts.keychainItemFor ? opts.keychainItemFor(ref.value) : ref.value;
|
|
324
|
-
return getKeychainToken(item
|
|
315
|
+
return getKeychainToken(item);
|
|
325
316
|
}
|
|
326
317
|
case 'env': {
|
|
327
318
|
const name = ref.value;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable install location for the signed macOS Keychain helper.
|
|
3
|
+
*
|
|
4
|
+
* Why a stable path: every npm publish re-signs `Agents CLI.app` with a fresh
|
|
5
|
+
* timestamp, producing a new code signature. macOS Keychain trusted-app ACLs
|
|
6
|
+
* are pinned to the exact signature, so the ACL invalidates on every release
|
|
7
|
+
* when the helper lives inside the npm package directory. Copying the .app
|
|
8
|
+
* once to `~/Library/Application Support/agents-cli/` gives it a path that
|
|
9
|
+
* survives `npm i -g`, `scripts/install.sh`, version bumps, etc.
|
|
10
|
+
*
|
|
11
|
+
* This module is the SINGLE SOURCE OF TRUTH for the helper path. Other
|
|
12
|
+
* modules in `src/lib/secrets/` must import `getKeychainHelperPath()` rather
|
|
13
|
+
* than recomputing it.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Idempotent install. Copies the bundled `.app` to the stable user path. Skips
|
|
17
|
+
* if the destination already exists and `codesign --verify` passes, unless
|
|
18
|
+
* `forceReinstall=true`.
|
|
19
|
+
*
|
|
20
|
+
* Notarization is checked via `spctl --assess` after install — a failure is
|
|
21
|
+
* logged as a warning but does NOT throw. Notarization checks require network
|
|
22
|
+
* access (Gatekeeper ticket lookup) and are not load-bearing for the helper's
|
|
23
|
+
* keychain ACL semantics.
|
|
24
|
+
*/
|
|
25
|
+
export declare function ensureKeychainHelperInstalled(opts?: {
|
|
26
|
+
forceReinstall?: boolean;
|
|
27
|
+
}): void;
|
|
28
|
+
/**
|
|
29
|
+
* Return the absolute path to the helper executable. If the installed bundle
|
|
30
|
+
* is missing, performs a lazy install first.
|
|
31
|
+
*
|
|
32
|
+
* Throws on non-darwin.
|
|
33
|
+
*/
|
|
34
|
+
export declare function getKeychainHelperPath(): string;
|
|
35
|
+
/** Diagnostic snapshot used by `agents helper status`. */
|
|
36
|
+
export interface KeychainHelperStatus {
|
|
37
|
+
source: string | null;
|
|
38
|
+
destination: string;
|
|
39
|
+
installed: boolean;
|
|
40
|
+
codesignOk: boolean;
|
|
41
|
+
codesignOutput: string;
|
|
42
|
+
spctlOk: boolean;
|
|
43
|
+
spctlOutput: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function getKeychainHelperStatus(): KeychainHelperStatus;
|