@phnx-labs/agents-cli 1.20.77 → 1.20.82

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 (212) hide show
  1. package/CHANGELOG.md +589 -0
  2. package/README.md +27 -25
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +74 -0
  12. package/dist/commands/doctor.js +718 -40
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +53 -8
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +195 -11
  29. package/dist/commands/routines.js +16 -4
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +108 -34
  33. package/dist/commands/sessions-browser.d.ts +45 -3
  34. package/dist/commands/sessions-browser.js +118 -12
  35. package/dist/commands/sessions-export.d.ts +3 -0
  36. package/dist/commands/sessions-export.js +13 -10
  37. package/dist/commands/sessions-inject.d.ts +7 -4
  38. package/dist/commands/sessions-inject.js +18 -18
  39. package/dist/commands/sessions-picker.d.ts +15 -1
  40. package/dist/commands/sessions-picker.js +90 -7
  41. package/dist/commands/sessions-resume.d.ts +16 -0
  42. package/dist/commands/sessions-resume.js +14 -13
  43. package/dist/commands/sessions.d.ts +126 -1
  44. package/dist/commands/sessions.js +365 -42
  45. package/dist/commands/set.d.ts +15 -0
  46. package/dist/commands/set.js +79 -0
  47. package/dist/commands/setup-fleet.d.ts +21 -0
  48. package/dist/commands/setup-fleet.js +201 -0
  49. package/dist/commands/setup-secrets.d.ts +20 -0
  50. package/dist/commands/setup-secrets.js +223 -0
  51. package/dist/commands/setup.js +17 -3
  52. package/dist/commands/skills.js +1 -1
  53. package/dist/commands/ssh.js +9 -1
  54. package/dist/commands/sync.js +7 -2
  55. package/dist/commands/teams-picker.js +0 -1
  56. package/dist/commands/teams.d.ts +33 -1
  57. package/dist/commands/teams.js +208 -44
  58. package/dist/commands/versions.js +10 -5
  59. package/dist/commands/view.d.ts +1 -0
  60. package/dist/commands/view.js +11 -0
  61. package/dist/index.js +8 -13
  62. package/dist/lib/acp/harnesses.js +0 -7
  63. package/dist/lib/activity.d.ts +156 -0
  64. package/dist/lib/activity.js +282 -0
  65. package/dist/lib/agents.d.ts +9 -23
  66. package/dist/lib/agents.js +72 -100
  67. package/dist/lib/auto-pull.d.ts +16 -8
  68. package/dist/lib/auto-pull.js +23 -28
  69. package/dist/lib/browser/service.js +3 -0
  70. package/dist/lib/browser/types.d.ts +7 -0
  71. package/dist/lib/capabilities.js +6 -2
  72. package/dist/lib/crabbox/lease.js +2 -2
  73. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  74. package/dist/lib/crabbox/setup-copy.js +4 -4
  75. package/dist/lib/daemon.d.ts +26 -40
  76. package/dist/lib/daemon.js +63 -123
  77. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  78. package/dist/lib/devices/fleet-divergence.js +188 -0
  79. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  80. package/dist/lib/devices/fleet-inventory.js +57 -0
  81. package/dist/lib/devices/fleet.d.ts +1 -1
  82. package/dist/lib/devices/fleet.js +1 -1
  83. package/dist/lib/devices/health-report.d.ts +10 -2
  84. package/dist/lib/devices/health-report.js +32 -1
  85. package/dist/lib/doctor-diff.d.ts +20 -0
  86. package/dist/lib/doctor-diff.js +6 -1
  87. package/dist/lib/drift.d.ts +31 -11
  88. package/dist/lib/drift.js +58 -7
  89. package/dist/lib/events.d.ts +8 -1
  90. package/dist/lib/events.js +10 -1
  91. package/dist/lib/exec.js +35 -8
  92. package/dist/lib/feed-post.d.ts +28 -1
  93. package/dist/lib/feed-post.js +110 -2
  94. package/dist/lib/fleet/apply.js +8 -1
  95. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  96. package/dist/lib/fleet/auth-sync.js +25 -11
  97. package/dist/lib/fleet/remote-login.js +5 -0
  98. package/dist/lib/git.d.ts +27 -14
  99. package/dist/lib/git.js +138 -43
  100. package/dist/lib/hooks.d.ts +42 -0
  101. package/dist/lib/hooks.js +137 -93
  102. package/dist/lib/hosts/dispatch.d.ts +28 -1
  103. package/dist/lib/hosts/dispatch.js +33 -5
  104. package/dist/lib/hosts/option.js +2 -2
  105. package/dist/lib/hosts/passthrough.d.ts +22 -1
  106. package/dist/lib/hosts/passthrough.js +238 -13
  107. package/dist/lib/hosts/remote-cmd.js +2 -0
  108. package/dist/lib/hosts/run-target.d.ts +2 -0
  109. package/dist/lib/hosts/run-target.js +1 -0
  110. package/dist/lib/hq/floor.d.ts +1 -1
  111. package/dist/lib/hq/floor.js +6 -1
  112. package/dist/lib/mcp.js +3 -71
  113. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  116. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  117. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  118. package/dist/lib/menubar/notify-desktop.js +41 -5
  119. package/dist/lib/merged-resources.d.ts +11 -0
  120. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  121. package/dist/lib/permissions.d.ts +0 -32
  122. package/dist/lib/permissions.js +4 -157
  123. package/dist/lib/picker.js +1 -1
  124. package/dist/lib/platform/process.d.ts +16 -5
  125. package/dist/lib/platform/process.js +54 -0
  126. package/dist/lib/plugins.d.ts +0 -8
  127. package/dist/lib/plugins.js +4 -110
  128. package/dist/lib/project-resources.js +4 -1
  129. package/dist/lib/refresh.d.ts +5 -3
  130. package/dist/lib/refresh.js +7 -5
  131. package/dist/lib/resources/commands.js +4 -1
  132. package/dist/lib/resources/mcp.js +0 -4
  133. package/dist/lib/resources/permissions.d.ts +1 -1
  134. package/dist/lib/resources/permissions.js +1 -5
  135. package/dist/lib/resources/rules.js +4 -1
  136. package/dist/lib/resources/skills.js +4 -1
  137. package/dist/lib/resources/subagents.js +4 -0
  138. package/dist/lib/resources/types.d.ts +1 -1
  139. package/dist/lib/routines.d.ts +20 -0
  140. package/dist/lib/routines.js +6 -0
  141. package/dist/lib/run-defaults.d.ts +1 -0
  142. package/dist/lib/run-defaults.js +9 -0
  143. package/dist/lib/runner.d.ts +0 -12
  144. package/dist/lib/runner.js +71 -29
  145. package/dist/lib/sandbox.js +0 -16
  146. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  147. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  149. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  151. package/dist/lib/secrets/agent.d.ts +18 -0
  152. package/dist/lib/secrets/agent.js +131 -17
  153. package/dist/lib/secrets/audit.d.ts +46 -0
  154. package/dist/lib/secrets/audit.js +56 -0
  155. package/dist/lib/secrets/bundles.d.ts +12 -9
  156. package/dist/lib/secrets/bundles.js +47 -31
  157. package/dist/lib/secrets/index.d.ts +2 -1
  158. package/dist/lib/secrets/index.js +8 -2
  159. package/dist/lib/secrets/remote.d.ts +75 -2
  160. package/dist/lib/secrets/remote.js +139 -5
  161. package/dist/lib/secrets/scope.d.ts +26 -0
  162. package/dist/lib/secrets/scope.js +29 -0
  163. package/dist/lib/secrets/session-store.d.ts +10 -0
  164. package/dist/lib/secrets/session-store.js +64 -11
  165. package/dist/lib/secrets/sync.js +3 -3
  166. package/dist/lib/session/active.d.ts +117 -26
  167. package/dist/lib/session/active.js +199 -53
  168. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  169. package/dist/lib/session/actor-sidecar.js +101 -0
  170. package/dist/lib/session/db.d.ts +41 -1
  171. package/dist/lib/session/db.js +173 -8
  172. package/dist/lib/session/digest.js +126 -21
  173. package/dist/lib/session/discover.d.ts +19 -2
  174. package/dist/lib/session/discover.js +32 -11
  175. package/dist/lib/session/hook-sessions.d.ts +9 -0
  176. package/dist/lib/session/hook-sessions.js +57 -8
  177. package/dist/lib/session/origin-machine.d.ts +18 -0
  178. package/dist/lib/session/origin-machine.js +34 -0
  179. package/dist/lib/session/pid-registry.d.ts +13 -0
  180. package/dist/lib/session/render.d.ts +2 -0
  181. package/dist/lib/session/render.js +1 -1
  182. package/dist/lib/session/state.d.ts +1 -0
  183. package/dist/lib/session/state.js +1 -1
  184. package/dist/lib/session/sync/config.js +2 -2
  185. package/dist/lib/session/types.d.ts +16 -0
  186. package/dist/lib/smart-launch.d.ts +86 -0
  187. package/dist/lib/smart-launch.js +172 -0
  188. package/dist/lib/staleness/detectors/commands.js +2 -2
  189. package/dist/lib/staleness/detectors/permissions.js +1 -45
  190. package/dist/lib/staleness/writers/commands.js +4 -5
  191. package/dist/lib/staleness/writers/hooks.js +1 -1
  192. package/dist/lib/startup/command-registry.d.ts +1 -2
  193. package/dist/lib/startup/command-registry.js +2 -4
  194. package/dist/lib/subagents-registry.js +5 -12
  195. package/dist/lib/subagents.d.ts +0 -10
  196. package/dist/lib/subagents.js +0 -12
  197. package/dist/lib/teams/agents.d.ts +0 -2
  198. package/dist/lib/teams/agents.js +10 -27
  199. package/dist/lib/teams/index.d.ts +1 -1
  200. package/dist/lib/teams/index.js +1 -1
  201. package/dist/lib/types.d.ts +17 -9
  202. package/dist/lib/usage.d.ts +28 -5
  203. package/dist/lib/usage.js +271 -8
  204. package/dist/lib/versions.js +7 -1
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +25 -13
  207. package/package.json +1 -2
  208. package/dist/commands/check.d.ts +0 -15
  209. package/dist/commands/check.js +0 -180
  210. package/dist/commands/resources.d.ts +0 -5
  211. package/dist/lib/secrets/account-token.d.ts +0 -20
  212. package/dist/lib/secrets/account-token.js +0 -64
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Canonical audit emitter for `agents secrets` value access and unlock grants.
3
+ *
4
+ * Every path that reads a secret VALUE or grants an unlock funnels its audit
5
+ * through here, so the operational event stream — `agents events`, backed by the
6
+ * append-only `~/.agents/events.jsonl` audit log — carries a uniform, value-free
7
+ * provenance record: bundle, key NAMES, the resolving agent/harness identity,
8
+ * operation, source, status. The ts / host / session / caller fields are filled
9
+ * in by `emit()` itself. The secret VALUE is never part of the payload; this
10
+ * helper only ever receives metadata, and `emit()`'s `sanitizePayload` is a
11
+ * second redaction layer.
12
+ *
13
+ * Two event types, both audit-level and non-milestone (so they surface in
14
+ * `agents events` and the persisted audit trail, but are NOT required in the
15
+ * curated `agents activity` / `agents feed` surfaces):
16
+ * - `secrets.get` — a value was READ (exec inject, export, `view --reveal`,
17
+ * raw `get <item>`, remote resolve, sync push,
18
+ * `run --secrets`, and every other bundle read).
19
+ * - `secrets.unlocked` — a bundle was GRANTED into the secrets broker / durable
20
+ * session by `agents secrets unlock`, then readable
21
+ * prompt-free for the grant TTL.
22
+ */
23
+ import { emit } from '../events.js';
24
+ /**
25
+ * The agent/harness identity to attribute a secret access to. Explicit callers
26
+ * (the bundle reader knows the scope it resolved under) win; otherwise fall back
27
+ * to the ambient `$AGENTS_AGENT_NAME` set on every agent launch.
28
+ */
29
+ export function resolveAuditAgent(explicit) {
30
+ const a = explicit || process.env.AGENTS_AGENT_NAME;
31
+ return a && a.length > 0 ? a : undefined;
32
+ }
33
+ /**
34
+ * Emit one value-free secret audit event to the raw/operational event stream and
35
+ * the persisted audit log. Only ever pass metadata — the secret value must never
36
+ * reach this function.
37
+ */
38
+ export function emitSecretAudit(p) {
39
+ const agent = resolveAuditAgent(p.agent);
40
+ emit(p.event, {
41
+ module: 'secrets',
42
+ ...(p.bundle !== undefined ? { bundle: p.bundle } : {}),
43
+ ...(p.item !== undefined ? { item: p.item } : {}),
44
+ ...(p.operation !== undefined ? { operation: p.operation } : {}),
45
+ ...(p.source !== undefined ? { source: p.source } : {}),
46
+ ...(p.status !== undefined ? { status: p.status } : {}),
47
+ ...(p.keys !== undefined ? { keys: p.keys } : {}),
48
+ ...(p.keyCount !== undefined ? { keyCount: p.keyCount } : {}),
49
+ ...(p.keychainKeys !== undefined ? { keychainKeys: p.keychainKeys } : {}),
50
+ ...(p.kindCounts !== undefined ? { kindCounts: p.kindCounts } : {}),
51
+ ...(agent !== undefined ? { agent } : {}),
52
+ ...(p.host !== undefined ? { host: p.host } : {}),
53
+ ...(p.ttlMs !== undefined ? { ttlMs: p.ttlMs } : {}),
54
+ ...(p.error !== undefined ? { error: p.error } : {}),
55
+ });
56
+ }
@@ -43,7 +43,9 @@ export interface VarMeta {
43
43
  }
