@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
@@ -14,7 +14,8 @@ import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, } from './rout
14
14
  import { getRunsDir } from './state.js';
15
15
  import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
16
16
  import { resolveModel, buildReasoningFlags } from './models.js';
17
- import { createTimer, maybeRotate, truncate } from './events.js';
17
+ import { createTimer, maybeRotate, redactPrompt } from './events.js';
18
+ import { normalizeMode } from './exec.js';
18
19
  /** CLI command templates per agent, with {prompt} as a placeholder. */
19
20
  const AGENT_COMMANDS = {
20
21
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -36,13 +37,20 @@ export function buildJobCommand(config, resolvedPrompt) {
36
37
  throw new Error(`Unsupported agent for daemon jobs: ${config.agent}`);
37
38
  }
38
39
  let cmd = template.map((part) => part.replace('{prompt}', resolvedPrompt));
40
+ // Canonicalize mode (accepts legacy `full` as alias for `skip`).
41
+ const mode = normalizeMode(config.mode);
39
42
  if (config.agent === 'claude') {
40
- if (config.mode === 'edit') {
43
+ if (mode === 'edit') {
41
44
  const planIndex = cmd.indexOf('plan');
42
45
  if (planIndex !== -1)
43
46
  cmd[planIndex] = 'acceptEdits';
44
47
  }
45
- else if (config.mode === 'full') {
48
+ else if (mode === 'auto') {
49
+ const planIndex = cmd.indexOf('plan');
50
+ if (planIndex !== -1)
51
+ cmd[planIndex] = 'auto';
52
+ }
53
+ else if (mode === 'skip') {
46
54
  // Replace --permission-mode plan with --dangerously-skip-permissions
47
55
  const pmIndex = cmd.indexOf('--permission-mode');
48
56
  if (pmIndex !== -1)
@@ -63,10 +71,10 @@ export function buildJobCommand(config, resolvedPrompt) {
63
71
  appendModelAndReasoning(cmd, config);
64
72
  }
65
73
  if (config.agent === 'codex') {
66
- if (config.mode === 'edit') {
74
+ if (mode === 'edit') {
67
75
  cmd.push('--full-auto');
68
76
  }
69
- else if (config.mode === 'full') {
77
+ else if (mode === 'skip') {
70
78
  // Remove sandbox restriction, just --full-auto
71
79
  const sbIndex = cmd.indexOf('--sandbox');
72
80
  if (sbIndex !== -1)
@@ -76,7 +84,10 @@ export function buildJobCommand(config, resolvedPrompt) {
76
84
  appendModelAndReasoning(cmd, config);
77
85
  }
78
86
  if (config.agent === 'gemini') {
79
- if (config.mode === 'edit' || config.mode === 'full') {
87
+ if (mode === 'edit') {
88
+ cmd.push('--approval-mode', 'auto_edit');
89
+ }
90
+ else if (mode === 'skip') {
80
91
  cmd.push('--yolo');
81
92
  }
82
93
  appendModelAndReasoning(cmd, config);
@@ -122,7 +133,7 @@ export async function executeJob(config) {
122
133
  version: config.version,
123
134
  jobName: config.name,
124
135
  mode: config.mode,
125
- prompt: truncate(config.prompt, 200),
136
+ ...redactPrompt(config.prompt),
126
137
  schedule: config.schedule,
127
138
  });
128
139
  const resolvedPrompt = resolveJobPrompt(config);
@@ -321,6 +332,39 @@ export function extractReport(stdoutPath, agentType) {
321
332
  return null;
322
333
  }
323
334
  }
335
+ /** Derive the final status of a detached run by reading the agent's stream-json
336
+ * tail. Detached children fire-and-forget, so we never see their exit code
337
+ * directly — but Claude's stream-json terminates with a `type: result` line
338
+ * that carries `is_error`. If we find it, the run completed cleanly (modulo
339
+ * agent-reported error). If not, the process likely died mid-stream and the
340
+ * caller should treat the run as failed. */
341
+ function inferFinalStatusFromLog(stdoutPath, agent) {
342
+ if (!fs.existsSync(stdoutPath))
343
+ return null;
344
+ try {
345
+ const content = fs.readFileSync(stdoutPath, 'utf-8');
346
+ const lines = content.split('\n').filter((l) => l.trim());
347
+ // Walk backwards over the last few lines — the result marker is always
348
+ // at the tail. Cap the scan so a huge stdout doesn't iterate forever.
349
+ for (let i = lines.length - 1, scanned = 0; i >= 0 && scanned < 20; i--, scanned++) {
350
+ try {
351
+ const parsed = JSON.parse(lines[i]);
352
+ if (agent === 'claude' && parsed.type === 'result') {
353
+ return parsed.is_error
354
+ ? { status: 'failed', exitCode: 1 }
355
+ : { status: 'completed', exitCode: 0 };
356
+ }
357
+ }
358
+ catch {
359
+ // malformed JSONL line — keep scanning
360
+ }
361
+ }
362
+ return null;
363
+ }
364
+ catch {
365
+ return null;
366
+ }
367
+ }
324
368
  /** Scan all runs marked "running" and finalize any whose process has exited. */
325
369
  export function monitorRunningJobs() {
326
370
  const runsDir = getRunsDir();
@@ -346,11 +390,21 @@ export function monitorRunningJobs() {
346
390
  process.kill(meta.pid, 0);
347
391
  }
348
392
  catch { /* process no longer running */
349
- meta.status = 'failed';
393
+ const runDirPath = path.join(jobRunsPath, runDirEntry.name);
394
+ const stdoutPath = path.join(runDirPath, 'stdout.log');
395
+ // Prefer the agent's own success/error marker; fall back to "failed"
396
+ // only when the stream ended without one (process killed mid-run).
397
+ const inferred = inferFinalStatusFromLog(stdoutPath, meta.agent);
398
+ if (inferred) {
399
+ meta.status = inferred.status;
400
+ meta.exitCode = inferred.exitCode;
401
+ }
402
+ else {
403
+ meta.status = 'failed';
404
+ }
350
405
  meta.completedAt = new Date().toISOString();
351
406
  writeRunMeta(meta);
352
- const stdoutPath = path.join(jobRunsPath, runDirEntry.name, 'stdout.log');
353
- extractAndSaveReport(stdoutPath, meta.agent, path.join(jobRunsPath, runDirEntry.name));
407
+ extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
354
408
  }
355
409
  }
356
410
  catch { /* corrupt or unreadable meta.json */ }
@@ -5,15 +5,7 @@
5
5
  <key>files</key>
6
6
  <dict/>
7
7
  <key>files2</key>
8
- <dict>
9
- <key>embedded.provisionprofile</key>
10
- <dict>
11
- <key>hash2</key>
12
- <data>
13
- 2vfA/eR3dTYgNc/fXhdADUPkp5tRIepPzE3FCLfDx4w=
14
- </data>
15
- </dict>
16
- </dict>
8
+ <dict/>
17
9
  <key>rules</key>
18
10
  <dict>
19
11
  <key>^Resources/</key>
@@ -1,15 +1,15 @@
1
1
  /**
2
- * Secret bundles -- named sets of keychain-backed environment variables.
2
+ * Secret bundles named sets of keychain-backed environment variables.
3
3
  *
4
4
  * Bundle metadata (name, description, vars map) is stored in the macOS
5
- * Keychain as a JSON blob under `agents-cli.bundles.<name>`. Bundles created
6
- * with `--icloud-sync` write the metadata to the iCloud-synced keychain so
7
- * the full bundle definition (not just secret values) propagates across
8
- * the user's Macs. Nothing about secrets ever lives in plaintext on disk.
5
+ * Keychain as a JSON blob under `agents-cli.bundles.<name>`. Secret values
6
+ * live one per keychain item under `agents-cli.secrets.<bundle>.<key>`.
7
+ * Every item is device-local and gated by Touch ID / device passcode — see
8
+ * src/lib/secrets/index.ts for the access-control story. Nothing about
9
+ * secrets ever lives in plaintext on disk.
9
10
  *
10
- * Secret values keep their old layout: one keychain item per key under
11
- * `agents-cli.secrets.<bundle>.<key>`, sync-state matching the bundle's
12
- * `icloud_sync` flag.
11
+ * Cross-machine sync is handled by src/lib/secrets/sync.ts via an explicit
12
+ * encrypted export/import flow; the bundle layer is sync-agnostic.
13
13
  */
14
14
  import { type BundleValue, type SecretRef } from './index.js';
15
15
  /** Allowed values for a secret's `type` metadata field. */
@@ -28,8 +28,6 @@ export interface SecretsBundle {
28
28
  name: string;
29
29
  description?: string;
30
30
  allow_exec?: boolean;
31
- /** When true, keychain-backed values and bundle metadata sync via iCloud Keychain. */
32
- icloud_sync?: boolean;
33
31
  /** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
34
32
  created_at?: string;
35
33
  /** ISO 8601 UTC timestamp. Refreshed on every writeBundle(). */
@@ -49,6 +47,7 @@ export declare const RESERVED_ENV_NAMES: Set<string>;
49
47
  export declare function bundleToEnvPrefix(name: string): string;
50
48
  export declare function isReservedEnvName(key: string): boolean;
51
49
  export declare function isLoaderOrInterpreterEnv(name: string): boolean;
50
+ export declare function sanitizeProcessEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
52
51
  /** Validate a bundle name against the allowed pattern. Throws on invalid input. */
53
52
  export declare function validateBundleName(name: string): void;
54
53
  export declare function validateEnvKey(key: string): void;
@@ -62,11 +61,7 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
62
61
  export declare function validateExpiresFutureDated(iso: string): void;
63
62
  export declare function bundleExists(name: string): boolean;
64
63
  export declare function readBundle(name: string): SecretsBundle;
65
- export interface WriteBundleOptions {
66
- /** Emit a secrets.set audit event. Internal bookkeeping writes turn this off. */
67
- emitEvent?: boolean;
68
- }
69
- export declare function writeBundle(bundle: SecretsBundle, opts?: WriteBundleOptions): void;
64
+ export declare function writeBundle(bundle: SecretsBundle): void;
70
65
  export declare function deleteBundle(name: string): boolean;
71
66
  export declare function listBundles(): SecretsBundle[];
72
67
  export interface BundleEntryInfo {
@@ -78,14 +73,15 @@ export declare function describeBundle(bundle: SecretsBundle): BundleEntryInfo[]
78
73
  /** Options for resolveBundleEnv. */
79
74
  export interface ResolveBundleOptions {
80
75
  /**
81
- * Human-readable label for who is requesting the secrets. Shown to the
82
- * user under the Touch ID prompt so they know what's about to read.
83
- * Example: "agent claude", "command curl", "browser profile".
84
- * When omitted the prompt only names the bundle.
76
+ * Human-readable label for who is requesting the secrets. Currently
77
+ * informational only the helper's Touch ID prompt is set by the OS and
78
+ * cannot be reliably customized once we drop the per-batch reason path,
79
+ * but we keep this in the API so call sites stay explicit about who's
80
+ * about to read the bundle.
85
81
  */
86
82
  caller?: string;
87
83
  }
88
- export declare function resolveBundleEnv(bundle: SecretsBundle, opts?: ResolveBundleOptions): Record<string, string>;
84
+ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
89
85
  /**
90
86
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
91
87
  *
@@ -135,8 +131,8 @@ export interface RenameOptions {
135
131
  * 4) write new bundle metadata
136
132
  * 5) delete the old per-key keychain items + old metadata
137
133
  *
138
- * Steps 1-4 are reversible. If 5 partially fails (e.g. iCloud Keychain
139
- * hiccup), running `rename` again is a safe no-op for the source items.
134
+ * Steps 1-4 are reversible. If 5 partially fails, running `rename` again is
135
+ * a safe no-op for the source items.
140
136
  */
141
137
  export declare function renameBundle(oldName: string, newName: string, opts?: RenameOptions): void;
142
138
  export declare function keychainItemsForBundle(bundle: SecretsBundle): Array<{
@@ -1,21 +1,21 @@
1
1
  /**
2
- * Secret bundles -- named sets of keychain-backed environment variables.
2
+ * Secret bundles named sets of keychain-backed environment variables.
3
3
  *
4
4
  * Bundle metadata (name, description, vars map) is stored in the macOS
5
- * Keychain as a JSON blob under `agents-cli.bundles.<name>`. Bundles created
6
- * with `--icloud-sync` write the metadata to the iCloud-synced keychain so
7
- * the full bundle definition (not just secret values) propagates across
8
- * the user's Macs. Nothing about secrets ever lives in plaintext on disk.
5
+ * Keychain as a JSON blob under `agents-cli.bundles.<name>`. Secret values
6
+ * live one per keychain item under `agents-cli.secrets.<bundle>.<key>`.
7
+ * Every item is device-local and gated by Touch ID / device passcode — see
8
+ * src/lib/secrets/index.ts for the access-control story. Nothing about
9
+ * secrets ever lives in plaintext on disk.
9
10
  *
10
- * Secret values keep their old layout: one keychain item per key under
11
- * `agents-cli.secrets.<bundle>.<key>`, sync-state matching the bundle's
12
- * `icloud_sync` flag.
11
+ * Cross-machine sync is handled by src/lib/secrets/sync.ts via an explicit
12
+ * encrypted export/import flow; the bundle layer is sync-agnostic.
13
13
  */
14
14
  import * as fs from 'fs';
15
15
  import * as os from 'os';
16
16
  import * as path from 'path';
17
17
  import * as yaml from 'yaml';
18
- import { deleteKeychainToken, getKeychainToken, getKeychainTokensBatch, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
18
+ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
19
19
  import { emit } from '../events.js';
20
20
  /** Allowed values for a secret's `type` metadata field. */
21
21
  export const SECRET_TYPES = [
@@ -63,6 +63,17 @@ export function isLoaderOrInterpreterEnv(name) {
63
63
  'CDPATH',
64
64
  ].includes(upper);
65
65
  }
66
+ export function sanitizeProcessEnv(env = process.env) {
67
+ const out = {};
68
+ for (const [k, v] of Object.entries(env)) {
69
+ if (v === undefined)
70
+ continue;
71
+ if (isLoaderOrInterpreterEnv(k))
72
+ continue;
73
+ out[k] = v;
74
+ }
75
+ return out;
76
+ }
66
77
  /** Validate a bundle name against the allowed pattern. Throws on invalid input. */
67
78
  export function validateBundleName(name) {
68
79
  if (!BUNDLE_NAME_PATTERN.test(name)) {
@@ -125,11 +136,12 @@ export function readBundle(name) {
125
136
  if (!parsed || typeof parsed !== 'object') {
126
137
  throw new Error(`Bundle '${name}' is malformed.`);
127
138
  }
139
+ // Unknown fields on the JSON (e.g. legacy sync flags) are silently dropped
140
+ // here; the SecretsBundle shape is the only source of truth.
128
141
  const bundle = {
129
142
  name,
130
143
  description: parsed.description,
131
144
  allow_exec: Boolean(parsed.allow_exec),
132
- icloud_sync: Boolean(parsed.icloud_sync),
133
145
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
134
146
  };
135
147
  if (typeof parsed.created_at === 'string')
@@ -146,7 +158,7 @@ export function readBundle(name) {
146
158
  }
147
159
  return bundle;
148
160
  }
149
- export function writeBundle(bundle, opts = {}) {
161
+ export function writeBundle(bundle) {
150
162
  validateBundleName(bundle.name);
151
163
  for (const key of Object.keys(bundle.vars)) {
152
164
  validateEnvKey(key);
@@ -179,7 +191,6 @@ export function writeBundle(bundle, opts = {}) {
179
191
  const payload = {
180
192
  description: bundle.description,
181
193
  allow_exec: bundle.allow_exec ? true : undefined,
182
- icloud_sync: bundle.icloud_sync ? true : undefined,
183
194
  created_at: bundle.created_at,
184
195
  updated_at: bundle.updated_at,
185
196
  last_used: bundle.last_used,
@@ -187,10 +198,8 @@ export function writeBundle(bundle, opts = {}) {
187
198
  meta,
188
199
  };
189
200
  const json = JSON.stringify(payload);
190
- setKeychainToken(bundleMetaItem(bundle.name), json, Boolean(bundle.icloud_sync));
191
- if (opts.emitEvent !== false) {
192
- emit('secrets.set', { bundle: bundle.name });
193
- }
201
+ setKeychainToken(bundleMetaItem(bundle.name), json);
202
+ emit('secrets.set', { bundle: bundle.name });
194
203
  }
195
204
  export function deleteBundle(name) {
196
205
  validateBundleName(name);
@@ -220,7 +229,7 @@ export function listBundles() {
220
229
  // SecItemCopyMatching calls collapses the prompt to one. Mirrors the pattern
221
230
  // already used by resolveBundleEnv for runtime secret injection.
222
231
  const itemsToFetch = names.map(bundleMetaItem);
223
- const fetched = getKeychainTokensBatch(itemsToFetch, false, 'list secrets bundles');
232
+ const fetched = getKeychainTokens(itemsToFetch);
224
233
  const out = [];
225
234
  for (const name of names) {
226
235
  const json = fetched.get(bundleMetaItem(name));
@@ -240,7 +249,6 @@ export function listBundles() {
240
249
  name,
241
250
  description: parsed.description,
242
251
  allow_exec: Boolean(parsed.allow_exec),
243
- icloud_sync: Boolean(parsed.icloud_sync),
244
252
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
245
253
  };
246
254
  if (typeof parsed.created_at === 'string')
@@ -294,90 +302,63 @@ function stampLastUsed(bundle) {
294
302
  }
295
303
  try {
296
304
  bundle.last_used = new Date(nowMs).toISOString();
297
- writeBundle(bundle, { emitEvent: false });
305
+ writeBundle(bundle);
298
306
  }
299
307
  catch {
300
308
  // Swallow — telemetry must never block secret resolution.
301
309
  }
302
310
  }
303
- export function resolveBundleEnv(bundle, opts = {}) {
311
+ // Walk the bundle and produce a flat env map. Every keychain: ref is gathered
312
+ // into a single batch read so macOS shows ONE Touch ID prompt for the whole
313
+ // bundle — including the metadata fetch that already happened in readBundle
314
+ // (the helper's auth context survives across separate invocations only via
315
+ // the per-process LAContext, so we still get one prompt for the batch even
316
+ // if metadata triggered an earlier one). Literals/env/file/exec refs are
317
+ // resolved inline and never reach the keychain.
318
+ export function resolveBundleEnv(bundle, _opts = {}) {
304
319
  stampLastUsed(bundle);
305
320
  const parsedByKey = new Map();
306
321
  const keychainItemsToFetch = [];
307
- const keychainKeys = [];
308
- const kindCounts = {};
309
322
  for (const [key, raw] of Object.entries(bundle.vars)) {
310
323
  const parsed = parseBundleValue(raw);
311
324
  parsedByKey.set(key, parsed);
312
- const kind = 'literal' in parsed ? 'literal' : parsed.ref.provider;
313
- kindCounts[kind] = (kindCounts[kind] ?? 0) + 1;
314
325
  if ('ref' in parsed && parsed.ref.provider === 'keychain') {
315
- const item = secretsKeychainItem(bundle.name, parsed.ref.value);
316
- keychainItemsToFetch.push(item);
317
- keychainKeys.push(key);
326
+ keychainItemsToFetch.push(secretsKeychainItem(bundle.name, parsed.ref.value));
318
327
  }
319
328
  }
320
- const keys = Object.keys(bundle.vars).sort();
321
- keychainKeys.sort();
322
- const emitReadAudit = (status, err) => {
323
- emit('secrets.get', {
324
- bundle: bundle.name,
325
- caller: opts.caller,
326
- status,
327
- keyCount: keys.length,
328
- keys,
329
- keychainKeys,
330
- kindCounts,
331
- error: err instanceof Error ? err.message : (err ? String(err) : undefined),
332
- });
333
- };
334
- // Build the localizedReason shown under the Touch ID prompt. Lowercase verb
335
- // phrase per Apple HIG — the system prepends "<App> is required to ".
336
- const reason = opts.caller
337
- ? `read ${bundle.name} secrets (for ${opts.caller})`
338
- : `read ${bundle.name} secrets`;
339
- try {
340
- // Single helper invocation, one biometric prompt.
341
- const fetched = keychainItemsToFetch.length > 0
342
- ? getKeychainTokensBatch(keychainItemsToFetch, bundle.icloud_sync, reason)
343
- : new Map();
344
- // Second pass: assemble env. Keychain values come from the batch; everything
345
- // else is resolved inline (literals and env/file/exec refs don't prompt).
346
- const env = {};
347
- for (const [key] of Object.entries(bundle.vars)) {
348
- const parsed = parsedByKey.get(key);
349
- if ('literal' in parsed) {
350
- env[key] = parsed.literal;
351
- continue;
352
- }
353
- if (parsed.ref.provider === 'keychain') {
354
- const item = secretsKeychainItem(bundle.name, parsed.ref.value);
355
- const value = fetched.get(item);
356
- if (value === undefined) {
357
- throw new Error(`Bundle '${bundle.name}' key '${key}': keychain item '${item}' not found. ` +
358
- `Run: agents secrets add ${bundle.name} ${key}`);
359
- }
360
- env[key] = value;
361
- continue;
362
- }
363
- try {
364
- env[key] = resolveRef(parsed.ref, {
365
- allowExec: bundle.allow_exec,
366
- iCloudSync: bundle.icloud_sync,
367
- keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
368
- });
369
- }
370
- catch (err) {
371
- throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
329
+ const fetched = keychainItemsToFetch.length > 0
330
+ ? getKeychainTokens(keychainItemsToFetch)
331
+ : new Map();
332
+ const env = {};
333
+ for (const [key, raw] of Object.entries(bundle.vars)) {
334
+ const parsed = parsedByKey.get(key);
335
+ if ('literal' in parsed) {
336
+ env[key] = parsed.literal;
337
+ continue;
338
+ }
339
+ if (parsed.ref.provider === 'keychain') {
340
+ const item = secretsKeychainItem(bundle.name, parsed.ref.value);
341
+ const value = fetched.get(item);
342
+ if (value === undefined) {
343
+ throw new Error(`Bundle '${bundle.name}' key '${key}': keychain item '${item}' not found. ` +
344
+ `Run: agents secrets add ${bundle.name} ${key}`);
372
345
  }
346
+ env[key] = value;
347
+ continue;
348
+ }
349
+ try {
350
+ env[key] = resolveRef(parsed.ref, {
351
+ allowExec: bundle.allow_exec,
352
+ keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
353
+ });
354
+ }
355
+ catch (err) {
356
+ throw new Error(`Bundle '${bundle.name}' key '${key}': ${err.message}`);
373
357
  }
374
- emitReadAudit('success');
375
- return env;
376
- }
377
- catch (err) {
378
- emitReadAudit('error', err);
379
- throw err;
380
358
  }
359
+ // `caller` is intentionally unused; see ResolveBundleOptions.
360
+ void _opts.caller;
361
+ return env;
381
362
  }
382
363
  /**
383
364
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
@@ -406,10 +387,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
406
387
  const reason = opts.caller
407
388
  ? `read ${name} secrets (for ${opts.caller})`
408
389
  : `read ${name} secrets`;
409
- // icloud_sync flag is unknown until we parse metadata, but the helper's
410
- // get-batch uses kSecAttrSynchronizableAny so it finds both synced and
411
- // device-local items in one shot.
412
- const fetched = getKeychainTokensBatch([metaItem, ...secretItems], false, reason);
390
+ void reason;
391
+ const fetched = getKeychainTokens([metaItem, ...secretItems]);
413
392
  const json = fetched.get(metaItem);
414
393
  if (json === undefined) {
415
394
  throw new Error(`Secrets bundle '${name}' not found.`);
@@ -428,7 +407,6 @@ export function readAndResolveBundleEnv(name, opts = {}) {
428
407
  name,
429
408
  description: parsed.description,
430
409
  allow_exec: Boolean(parsed.allow_exec),
431
- icloud_sync: Boolean(parsed.icloud_sync),
432
410
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
433
411
  };
434
412
  if (typeof parsed.created_at === 'string')
@@ -490,7 +468,6 @@ export function readAndResolveBundleEnv(name, opts = {}) {
490
468
  try {
491
469
  env[key] = resolveRef(p.ref, {
492
470
  allowExec: bundle.allow_exec,
493
- iCloudSync: bundle.icloud_sync,
494
471
  keychainItemFor: (shortId) => secretsKeychainItem(bundle.name, shortId),
495
472
  });
496
473
  }
@@ -529,7 +506,7 @@ export function rotateBundleSecret(bundle, key, opts) {
529
506
  }
530
507
  const shortId = raw.slice('keychain:'.length);
531
508
  const item = secretsKeychainItem(bundle.name, shortId);
532
- setKeychainToken(item, opts.newValue, bundle.icloud_sync);
509
+ setKeychainToken(item, opts.newValue);
533
510
  if (opts.clearMeta) {
534
511
  if (bundle.meta)
535
512
  delete bundle.meta[key];
@@ -560,8 +537,8 @@ export function rotateBundleSecret(bundle, key, opts) {
560
537
  * 4) write new bundle metadata
561
538
  * 5) delete the old per-key keychain items + old metadata
562
539
  *
563
- * Steps 1-4 are reversible. If 5 partially fails (e.g. iCloud Keychain
564
- * hiccup), running `rename` again is a safe no-op for the source items.
540
+ * Steps 1-4 are reversible. If 5 partially fails, running `rename` again is
541
+ * a safe no-op for the source items.
565
542
  */
566
543
  export function renameBundle(oldName, newName, opts = {}) {
567
544
  validateBundleName(oldName);
@@ -579,7 +556,7 @@ export function renameBundle(oldName, newName, opts = {}) {
579
556
  }
580
557
  const dest = readBundle(newName);
581
558
  for (const { item } of keychainItemsForBundle(dest)) {
582
- deleteKeychainToken(item, dest.icloud_sync);
559
+ deleteKeychainToken(item);
583
560
  }
584
561
  deleteBundle(newName);
585
562
  }
@@ -592,15 +569,15 @@ export function renameBundle(oldName, newName, opts = {}) {
592
569
  continue;
593
570
  const shortId = raw.slice('keychain:'.length);
594
571
  const newItem = secretsKeychainItem(newName, shortId);
595
- const value = getKeychainToken(oldItem, source.icloud_sync);
596
- setKeychainToken(newItem, value, source.icloud_sync);
572
+ const value = getKeychainToken(oldItem);
573
+ setKeychainToken(newItem, value);
597
574
  }
598
575
  // writeBundle preserves source.created_at and refreshes updated_at.
599
576
  const renamed = { ...source, name: newName };
600
577
  writeBundle(renamed);
601
578
  // Cleanup: delete the old per-key keychain items, then the old metadata.
602
579
  for (const { item: oldItem } of sourceItems) {
603
- deleteKeychainToken(oldItem, source.icloud_sync);
580
+ deleteKeychainToken(oldItem);
604
581
  }
605
582
  deleteBundle(oldName);
606
583
  emit('secrets.rename', { from: oldName, to: newName });
@@ -674,7 +651,6 @@ export async function migrateLegacyBundles(confirmBundle) {
674
651
  name,
675
652
  description: parsed.description,
676
653
  allow_exec: Boolean(parsed.allow_exec),
677
- icloud_sync: Boolean(parsed.icloud_sync),
678
654
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
679
655
  };
680
656
  const keys = Object.keys(bundle.vars);