@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.
Files changed (156) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -12
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/browser.js +88 -16
  5. package/dist/commands/cli.d.ts +14 -0
  6. package/dist/commands/cli.js +244 -0
  7. package/dist/commands/cloud.js +1 -1
  8. package/dist/commands/commands.js +27 -10
  9. package/dist/commands/computer.js +18 -1
  10. package/dist/commands/doctor.d.ts +1 -1
  11. package/dist/commands/doctor.js +2 -2
  12. package/dist/commands/exec.js +38 -18
  13. package/dist/commands/factory.d.ts +3 -14
  14. package/dist/commands/factory.js +3 -3
  15. package/dist/commands/feedback.d.ts +7 -0
  16. package/dist/commands/feedback.js +89 -0
  17. package/dist/commands/helper.d.ts +12 -0
  18. package/dist/commands/helper.js +87 -0
  19. package/dist/commands/hooks.js +89 -10
  20. package/dist/commands/mcp.js +166 -10
  21. package/dist/commands/packages.js +196 -27
  22. package/dist/commands/permissions.js +21 -6
  23. package/dist/commands/plugins.js +11 -4
  24. package/dist/commands/profiles.d.ts +8 -0
  25. package/dist/commands/profiles.js +118 -5
  26. package/dist/commands/prune.js +39 -160
  27. package/dist/commands/pull.js +58 -5
  28. package/dist/commands/routines.js +107 -14
  29. package/dist/commands/rules.js +8 -4
  30. package/dist/commands/secrets-migrate.d.ts +24 -0
  31. package/dist/commands/secrets-migrate.js +198 -0
  32. package/dist/commands/secrets-sync.d.ts +11 -0
  33. package/dist/commands/secrets-sync.js +155 -0
  34. package/dist/commands/secrets.js +79 -46
  35. package/dist/commands/sessions.d.ts +28 -0
  36. package/dist/commands/sessions.js +98 -33
  37. package/dist/commands/setup.d.ts +1 -0
  38. package/dist/commands/setup.js +37 -28
  39. package/dist/commands/skills.js +25 -8
  40. package/dist/commands/subagents.js +69 -49
  41. package/dist/commands/teams.js +61 -10
  42. package/dist/commands/utils.d.ts +33 -0
  43. package/dist/commands/utils.js +139 -0
  44. package/dist/commands/versions.d.ts +4 -3
  45. package/dist/commands/versions.js +134 -130
  46. package/dist/commands/view.d.ts +6 -0
  47. package/dist/commands/view.js +175 -19
  48. package/dist/commands/workflows.js +29 -6
  49. package/dist/computer.js +0 -0
  50. package/dist/index.js +38 -6
  51. package/dist/lib/acp/client.js +6 -1
  52. package/dist/lib/acp/harnesses.js +8 -0
  53. package/dist/lib/agents.d.ts +4 -0
  54. package/dist/lib/agents.js +125 -34
  55. package/dist/lib/auto-pull-worker.js +18 -1
  56. package/dist/lib/browser/cdp.d.ts +8 -1
  57. package/dist/lib/browser/cdp.js +40 -3
  58. package/dist/lib/browser/chrome.d.ts +13 -0
  59. package/dist/lib/browser/chrome.js +46 -3
  60. package/dist/lib/browser/domain-skills.d.ts +51 -0
  61. package/dist/lib/browser/domain-skills.js +157 -0
  62. package/dist/lib/browser/drivers/local.js +45 -4
  63. package/dist/lib/browser/drivers/ssh.js +2 -2
  64. package/dist/lib/browser/ipc.d.ts +8 -1
  65. package/dist/lib/browser/ipc.js +37 -28
  66. package/dist/lib/browser/profiles.d.ts +16 -3
  67. package/dist/lib/browser/profiles.js +44 -4
  68. package/dist/lib/browser/service.d.ts +3 -0
  69. package/dist/lib/browser/service.js +40 -5
  70. package/dist/lib/browser/types.d.ts +11 -4
  71. package/dist/lib/cli-resources.d.ts +137 -0
  72. package/dist/lib/cli-resources.js +477 -0
  73. package/dist/lib/cloud/factory.d.ts +1 -1
  74. package/dist/lib/cloud/factory.js +1 -1
  75. package/dist/lib/cloud/rush.js +5 -5
  76. package/dist/lib/command-skills.js +0 -2
  77. package/dist/lib/computer-rpc.d.ts +3 -0
  78. package/dist/lib/computer-rpc.js +53 -0
  79. package/dist/lib/daemon.js +20 -0
  80. package/dist/lib/events.d.ts +16 -2
  81. package/dist/lib/events.js +33 -2
  82. package/dist/lib/exec.d.ts +42 -13
  83. package/dist/lib/exec.js +127 -33
  84. package/dist/lib/help.js +11 -5
  85. package/dist/lib/hooks/cache.d.ts +38 -0
  86. package/dist/lib/hooks/cache.js +242 -0
  87. package/dist/lib/hooks/profile.d.ts +33 -0
  88. package/dist/lib/hooks/profile.js +129 -0
  89. package/dist/lib/hooks.d.ts +0 -10
  90. package/dist/lib/hooks.js +246 -11
  91. package/dist/lib/mcp.d.ts +15 -0
  92. package/dist/lib/mcp.js +46 -0
  93. package/dist/lib/migrate.js +1 -1
  94. package/dist/lib/overdue.d.ts +26 -0
  95. package/dist/lib/overdue.js +101 -0
  96. package/dist/lib/permissions.d.ts +13 -0
  97. package/dist/lib/permissions.js +55 -1
  98. package/dist/lib/plugin-marketplace.js +1 -1
  99. package/dist/lib/plugins.js +15 -1
  100. package/dist/lib/profiles-presets.d.ts +26 -0
  101. package/dist/lib/profiles-presets.js +216 -0
  102. package/dist/lib/profiles.d.ts +34 -0
  103. package/dist/lib/profiles.js +112 -1
  104. package/dist/lib/resources/mcp.js +37 -0
  105. package/dist/lib/resources.d.ts +1 -1
  106. package/dist/lib/rotate.js +10 -4
  107. package/dist/lib/routines-format.d.ts +47 -0
  108. package/dist/lib/routines-format.js +194 -0
  109. package/dist/lib/routines.d.ts +8 -2
  110. package/dist/lib/routines.js +34 -14
  111. package/dist/lib/runner.js +83 -15
  112. package/dist/lib/scheduler.js +8 -1
  113. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  114. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  115. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
  116. package/dist/lib/secrets/bundles.d.ts +34 -17
  117. package/dist/lib/secrets/bundles.js +210 -36
  118. package/dist/lib/secrets/index.d.ts +49 -30
  119. package/dist/lib/secrets/index.js +126 -115
  120. package/dist/lib/secrets/install-helper.d.ts +45 -0
  121. package/dist/lib/secrets/install-helper.js +165 -0
  122. package/dist/lib/secrets/linux.js +4 -4
  123. package/dist/lib/secrets/sync.d.ts +56 -0
  124. package/dist/lib/secrets/sync.js +180 -0
  125. package/dist/lib/session/active.d.ts +8 -0
  126. package/dist/lib/session/active.js +3 -2
  127. package/dist/lib/session/db.d.ts +0 -4
  128. package/dist/lib/session/db.js +0 -26
  129. package/dist/lib/session/parse.d.ts +1 -0
  130. package/dist/lib/session/parse.js +44 -0
  131. package/dist/lib/session/render.js +4 -4
  132. package/dist/lib/session/types.d.ts +2 -2
  133. package/dist/lib/session/types.js +1 -1
  134. package/dist/lib/shims.d.ts +5 -2
  135. package/dist/lib/shims.js +70 -38
  136. package/dist/lib/state.d.ts +14 -2
  137. package/dist/lib/state.js +51 -20
  138. package/dist/lib/teams/agents.d.ts +5 -4
  139. package/dist/lib/teams/agents.js +48 -22
  140. package/dist/lib/teams/api.d.ts +2 -1
  141. package/dist/lib/teams/api.js +4 -3
  142. package/dist/lib/teams/parsers.d.ts +1 -1
  143. package/dist/lib/teams/parsers.js +153 -3
  144. package/dist/lib/teams/summarizer.js +18 -2
  145. package/dist/lib/teams/worktree.js +14 -3
  146. package/dist/lib/types.d.ts +63 -4
  147. package/dist/lib/types.js +8 -3
  148. package/dist/lib/usage.d.ts +27 -2
  149. package/dist/lib/usage.js +100 -17
  150. package/dist/lib/versions.d.ts +45 -3
  151. package/dist/lib/versions.js +455 -60
  152. package/package.json +15 -14
  153. package/scripts/install-helper.js +97 -0
  154. package/scripts/postinstall.js +16 -0
  155. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  156. package/npm-shrinkwrap.json +0 -3162
