@phnx-labs/agents-cli 1.20.51 → 1.20.53

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 (142) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +215 -7
  5. package/dist/commands/cloud.d.ts +3 -0
  6. package/dist/commands/cloud.js +8 -1
  7. package/dist/commands/events.d.ts +1 -1
  8. package/dist/commands/events.js +2 -3
  9. package/dist/commands/exec.js +82 -3
  10. package/dist/commands/factory.js +8 -0
  11. package/dist/commands/feed.d.ts +38 -0
  12. package/dist/commands/feed.js +274 -0
  13. package/dist/commands/logs.d.ts +5 -1
  14. package/dist/commands/logs.js +248 -3
  15. package/dist/commands/mcp.js +7 -0
  16. package/dist/commands/memory.d.ts +9 -0
  17. package/dist/commands/memory.js +164 -0
  18. package/dist/commands/message.d.ts +11 -6
  19. package/dist/commands/message.js +140 -5
  20. package/dist/commands/routines.js +12 -0
  21. package/dist/commands/secrets-migrate.d.ts +2 -1
  22. package/dist/commands/secrets-migrate.js +88 -13
  23. package/dist/commands/secrets.d.ts +22 -0
  24. package/dist/commands/secrets.js +176 -42
  25. package/dist/commands/sessions.js +10 -1
  26. package/dist/commands/teams.js +4 -0
  27. package/dist/commands/worktree.js +4 -2
  28. package/dist/index.js +21 -22
  29. package/dist/lib/agents.js +249 -17
  30. package/dist/lib/answer-router.d.ts +75 -0
  31. package/dist/lib/answer-router.js +149 -0
  32. package/dist/lib/ask-classifier.d.ts +71 -0
  33. package/dist/lib/ask-classifier.js +197 -0
  34. package/dist/lib/browser/login-detection.d.ts +94 -0
  35. package/dist/lib/browser/login-detection.js +274 -0
  36. package/dist/lib/browser/profiles.d.ts +17 -8
  37. package/dist/lib/browser/profiles.js +27 -8
  38. package/dist/lib/browser/secret-ref.d.ts +10 -0
  39. package/dist/lib/browser/secret-ref.js +14 -0
  40. package/dist/lib/browser/service.js +14 -12
  41. package/dist/lib/cloud/antigravity.d.ts +0 -2
  42. package/dist/lib/cloud/antigravity.js +2 -17
  43. package/dist/lib/cloud/codex.js +3 -18
  44. package/dist/lib/cloud/rush.d.ts +15 -0
  45. package/dist/lib/cloud/rush.js +10 -16
  46. package/dist/lib/cloud/stream.js +2 -0
  47. package/dist/lib/cloud/types.d.ts +21 -0
  48. package/dist/lib/cloud/types.js +81 -0
  49. package/dist/lib/crabbox/cli.d.ts +1 -1
  50. package/dist/lib/crabbox/cli.js +12 -2
  51. package/dist/lib/crabbox/lease.d.ts +19 -0
  52. package/dist/lib/crabbox/lease.js +22 -11
  53. package/dist/lib/crabbox/progress.d.ts +62 -0
  54. package/dist/lib/crabbox/progress.js +129 -0
  55. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  56. package/dist/lib/crabbox/runtimes.js +98 -5
  57. package/dist/lib/daemon.d.ts +12 -9
  58. package/dist/lib/daemon.js +32 -17
  59. package/dist/lib/events.d.ts +31 -5
  60. package/dist/lib/events.js +291 -101
  61. package/dist/lib/exec.js +20 -1
  62. package/dist/lib/feed-outcome.d.ts +101 -0
  63. package/dist/lib/feed-outcome.js +244 -0
  64. package/dist/lib/feed-policy.d.ts +30 -0
  65. package/dist/lib/feed-policy.js +133 -0
  66. package/dist/lib/feed.d.ts +180 -0
  67. package/dist/lib/feed.js +627 -0
  68. package/dist/lib/git.d.ts +17 -1
  69. package/dist/lib/git.js +20 -1
  70. package/dist/lib/hooks.js +529 -14
  71. package/dist/lib/hosts/passthrough.d.ts +3 -3
  72. package/dist/lib/hosts/passthrough.js +3 -3
  73. package/dist/lib/mailbox-gc.d.ts +22 -0
  74. package/dist/lib/mailbox-gc.js +161 -0
  75. package/dist/lib/mailbox.d.ts +26 -2
  76. package/dist/lib/mailbox.js +80 -5
  77. package/dist/lib/mcp.js +82 -0
  78. package/dist/lib/memory.d.ts +55 -0
  79. package/dist/lib/memory.js +274 -0
  80. package/dist/lib/notify.d.ts +16 -0
  81. package/dist/lib/notify.js +61 -0
  82. package/dist/lib/operator.d.ts +26 -0
  83. package/dist/lib/operator.js +107 -0
  84. package/dist/lib/plugins.d.ts +35 -0
  85. package/dist/lib/plugins.js +217 -0
  86. package/dist/lib/remote-agents-json.d.ts +14 -0
  87. package/dist/lib/remote-agents-json.js +94 -0
  88. package/dist/lib/resources/mcp.js +44 -0
  89. package/dist/lib/resources/memory.d.ts +15 -0
  90. package/dist/lib/resources/memory.js +46 -0
  91. package/dist/lib/resources/types.d.ts +2 -2
  92. package/dist/lib/rotate.js +2 -0
  93. package/dist/lib/runner.d.ts +43 -0
  94. package/dist/lib/runner.js +323 -74
  95. package/dist/lib/sandbox.js +6 -0
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.d.ts +21 -0
  99. package/dist/lib/secrets/agent.js +63 -1
  100. package/dist/lib/secrets/bundles.d.ts +33 -1
  101. package/dist/lib/secrets/bundles.js +76 -21
  102. package/dist/lib/secrets/icloud-import.d.ts +79 -0
  103. package/dist/lib/secrets/icloud-import.js +203 -0
  104. package/dist/lib/secrets/index.d.ts +142 -2
  105. package/dist/lib/secrets/index.js +695 -30
  106. package/dist/lib/secrets/remote.js +1 -1
  107. package/dist/lib/secrets/sync.js +1 -1
  108. package/dist/lib/session/active.d.ts +18 -0
  109. package/dist/lib/session/active.js +47 -17
  110. package/dist/lib/session/db.d.ts +9 -1
  111. package/dist/lib/session/db.js +18 -3
  112. package/dist/lib/session/discover.d.ts +13 -0
  113. package/dist/lib/session/discover.js +32 -2
  114. package/dist/lib/session/parse.d.ts +8 -0
  115. package/dist/lib/session/parse.js +42 -21
  116. package/dist/lib/session/remote-active.js +8 -89
  117. package/dist/lib/session/state.d.ts +11 -0
  118. package/dist/lib/session/state.js +50 -1
  119. package/dist/lib/session/tail.d.ts +23 -4
  120. package/dist/lib/session/tail.js +34 -16
  121. package/dist/lib/session/throughput.d.ts +30 -0
  122. package/dist/lib/session/throughput.js +86 -0
  123. package/dist/lib/shim-heal.d.ts +12 -3
  124. package/dist/lib/shim-heal.js +12 -6
  125. package/dist/lib/staleness/detectors/subagents.js +57 -3
  126. package/dist/lib/staleness/writers/hooks.js +7 -3
  127. package/dist/lib/staleness/writers/subagents.js +37 -6
  128. package/dist/lib/startup/command-registry.d.ts +2 -0
  129. package/dist/lib/startup/command-registry.js +4 -0
  130. package/dist/lib/state.d.ts +2 -0
  131. package/dist/lib/state.js +25 -8
  132. package/dist/lib/subagents.d.ts +52 -0
  133. package/dist/lib/subagents.js +315 -12
  134. package/dist/lib/teams/agents.js +6 -3
  135. package/dist/lib/teams/worktree.d.ts +8 -0
  136. package/dist/lib/teams/worktree.js +8 -0
  137. package/dist/lib/types.d.ts +20 -2
  138. package/dist/lib/versions.js +38 -48
  139. package/dist/lib/whats-new.d.ts +5 -3
  140. package/dist/lib/whats-new.js +25 -5
  141. package/package.json +4 -3
  142. package/scripts/postinstall.js +61 -1