44
44
  /**
45
45
  * A bundle's prompt policy — how often macOS asks for Touch ID to read it:
46
- * - `daily` (default): ask once, then hold it silently for up to ~7 days.
46
+ * - `hold` (default): ask once, then serve it silently for the configured hold
47
+ * duration (`secrets.agent.holdMs`, 7d by default). Named for what it does —
48
+ * it was called `daily`, which stated a period it never had.
47
49
  * (Historical name — the window is now a rolling ~1 week, not one calendar day.)
48
50
  * Eligible for the secrets-agent — the first real keychain read auto-loads it
49
51
  * (auto-cache is on by default) so concurrent runs read it silently, or `unlock`
@@ -61,12 +63,12 @@ export interface VarMeta {
61
63
  * it loudly rather than silently downgrading to `always`.
62
64
  *
63
65
  * The default is configurable via `secrets.policy` in agents.yaml. Stored on disk
64
- * under the legacy `tier` key (`session` == `daily`, `biometry` == explicit
66
+ * under the legacy `tier` key (`session` == `hold`, `biometry` == explicit
65
67
  * `always`, `none` == `never`, absent == inherit the default) so bundles stay
66
68
  * readable across mixed CLI versions on synced machines. The user-facing
67
- * vocabulary is `policy`/`always`/`daily`/`never`.
69
+ * vocabulary is `policy`/`always`/`hold`/`never`.
68
70
  */
