@phnx-labs/agents-cli 1.19.2 → 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 +140 -0
- package/README.md +72 -12
- package/dist/browser.js +0 -0
- package/dist/commands/browser.js +88 -16
- package/dist/commands/cli.d.ts +14 -0
- package/dist/commands/cli.js +244 -0
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/commands.js +27 -10
- package/dist/commands/computer.js +18 -1
- package/dist/commands/doctor.d.ts +1 -1
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/exec.js +38 -18
- package/dist/commands/factory.d.ts +3 -14
- package/dist/commands/factory.js +3 -3
- 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 +89 -10
- package/dist/commands/mcp.js +166 -10
- package/dist/commands/packages.js +196 -27
- package/dist/commands/permissions.js +21 -6
- package/dist/commands/plugins.js +11 -4
- package/dist/commands/profiles.d.ts +8 -0
- package/dist/commands/profiles.js +118 -5
- package/dist/commands/prune.js +39 -160
- package/dist/commands/pull.js +58 -5
- package/dist/commands/routines.js +107 -14
- 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 +79 -46
- package/dist/commands/sessions.d.ts +28 -0
- package/dist/commands/sessions.js +98 -33
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +37 -28
- package/dist/commands/skills.js +25 -8
- package/dist/commands/subagents.js +69 -49
- package/dist/commands/teams.js +61 -10
- package/dist/commands/utils.d.ts +33 -0
- package/dist/commands/utils.js +139 -0
- package/dist/commands/versions.d.ts +4 -3
- package/dist/commands/versions.js +134 -130
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +175 -19
- package/dist/commands/workflows.js +29 -6
- package/dist/computer.js +0 -0
- package/dist/index.js +38 -6
- package/dist/lib/acp/client.js +6 -1
- package/dist/lib/acp/harnesses.js +8 -0
- package/dist/lib/agents.d.ts +4 -0
- package/dist/lib/agents.js +125 -34
- package/dist/lib/auto-pull-worker.js +18 -1
- package/dist/lib/browser/cdp.d.ts +8 -1
- package/dist/lib/browser/cdp.js +40 -3
- package/dist/lib/browser/chrome.d.ts +13 -0
- package/dist/lib/browser/chrome.js +46 -3
- package/dist/lib/browser/domain-skills.d.ts +51 -0
- package/dist/lib/browser/domain-skills.js +157 -0
- package/dist/lib/browser/drivers/local.js +45 -4
- package/dist/lib/browser/drivers/ssh.js +2 -2
- package/dist/lib/browser/ipc.d.ts +8 -1
- package/dist/lib/browser/ipc.js +37 -28
- package/dist/lib/browser/profiles.d.ts +16 -3
- package/dist/lib/browser/profiles.js +44 -4
- package/dist/lib/browser/service.d.ts +3 -0
- package/dist/lib/browser/service.js +40 -5
- package/dist/lib/browser/types.d.ts +11 -4
- package/dist/lib/cli-resources.d.ts +137 -0
- package/dist/lib/cli-resources.js +477 -0
- package/dist/lib/cloud/factory.d.ts +1 -1
- package/dist/lib/cloud/factory.js +1 -1
- package/dist/lib/cloud/rush.js +5 -5
- package/dist/lib/command-skills.js +0 -2
- package/dist/lib/computer-rpc.d.ts +3 -0
- package/dist/lib/computer-rpc.js +53 -0
- package/dist/lib/daemon.js +20 -0
- package/dist/lib/events.d.ts +16 -2
- package/dist/lib/events.js +33 -2
- package/dist/lib/exec.d.ts +42 -13
- package/dist/lib/exec.js +127 -33
- 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 +246 -11
- package/dist/lib/mcp.d.ts +15 -0
- package/dist/lib/mcp.js +46 -0
- package/dist/lib/migrate.js +1 -1
- package/dist/lib/overdue.d.ts +26 -0
- package/dist/lib/overdue.js +101 -0
- package/dist/lib/permissions.d.ts +13 -0
- package/dist/lib/permissions.js +55 -1
- package/dist/lib/plugin-marketplace.js +1 -1
- package/dist/lib/plugins.js +15 -1
- package/dist/lib/profiles-presets.d.ts +26 -0
- package/dist/lib/profiles-presets.js +216 -0
- package/dist/lib/profiles.d.ts +34 -0
- package/dist/lib/profiles.js +112 -1
- package/dist/lib/resources/mcp.js +37 -0
- package/dist/lib/resources.d.ts +1 -1
- package/dist/lib/rotate.js +10 -4
- package/dist/lib/routines-format.d.ts +47 -0
- package/dist/lib/routines-format.js +194 -0
- package/dist/lib/routines.d.ts +8 -2
- package/dist/lib/routines.js +34 -14
- package/dist/lib/runner.js +83 -15
- package/dist/lib/scheduler.js +8 -1
- 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 +34 -17
- package/dist/lib/secrets/bundles.js +210 -36
- package/dist/lib/secrets/index.d.ts +49 -30
- package/dist/lib/secrets/index.js +126 -115
- 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/active.d.ts +8 -0
- package/dist/lib/session/active.js +3 -2
- package/dist/lib/session/db.d.ts +0 -4
- package/dist/lib/session/db.js +0 -26
- package/dist/lib/session/parse.d.ts +1 -0
- package/dist/lib/session/parse.js +44 -0
- package/dist/lib/session/render.js +4 -4
- package/dist/lib/session/types.d.ts +2 -2
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +5 -2
- package/dist/lib/shims.js +70 -38
- package/dist/lib/state.d.ts +14 -2
- package/dist/lib/state.js +51 -20
- package/dist/lib/teams/agents.d.ts +5 -4
- package/dist/lib/teams/agents.js +48 -22
- package/dist/lib/teams/api.d.ts +2 -1
- package/dist/lib/teams/api.js +4 -3
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/parsers.js +153 -3
- package/dist/lib/teams/summarizer.js +18 -2
- package/dist/lib/teams/worktree.js +14 -3
- package/dist/lib/types.d.ts +63 -4
- package/dist/lib/types.js +8 -3
- package/dist/lib/usage.d.ts +27 -2
- package/dist/lib/usage.js +100 -17
- package/dist/lib/versions.d.ts +45 -3
- package/dist/lib/versions.js +455 -60
- package/package.json +15 -14
- 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
- package/npm-shrinkwrap.json +0 -3162
|
@@ -1,22 +1,33 @@
|
|
|
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';
|
|
29
|
+
const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
|
|
30
|
+
const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
|
|
20
31
|
const REF_PATTERN = /^(keychain|env|file|exec):(.+)$/s;
|
|
21
32
|
/** Parse a bundle value into either a literal string or a typed secret ref. */
|
|
22
33
|
export function parseBundleValue(raw) {
|
|
@@ -37,39 +48,24 @@ export function serializeRef(ref) {
|
|
|
37
48
|
}
|
|
38
49
|
function assertSupportedPlatform() {
|
|
39
50
|
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
|
40
|
-
throw new Error('
|
|
41
|
-
'
|
|
51
|
+
throw new Error('agents secrets requires macOS Keychain or Linux libsecret.\n' +
|
|
52
|
+
'Windows is not supported — use environment variables or a .env file instead.\n' +
|
|
53
|
+
'WSL2 is supported (libsecret via gnome-keyring).');
|
|
42
54
|
}
|
|
43
55
|
}
|
|
44
56
|
function isLinux() {
|
|
45
57
|
return process.platform === 'linux';
|
|
46
58
|
}
|
|
47
|
-
function isMacOS() {
|
|
48
|
-
return process.platform === 'darwin';
|
|
49
|
-
}
|
|
50
59
|
/** Build the keychain item name for a profile provider token. */
|
|
51
60
|
export function profileKeychainItem(provider) {
|
|
52
61
|
return `${SERVICE_PREFIX}.${provider}.token`;
|
|
53
62
|
}
|
|
54
63
|
/** Build the keychain item name for a secrets-bundle key. */
|
|
55
64
|
export function secretsKeychainItem(bundle, key) {
|
|
56
|
-
return `${
|
|
65
|
+
return `${SECRETS_ITEM_PREFIX}${bundle}.${key}`;
|
|
57
66
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// grants the application-identifier + keychain-access-groups entitlements
|
|
61
|
-
// macOS requires for kSecAttrSynchronizable writes. Bare CLI binaries
|
|
62
|
-
// (ad-hoc or Developer ID) cannot do this; only an .app with an embedded
|
|
63
|
-
// profile can. So compile-on-first-use is not possible — the binary must
|
|
64
|
-
// be prebuilt by `scripts/build-keychain-helper.sh` and shipped.
|
|
65
|
-
function ensureKeychainHelper() {
|
|
66
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
67
|
-
const binPath = path.join(here, 'Agents CLI.app', 'Contents', 'MacOS', 'Agents CLI');
|
|
68
|
-
if (!fs.existsSync(binPath)) {
|
|
69
|
-
throw new Error(`Keychain helper missing at ${binPath}. ` +
|
|
70
|
-
'This npm package was built without the signed helper bundle. Reinstall agents-cli.');
|
|
71
|
-
}
|
|
72
|
-
return binPath;
|
|
67
|
+
function keychainItemRequiresUserPresence(item) {
|
|
68
|
+
return item.startsWith(SECRETS_ITEM_PREFIX) || item.startsWith(BUNDLES_ITEM_PREFIX);
|
|
73
69
|
}
|
|
74
70
|
let backend = null;
|
|
75
71
|
/** Install a custom keychain backend (test only). Returns the previous backend so callers can restore. */
|
|
@@ -78,85 +74,94 @@ export function setKeychainBackendForTest(b) {
|
|
|
78
74
|
backend = b;
|
|
79
75
|
return prev;
|
|
80
76
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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) {
|
|
89
91
|
if (backend)
|
|
90
|
-
return backend.has(item
|
|
92
|
+
return backend.has(item);
|
|
91
93
|
assertSupportedPlatform();
|
|
92
94
|
if (isLinux())
|
|
93
|
-
return linuxBackend.has(item
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
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();
|
|
101
102
|
return spawnSync(bin, ['has', item, os.userInfo().username], {
|
|
102
103
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
103
104
|
}).status === 0;
|
|
104
105
|
}
|
|
105
|
-
/**
|
|
106
|
-
|
|
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) {
|
|
107
114
|
if (backend)
|
|
108
|
-
return backend.get(item
|
|
115
|
+
return backend.get(item);
|
|
109
116
|
assertSupportedPlatform();
|
|
110
117
|
if (isLinux())
|
|
111
|
-
return linuxBackend.get(item
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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'], {
|
|
121
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
122
|
+
});
|
|
123
|
+
if (sec.status === 0) {
|
|
124
|
+
const token = sec.stdout?.toString().trim();
|
|
125
|
+
if (token)
|
|
126
|
+
return token;
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`Keychain item '${item}' not found.`);
|
|
120
129
|
}
|
|
121
|
-
|
|
122
|
-
// `get` is the unauthenticated path — no LocalAuthentication prompt. Used by
|
|
123
|
-
// profiles.ts (OAuth refresh) where biometric on every API call is too noisy.
|
|
124
|
-
const bin = ensureKeychainHelper();
|
|
130
|
+
const bin = getKeychainHelperPath();
|
|
125
131
|
const result = spawnSync(bin, ['get', item, os.userInfo().username], {
|
|
126
132
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
127
133
|
});
|
|
128
134
|
if (result.status === 1)
|
|
129
135
|
throw new Error(`Keychain item '${item}' not found.`);
|
|
136
|
+
if (result.status === 4)
|
|
137
|
+
throw new Error(`Touch ID cancelled while reading '${item}'.`);
|
|
130
138
|
if (result.status !== 0) {
|
|
131
139
|
const msg = result.stderr?.toString().trim();
|
|
132
140
|
throw new Error(msg || `Failed to read keychain item '${item}'.`);
|
|
133
141
|
}
|
|
134
|
-
const token = result.stdout?.toString()
|
|
142
|
+
const token = result.stdout?.toString();
|
|
135
143
|
if (!token)
|
|
136
144
|
throw new Error(`Keychain item '${item}' exists but is empty.`);
|
|
137
145
|
return token;
|
|
138
146
|
}
|
|
139
147
|
/**
|
|
140
|
-
* Batch-read multiple keychain items behind a single
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* items are absent from the map (caller decides
|
|
144
|
-
*
|
|
145
|
-
* `reason` is shown to the user under the Touch ID prompt — e.g.
|
|
146
|
-
* "read 'hetzner.com' secrets for agent 'claude'". Apple's HIG recommends
|
|
147
|
-
* a lowercase verb phrase that completes the sentence "X is required to ___".
|
|
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).
|
|
148
153
|
*
|
|
149
154
|
* On Linux or when a test backend is installed, falls back to individual
|
|
150
|
-
*
|
|
155
|
+
* lookups — no biometric prompt path on those platforms.
|
|
151
156
|
*/
|
|
152
|
-
export function
|
|
157
|
+
export function getKeychainTokens(items) {
|
|
153
158
|
const result = new Map();
|
|
154
159
|
if (items.length === 0)
|
|
155
160
|
return result;
|
|
156
161
|
if (backend) {
|
|
157
162
|
for (const item of items) {
|
|
158
163
|
try {
|
|
159
|
-
result.set(item, backend.get(item
|
|
164
|
+
result.set(item, backend.get(item));
|
|
160
165
|
}
|
|
161
166
|
catch { /* missing — skip */ }
|
|
162
167
|
}
|
|
@@ -166,34 +171,31 @@ export function getKeychainTokensBatch(items, _sync = false, reason) {
|
|
|
166
171
|
if (isLinux()) {
|
|
167
172
|
for (const item of items) {
|
|
168
173
|
try {
|
|
169
|
-
result.set(item, linuxBackend.get(item
|
|
174
|
+
result.set(item, linuxBackend.get(item));
|
|
170
175
|
}
|
|
171
176
|
catch { /* missing — skip */ }
|
|
172
177
|
}
|
|
173
178
|
return result;
|
|
174
179
|
}
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
const helperArgs = ['get-batch', os.userInfo().username];
|
|
178
|
-
if (reason)
|
|
179
|
-
helperArgs.push('--reason', reason);
|
|
180
|
-
helperArgs.push(...items);
|
|
181
|
-
const child = spawnSync(bin, helperArgs, {
|
|
180
|
+
const bin = getKeychainHelperPath();
|
|
181
|
+
const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...items], {
|
|
182
182
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
183
183
|
});
|
|
184
|
+
if (child.status === 4) {
|
|
185
|
+
throw new Error(`Touch ID cancelled while reading ${items.length} keychain item(s).`);
|
|
186
|
+
}
|
|
184
187
|
if (child.status !== 0) {
|
|
185
188
|
const msg = child.stderr?.toString().trim();
|
|
186
189
|
throw new Error(msg || `Failed to batch-read ${items.length} keychain items.`);
|
|
187
190
|
}
|
|
188
191
|
const out = child.stdout?.toString() ?? '';
|
|
189
|
-
//
|
|
192
|
+
// Output is a sequence of records, one per service in input order:
|
|
190
193
|
// "V <service>\n<value>\n" (present)
|
|
191
194
|
// "M <service>\n" (missing)
|
|
192
|
-
// Service names
|
|
193
|
-
//
|
|
195
|
+
// Service names are validated newline/'='-free by setKeychainToken below
|
|
196
|
+
// and values are rejected if they contain newlines — so splitting on '\n'
|
|
194
197
|
// and walking line-by-line is unambiguous.
|
|
195
198
|
const lines = out.split('\n');
|
|
196
|
-
// Last entry from split is the empty string after a trailing newline.
|
|
197
199
|
let i = 0;
|
|
198
200
|
while (i < lines.length) {
|
|
199
201
|
const line = lines[i];
|
|
@@ -217,10 +219,10 @@ export function getKeychainTokensBatch(items, _sync = false, reason) {
|
|
|
217
219
|
}
|
|
218
220
|
return result;
|
|
219
221
|
}
|
|
220
|
-
/** Store or update a secret value in the keychain/keyring.
|
|
221
|
-
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) {
|
|
222
224
|
if (backend) {
|
|
223
|
-
backend.set(item, value
|
|
225
|
+
backend.set(item, value);
|
|
224
226
|
return;
|
|
225
227
|
}
|
|
226
228
|
assertSupportedPlatform();
|
|
@@ -231,20 +233,11 @@ export function setKeychainToken(item, value, sync = false) {
|
|
|
231
233
|
if (/[\x00=\r\n]/.test(item))
|
|
232
234
|
throw new Error('Secret item name contains invalid characters.');
|
|
233
235
|
if (isLinux()) {
|
|
234
|
-
linuxBackend.set(item, value
|
|
236
|
+
linuxBackend.set(item, value);
|
|
235
237
|
return;
|
|
236
238
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
// is what stops macOS from showing the legacy "enter password" sheet on
|
|
240
|
-
// subsequent reads. The helper takes an optional `nosync` arg for
|
|
241
|
-
// device-local writes; sync writes get kSecAttrSynchronizable=true by
|
|
242
|
-
// default.
|
|
243
|
-
const bin = ensureKeychainHelper();
|
|
244
|
-
const args = ['set', item, os.userInfo().username];
|
|
245
|
-
if (!sync)
|
|
246
|
-
args.push('nosync');
|
|
247
|
-
const result = spawnSync(bin, args, {
|
|
239
|
+
const bin = getKeychainHelperPath();
|
|
240
|
+
const result = spawnSync(bin, ['set', item, os.userInfo().username], {
|
|
248
241
|
input: value,
|
|
249
242
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
250
243
|
});
|
|
@@ -253,20 +246,14 @@ export function setKeychainToken(item, value, sync = false) {
|
|
|
253
246
|
throw new Error(msg || `Failed to write keychain item '${item}'.`);
|
|
254
247
|
}
|
|
255
248
|
}
|
|
256
|
-
/** Delete a keychain/keyring item. Returns true if it existed. */
|
|
257
|
-
export function deleteKeychainToken(item
|
|
249
|
+
/** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
|
|
250
|
+
export function deleteKeychainToken(item) {
|
|
258
251
|
if (backend)
|
|
259
|
-
return backend.delete(item
|
|
252
|
+
return backend.delete(item);
|
|
260
253
|
assertSupportedPlatform();
|
|
261
254
|
if (isLinux())
|
|
262
|
-
return linuxBackend.delete(item
|
|
263
|
-
|
|
264
|
-
if (!sync && spawnSync('security', ['delete-generic-password', '-a', os.userInfo().username, '-s', item], {
|
|
265
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
266
|
-
}).status === 0)
|
|
267
|
-
return true;
|
|
268
|
-
// Fallback: binary deletes synced items via kSecAttrSynchronizableAny
|
|
269
|
-
const bin = ensureKeychainHelper();
|
|
255
|
+
return linuxBackend.delete(item);
|
|
256
|
+
const bin = getKeychainHelperPath();
|
|
270
257
|
return spawnSync(bin, ['delete', item, os.userInfo().username], {
|
|
271
258
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
272
259
|
}).status === 0;
|
|
@@ -278,8 +265,7 @@ export function listKeychainItems(prefix) {
|
|
|
278
265
|
assertSupportedPlatform();
|
|
279
266
|
if (isLinux())
|
|
280
267
|
return linuxBackend.list(prefix);
|
|
281
|
-
|
|
282
|
-
const bin = ensureKeychainHelper();
|
|
268
|
+
const bin = getKeychainHelperPath();
|
|
283
269
|
const result = spawnSync(bin, ['list', prefix], {
|
|
284
270
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
285
271
|
});
|
|
@@ -290,6 +276,31 @@ export function listKeychainItems(prefix) {
|
|
|
290
276
|
const out = result.stdout?.toString() || '';
|
|
291
277
|
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
292
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
|
+
}
|
|
293
304
|
function expandHome(p) {
|
|
294
305
|
if (p.startsWith('~/') || p === '~') {
|
|
295
306
|
return path.join(os.homedir(), p.slice(1));
|
|
@@ -301,7 +312,7 @@ export function resolveRef(ref, opts = {}) {
|
|
|
301
312
|
switch (ref.provider) {
|
|
302
313
|
case 'keychain': {
|
|
303
314
|
const item = opts.keychainItemFor ? opts.keychainItemFor(ref.value) : ref.value;
|
|
304
|
-
return getKeychainToken(item
|
|
315
|
+
return getKeychainToken(item);
|
|
305
316
|
}
|
|
306
317
|
case 'env': {
|
|
307
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;
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import { spawnSync } from 'child_process';
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as os from 'os';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
const APP_BUNDLE_NAME = 'Agents CLI.app';
|
|
21
|
+
const INSTALL_DIR_NAME = 'agents-cli';
|
|
22
|
+
/** Absolute path to the installed `.app` bundle directory (not the executable). */
|
|
23
|
+
function installedAppPath() {
|
|
24
|
+
return path.join(os.homedir(), 'Library', 'Application Support', INSTALL_DIR_NAME, APP_BUNDLE_NAME);
|
|
25
|
+
}
|
|
26
|
+
/** Absolute path to the executable inside the installed `.app` bundle. */
|
|
27
|
+
function installedExecutablePath() {
|
|
28
|
+
return path.join(installedAppPath(), 'Contents', 'MacOS', 'Agents CLI');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Locate the source `.app` bundle shipped alongside the compiled JS.
|
|
32
|
+
*
|
|
33
|
+
* Resolution order:
|
|
34
|
+
* 1. dist/lib/secrets/Agents CLI.app — sibling of this compiled file (npm install layout)
|
|
35
|
+
* 2. <repo>/bin/Agents CLI.app — raw working tree (`bun run dev`, tsx from src/)
|
|
36
|
+
*
|
|
37
|
+
* Throws if neither exists.
|
|
38
|
+
*/
|
|
39
|
+
function sourceAppPath() {
|
|
40
|
+
const candidates = [];
|
|
41
|
+
try {
|
|
42
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
candidates.push(path.join(here, APP_BUNDLE_NAME));
|
|
44
|
+
// tsx/src case: src/lib/secrets/install-helper.ts -> ../../../bin/Agents CLI.app
|
|
45
|
+
candidates.push(path.resolve(here, '..', '..', '..', 'bin', APP_BUNDLE_NAME));
|
|
46
|
+
}
|
|
47
|
+
catch { /* import.meta.url unavailable */ }
|
|
48
|
+
for (const c of candidates) {
|
|
49
|
+
if (fs.existsSync(c))
|
|
50
|
+
return c;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Source ${APP_BUNDLE_NAME} not found. Looked in:\n ${candidates.join('\n ')}\n` +
|
|
53
|
+
'The npm package may have been built without the signed helper bundle. Reinstall agents-cli.');
|
|
54
|
+
}
|
|
55
|
+
function assertDarwin() {
|
|
56
|
+
if (process.platform !== 'darwin') {
|
|
57
|
+
throw new Error('Keychain helper is macOS only.');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function codesignVerify(appPath) {
|
|
61
|
+
const r = spawnSync('codesign', ['--verify', '--deep', '--strict', appPath], {
|
|
62
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
63
|
+
encoding: 'utf-8',
|
|
64
|
+
});
|
|
65
|
+
return { ok: r.status === 0, output: (r.stderr || r.stdout || '').toString().trim() };
|
|
66
|
+
}
|
|
67
|
+
function spctlAssess(appPath) {
|
|
68
|
+
const r = spawnSync('spctl', ['--assess', '--type', 'execute', '--verbose=2', appPath], {
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
});
|
|
72
|
+
return { ok: r.status === 0, output: (r.stderr || r.stdout || '').toString().trim() };
|
|
73
|
+
}
|
|
74
|
+
function copyAppBundle(src, dest) {
|
|
75
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
76
|
+
if (fs.existsSync(dest))
|
|
77
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
78
|
+
// `cp -R` preserves the bundle's signature, symlinks, and resource forks.
|
|
79
|
+
// `fs.cpSync({recursive: true})` works on simple trees but has historically
|
|
80
|
+
// mishandled extended attributes on `.app` bundles, breaking codesign.
|
|
81
|
+
const r = spawnSync('cp', ['-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
|
|
82
|
+
if (r.status !== 0) {
|
|
83
|
+
const msg = (r.stderr || r.stdout || '').toString().trim();
|
|
84
|
+
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Idempotent install. Copies the bundled `.app` to the stable user path. Skips
|
|
89
|
+
* if the destination already exists and `codesign --verify` passes, unless
|
|
90
|
+
* `forceReinstall=true`.
|
|
91
|
+
*
|
|
92
|
+
* Notarization is checked via `spctl --assess` after install — a failure is
|
|
93
|
+
* logged as a warning but does NOT throw. Notarization checks require network
|
|
94
|
+
* access (Gatekeeper ticket lookup) and are not load-bearing for the helper's
|
|
95
|
+
* keychain ACL semantics.
|
|
96
|
+
*/
|
|
97
|
+
export function ensureKeychainHelperInstalled(opts = {}) {
|
|
98
|
+
assertDarwin();
|
|
99
|
+
const dest = installedAppPath();
|
|
100
|
+
if (!opts.forceReinstall && fs.existsSync(dest)) {
|
|
101
|
+
const { ok } = codesignVerify(dest);
|
|
102
|
+
if (ok)
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const src = sourceAppPath();
|
|
106
|
+
copyAppBundle(src, dest);
|
|
107
|
+
const verify = codesignVerify(dest);
|
|
108
|
+
if (!verify.ok) {
|
|
109
|
+
throw new Error(`Installed helper failed codesign verification at ${dest}.\n${verify.output}\n` +
|
|
110
|
+
'The bundle may be corrupted. Try `agents helper install` to reinstall, or reinstall agents-cli.');
|
|
111
|
+
}
|
|
112
|
+
const assess = spctlAssess(dest);
|
|
113
|
+
if (!assess.ok) {
|
|
114
|
+
// Warn, do not fail. Gatekeeper ticket lookup needs network; offline
|
|
115
|
+
// installs and CI runners commonly fail this check. The ACL semantics
|
|
116
|
+
// we care about depend on codesign, not spctl.
|
|
117
|
+
process.stderr.write(`agents-cli: notarization check (spctl) did not pass for ${dest}: ${assess.output}\n`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Return the absolute path to the helper executable. If the installed bundle
|
|
122
|
+
* is missing, performs a lazy install first.
|
|
123
|
+
*
|
|
124
|
+
* Throws on non-darwin.
|
|
125
|
+
*/
|
|
126
|
+
export function getKeychainHelperPath() {
|
|
127
|
+
assertDarwin();
|
|
128
|
+
const exec = installedExecutablePath();
|
|
129
|
+
if (!fs.existsSync(exec)) {
|
|
130
|
+
ensureKeychainHelperInstalled();
|
|
131
|
+
}
|
|
132
|
+
return exec;
|
|
133
|
+
}
|
|
134
|
+
export function getKeychainHelperStatus() {
|
|
135
|
+
assertDarwin();
|
|
136
|
+
const destApp = installedAppPath();
|
|
137
|
+
let src = null;
|
|
138
|
+
try {
|
|
139
|
+
src = sourceAppPath();
|
|
140
|
+
}
|
|
141
|
+
catch { /* missing source is reported as null */ }
|
|
142
|
+
const installed = fs.existsSync(destApp);
|
|
143
|
+
if (!installed) {
|
|
144
|
+
return {
|
|
145
|
+
source: src,
|
|
146
|
+
destination: destApp,
|
|
147
|
+
installed: false,
|
|
148
|
+
codesignOk: false,
|
|
149
|
+
codesignOutput: 'not installed',
|
|
150
|
+
spctlOk: false,
|
|
151
|
+
spctlOutput: 'not installed',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const cs = codesignVerify(destApp);
|
|
155
|
+
const sp = spctlAssess(destApp);
|
|
156
|
+
return {
|
|
157
|
+
source: src,
|
|
158
|
+
destination: destApp,
|
|
159
|
+
installed: true,
|
|
160
|
+
codesignOk: cs.ok,
|
|
161
|
+
codesignOutput: cs.output || 'ok',
|
|
162
|
+
spctlOk: sp.ok,
|
|
163
|
+
spctlOutput: sp.output || 'ok',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -143,16 +143,16 @@ export function listSecretToolItems(prefix) {
|
|
|
143
143
|
}
|
|
144
144
|
/** KeychainBackend implementation for Linux using secret-tool */
|
|
145
145
|
export const linuxBackend = {
|
|
146
|
-
has(item
|
|
146
|
+
has(item) {
|
|
147
147
|
return hasSecretToolToken(item);
|
|
148
148
|
},
|
|
149
|
-
get(item
|
|
149
|
+
get(item) {
|
|
150
150
|
return getSecretToolToken(item);
|
|
151
151
|
},
|
|
152
|
-
set(item, value
|
|
152
|
+
set(item, value) {
|
|
153
153
|
setSecretToolToken(item, value);
|
|
154
154
|
},
|
|
155
|
-
delete(item
|
|
155
|
+
delete(item) {
|
|
156
156
|
return deleteSecretToolToken(item);
|
|
157
157
|
},
|
|
158
158
|
list(prefix) {
|