@@ -89,6 +89,9 @@ export interface LegacyBundleCandidate {
89
89
  file: string;
90
90
  keys: string[];
91
91
  }
92
+ export declare const BUNDLE_NAME_PATTERN: RegExp;
93
+ export declare const ENV_KEY_PATTERN: RegExp;
94
+ export declare const BUNDLE_META_PREFIX = "agents-cli.bundles.";
92
95
  export declare const RESERVED_ENV_NAMES: Set<string>;
93
96
  export declare function bundleToEnvPrefix(name: string): string;
94
97
  export declare function isReservedEnvName(key: string): boolean;
@@ -114,7 +117,29 @@ export declare function readBundle(name: string): SecretsBundle;
114
117
  export declare function secretsDefaultPolicy(): SecretsPolicy;
115
118
  /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
116
119
  export declare function bundlePolicy(bundle: SecretsBundle): SecretsPolicy;
117
- export declare function writeBundle(bundle: SecretsBundle): void;
120
+ /** Options for writeBundle. */
121
+ export interface WriteBundleOptions {
122
+ /**
123
+ * Skip evicting the bundle from the secrets-agent broker after the write.
124
+ * Only for writers that change nothing the broker serves — today that is
125
+ * stampLastUsed (a usage-telemetry timestamp, fired on every broker HIT):
126
+ * evicting there would make the cache destroy itself on first use. Every
127
+ * mutating writer (add / rotate / remove / rename / policy / import) must
128
+ * leave this unset so a broker-held copy never serves stale values for up
129
+ * to the ~7d hold.
130
+ */
131
+ skipBrokerEviction?: boolean;
132
+ }
133
+ /**
134
+ * Whether a bundle write should evict the broker-held copy. Pure + exported
135
+ * for regression coverage. Skips when the writer opted out (stampLastUsed),
136
+ * when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
137
+ * kill-switch the read fast-path honors), or when a test keychain backend is
138
+ * installed (an in-memory backend has no real keychain behind it, and a test
139
+ * writing bundle 'prod' must never evict the user's real 'prod' unlock).
140
+ */
141
+ export declare function shouldEvictAfterBundleWrite(skipRequested: boolean, noAgentEnv: string | undefined, backendOverridden: boolean): boolean;
142
+ export declare function writeBundle(bundle: SecretsBundle, opts?: WriteBundleOptions): void;
118
143
  export declare function deleteBundle(name: string): boolean;