69
- export type SecretsPolicy = 'always' | 'daily' | 'never';
71
+ export type SecretsPolicy = 'always' | 'hold' | 'never';
70
72
  /** A named set of environment variable definitions backed by various secret providers. */
71
73
  export interface SecretsBundle {
72
74
  name: string;
@@ -74,7 +76,7 @@ export interface SecretsBundle {
74
76
  allow_exec?: boolean;
75
77
  /** Which store carries this bundle's items. Absent ⇒ `keychain` (the default). */
76
78
  backend?: SecretsBackend;
77
- /** Prompt policy. Absent ⇒ the configured default (`daily`). Serialized under
79
+ /** Prompt policy. Absent ⇒ the configured default (`hold`). Serialized under
78
80
  * the legacy `tier` key — see SecretsPolicy. */
79
81
  policy?: SecretsPolicy;
80
82
  /** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
@@ -142,9 +144,10 @@ export declare class BundleUndecryptableError extends Error {
142
144
  export declare function readBundleIfDecryptable(name: string): SecretsBundle | null;
143
145
  export declare function readBundle(name: string): SecretsBundle;
144
146
  /** The default prompt policy applied to bundles without an explicit per-bundle
145
- * policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
146
- * ID per ~7d) unless the user explicitly opts back into prompt-every-time with
147
- * `always`. Best-effort: an unreadable config falls back to the `daily` default. */
147
+ * policy. Configurable via `secrets.policy` in agents.yaml; `hold` (one Touch ID
148
+ * per hold window `secrets.agent.holdMs`, 7d by default) unless the user
149
+ * explicitly opts back into prompt-every-time with `always`. Best-effort: an
150
+ * unreadable config falls back to the `hold` default. */
148
151
  export declare function secretsDefaultPolicy(): SecretsPolicy;
149
152
  /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
150
153
  export declare function bundlePolicy(bundle: SecretsBundle): SecretsPolicy;
@@ -301,7 +304,7 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
301
304
  *
302
305
  * A read in a macOS headless context resolves broker-only (agentOnly) and fails
303
306
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
304
- * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
307
+ * the per-caller broker-only pattern used across the headless secrets readers.
305
308
  */
306
309
  export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
307
310
  stdin?: boolean;
@@ -28,10 +28,12 @@ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainTo
28
28
  import { fileStore } from './filestore.js';
29
29
  import { getVaultSession, vaultDeleteItem, vaultExists, vaultGetItems, vaultGetItem, vaultHasItem, vaultListItems, vaultSetItems, vaultSetItem, } from './vault.js';
30
30
  import { emit } from '../events.js';
31
+ import { emitSecretAudit } from './audit.js';
31
32
  import { readMeta, getHelpersDir } from '../state.js';
32
33
  import { assertNameActiveInResourceProfile, filterNamesForActiveResourceProfile } from '../resource-profiles.js';
33
34
  import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
34
- import { loadSession, deleteSession } from './session-store.js';
35
+ import { GLOBAL_HARNESS } from './scope.js';
36
+ import { resolveSession, deleteSession } from './session-store.js';
35
37
  import { createHash } from 'node:crypto';
36
38
  const keychainStore = {
37
39
  has: hasKeychainToken,
@@ -323,7 +325,7 @@ export function readBundle(name) {
323
325
  // Absent ⇒ keychain; only set when non-keychain so a keychain bundle
324
326
  // round-trips byte-for-byte.
325
327
  backend: backend === 'keychain' ? undefined : backend,
326
- // Legacy wire key: the policy is persisted under `tier` (`session` == `daily`).
328
+ // Legacy wire key: the policy is persisted under `tier` (`session` == `hold`).
327
329
  policy: parsePolicy(parsed.tier),
328
330
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
329
331
  };
@@ -342,14 +344,14 @@ export function readBundle(name) {
342
344
  return bundle;
343
345
  }
344
346
  /** Normalize the persisted prompt policy. The on-disk `tier` key uses legacy
345
- * tokens for cross-version compatibility: `session` ⇒ `daily`, `biometry` ⇒ an
347
+ * tokens for cross-version compatibility: `session` ⇒ `hold`, `biometry` ⇒ an
346
348
  * explicit `always`. An absent token ⇒ undefined, which resolves to the
347
- * configured default policy (`daily`). Persisting an explicit `always` as the
348
- * legacy `biometry` token keeps older CLIs correct — they don't know `daily`,
349
+ * configured default policy (`hold`). Persisting an explicit `always` as the
350
+ * legacy `biometry` token keeps older CLIs correct — they don't know `hold`,
349
351
  * read `biometry` as undefined, and fall back to their own always default. */
350
352
  function parsePolicy(raw) {
351
- if (raw === 'daily' || raw === 'session')
352
- return 'daily';
353
+ if (raw === 'hold' || raw === 'daily' || raw === 'session')
354
+ return 'hold';
353
355
  if (raw === 'always' || raw === 'biometry')
354
356
  return 'always';
355
357
  if (raw === 'never' || raw === 'none')
@@ -357,15 +359,16 @@ function parsePolicy(raw) {
357
359
  return undefined;
358
360
  }
359
361
  /** The default prompt policy applied to bundles without an explicit per-bundle
360
- * policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
361
- * ID per ~7d) unless the user explicitly opts back into prompt-every-time with
362
- * `always`. Best-effort: an unreadable config falls back to the `daily` default. */
362
+ * policy. Configurable via `secrets.policy` in agents.yaml; `hold` (one Touch ID
363
+ * per hold window `secrets.agent.holdMs`, 7d by default) unless the user
364
+ * explicitly opts back into prompt-every-time with `always`. Best-effort: an
365
+ * unreadable config falls back to the `hold` default. */
363
366
  export function secretsDefaultPolicy() {
364
367
  try {
365
- return readMeta().secrets?.policy === 'always' ? 'always' : 'daily';
368
+ return readMeta().secrets?.policy === 'always' ? 'always' : 'hold';
366
369
  }
367
370
  catch {
368
- return 'daily';
371
+ return 'hold';
369
372
  }
370
373
  }
371
374
  /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
@@ -433,12 +436,12 @@ function prepareBundleWrite(bundle) {
433
436
  allow_exec: bundle.allow_exec ? true : undefined,
434
437
  backend: backend === 'keychain' ? undefined : backend,
435
438
  // Wire format: persist the policy under the legacy `tier` token so older CLI
436
- // versions on other synced machines keep reading it — `daily`⇒`session`,
439
+ // versions on other synced machines keep reading it — `hold`⇒`session`,
437
440
  // explicit `always`⇒`biometry`, `never`⇒`none`. An absent policy omits the
438
- // token entirely and resolves to the configured default (`daily`) on read.
441
+ // token entirely and resolves to the configured default (`hold`) on read.
439
442
  // An older CLI that doesn't know `none` reads it as undefined and falls back
440
443
  // to its own default — safe, since it also lacks the no-ACL write path.
441
- tier: bundle.policy === 'daily' ? 'session'
444
+ tier: bundle.policy === 'hold' ? 'session'
442
445
  : bundle.policy === 'always' ? 'biometry'
443
446
  : bundle.policy === 'never' ? 'none'
444
447
  : undefined,
@@ -545,7 +548,7 @@ function parseBundleMeta(nameHint, json, backend) {
545
548
  description: parsed.description,
546
549
  allow_exec: Boolean(parsed.allow_exec),
547
550
  backend: backend === 'keychain' ? undefined : backend,
548
- // Legacy wire key: the policy is persisted under `tier` (`session` == `daily`).
551
+ // Legacy wire key: the policy is persisted under `tier` (`session` == `hold`).
549
552
  policy: parsePolicy(parsed.tier),
550
553
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
551
554
  };
@@ -1043,7 +1046,7 @@ export function resolveBundleEnv(bundle, _opts = {}) {
1043
1046
  *
1044
1047
  * A read in a macOS headless context resolves broker-only (agentOnly) and fails
1045
1048
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
1046
- * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
1049
+ * the per-caller broker-only pattern used across the headless secrets readers.
1047
1050
  */
1048
1051
  export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
1049
1052
  // Injected so the TTY branch below is testable: it is the branch that decides a
@@ -1097,7 +1100,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1097
1100
  // file-backed bundle has none to dedup. The never-unlocked path is a single
1098
1101
  // stat (agentSocketExists) so it costs nothing when the agent isn't running.
1099
1102
  if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
1100
- const harness = opts.agent || process.env.AGENTS_AGENT_NAME || 'cli';
1103
+ // The scope this reader asks under. Falls back to the GLOBAL scope, not to a
1104
+ // literal `'cli'` harness — the broker and the durable store both resolve
1105
+ // own-harness → global (bundleScopeChain), so an unscoped unlock is visible
1106
+ // here whether this process was launched by an agent or typed in a terminal.
1107
+ const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
1101
1108
  const hit = agentGetSync(name, harness);
1102
1109
  if (hit) {
1103
1110
  // The agent stores the FULL bundle env. Apply the same subset filter and
@@ -1106,13 +1113,14 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1106
1113
  // the first cache-populating run.
1107
1114
  const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
1108
1115
  stampLastUsed(filtered.bundle);
1109
- emit('secrets.get', {
1110
- module: 'secrets',
1116
+ emitSecretAudit({
1117
+ event: 'secrets.get',
1111
1118
  bundle: name,
1112
1119
  operation: opts.caller,
1113
1120
  status: 'success',
1114
1121
  source: 'agent',
1115
1122
  keyCount: Object.keys(filtered.env).length,
1123
+ agent: harness,
1116
1124
  });
1117
1125
  return filtered;
1118
1126
  }
@@ -1122,20 +1130,24 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1122
1130
  // Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
1123
1131
  // across restart — this fixes BOTH the interactive re-prompt and the headless
1124
1132
  // throw below (which now fires only when there is genuinely no session).
1125
- const session = loadSession(name, Date.now(), harness);
1126
- if (session) {
1133
+ const resolved = resolveSession(name, Date.now(), harness);
1134
+ if (resolved) {
1135
+ const session = resolved.entry;
1127
1136
  const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
1128
1137
  stampLastUsed(filtered.bundle);
1129
1138
  // Re-warm the broker with the remaining TTL so later reads hit RAM and
1130
- // `agents secrets status` is honest. Best-effort; no-ops off darwin.
1131
- agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), harness);
1132
- emit('secrets.get', {
1133
- module: 'secrets',
1139
+ // `agents secrets status` is honest. Re-warm under the scope the grant was
1140
+ // MADE in (resolved.harness), never the asking scope — re-warming a global
1141
+ // grant as `claude` would silently narrow it for every other harness.
1142
+ agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness);
1143
+ emitSecretAudit({
1144
+ event: 'secrets.get',
1134
1145
  bundle: name,
1135
1146
  operation: opts.caller,
1136
1147
  status: 'success',
1137
1148
  source: 'session',
1138
1149
  keyCount: Object.keys(filtered.env).length,
1150
+ agent: harness,
1139
1151
  });
1140
1152
  return filtered;
1141
1153
  }
@@ -1190,6 +1202,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1190
1202
  ? getKeychainTokens([...new Set([metaItem, ...secretItems])], {
1191
1203
  agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
1192
1204
  bundle: name,
1205
+ // The session that triggered the read, so a Touch ID prompt is
1206
+ // attributable when several agents run at once. Exported by exec.ts.
1207
+ sessionId: process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID,
1193
1208
  reason: opts.caller ? `to ${opts.caller}` : reason,
1194
1209
  duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
1195
1210
  defaultPolicy: secretsDefaultPolicy(),
@@ -1225,7 +1240,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1225
1240
  description: parsed.description,
1226
1241
  allow_exec: Boolean(parsed.allow_exec),
1227
1242
  backend: backend === 'keychain' ? undefined : backend,
1228
- // Legacy wire key: the policy is persisted under `tier` (`session` == `daily`).
1243
+ // Legacy wire key: the policy is persisted under `tier` (`session` == `hold`).
1229
1244
  policy: parsePolicy(parsed.tier),
1230
1245
  vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
1231
1246
  };
@@ -1261,8 +1276,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1261
1276
  const keys = [...selectedKeys].sort();
1262
1277
  keychainKeys.sort();
1263
1278
  const emitReadAudit = (status, err) => {
1264
- emit('secrets.get', {
1265
- module: 'secrets',
1279
+ emitSecretAudit({
1280
+ event: 'secrets.get',
1266
1281
  bundle: bundle.name,
1267
1282
  operation: opts.caller,
1268
1283
  status,
@@ -1270,6 +1285,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1270
1285
  keys,
1271
1286
  keychainKeys,
1272
1287
  kindCounts,
1288
+ agent: opts.agent,
1273
1289
  error: err instanceof Error ? err.message : (err ? String(err) : undefined),
1274
1290
  });
1275
1291
  };
@@ -1320,10 +1336,10 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1320
1336
  if (backend === 'keychain' &&
1321
1337
  !opts.noAgent &&
1322
1338
  process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
1323
- bundlePolicy(bundle) === 'daily' &&
1339
+ bundlePolicy(bundle) === 'hold' &&
1324
1340
  secretsAgentAutoEnabled() &&
1325
1341
  canCacheResolvedEnv(bundle, selectedKeys, opts.keyMode)) {
1326
- agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || 'cli');
1342
+ agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS);
1327
1343
  }
1328
1344
  return { bundle, env };
1329
1345
  }
@@ -213,9 +213,10 @@ export declare function hasKeychainToken(item: string): boolean;
213
213
  export interface KeychainReadContext {
214
214
  agent?: string;
215
215
  bundle?: string;
216
+ sessionId?: string;
216
217
  reason?: string;
217
218
  duration?: string;
218
- defaultPolicy?: 'daily' | 'always' | 'never';
219
+ defaultPolicy?: 'hold' | 'always' | 'never';
219
220
  forceDuration?: boolean;
220
221
  }
221
222
  export declare function keychainOperationPrompt(context?: KeychainReadContext): string;
@@ -30,6 +30,7 @@ import * as path from 'path';
30
30
  import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSecretToolItems } from './linux.js';
31
31
  import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
32
32
  import { getKeychainHelperPath } from './install-helper.js';
33
+ import { deriveShortId } from '../session/short-id.js';
33
34
  const SERVICE_PREFIX = 'agents-cli';
34
35
  export const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
35
36
  const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
@@ -710,9 +711,12 @@ export function hasKeychainToken(item) {
710
711
  export function keychainOperationPrompt(context = {}) {
711
712
  const agent = context.agent || 'Agents CLI';
712
713
  const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
714
+ // Which session triggered the read — the short-id disambiguates an unexpected
715
+ // prompt when several agents run at once (interactive + headless + exec).
716
+ const session = context.sessionId ? ` (session ${deriveShortId(context.sessionId)})` : '';
713
717
  const duration = context.duration ? ` for ${context.duration}` : '';
714
718
  const reason = context.reason ? ` ${context.reason}` : '';
715
- return `${agent} is requesting to unlock${bundle}${duration}${reason}.`;
719
+ return `${agent} is requesting to unlock${bundle}${session}${duration}${reason}.`;
716
720
  }
717
721
  export function getKeychainToken(item, context = {}) {
718
722
  // Errors keep the requested (human-readable) name; the storage name may be
@@ -820,7 +824,9 @@ export function getKeychainTokens(items, context = {}) {
820
824
  ...process.env,
821
825
  AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
822
826
  AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
823
- AGENTS_KEYCHAIN_DEFAULT_POLICY: context.defaultPolicy || 'daily',
827
+ // The signed helper's own vocabulary is unchanged (it predates the rename
828
+ // and ships as a separately-versioned binary), so map to its legacy token.
829
+ AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
824
830
  AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
825
831
  },
826
832
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -43,12 +43,17 @@ export declare function isDangerousRemoteEnvKey(name: string): boolean;
43
43
  */
44
44
  export declare function resolveHostSshTarget(nameOrAlias: string): Promise<string>;
45
45
  /**
46
- * Merge `--host <single>` and `--hosts <a,b,c>` into an ordered, de-duplicated
47
- * list. Both flags compose; either alone works. Empty when neither is set.
46
+ * Merge `--host <single>` / `--hosts <a,b,c>` (and their `--device` / `--devices`
47
+ * aliases) into an ordered, de-duplicated list. All four flags compose; any alone
48
+ * works. `--device`/`--devices` resolve identically to `--host`/`--hosts` so the
49
+ * fleet-wide `--device` vocabulary (see `agents activity`, `agents run --device`)
50
+ * works on the secrets remote commands too. Empty when none is set.
48
51
  */
49
52
  export declare function parseHostsOption(opts: {
50
53
  host?: string;
51
54
  hosts?: string;
55
+ device?: string;
56
+ devices?: string;
52
57
  }): string[];
53
58
  /**
54
59
  * Split a `bundle@host` reference. No `@` → a local bundle (host undefined).
@@ -104,3 +109,71 @@ export declare function remoteSecretsStream(target: string, args: string[], opts
104
109
  export declare function remoteResolveEnv(target: string, bundle: string, opts?: {
105
110
  osLookupName?: string;
106
111
  }): Promise<Record<string, string>>;
112
+ /**
113
+ * Outcome of a post-push read-back verification (see verifyRemoteKeychainPush).
114
+ * - `ok` — the pushed keys materialized readably on the remote.
115
+ * - `locked-keychain` — the read-back gave the SPECIFIC signal of a keychain
116
+ * that didn't persist (the remote's headless "not unlocked
117
+ * in the secrets agent" guard, or a "stored item … not
118
+ * found" on read-back, or pushed keys simply absent). Only
119
+ * this verdict earns the locked-login-keychain diagnosis +
120
+ * `--remote-backend file` steer.
121
+ * - `error` — a DIFFERENT failure (flaky SSH, timeout, unparseable
122
+ * payload). The raw error is re-surfaced verbatim, never
123
+ * mislabeled as a locked keychain.
124
+ */
125
+ export type RemoteKeychainWriteVerification = {
126
+ ok: true;
127
+ } | {
128
+ ok: false;
129
+ kind: 'locked-keychain';
130
+ reason: string;
131
+ } | {
132
+ ok: false;
133
+ kind: 'error';
134
+ reason: string;
135
+ };
136
+ /**
137
+ * Decide whether a keychain-backed push to a remote actually PERSISTED its secret
138
+ * value items, given the read-back of that bundle from the remote's own store.
139
+ *
140
+ * The silent-failure this guards: pushing `--remote-backend keychain` (default) to
141
+ * a macOS host over headless SSH lands the bundle METADATA but not readable value
142
+ * items — the remote login keychain is locked in the non-interactive SSH context,
143
+ * so Security accepts the item WRITE at the DB level but the biometry-ACL'd item is
144
+ * unreadable, and the remote `import` still reports success (values written first,
145
+ * metadata `noAcl` last — bundles.ts writeBundleWithItems). The metadata-only bundle
146
+ * then fails every later read with the confusing `Bundle '<b>' key '<k>': stored
147
+ * item '<item>' not found` (bundles.ts resolveBundleEnv). We catch it by reading
148
+ * the bundle back the same way a release will (`secrets export --plaintext --format
149
+ * json`, driven headlessly on the remote so its `agentOnly` guard FAILS FAST before
150
+ * any keychain read — no Touch ID prompt) and confirming every pushed key returned.
151
+ *
152
+ * Pure so both branches are unit-testable without a real locked keychain: inject the
153
+ * "read-back failed / key absent" condition through `readBack`.
154
+ */
155
+ export declare function evaluateKeychainWriteVerification(pushedKeys: string[], readBack: {
156
+ ok: true;
157
+ keys: string[];
158
+ } | {
159
+ ok: false;
160
+ stderr: string;
161
+ }): RemoteKeychainWriteVerification;
162
+ /**
163
+ * The actionable error message for a failed keychain-over-SSH push verification.
164
+ * Names the cause (locked remote login keychain) and steers to the two real fixes:
165
+ * re-run with the headless-readable file backend, or unlock the remote keychain.
166
+ * Pure + exported so the exact guidance is asserted in tests.
167
+ */
168
+ export declare function keychainWriteFailureMessage(host: string, bundle: string, reason: string): string;
169
+ /**
170
+ * Read a bundle back from a remote over SSH (headlessly, so it fails fast rather
171
+ * than prompting Touch ID) and confirm the pushed keys materialized. Drives the
172
+ * remote's own `secrets export <bundle> --plaintext --format json` — the same read
173
+ * a headless release performs — but keeps only the KEY NAMES; the plaintext values
174
+ * are dropped immediately and never retained or logged. Returns a verification
175
+ * verdict; the caller renders `keychainWriteFailureMessage` on failure.
176
+ */
177
+ export declare function verifyRemoteKeychainPush(target: string, bundle: string, pushedKeys: string[], opts?: {
178
+ osLookupName?: string;
179
+ }): RemoteKeychainWriteVerification;
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { sshExec, sshStream, assertValidSshTarget } from '../ssh-exec.js';
19
19
  import { resolveHost } from '../hosts/registry.js';
20
- import { emit } from '../events.js';
20
+ import { emitSecretAudit } from './audit.js';
21
21
  import { sshTargetFor } from '../hosts/types.js';
22
22
  import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
23
23
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
@@ -74,8 +74,11 @@ export async function resolveHostSshTarget(nameOrAlias) {
74
74
  return nameOrAlias;
75
75
  }
76
76
  /**
77
- * Merge `--host <single>` and `--hosts <a,b,c>` into an ordered, de-duplicated
78
- * list. Both flags compose; either alone works. Empty when neither is set.
77
+ * Merge `--host <single>` / `--hosts <a,b,c>` (and their `--device` / `--devices`
78
+ * aliases) into an ordered, de-duplicated list. All four flags compose; any alone
79
+ * works. `--device`/`--devices` resolve identically to `--host`/`--hosts` so the
80
+ * fleet-wide `--device` vocabulary (see `agents activity`, `agents run --device`)
81
+ * works on the secrets remote commands too. Empty when none is set.
79
82
  */
80
83
  export function parseHostsOption(opts) {
81
84
  const out = [];
@@ -89,9 +92,14 @@ export function parseHostsOption(opts) {
89
92
  };
90
93
  if (opts.host)
91
94
  push(opts.host);
95
+ if (opts.device)
96
+ push(opts.device);
92
97
  if (opts.hosts)
93
98
  for (const h of opts.hosts.split(','))
94
99
  push(h);
100
+ if (opts.devices)
101
+ for (const h of opts.devices.split(','))
102
+ push(h);
95
103
  return out;
96
104
  }
97
105
  /**
@@ -203,8 +211,8 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
203
211
  // event on the INITIATING host too (values were pulled into this process and
204
212
  // injected locally). Covers `secrets exec --host` and `run --secrets b@host`.
205
213
  // Values never enter the payload — only the bundle, target host, and count.
206
- emit('secrets.get', {
207
- module: 'secrets',
214
+ emitSecretAudit({
215
+ event: 'secrets.get',
208
216
  bundle,
209
217
  operation: 'remote resolve',
210
218
  source: 'remote',
@@ -214,3 +222,129 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
214
222
  });
215
223
  return env;
216
224
  }
225
+ /**
226
+ * The remote's headless read-back raises one of these when a keychain-backed bundle
227
+ * has metadata but no readable value items — the exact locked-login-keychain
228
+ * signature. Anything else (connection refused, timeout, host key error) is a
229
+ * transient/unrelated failure and must NOT be mislabeled as a locked keychain.
230
+ */
231
+ function isLockedKeychainReadBackError(stderr) {
232
+ const s = stderr.toLowerCase();
233
+ return (
234
+ // bundles.ts agentOnly guard: "…is not unlocked in the secrets agent…"
235
+ s.includes('not unlocked') ||
236
+ s.includes('secrets agent') ||
237
+ // resolveBundleEnv: "Bundle '<b>' key '<k>': stored item '<item>' not found."
238
+ s.includes('stored item') ||
239
+ s.includes('not found'));
240
+ }
241
+ /**
242
+ * Decide whether a keychain-backed push to a remote actually PERSISTED its secret
243
+ * value items, given the read-back of that bundle from the remote's own store.
244
+ *
245
+ * The silent-failure this guards: pushing `--remote-backend keychain` (default) to
246
+ * a macOS host over headless SSH lands the bundle METADATA but not readable value
247
+ * items — the remote login keychain is locked in the non-interactive SSH context,
248
+ * so Security accepts the item WRITE at the DB level but the biometry-ACL'd item is
249
+ * unreadable, and the remote `import` still reports success (values written first,
250
+ * metadata `noAcl` last — bundles.ts writeBundleWithItems). The metadata-only bundle
251
+ * then fails every later read with the confusing `Bundle '<b>' key '<k>': stored
252
+ * item '<item>' not found` (bundles.ts resolveBundleEnv). We catch it by reading
253
+ * the bundle back the same way a release will (`secrets export --plaintext --format
254
+ * json`, driven headlessly on the remote so its `agentOnly` guard FAILS FAST before
255
+ * any keychain read — no Touch ID prompt) and confirming every pushed key returned.
256
+ *
257
+ * Pure so both branches are unit-testable without a real locked keychain: inject the
258
+ * "read-back failed / key absent" condition through `readBack`.
259
+ */
260
+ export function evaluateKeychainWriteVerification(pushedKeys, readBack) {
261
+ if (!readBack.ok) {
262
+ const stderr = readBack.stderr.trim();
263
+ if (isLockedKeychainReadBackError(stderr)) {
264
+ // The remote's own headless read raised the not-unlocked / not-found signal —
265
+ // exactly the confusing error the user hits later. Surface it now, at push
266
+ // time, with the fix.
267
+ return {
268
+ ok: false,
269
+ kind: 'locked-keychain',
270
+ reason: `the remote could not read it back${stderr ? ` (${stderr})` : ''}`,
271
+ };
272
+ }
273
+ // A transient / unrelated failure (flaky SSH, timeout, bad payload). Re-surface
274
+ // verbatim — do NOT diagnose a locked keychain from a connection error.
275
+ return {
276
+ ok: false,
277
+ kind: 'error',
278
+ reason: stderr || 'read-back failed',
279
+ };
280
+ }
281
+ const present = new Set(readBack.keys);
282
+ const missing = pushedKeys.filter((k) => !present.has(k));
283
+ if (missing.length > 0) {
284
+ // Read-back succeeded but some pushed keys are absent — the value items didn't
285
+ // persist. Same locked-keychain cause and fix.
286
+ return {
287
+ ok: false,
288
+ kind: 'locked-keychain',
289
+ reason: `${missing.length} of ${pushedKeys.length} key(s) did not persist on the remote ` +
290
+ `(missing: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''})`,
291
+ };
292
+ }
293
+ return { ok: true };
294
+ }
295
+ /**
296
+ * The actionable error message for a failed keychain-over-SSH push verification.
297
+ * Names the cause (locked remote login keychain) and steers to the two real fixes:
298
+ * re-run with the headless-readable file backend, or unlock the remote keychain.
299
+ * Pure + exported so the exact guidance is asserted in tests.
300
+ */
301
+ export function keychainWriteFailureMessage(host, bundle, reason) {
302
+ return (`${host}: pushed '${bundle}' but the keychain items did not persist — ${reason}. ` +
303
+ `A macOS login keychain is LOCKED under headless SSH, so a keychain-backed write ` +
304
+ `lands the bundle metadata but no readable secret items, and later reads fail with ` +
305
+ `"stored item '…' not found". Re-run with a headless-readable backend:\n` +
306
+ ` agents secrets export ${bundle} --host ${host} --remote-backend file\n` +
307
+ `(needs AGENTS_SECRETS_PASSPHRASE set locally), or unlock the remote keychain first ` +
308
+ `(e.g. an interactive login / \`agents secrets unlock\` on ${host}) and retry.`);
309
+ }
310
+ /**
311
+ * Read a bundle back from a remote over SSH (headlessly, so it fails fast rather
312
+ * than prompting Touch ID) and confirm the pushed keys materialized. Drives the
313
+ * remote's own `secrets export <bundle> --plaintext --format json` — the same read
314
+ * a headless release performs — but keeps only the KEY NAMES; the plaintext values
315
+ * are dropped immediately and never retained or logged. Returns a verification
316
+ * verdict; the caller renders `keychainWriteFailureMessage` on failure.
317
+ */
318
+ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {}) {
319
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName));
320
+ const res = sshExec(target, remoteCmd, { timeoutMs: REMOTE_TIMEOUT_MS });
321
+ if (res.code !== 0) {
322
+ const why = res.timedOut ? 'timed out' : res.code === null ? 'ssh failed' : `exit ${res.code}`;
323
+ const stderr = `${why}${(res.stderr || res.stdout || '').trim() ? `: ${(res.stderr || res.stdout).trim()}` : ''}`;
324
+ return evaluateKeychainWriteVerification(pushedKeys, { ok: false, stderr });
325
+ }
326
+ // Take the outer { … } object (tolerate login-shell banner noise), read the key
327
+ // names, and immediately discard the values — we only need presence here.
328
+ const raw = res.stdout;
329
+ const start = raw.indexOf('{');
330
+ const end = raw.lastIndexOf('}');
331
+ const jsonText = start >= 0 && end >= start ? raw.slice(start, end + 1) : raw.trim();
332
+ let keys;
333
+ try {
334
+ const parsed = JSON.parse(jsonText);
335
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
336
+ return evaluateKeychainWriteVerification(pushedKeys, {
337
+ ok: false,
338
+ stderr: 'unexpected read-back payload',
339
+ });
340
+ }
341
+ keys = Object.keys(parsed);
342
+ }
343
+ catch {
344
+ return evaluateKeychainWriteVerification(pushedKeys, {
345
+ ok: false,
346
+ stderr: 'could not parse read-back JSON',
347
+ });
348
+ }
349
+ return evaluateKeychainWriteVerification(pushedKeys, { ok: true, keys });
350
+ }