@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +4 -4
  3. package/dist/commands/cli.js +3 -3
  4. package/dist/commands/cloud.js +1 -1
  5. package/dist/commands/commands.js +24 -7
  6. package/dist/commands/exec.js +36 -16
  7. package/dist/commands/feedback.d.ts +7 -0
  8. package/dist/commands/feedback.js +89 -0
  9. package/dist/commands/helper.d.ts +12 -0
  10. package/dist/commands/helper.js +87 -0
  11. package/dist/commands/hooks.js +86 -7
  12. package/dist/commands/mcp.js +166 -10
  13. package/dist/commands/packages.js +196 -27
  14. package/dist/commands/permissions.js +21 -6
  15. package/dist/commands/profiles.d.ts +8 -0
  16. package/dist/commands/profiles.js +117 -4
  17. package/dist/commands/pull.js +4 -4
  18. package/dist/commands/routines.js +6 -6
  19. package/dist/commands/rules.js +8 -4
  20. package/dist/commands/secrets-migrate.d.ts +24 -0
  21. package/dist/commands/secrets-migrate.js +198 -0
  22. package/dist/commands/secrets-sync.d.ts +11 -0
  23. package/dist/commands/secrets-sync.js +155 -0
  24. package/dist/commands/secrets.js +74 -39
  25. package/dist/commands/skills.js +22 -5
  26. package/dist/commands/subagents.js +69 -49
  27. package/dist/commands/teams.js +48 -10
  28. package/dist/commands/utils.d.ts +33 -0
  29. package/dist/commands/utils.js +139 -0
  30. package/dist/commands/versions.js +4 -4
  31. package/dist/commands/view.d.ts +6 -0
  32. package/dist/commands/view.js +164 -8
  33. package/dist/commands/workflows.js +29 -6
  34. package/dist/index.js +4 -0
  35. package/dist/lib/acp/client.js +6 -1
  36. package/dist/lib/agents.d.ts +4 -0
  37. package/dist/lib/agents.js +18 -14
  38. package/dist/lib/auto-pull-worker.js +18 -1
  39. package/dist/lib/browser/chrome.js +4 -0
  40. package/dist/lib/browser/drivers/ssh.js +1 -1
  41. package/dist/lib/browser/profiles.d.ts +3 -3
  42. package/dist/lib/browser/profiles.js +3 -3
  43. package/dist/lib/browser/service.js +19 -0
  44. package/dist/lib/browser/types.d.ts +4 -4
  45. package/dist/lib/cli-resources.d.ts +36 -8
  46. package/dist/lib/cli-resources.js +268 -46
  47. package/dist/lib/cloud/factory.d.ts +1 -1
  48. package/dist/lib/cloud/factory.js +1 -1
  49. package/dist/lib/events.d.ts +16 -2
  50. package/dist/lib/events.js +33 -2
  51. package/dist/lib/exec.d.ts +39 -11
  52. package/dist/lib/exec.js +90 -31
  53. package/dist/lib/help.js +11 -5
  54. package/dist/lib/hooks/cache.d.ts +38 -0
  55. package/dist/lib/hooks/cache.js +242 -0
  56. package/dist/lib/hooks/profile.d.ts +33 -0
  57. package/dist/lib/hooks/profile.js +129 -0
  58. package/dist/lib/hooks.d.ts +0 -10
  59. package/dist/lib/hooks.js +68 -15
  60. package/dist/lib/mcp.d.ts +15 -0
  61. package/dist/lib/mcp.js +40 -0
  62. package/dist/lib/permissions.d.ts +13 -0
  63. package/dist/lib/permissions.js +51 -1
  64. package/dist/lib/plugins.js +15 -1
  65. package/dist/lib/profiles-presets.d.ts +26 -0
  66. package/dist/lib/profiles-presets.js +187 -8
  67. package/dist/lib/profiles.d.ts +34 -0
  68. package/dist/lib/profiles.js +112 -1
  69. package/dist/lib/routines-format.d.ts +17 -5
  70. package/dist/lib/routines-format.js +37 -16
  71. package/dist/lib/routines.d.ts +1 -1
  72. package/dist/lib/routines.js +2 -2
  73. package/dist/lib/runner.js +64 -10
  74. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -9
  77. package/dist/lib/secrets/bundles.d.ts +18 -22
  78. package/dist/lib/secrets/bundles.js +75 -99
  79. package/dist/lib/secrets/index.d.ts +51 -27
  80. package/dist/lib/secrets/index.js +147 -156
  81. package/dist/lib/secrets/install-helper.d.ts +45 -0
  82. package/dist/lib/secrets/install-helper.js +165 -0
  83. package/dist/lib/secrets/linux.js +4 -4
  84. package/dist/lib/secrets/sync.d.ts +56 -0
  85. package/dist/lib/secrets/sync.js +180 -0
  86. package/dist/lib/session/render.js +4 -4
  87. package/dist/lib/session/types.d.ts +1 -1
  88. package/dist/lib/shims.d.ts +4 -1
  89. package/dist/lib/shims.js +5 -35
  90. package/dist/lib/state.d.ts +14 -1
  91. package/dist/lib/state.js +49 -5
  92. package/dist/lib/teams/agents.d.ts +5 -4
  93. package/dist/lib/teams/agents.js +47 -21
  94. package/dist/lib/teams/api.d.ts +2 -1
  95. package/dist/lib/teams/api.js +4 -3
  96. package/dist/lib/types.d.ts +57 -1
  97. package/dist/lib/types.js +2 -0
  98. package/dist/lib/usage.d.ts +27 -2
  99. package/dist/lib/usage.js +100 -17
  100. package/dist/lib/versions.d.ts +35 -1
  101. package/dist/lib/versions.js +267 -64
  102. package/package.json +9 -8
  103. package/scripts/install-helper.js +97 -0
  104. package/scripts/postinstall.js +16 -0
  105. 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: 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
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, sync: boolean): boolean;
48
- get(item: string, sync: boolean): string;
49
- set(item: string, value: string, sync: boolean): void;
50
- delete(item: string, sync: boolean): boolean;
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, sync?: boolean): boolean;
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
- * Read multiple keychain items, returning a Map keyed by item name. Missing or
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 uses the signed helper's `get-batch` command so one LAContext
65
- * can satisfy all protected item reads for the bundle.
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 getKeychainTokensBatch(items: string[], sync?: boolean, reason?: string): Map<string, string>;
68
- /** Store or update a secret value in the keychain/keyring. iCloud-synced when sync=true (macOS only). */
69
- export declare function setKeychainToken(item: string, value: string, sync?: boolean): void;
70
- /** Delete a keychain/keyring item. Returns true if it existed. */
71
- export declare function deleteKeychainToken(item: string, sync?: boolean): boolean;
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: 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';
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
- // Backend routing: non-sync items go through /usr/bin/security with an empty
88
- // trusted-app ACL; existing items written by older versions retain their ACL.
89
- // Sync items must go through the signed .app only the .app
90
- // holds the keychain-access-groups entitlement macOS requires for
91
- // kSecAttrSynchronizable. Enumeration also goes through the .app because the
92
- // security CLI doesn't expose listing by service prefix.
93
- /** Check if a keychain/keyring item exists. */
94
- 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) {
95
91
  if (backend)
96
- return backend.has(item, sync);
92
+ return backend.has(item);
97
93
  assertSupportedPlatform();
98
94
  if (isLinux())
99
- return linuxBackend.has(item, sync);
100
- // macOS: `security find-generic-password` *without* `-w` only reads item
101
- // metadata, never the value, so it doesn't consult the item's ACL — no
102
- // prompt. The previous code passed `-w`, which forced decryption and
103
- // triggered the "security wants to access keychain" sheet on every item
104
- // the helper had written. Existence checks never need the value.
105
- if (spawnSync('security', ['find-generic-password', '-a', os.userInfo().username, '-s', item], {
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
- /** Retrieve a secret value from the keychain/keyring. Throws if not found. */
116
- 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) {
117
114
  if (backend)
118
- return backend.get(item, sync);
115
+ return backend.get(item);
119
116
  assertSupportedPlatform();
120
117
  if (isLinux())
121
- return linuxBackend.get(item, sync);
122
- // macOS: read through the signed helper FIRST. The helper holds the
123
- // keychain-access-group entitlement (so it reads iCloud-synced items) and
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 (secResult.status === 0) {
143
- const token = secResult.stdout?.toString().trim();
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
- // Helper searches both synced and non-synced via kSecAttrSynchronizableAny.
150
- const args = keychainItemRequiresUserPresence(item)
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().trim();
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
- * Read multiple keychain items, returning a Map keyed by item name. Missing or
169
- * unreadable items are simply absent from the map (the caller decides whether a
170
- * given key was required).
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 macOS this uses the signed helper's `get-batch` command so one LAContext
173
- * can satisfy all protected item reads for the bundle.
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 getKeychainTokensBatch(items, sync = false, reason = 'read agents-cli secrets') {
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, sync));
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, sync));
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 proc = spawnSync(bin, ['get-batch', os.userInfo().username, '--reason', reason, ...items], {
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 (proc.status !== 0)
219
- return result;
220
- const out = proc.stdout?.toString() || '';
221
- for (const line of out.split('\n')) {
222
- if (!line)
223
- continue;
224
- const tab = line.indexOf('\t');
225
- if (tab <= 0)
226
- continue;
227
- const item = line.slice(0, tab);
228
- const encoded = line.slice(tab + 1);
229
- result.set(item, Buffer.from(encoded, 'base64').toString('utf8'));
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. iCloud-synced when sync=true (macOS only). */
234
- 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) {
235
224
  if (backend) {
236
- backend.set(item, value, sync);
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, sync);
236
+ linuxBackend.set(item, value);
248
237
  return;
249
238
  }
250
- // macOS path. Both sync and non-sync writes go through the .app helper so
251
- // the item picks up kSecAttrAccessControl user-presence protection. The
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, sync = false) {
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, sync);
252
+ return backend.delete(item);
271
253
  assertSupportedPlatform();
272
254
  if (isLinux())
273
- return linuxBackend.delete(item, sync);
274
- // macOS: delete through the signed helper FIRST. `security delete-generic-password`
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
- // macOS path
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, opts.iCloudSync);
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;