119
144
  export declare function listBundles(): SecretsBundle[];
120
145
  export interface BundleEntryInfo {
@@ -140,6 +165,13 @@ export interface ResolveBundleOptions {
140
165
  * needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
141
166
  */
142
167
  noAgent?: boolean;
168
+ /**
169
+ * Resolve only from an already-unlocked secrets-agent snapshot. If the
170
+ * broker has no snapshot, fail before touching Keychain or any other store.
171
+ * Background processes use this to guarantee they never surface a biometric
172
+ * prompt that nobody can answer.
173
+ */
174
+ agentOnly?: boolean;
143
175
  /**
144
176
  * Inject only this subset of keys from the bundle. Keys not in this list are
145
177
  * silently excluded from the returned env map. An error is thrown if any
@@ -21,11 +21,11 @@ import * as fs from 'fs';
21
21
  import * as os from 'os';
22
22
  import * as path from 'path';
23
23
  import * as yaml from 'yaml';
24
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, isKeychainBackendOverridden, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
24
+ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, isKeychainBackendOverridden, keychainServiceAlias, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
25
25
  import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
27
  import { readMeta } from '../state.js';
28
- import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
28
+ import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
29
29
  import { createHash } from 'node:crypto';
30
30
  const keychainStore = {
31
31
  has: hasKeychainToken,
@@ -105,9 +105,9 @@ export const SECRET_TYPES = [
105
105
  ];
106
106
  /** Minimum gap between last_used updates so the keychain isn't written on every secrets injection. */
107
107
  const LAST_USED_THROTTLE_MS = 60_000;
108
- const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
109
- const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
110
- const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
108
+ export const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
109
+ export const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
110
+ export const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
111
111
  const SECRETS_ITEM_PREFIX = 'agents-cli.secrets.';
112
112
  export const RESERVED_ENV_NAMES = new Set([
113
113
  'PATH', 'HOME', 'USER', 'USERNAME', 'SHELL', 'PWD', 'OLDPWD',
@@ -277,7 +277,24 @@ export function secretsDefaultPolicy() {
277
277
  export function bundlePolicy(bundle) {
278
278
  return bundle.policy ?? secretsDefaultPolicy();
279
279
  }
280
- export function writeBundle(bundle) {
280
+ /**
281
+ * Whether a bundle write should evict the broker-held copy. Pure + exported
282
+ * for regression coverage. Skips when the writer opted out (stampLastUsed),
283
+ * when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
284
+ * kill-switch the read fast-path honors), or when a test keychain backend is
285
+ * installed (an in-memory backend has no real keychain behind it, and a test
286
+ * writing bundle 'prod' must never evict the user's real 'prod' unlock).
287
+ */
288
+ export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOverridden) {
289
+ if (skipRequested)
290
+ return false;
291
+ if (noAgentEnv === '1')
292
+ return false;
293
+ if (backendOverridden)
294
+ return false;
295
+ return true;
296
+ }
297
+ export function writeBundle(bundle, opts = {}) {
281
298
  validateBundleName(bundle.name);
282
299
  const backend = bundle.backend ?? 'keychain';
283
300
  if (backend === 'file')
@@ -311,6 +328,10 @@ export function writeBundle(bundle) {
311
328
  bundle.created_at = now;
312
329
  bundle.updated_at = now;
313
330
  const payload = {
331
+ // The bundle's own name, persisted since #316: with hashed service names
332
+ // the keychain item name is opaque, so listBundles recovers the display
333
+ // name from this field. Older CLIs drop unknown fields on read — safe.
334
+ name: bundle.name,
314
335
  description: bundle.description,
315
336
  allow_exec: bundle.allow_exec ? true : undefined,
316
337
  backend: backend === 'file' ? 'file' : undefined,
@@ -337,12 +358,20 @@ export function writeBundle(bundle) {
337
358
  // no-ACL command is missing) rather than silently landing an ACL'd item.
338
359
  itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
339
360
  emit('secrets.set', { module: 'secrets', bundle: bundle.name });
361
+ // A broker-held snapshot predates this write; evict it so the next read
362
+ // re-resolves from the keychain instead of serving stale values.
363
+ if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
364
+ agentEvictSync(bundle.name);
365
+ }
340
366
  }
341
367
  export function deleteBundle(name) {
342
368
  validateBundleName(name);
343
369
  const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
344
370
  if (deleted) {
345
371
  emit('secrets.delete', { module: 'secrets', bundle: name });
372
+ if (shouldEvictAfterBundleWrite(false, process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
373
+ agentEvictSync(name);
374
+ }
346
375
  }
347
376
  return deleted;
348
377
  }
@@ -351,8 +380,14 @@ export function deleteBundle(name) {
351
380
  * posture listBundles wants (skip malformed / invalid-key bundles rather than
352
381
  * throw). `backend` is authoritative from where the item was found. Returns
353
382
  * null to skip.
383
+ *
384
+ * `nameHint` is the name recovered from a cleartext service name (Linux, the
385
+ * file store, pre-re-key items) — authoritative when present, and the only
386
+ * source for legacy metadata that predates the persisted `name` field. With
387
+ * hashed service names (macOS, #316) the hint is undefined and the name comes
388
+ * from the JSON payload written by writeBundle.
354
389
  */
355
- function parseBundleMeta(name, json, backend) {
390
+ function parseBundleMeta(nameHint, json, backend) {
356
391
  let parsed;
357
392
  try {
358
393
  parsed = JSON.parse(json);
@@ -362,6 +397,9 @@ function parseBundleMeta(name, json, backend) {
362
397
  }
363
398
  if (!parsed || typeof parsed !== 'object')
364
399
  return null;
400
+ const name = nameHint ?? (typeof parsed.name === 'string' ? parsed.name : undefined);
401
+ if (!name || !BUNDLE_NAME_PATTERN.test(name))
402
+ return null;
365
403
  const bundle = {
366
404
  name,
367
405
  description: parsed.description,
@@ -406,10 +444,12 @@ export function listBundles() {
406
444
  catch {
407
445
  keychainServices = [];
408
446
  }
409
- const keychainNames = keychainServices
410
- .map((s) => s.slice(BUNDLE_META_PREFIX.length))
411
- .filter((n) => BUNDLE_NAME_PATTERN.test(n));
412
- if (keychainNames.length > 0) {
447
+ // With hashed service names (macOS, #316) the enumerated services are
448
+ // opaque (`agents-cli.h.<ns>.m`) — the display name is recovered from the
449
+ // metadata JSON after the batch read below. Cleartext services (Linux,
450
+ // pre-re-key items) still carry the name; it's kept as the parse hint so
451
+ // legacy metadata without the persisted `name` field keeps listing.
452
+ if (keychainServices.length > 0) {
413
453
  // Daily-policy fast-path (macOS). Bundle metadata items are biometry-gated,
414
454
  // so the getKeychainTokens batch below pops Touch ID on every `secrets
415
455
  // list` — the broker/`daily` mechanism only ever covered value reads, not
@@ -427,7 +467,7 @@ export function listBundles() {
427
467
  !isKeychainBackendOverridden() &&
428
468
  secretsAgentAutoEnabled();
429
469
  const nameSetHash = createHash('sha256')
430
- .update([...keychainNames].sort().join('\n'))
470
+ .update([...keychainServices].sort().join('\n'))
431
471
  .digest('hex')
432
472
  .slice(0, 32);
433
473
  const cached = useAgent ? agentGetMetaSync(nameSetHash) : null;
@@ -436,13 +476,16 @@ export function listBundles() {
436
476
  out.push(bundle);
437
477
  }
438
478
  else {
439
- const fetched = getKeychainTokens(keychainNames.map(bundleMetaItem));
479
+ const fetched = getKeychainTokens(keychainServices);
440
480
  const keychainBundles = [];
441
- for (const name of keychainNames) {
442
- const json = fetched.get(bundleMetaItem(name));
481
+ for (const service of keychainServices) {
482
+ const json = fetched.get(service);
443
483
  if (json === undefined)
444
484
  continue;
445
- const bundle = parseBundleMeta(name, json, 'keychain');
485
+ const nameHint = service.startsWith(BUNDLE_META_PREFIX)
486
+ ? service.slice(BUNDLE_META_PREFIX.length)
487
+ : undefined;
488
+ const bundle = parseBundleMeta(nameHint, json, 'keychain');
446
489
  if (bundle)
447
490
  keychainBundles.push(bundle);
448
491
  }
@@ -513,7 +556,9 @@ function stampLastUsed(bundle) {
513
556
  }
514
557
  try {
515
558
  bundle.last_used = new Date(nowMs).toISOString();
516
- writeBundle(bundle);
559
+ // skipBrokerEviction: this stamp fires on every broker HIT; letting it
560
+ // evict would make the cache destroy itself on first use.
561
+ writeBundle(bundle, { skipBrokerEviction: true });
517
562
  }
518
563
  catch {
519
564
  // Swallow — telemetry must never block secret resolution.
@@ -694,7 +739,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
694
739
  emit('secrets.get', {
695
740
  module: 'secrets',
696
741
  bundle: name,
697
- caller: opts.caller,
742
+ operation: opts.caller,
698
743
  status: 'success',
699
744
  source: 'agent',
700
745
  keyCount: Object.keys(filtered.env).length,
@@ -702,6 +747,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
702
747
  return filtered;
703
748
  }
704
749
  }
750
+ if (opts.agentOnly) {
751
+ throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent.`);
752
+ }
705
753
  if (backend === 'file')
706
754
  assertFileBackendUsable(name);
707
755
  const store = itemStore(backend);
@@ -718,7 +766,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
718
766
  ? `read ${name} secrets (for ${opts.caller})`
719
767
  : `read ${name} secrets`;
720
768
  void reason;
721
- const fetched = store.getBatch([metaItem, ...secretItems]);
769
+ // secretItems are storage names as enumerated (opaque hashed names on macOS
770
+ // with #316 hashing active, cleartext elsewhere); metaItem is cleartext and
771
+ // hashed inside getBatch. Deduped because the hashed enumeration spans the
772
+ // bundle's whole namespace.
773
+ const fetched = store.getBatch([...new Set([metaItem, ...secretItems])]);
722
774
  const json = fetched.get(metaItem);
723
775
  if (json === undefined) {
724
776
  // For a file-backed bundle the metadata item is on disk (that's how
@@ -784,7 +836,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
784
836
  emit('secrets.get', {
785
837
  module: 'secrets',
786
838
  bundle: bundle.name,
787
- caller: opts.caller,
839
+ operation: opts.caller,
788
840
  status,
789
841
  keyCount: keys.length,
790
842
  keys,
@@ -805,7 +857,10 @@ export function readAndResolveBundleEnv(name, opts = {}) {
805
857
  }
806
858
  if (p.ref.provider === 'keychain') {
807
859
  const item = secretsKeychainItem(bundle.name, p.ref.value);
808
- const value = fetched.get(item);
860
+ // The batch keys results by the names it was ASKED for: the cleartext
861
+ // metaItem, plus enumerated storage names. Look up the cleartext name
862
+ // first (Linux / file store), then its hashed storage alias (macOS).
863
+ const value = fetched.get(item) ?? fetched.get(keychainServiceAlias(item));
809
864
  if (value === undefined) {
810
865
  throw new Error(`Bundle '${bundle.name}' key '${key}': stored item '${item}' not found. ` +
811
866
  `Run: agents secrets add ${bundle.name} ${key}`);
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Recovery for LEGACY SYNCHRONIZABLE (iCloud Keychain) bundles.
3
+ *
4
+ * The pre-biometry helper era defaulted bundles to iCloud Keychain sync. The
5
+ * device-local cutover (biometry ACL + kSecAttrSynchronizable false on every
6
+ * query) orphaned those items: they still sync back via iCloud Keychain, but
7
+ * neither `secrets list` nor `migrate-acl` can see them. This module powers
8
+ * `agents secrets import --from icloud` — discover the orphaned bundles,
9
+ * re-import them as normal device-local bundles, and optionally purge the
10
+ * iCloud copies.
11
+ *
12
+ * The item-name scheme is the same one the modern store uses (see bundles.ts):
13
+ * metadata under `agents-cli.bundles.<name>`, one value per key under
14
+ * `agents-cli.secrets.<bundle>.<key>`. Env keys can never contain a dot
15
+ * (ENV_KEY_PATTERN), so splitting a secret service at its LAST dot recovers
16
+ * the bundle/key boundary even for dotted bundle names like `hetzner.com`.
17
+ */
18
+ import { type SecretsBackend } from './bundles.js';
19
+ /** One orphaned iCloud bundle, as discovered from the synced item names. */
20
+ export interface SyncedBundleCandidate {
21
+ /** Bundle name derived from the iCloud service names. */
22
+ name: string;
23
+ /** Env keys that have a per-key secret item in the iCloud keychain. */
24
+ keys: string[];
25
+ /** True when an `agents-cli.bundles.<name>` metadata item exists in iCloud. */
26
+ hasMeta: boolean;
27
+ /** Every iCloud service name belonging to this candidate (the purge set). */
28
+ services: string[];
29
+ }
30
+ /**
31
+ * Group raw synced service names into per-bundle candidates. Pure — separated
32
+ * from discovery so the parsing rules are unit-testable without a keychain.
33
+ *
34
+ * A bundle can surface as metadata only (`agents-cli.bundles.<name>`), as
35
+ * secret items only (`agents-cli.secrets.<bundle>.<KEY>` — metadata never
36
+ * synced), or both; all three shapes appear in real iCloud strays, so every
37
+ * one becomes a candidate.
38
+ */
39
+ export declare function groupSyncedServices(services: string[]): SyncedBundleCandidate[];
40
+ /** Enumerate the iCloud keychain and return every orphaned bundle candidate. */
41
+ export declare function discoverSyncedBundles(): SyncedBundleCandidate[];
42
+ export interface ImportSyncedOptions {
43
+ /** Overwrite keys that already exist in the local bundle. */
44
+ force?: boolean;
45
+ /** Store imported values as literals in the bundle metadata (no keychain items). */
46
+ allPlaintext?: boolean;
47
+ /** Backend for a newly created bundle (existing bundles keep theirs). */
48
+ backend?: SecretsBackend;
49
+ /** Delete the iCloud copies of successfully-read items after import. */
50
+ purge?: boolean;
51
+ }
52
+ export interface ImportSyncedResult {
53
+ name: string;
54
+ added: number;
55
+ skipped: number;
56
+ /** Keys whose iCloud value could not be read (left in place, never purged). */
57
+ missing: string[];
58
+ /**
59
+ * Keys the modern store refuses by policy (reserved env names like USER,
60
+ * loader/interpreter vars like DYLD_*) — the pre-cutover store accepted
61
+ * them. Left in iCloud, never purged: the iCloud item is the only copy.
62
+ */
63
+ unimportable: string[];
64
+ purged: number;
65
+ }
66
+ /**
67
+ * Import one discovered iCloud bundle into the local (device-local) store.
68
+ *
69
+ * Values come from the synced secret items; the synced metadata item, when
70
+ * present, contributes the description, literal vars, and non-keychain refs
71
+ * (env:/file:/exec: refs carry no stored secret, so copying the ref preserves
72
+ * them exactly). Existing local keys are skipped unless `force`. Keys the
73
+ * modern store refuses by policy (reserved / loader env names the pre-cutover
74
+ * store accepted) are reported as `unimportable` instead of aborting the whole
75
+ * bundle. With `purge`, only services whose value provably lives locally
76
+ * (imported now, or skipped-because-present) are deleted from iCloud — an
77
+ * unreadable or unimportable item is never destroyed.
78
+ */
79
+ export declare function importSyncedBundle(candidate: SyncedBundleCandidate, opts?: ImportSyncedOptions): ImportSyncedResult;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Recovery for LEGACY SYNCHRONIZABLE (iCloud Keychain) bundles.
3
+ *
4
+ * The pre-biometry helper era defaulted bundles to iCloud Keychain sync. The
5
+ * device-local cutover (biometry ACL + kSecAttrSynchronizable false on every
6
+ * query) orphaned those items: they still sync back via iCloud Keychain, but
7
+ * neither `secrets list` nor `migrate-acl` can see them. This module powers
8
+ * `agents secrets import --from icloud` — discover the orphaned bundles,
9
+ * re-import them as normal device-local bundles, and optionally purge the
10
+ * iCloud copies.
11
+ *
12
+ * The item-name scheme is the same one the modern store uses (see bundles.ts):
13
+ * metadata under `agents-cli.bundles.<name>`, one value per key under
14
+ * `agents-cli.secrets.<bundle>.<key>`. Env keys can never contain a dot
15
+ * (ENV_KEY_PATTERN), so splitting a secret service at its LAST dot recovers
16
+ * the bundle/key boundary even for dotted bundle names like `hetzner.com`.
17
+ */
18
+ import { deleteSyncedKeychainItem, getSyncedKeychainTokens, listSyncedKeychainItems, parseBundleValue, secretsKeychainItem, serializeRef, SECRETS_ITEM_PREFIX, } from './index.js';
19
+ import { BUNDLE_META_PREFIX, BUNDLE_NAME_PATTERN, ENV_KEY_PATTERN, bundleExists, bundleItemStore, bundlePolicy, isLoaderOrInterpreterEnv, isReservedEnvName, keychainRef, readBundle, writeBundle, } from './bundles.js';
20
+ /**
21
+ * Group raw synced service names into per-bundle candidates. Pure — separated
22
+ * from discovery so the parsing rules are unit-testable without a keychain.
23
+ *
24
+ * A bundle can surface as metadata only (`agents-cli.bundles.<name>`), as
25
+ * secret items only (`agents-cli.secrets.<bundle>.<KEY>` — metadata never
26
+ * synced), or both; all three shapes appear in real iCloud strays, so every
27
+ * one becomes a candidate.
28
+ */
29
+ export function groupSyncedServices(services) {
30
+ const byName = new Map();
31
+ const claim = (name) => {
32
+ let c = byName.get(name);
33
+ if (!c) {
34
+ c = { name, keys: [], hasMeta: false, services: [] };
35
+ byName.set(name, c);
36
+ }
37
+ return c;
38
+ };
39
+ for (const svc of services) {
40
+ if (svc.startsWith(BUNDLE_META_PREFIX)) {
41
+ const name = svc.slice(BUNDLE_META_PREFIX.length);
42
+ if (!BUNDLE_NAME_PATTERN.test(name))
43
+ continue;
44
+ const c = claim(name);
45
+ c.hasMeta = true;
46
+ c.services.push(svc);
47
+ }
48
+ else if (svc.startsWith(SECRETS_ITEM_PREFIX)) {
49
+ const rest = svc.slice(SECRETS_ITEM_PREFIX.length);
50
+ const cut = rest.lastIndexOf('.');
51
+ if (cut <= 0)
52
+ continue;
53
+ const name = rest.slice(0, cut);
54
+ const key = rest.slice(cut + 1);
55
+ if (!BUNDLE_NAME_PATTERN.test(name) || !ENV_KEY_PATTERN.test(key))
56
+ continue;
57
+ const c = claim(name);
58
+ if (!c.keys.includes(key))
59
+ c.keys.push(key);
60
+ c.services.push(svc);
61
+ }
62
+ }
63
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
64
+ }
65
+ /** Enumerate the iCloud keychain and return every orphaned bundle candidate. */
66
+ export function discoverSyncedBundles() {
67
+ return groupSyncedServices(listSyncedKeychainItems('agents-cli.'));
68
+ }
69
+ /**
70
+ * Import one discovered iCloud bundle into the local (device-local) store.
71
+ *
72
+ * Values come from the synced secret items; the synced metadata item, when
73
+ * present, contributes the description, literal vars, and non-keychain refs
74
+ * (env:/file:/exec: refs carry no stored secret, so copying the ref preserves
75
+ * them exactly). Existing local keys are skipped unless `force`. Keys the
76
+ * modern store refuses by policy (reserved / loader env names the pre-cutover
77
+ * store accepted) are reported as `unimportable` instead of aborting the whole
78
+ * bundle. With `purge`, only services whose value provably lives locally
79
+ * (imported now, or skipped-because-present) are deleted from iCloud — an
80
+ * unreadable or unimportable item is never destroyed.
81
+ */
82
+ export function importSyncedBundle(candidate, opts = {}) {
83
+ const values = getSyncedKeychainTokens(candidate.services);
84
+ let bundle;
85
+ if (bundleExists(candidate.name)) {
86
+ bundle = readBundle(candidate.name);
87
+ }
88
+ else {
89
+ bundle = {
90
+ name: candidate.name,
91
+ backend: opts.backend === 'file' ? 'file' : undefined,
92
+ vars: {},
93
+ };
94
+ }
95
+ const metaService = BUNDLE_META_PREFIX + candidate.name;
96
+ let metaVars = {};
97
+ const metaJson = values.get(metaService);
98
+ if (metaJson !== undefined) {
99
+ try {
100
+ const parsed = JSON.parse(metaJson);
101
+ if (parsed && typeof parsed === 'object') {
102
+ if (typeof parsed.description === 'string' && !bundle.description) {
103
+ bundle.description = parsed.description;
104
+ }
105
+ if (parsed.vars && typeof parsed.vars === 'object') {
106
+ metaVars = parsed.vars;
107
+ }
108
+ }
109
+ }
110
+ catch {
111
+ // Corrupt legacy metadata — the per-key secret items still import.
112
+ }
113
+ }
114
+ const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
115
+ let added = 0;
116
+ let skipped = 0;
117
+ const missing = [];
118
+ const unimportable = [];
119
+ // Services whose value now provably lives in the local store — imported this
120
+ // run, or skipped because the local bundle already carries the key. Only
121
+ // these may be purged; a missing or unimportable key's iCloud item is its
122
+ // only copy and must survive.
123
+ const purgeable = new Set();
124
+ const rejectedByPolicy = (key) => isLoaderOrInterpreterEnv(key) || isReservedEnvName(key);
125
+ // Keys with a synced secret item: re-store the value device-locally.
126
+ for (const key of candidate.keys) {
127
+ // The pre-cutover store accepted keys the modern one refuses (writeBundle
128
+ // throws) — skip them instead of aborting the whole bundle.
129
+ if (rejectedByPolicy(key)) {
130
+ unimportable.push(key);
131
+ continue;
132
+ }
133
+ const value = values.get(secretsKeychainItem(candidate.name, key));
134
+ if (value === undefined) {
135
+ missing.push(key);
136
+ continue;
137
+ }
138
+ if (!opts.force && key in bundle.vars) {
139
+ skipped++;
140
+ purgeable.add(secretsKeychainItem(candidate.name, key));
141
+ continue;
142
+ }
143
+ if (opts.allPlaintext) {
144
+ bundle.vars[key] = { value };
145
+ }
146
+ else {
147
+ store.set(secretsKeychainItem(candidate.name, key), value);
148
+ bundle.vars[key] = keychainRef(key);
149
+ }
150
+ purgeable.add(secretsKeychainItem(candidate.name, key));
151
+ added++;
152
+ }
153
+ // Vars declared only in the synced metadata: literals and non-keychain refs
154
+ // carry everything they need; a keychain ref without its synced item is
155
+ // unrecoverable.
156
+ for (const [key, raw] of Object.entries(metaVars)) {
157
+ if (!ENV_KEY_PATTERN.test(key))
158
+ continue;
159
+ if (candidate.keys.includes(key))
160
+ continue; // the secret item already covered it
161
+ if (rejectedByPolicy(key)) {
162
+ if (!unimportable.includes(key))
163
+ unimportable.push(key);
164
+ continue;
165
+ }
166
+ let parsed;
167
+ try {
168
+ parsed = parseBundleValue(raw);
169
+ }
170
+ catch {
171
+ continue; // malformed legacy entry
172
+ }
173
+ if ('ref' in parsed && parsed.ref.provider === 'keychain') {
174
+ if (!missing.includes(key))
175
+ missing.push(key);
176
+ continue;
177
+ }
178
+ if (!opts.force && key in bundle.vars) {
179
+ skipped++;
180
+ continue;
181
+ }
182
+ bundle.vars[key] = 'literal' in parsed ? { value: parsed.literal } : serializeRef(parsed.ref);
183
+ added++;
184
+ }
185
+ writeBundle(bundle);
186
+ // The metadata item is only a projection of what was just written locally —
187
+ // safe to purge once writeBundle has succeeded, UNLESS it still names keys
188
+ // that never made it over (missing/unimportable): then it stays as the only
189
+ // record of them.
190
+ if (metaJson !== undefined && missing.length === 0 && unimportable.length === 0) {
191
+ purgeable.add(metaService);
192
+ }
193
+ let purged = 0;
194
+ if (opts.purge) {
195
+ for (const svc of candidate.services) {
196
+ if (!purgeable.has(svc))
197
+ continue;
198
+ if (deleteSyncedKeychainItem(svc))
199
+ purged++;
200
+ }
201
+ }
202
+ return { name: candidate.name, added, skipped, missing, unimportable, purged };
203
+ }