@@ -1,22 +1,33 @@
1
1
  /**
2
2
  * Cross-platform secure credential storage.
3
3
  *
4
- * macOS: Uses Keychain via signed Swift helper (Agents CLI.app) or `security` CLI.
5
- * Linux: Uses libsecret (GNOME Keyring) via `secret-tool` CLI.
6
- * Windows: Not yet supported.
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
- * The .app embeds a provisioning profile that grants the application-identifier
9
- * + keychain-access-groups entitlement macOS requires for kSecAttrSynchronizable
10
- * writes (iCloud Keychain). For device-local writes the helper is invoked with
11
- * the `nosync` arg.
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('Secure credential storage requires macOS or Linux. ' +
41
- 'On Windows, use environment variables or .env files instead.');
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 `${SERVICE_PREFIX}.secrets.${bundle}.${key}`;
65
+ return `${SECRETS_ITEM_PREFIX}${bundle}.${key}`;
57
66
  }
58
- // Resolve the bundled, signed-and-notarized Agents CLI.app shipped
59
- // alongside the compiled JS. The .app embeds a provisioning profile that
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
- // Backend routing: non-sync items go through /usr/bin/security with an empty
82
- // trusted-app ACL; existing items written by older versions retain their ACL.
83
- // Sync items must go through the signed .app only the .app
84
- // holds the keychain-access-groups entitlement macOS requires for
85
- // kSecAttrSynchronizable. Enumeration also goes through the .app because the
86
- // security CLI doesn't expose listing by service prefix.
87
- /** Check if a keychain/keyring item exists. */
88
- export function hasKeychainToken(item, sync = false) {
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, sync);
92
+ return backend.has(item);
91
93
  assertSupportedPlatform();
92
94
  if (isLinux())
93
- return linuxBackend.has(item, sync);
94
- // macOS: Try security first (no prompts for local items), fall back to binary for synced items.
95
- if (spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
96
- stdio: ['ignore', 'pipe', 'pipe'],
97
- }).status === 0)
98
- return true;
99
- // Fallback: binary searches both synced and non-synced via kSecAttrSynchronizableAny
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
- /** Retrieve a secret value from the keychain/keyring. Throws if not found. */
106
- export function getKeychainToken(item, sync = false) {
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, sync);
115
+ return backend.get(item);
109
116
  assertSupportedPlatform();
110
117
  if (isLinux())
111
- return linuxBackend.get(item, sync);
112
- // macOS: Try security first (no prompts for local items)
113
- const secResult = spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item, '-w'], {
114
- stdio: ['ignore', 'pipe', 'pipe'],
115
- });
116
- if (secResult.status === 0) {
117
- const token = secResult.stdout?.toString().trim();
118
- if (token)
119
- return token;
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
- // Fallback: binary searches both synced and non-synced via kSecAttrSynchronizableAny.
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().trim();
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 LocalAuthentication
141
- * prompt. macOS shows ONE Touch ID prompt and every requested item is
142
- * unlocked in the same process. Returns a Map keyed by item name. Missing
143
- * items are absent from the map (caller decides whether that's an error).
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
- * `get` calls — no biometric prompt path on those platforms.
155
+ * lookups — no biometric prompt path on those platforms.
151
156
  */
152
- export function getKeychainTokensBatch(items, _sync = false, reason) {
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, _sync));
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, _sync));
174
+ result.set(item, linuxBackend.get(item));
170
175
  }
171
176
  catch { /* missing — skip */ }
172
177
  }
173
178
  return result;
174
179
  }
175
- // macOS: single helper invocation with Touch ID gate.
176
- const bin = ensureKeychainHelper();
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
- // Parser. Output is a sequence of records:
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 cannot contain '\n' (validated at write time); values are
193
- // also newline-free (rejected by setKeychainToken). So splitting on '\n'
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. iCloud-synced when sync=true (macOS only). */
221
- export function setKeychainToken(item, value, sync = false) {
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, sync);
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, sync);
236
+ linuxBackend.set(item, value);
235
237
  return;
236
238
  }
237
- // macOS path. Both sync and non-sync writes go through the .app helper so
238
- // the item picks up our SecAccess ACL (helper as trusted reader). That ACL
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, sync = false) {
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, sync);
252
+ return backend.delete(item);
260
253
  assertSupportedPlatform();
261
254
  if (isLinux())
262
- return linuxBackend.delete(item, sync);
263
- // macOS: Try security first (no prompts for local items), fall back to binary for synced items.
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
- // macOS path
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, opts.iCloudSync);
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, _sync) {
146
+ has(item) {
147
147
  return hasSecretToolToken(item);
148
148
  },
149
- get(item, _sync) {
149
+ get(item) {
150
150
  return getSecretToolToken(item);
151
151
  },
152
- set(item, value, _sync) {
152
+ set(item, value) {
153
153
  setSecretToolToken(item, value);
154
154
  },
155
- delete(item, _sync) {
155
+ delete(item) {
156
156
  return deleteSecretToolToken(item);
157
157
  },
158
158
  list(prefix) {