@phnx-labs/agents-cli 1.22.52 → 1.22.54

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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +220 -174
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
@@ -14,6 +14,15 @@
14
14
  * for local ones; {@link resolveProjectKey} adds the filesystem repo-root walk
15
15
  * for paths this machine can see.
16
16
  */
17
+ /**
18
+ * Convert an absolute cwd to Claude Code's own project-folder name under
19
+ * `~/.claude/projects/` (slashes and dots become dashes) — the encoding
20
+ * Claude Code itself performs to name each project's session-transcript and
21
+ * native-memory directory. Pure, no filesystem: session discovery and
22
+ * native-memory sync both derive from this so they agree on one directory
23
+ * for a given cwd.
24
+ */
25
+ export declare function claudeProjectDirName(cwd: string): string;
17
26
  /**
18
27
  * Resolve a stable project key from a working directory, or `undefined` when
19
28
  * the path carries nothing usable (empty, `/`, whitespace).
@@ -18,6 +18,17 @@ import * as fs from 'fs';
18
18
  import * as os from 'os';
19
19
  import * as path from 'path';
20
20
  const WORKTREE_SEGMENT = '/.agents/worktrees/';
21
+ /**
22
+ * Convert an absolute cwd to Claude Code's own project-folder name under
23
+ * `~/.claude/projects/` (slashes and dots become dashes) — the encoding
24
+ * Claude Code itself performs to name each project's session-transcript and
25
+ * native-memory directory. Pure, no filesystem: session discovery and
26
+ * native-memory sync both derive from this so they agree on one directory
27
+ * for a given cwd.
28
+ */
29
+ export function claudeProjectDirName(cwd) {
30
+ return cwd.replace(/[/.]/g, '-');
31
+ }
21
32
  /**
22
33
  * Resolve a stable project key from a working directory, or `undefined` when
23
34
  * the path carries nothing usable (empty, `/`, whitespace).
@@ -101,6 +101,41 @@ export declare const ENV_KEY_PATTERN: RegExp;
101
101
  export declare const BUNDLE_KEY_PATTERN: RegExp;
102
102
  export declare const BUNDLE_META_PREFIX = "agents-cli.bundles.";
103
103
  export declare const RESERVED_ENV_NAMES: Set<string>;
104
+ /**
105
+ * The reserved FILE-BACKED bundle that holds long-lived Claude setup-tokens.
106
+ * Usage/probe reads authenticate with these instead of the ACL-bound login item,
107
+ * so they never pop Touch ID and they can cross the fleet. A keychain- or
108
+ * vault-backed bundle of this name is a misconfiguration: the consumer used to
109
+ * return null (SEC-GAP-3) and silently fall through to Touch ID.
110
+ */
111
+ export declare const AUTH_BUNDLE_NAME = "auth";
112
+ export declare const AUTH_BUNDLE_BACKEND: SecretsBackend;
113
+ export declare const RESERVED_BUNDLE_NAMES: Set<string>;
114
+ export declare function isReservedBundleName(name: string): boolean;
115
+ /** Thrown when a reserved bundle is written or resolved on the wrong backend. */
116
+ export declare class ReservedBundleWrongBackendError extends Error {
117
+ readonly bundle: string;
118
+ readonly backend: SecretsBackend;
119
+ constructor(bundle: string, backend: SecretsBackend);
120
+ }
121
+ /** Fail loud when `name` is reserved and `backend` is not the required one. */
122
+ export declare function assertReservedBundleBackend(name: string, backend: SecretsBackend): void;
123
+ /**
124
+ * Presence + backend of the reserved `auth` bundle. `ok` is true when the
125
+ * bundle is absent (nothing to fix) or present and file-backed.
126
+ */
127
+ export declare function inspectReservedAuthBundle(): {
128
+ exists: boolean;
129
+ backend: SecretsBackend | null;
130
+ ok: boolean;
131
+ };
132
+ /**
133
+ * After a file-backed import, actually decrypt the keys and fail if any are
134
+ * unreadable. Import used to print "Imported N key(s)" from the write tally
135
+ * alone — ciphertext sealed under a forwarded AGENTS_SECRETS_PASSPHRASE that
136
+ * the destination daemon does not hold still counted as success.
137
+ */
138
+ export declare function assertFileBundleDecryptable(name: string, keys: string[]): void;
104
139
  export declare function bundleToEnvPrefix(name: string): string;
105
140
  export declare function isReservedEnvName(key: string): boolean;
106
141
  export declare function bundleKeyToEnvKey(key: string): string;
@@ -137,6 +137,82 @@ export const RESERVED_ENV_NAMES = new Set([
137
137
  'TERM', 'LANG', 'LC_ALL', 'DISPLAY', 'EDITOR', 'VISUAL',
138
138
  'TMPDIR', 'TMP', 'TEMP', 'LOGNAME', 'UID', 'EUID', 'HOSTNAME',
139
139
  ]);
140
+ /**
141
+ * The reserved FILE-BACKED bundle that holds long-lived Claude setup-tokens.
142
+ * Usage/probe reads authenticate with these instead of the ACL-bound login item,
143
+ * so they never pop Touch ID and they can cross the fleet. A keychain- or
144
+ * vault-backed bundle of this name is a misconfiguration: the consumer used to
145
+ * return null (SEC-GAP-3) and silently fall through to Touch ID.
146
+ */
147
+ export const AUTH_BUNDLE_NAME = 'auth';
148
+ export const AUTH_BUNDLE_BACKEND = 'file';
149
+ export const RESERVED_BUNDLE_NAMES = new Set([AUTH_BUNDLE_NAME]);
150
+ export function isReservedBundleName(name) {
151
+ return RESERVED_BUNDLE_NAMES.has(name.trim().toLowerCase());
152
+ }
153
+ /** Thrown when a reserved bundle is written or resolved on the wrong backend. */
154
+ export class ReservedBundleWrongBackendError extends Error {
155
+ bundle;
156
+ backend;
157
+ constructor(bundle, backend) {
158
+ super(`Bundle '${bundle}' is reserved for file-backed setup-tokens (headless, fleet-shareable). ` +
159
+ `A ${backend}-backed '${bundle}' bundle is ignored by usage/probe instead of authenticating. ` +
160
+ `Recreate it as file-backed: agents secrets delete ${bundle} --yes && agents secrets create ${bundle} --backend file`);
161
+ this.name = 'ReservedBundleWrongBackendError';
162
+ this.bundle = bundle;
163
+ this.backend = backend;
164
+ }
165
+ }
166
+ /** Fail loud when `name` is reserved and `backend` is not the required one. */
167
+ export function assertReservedBundleBackend(name, backend) {
168
+ if (!isReservedBundleName(name))
169
+ return;
170
+ if (backend !== AUTH_BUNDLE_BACKEND) {
171
+ throw new ReservedBundleWrongBackendError(name, backend);
172
+ }
173
+ }
174
+ /**
175
+ * Presence + backend of the reserved `auth` bundle. `ok` is true when the
176
+ * bundle is absent (nothing to fix) or present and file-backed.
177
+ */
178
+ export function inspectReservedAuthBundle() {
179
+ if (!bundleExists(AUTH_BUNDLE_NAME)) {
180
+ return { exists: false, backend: null, ok: true };
181
+ }
182
+ const backend = bundleBackend(AUTH_BUNDLE_NAME);
183
+ return { exists: true, backend, ok: backend === AUTH_BUNDLE_BACKEND };
184
+ }
185
+ /**
186
+ * After a file-backed import, actually decrypt the keys and fail if any are
187
+ * unreadable. Import used to print "Imported N key(s)" from the write tally
188
+ * alone — ciphertext sealed under a forwarded AGENTS_SECRETS_PASSPHRASE that
189
+ * the destination daemon does not hold still counted as success.
190
+ */
191
+ export function assertFileBundleDecryptable(name, keys) {
192
+ if (keys.length === 0)
193
+ return;
194
+ if (bundleBackend(name) !== 'file')
195
+ return;
196
+ let env;
197
+ try {
198
+ ({ env } = readAndResolveBundleEnv(name, { caller: 'import-verify', agentOnly: true, keyMode: 'storage' }));
199
+ }
200
+ catch (err) {
201
+ throw new Error(`Imported '${name}' reported success but the file store could not decrypt it. ` +
202
+ `${err.message} Typically because AGENTS_SECRETS_PASSPHRASE was forwarded ` +
203
+ `and this process does not hold it. Re-import without that env var.`);
204
+ }
205
+ const missing = keys.filter((k) => {
206
+ const v = env[k];
207
+ return typeof v !== 'string' || v.length === 0;
208
+ });
209
+ if (missing.length === 0)
210
+ return;
211
+ throw new Error(`Imported '${name}' reported success but ${missing.length} key(s) are unreadable ` +
212
+ `(${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}). ` +
213
+ `The destination store could not decrypt them — typically because AGENTS_SECRETS_PASSPHRASE ` +
214
+ `was forwarded and this process does not hold it. Re-import without that env var.`);
215
+ }
140
216
  export function bundleToEnvPrefix(name) {
141
217
  return name.replace(/[-\.]/g, '_').toUpperCase();
142
218
  }
@@ -394,6 +470,7 @@ export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOv
394
470
  function prepareBundleWrite(bundle) {
395
471
  validateBundleName(bundle.name);
396
472
  const backend = bundle.backend ?? 'keychain';
473
+ assertReservedBundleBackend(bundle.name, backend);
397
474
  if (backend === 'vault')
398
475
  assertVaultBackendUsable(bundle.name);
399
476
  for (const key of Object.keys(bundle.vars)) {
@@ -1323,7 +1400,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1323
1400
  // If the secrets broker is explicitly disabled and this bundle would otherwise
1324
1401
  // fall through to a keychain read (Touch ID prompt), fail loud now. Never-policy
1325
1402
  // bundles are verified below and remain silent; vault/file backends are unaffected.
1326
- if (backend === 'keychain' && !isSecretsBrokerEnabled() && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
1403
+ if (backend === 'keychain' && !verifiedNoAclBundle && !isSecretsBrokerEnabled() && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
1327
1404
  throw new Error(`Secrets broker is disabled — re-enable with 'agents daemon services enable secrets-broker'. ` +
1328
1405
  `If you meant to read directly from the keychain, set AGENTS_SECRETS_NO_AGENT=1.`);
1329
1406
  }
@@ -21,14 +21,9 @@ export interface PushBundleOptions {
21
21
  /** Overwrite a key that already exists on the remote. */
22
22
  force?: boolean;
23
23
  /**
24
- * Forwarded to the remote as the FIRST stdin line for the FILE backend only,
25
- * and only when non-empty.
26
- *
27
- * Empty is the DEFAULT and the good path: the remote's file store then
28
- * auto-provisions its own machine-local key (0600, `~/.agents/.secrets-key/`)
29
- * and reads headlessly. Setting this keys the remote bundle under a shared
30
- * off-disk secret instead — an opt-in, never a requirement. Requiring one is
31
- * what pushed operators toward exporting the master key fleet-wide (RUSH-1968).
24
+ * Ignored. File-backend export never forwards AGENTS_SECRETS_PASSPHRASE
25
+ * (PHNX-2371); the remote auto-provisions its own machine-local key.
26
+ * Kept on the options type so existing callers that passed one still compile.
32
27
  */
33
28
  passphrase?: string;
34
29
  /** Label for the audit trail — `export --device` vs `fleet apply`. */
@@ -87,7 +87,6 @@ export function planPushTransport(resolved, bundle, host, opts) {
87
87
  return { kind: 'refuse', message: 'file backend export to a Windows target is not yet supported' };
88
88
  }
89
89
  const { remoteCmd, input } = buildRemoteFileImportCommand(bundle, resolved.dotenv, {
90
- passphrase: opts.passphrase ?? '',
91
90
  force: opts.force,
92
91
  policyNever: opts.policyNever,
93
92
  });
@@ -148,20 +147,25 @@ export function pushResolvedBundleToHost(resolved, bundle, host, opts) {
148
147
  const msg = (res.stderr || res.stdout || '').trim();
149
148
  return fail(`remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`);
150
149
  }
151
- // A keychain-backed push to a macOS remote over headless SSH can land the
152
- // bundle metadata but no READABLE value items: the remote login keychain is
153
- // locked in the non-interactive SSH context, so Security accepts the write but
154
- // the biometry-ACL'd item is unreadable — and the remote `import` still exits
155
- // 0. Read it back the way a release will and FAIL LOUDLY, rather than leave a
156
- // metadata-only bundle that breaks later with "stored item not found". The
157
- // file backend is headless-readable by construction, so it is skipped.
158
- if (opts.remoteBackend === 'keychain') {
159
- const verdict = verifyRemoteKeychainPush(host, bundle, Object.keys(resolved.env), { osLookupName: host, secret: true });
160
- if (!verdict.ok) {
161
- return fail(verdict.kind === 'locked-keychain'
162
- ? keychainWriteFailureMessage(host, bundle, verdict.reason)
163
- : `pushed '${bundle}' but could not verify it on the remote: ${verdict.reason}`);
150
+ // A successful remote import is not proof the keys are readable. Two silent
151
+ // failures hide behind exit 0:
152
+ // - keychain: a macOS login keychain is locked under headless SSH, so the
153
+ // write lands metadata but no readable value items.
154
+ // - file: a forwarded AGENTS_SECRETS_PASSPHRASE keys ciphertext to a secret
155
+ // the destination daemon does not hold (PHNX-2371).
156
+ // Read the bundle back the way a later resolve will and FAIL LOUDLY.
157
+ const verdict = verifyRemoteKeychainPush(host, bundle, Object.keys(resolved.env), { osLookupName: host, secret: true });
158
+ if (!verdict.ok) {
159
+ if (opts.remoteBackend === 'keychain' && verdict.kind === 'locked-keychain') {
160
+ return fail(keychainWriteFailureMessage(host, bundle, verdict.reason));
164
161
  }
162
+ if (opts.remoteBackend === 'file') {
163
+ return fail(`${host}: pushed '${bundle}' but the remote could not decrypt it (${verdict.reason}). ` +
164
+ `File-backend export never forwards AGENTS_SECRETS_PASSPHRASE — the remote keys ` +
165
+ `the bundle under its own machine-local key. If the destination still cannot read, ` +
166
+ `unset a stale AGENTS_SECRETS_PASSPHRASE there (\`agents doctor\`).`);
167
+ }
168
+ return fail(`pushed '${bundle}' but could not verify it on the remote: ${verdict.reason}`);
165
169
  }
166
170
  for (const step of planLiteralRestoration(bundle, opts.literalValues)) {
167
171
  const removed = remoteSecretsRaw(host, step.removeArgs, { osLookupName: host, secret: true });
@@ -11,9 +11,9 @@
11
11
  *
12
12
  * Trust model: relies on the operator's existing SSH access to the host (same
13
13
  * boundary as `export --device` / `run --device`). Bundle names are shell-quoted
14
- * into the remote command; resolved VALUES return over ssh stdout; a forwarded
15
- * file-backend passphrase travels over ssh stdin (first line) so it never lands
16
- * in argv / `ps` / remote shell history. Nothing is persisted locally.
14
+ * into the remote command; resolved VALUES return over ssh stdout. File-backend
15
+ * import never forwards AGENTS_SECRETS_PASSPHRASE (PHNX-2371). Nothing is
16
+ * persisted locally.
17
17
  */
18
18
  import { type SshExecResult } from '../ssh-exec.js';
19
19
  /**
@@ -208,26 +208,17 @@ export declare function verifyRemoteKeychainPush(target: string, bundle: string,
208
208
  * The `bash -lc` command + stdin payload that drives a **file-backed** remote
209
209
  * import for `secrets export --device … --remote-backend file`.
210
210
  *
211
- * The file store is passphrase-free by default: with `AGENTS_SECRETS_PASSPHRASE`
212
- * unset the remote `agents secrets import --backend file` auto-provisions the
213
- * remote's own machine-local key (0600 under `~/.agents/.secrets-key/`), so its
214
- * reads are HEADLESS — no passphrase, no Touch ID. A passphrase is therefore
215
- * OPTIONAL and only forwarded when the operator sets one locally (opt-in, e.g. to
216
- * key the bundle off-disk under a shared secret):
217
- *
218
- * - **No passphrase** → the remote runs `import … --backend file` directly with
219
- * ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
220
- * prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
221
- * machine-local key path → headless reads.
222
- * - **Passphrase set** → forward it as the FIRST stdin line, consumed by
223
- * `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
224
- * then the .env. The remote then keys the bundle under that shared passphrase.
211
+ * The file store is passphrase-free: the remote `agents secrets import --backend
212
+ * file` auto-provisions the remote's own machine-local key (0600 under
213
+ * `~/.agents/.secrets-key/`), so its reads are HEADLESS — no passphrase, no
214
+ * Touch ID. AGENTS_SECRETS_PASSPHRASE is a deprecated override and MUST NOT be
215
+ * forwarded (PHNX-2371): a remote keyed to a secret its daemon does not hold
216
+ * reports "Imported N key(s)" then fails every later decrypt.
225
217
  *
226
218
  * Pure — no I/O — so the exact command string and stdin ordering are unit-testable
227
219
  * against the SSH boundary the same way `remoteSecretsRaw` is.
228
220
  */
229
221
  export declare function buildRemoteFileImportCommand(bundle: string, dotenv: string, opts?: {
230
- passphrase?: string;
231
222
  force?: boolean;
232
223
  policyNever?: boolean;
233
224
  }): {
@@ -11,9 +11,9 @@
11
11
  *
12
12
  * Trust model: relies on the operator's existing SSH access to the host (same
13
13
  * boundary as `export --device` / `run --device`). Bundle names are shell-quoted
14
- * into the remote command; resolved VALUES return over ssh stdout; a forwarded
15
- * file-backend passphrase travels over ssh stdin (first line) so it never lands
16
- * in argv / `ps` / remote shell history. Nothing is persisted locally.
14
+ * into the remote command; resolved VALUES return over ssh stdout. File-backend
15
+ * import never forwards AGENTS_SECRETS_PASSPHRASE (PHNX-2371). Nothing is
16
+ * persisted locally.
17
17
  */
18
18
  import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
19
  import { resolveHost } from '../hosts/registry.js';
@@ -417,20 +417,12 @@ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {})
417
417
  * The `bash -lc` command + stdin payload that drives a **file-backed** remote
418
418
  * import for `secrets export --device … --remote-backend file`.
419
419
  *
420
- * The file store is passphrase-free by default: with `AGENTS_SECRETS_PASSPHRASE`
421
- * unset the remote `agents secrets import --backend file` auto-provisions the
422
- * remote's own machine-local key (0600 under `~/.agents/.secrets-key/`), so its
423
- * reads are HEADLESS — no passphrase, no Touch ID. A passphrase is therefore
424
- * OPTIONAL and only forwarded when the operator sets one locally (opt-in, e.g. to
425
- * key the bundle off-disk under a shared secret):
426
- *
427
- * - **No passphrase** → the remote runs `import … --backend file` directly with
428
- * ONLY the .env on stdin. No `read`/`export AGENTS_SECRETS_PASSPHRASE`
429
- * prologue, so `AGENTS_SECRETS_PASSPHRASE` stays UNSET on the remote → the
430
- * machine-local key path → headless reads.
431
- * - **Passphrase set** → forward it as the FIRST stdin line, consumed by
432
- * `IFS= read -r` (so it never lands in argv / `ps` / remote shell history),
433
- * then the .env. The remote then keys the bundle under that shared passphrase.
420
+ * The file store is passphrase-free: the remote `agents secrets import --backend
421
+ * file` auto-provisions the remote's own machine-local key (0600 under
422
+ * `~/.agents/.secrets-key/`), so its reads are HEADLESS — no passphrase, no
423
+ * Touch ID. AGENTS_SECRETS_PASSPHRASE is a deprecated override and MUST NOT be
424
+ * forwarded (PHNX-2371): a remote keyed to a secret its daemon does not hold
425
+ * reports "Imported N key(s)" then fails every later decrypt.
434
426
  *
435
427
  * Pure — no I/O — so the exact command string and stdin ordering are unit-testable
436
428
  * against the SSH boundary the same way `remoteSecretsRaw` is.
@@ -439,14 +431,7 @@ export function buildRemoteFileImportCommand(bundle, dotenv, opts = {}) {
439
431
  const force = opts.force ? ' --force' : '';
440
432
  const policy = opts.policyNever ? ' --policy never --i-understand' : '';
441
433
  const importCmd = `agents secrets import ${shellQuote(bundle)} --from - --backend file${force}${policy}`;
442
- const passphrase = opts.passphrase ?? '';
443
- if (passphrase) {
444
- // Opt-in shared passphrase: read it off the FIRST stdin line, export it, then
445
- // let `import --from -` read the .env remainder.
446
- const remoteAgents = `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ${importCmd}`;
447
- return { remoteCmd: `bash -lc ${shellQuote(remoteAgents)}`, input: `${passphrase}\n${dotenv}` };
448
- }
449
- // No passphrase: NO prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the
450
- // remote, so the file store falls back to its machine-local key (headless).
434
+ // No prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the remote, so the
435
+ // file store falls back to its machine-local key (headless).
451
436
  return { remoteCmd: `bash -lc ${shellQuote(importCmd)}`, input: dotenv };
452
437
  }
@@ -0,0 +1,65 @@
1
+ import { type PushBundleResult } from './push.js';
2
+ import { type DeviceProfile } from '../devices/registry.js';
3
+ import type { DeviceProbe } from '../fleet/types.js';
4
+ export interface AuthSyncDevice {
5
+ name: string;
6
+ reachable: boolean;
7
+ pinned: boolean;
8
+ remoteHasAuth: boolean;
9
+ }
10
+ export type AuthSyncPlanItem = {
11
+ action: 'push';
12
+ device: string;
13
+ } | {
14
+ action: 'skip';
15
+ device: string;
16
+ reason: string;
17
+ };
18
+ export declare function planAuthBundlePush(localAuthOk: boolean, devices: AuthSyncDevice[]): AuthSyncPlanItem[];
19
+ export interface AuthSyncResult {
20
+ pushed: string[];
21
+ skipped: Array<{
22
+ device: string;
23
+ reason: string;
24
+ }>;
25
+ errors: Array<{
26
+ device: string;
27
+ message: string;
28
+ }>;
29
+ }
30
+ export interface AuthSyncProbe {
31
+ reachable: boolean;
32
+ remoteHasAuth: boolean;
33
+ }
34
+ export interface AuthSyncDeps {
35
+ inspectLocal?: () => {
36
+ exists: boolean;
37
+ ok: boolean;
38
+ };
39
+ listDevices?: () => DeviceProfile[];
40
+ localName?: string;
41
+ isPinned?: (name: string) => boolean;
42
+ /**
43
+ * Per-device reachability + auth presence. Default: `probeDevice({ withSecrets: true })`
44
+ * — the same remote-presence probe `fleet apply` uses in `decideSecretPush`.
45
+ */
46
+ probe?: (device: DeviceProfile) => AuthSyncProbe;
47
+ push?: (bundle: string, host: string) => PushBundleResult;
48
+ sshTarget?: (device: DeviceProfile) => string;
49
+ }
50
+ /**
51
+ * Map a `fleet apply` device probe onto the auth-sync skip/push inputs.
52
+ *
53
+ * Presence is an own-property check on `remoteBundles` (same gate as
54
+ * `decideSecretPush`): a missing listing, a parse failure, or a prototype
55
+ * name must all mean "not present, so push" — never "present, so skip".
56
+ */
57
+ export declare function authPresenceFromProbe(probe: DeviceProbe): AuthSyncProbe;
58
+ /** Production default: live SSH probe of reachability + `secrets list --json`. */
59
+ export declare function defaultAuthSyncProbe(device: DeviceProfile): AuthSyncProbe;
60
+ /**
61
+ * Push the local file-backed `auth` bundle to every pinned registered device
62
+ * that does not already have it. No-op when the local bundle is missing or on
63
+ * the wrong backend (doctor flags that separately).
64
+ */
65
+ export declare function syncReservedAuthBundle(deps?: AuthSyncDeps): AuthSyncResult;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Fleet sync of the reserved file-backed `auth` bundle (PHNX-2371).
3
+ *
4
+ * Setup-tokens drift per box when sync is manual. This module plans + executes
5
+ * a push of the local file-backed `auth` bundle to registered devices that do
6
+ * not yet have it, always with `--remote-backend file` and never forwarding
7
+ * AGENTS_SECRETS_PASSPHRASE. Each destination auto-provisions its own
8
+ * machine-local key and the push read-back-verifies decryptability.
9
+ *
10
+ * Double-fire: each device's daemon only writes that device's own store (the
11
+ * destination). Two boxes that both have `auth` skip each other (already
12
+ * present). A box without `auth` is a pull target for a provisioned peer.
13
+ *
14
+ * The planner is pure so tests cover every skip/push branch with no SSH.
15
+ */
16
+ import { AUTH_BUNDLE_NAME, inspectReservedAuthBundle } from './bundles.js';
17
+ import { pushBundleToHost } from './push.js';
18
+ import { loadDevicesSync } from '../devices/registry.js';
19
+ import { sshTargetFor } from '../devices/connect.js';
20
+ import { isHostPinned, managedKnownHostsPath } from '../devices/known-hosts.js';
21
+ import { machineId, normalizeHost } from '../session/sync/config.js';
22
+ import { probeDevice } from '../fleet/apply.js';
23
+ export function planAuthBundlePush(localAuthOk, devices) {
24
+ if (!localAuthOk) {
25
+ return devices.map((d) => ({
26
+ action: 'skip',
27
+ device: d.name,
28
+ reason: 'no local file-backed auth bundle',
29
+ }));
30
+ }
31
+ return devices.map((d) => {
32
+ if (!d.reachable)
33
+ return { action: 'skip', device: d.name, reason: 'unreachable' };
34
+ if (!d.pinned) {
35
+ return {
36
+ action: 'skip',
37
+ device: d.name,
38
+ reason: `host key not pinned; run \`agents ssh ${d.name}\` once`,
39
+ };
40
+ }
41
+ if (d.remoteHasAuth)
42
+ return { action: 'skip', device: d.name, reason: 'already present' };
43
+ return { action: 'push', device: d.name };
44
+ });
45
+ }
46
+ /**
47
+ * Map a `fleet apply` device probe onto the auth-sync skip/push inputs.
48
+ *
49
+ * Presence is an own-property check on `remoteBundles` (same gate as
50
+ * `decideSecretPush`): a missing listing, a parse failure, or a prototype
51
+ * name must all mean "not present, so push" — never "present, so skip".
52
+ */
53
+ export function authPresenceFromProbe(probe) {
54
+ return {
55
+ reachable: probe.reachable,
56
+ remoteHasAuth: !!(probe.remoteBundles &&
57
+ Object.prototype.hasOwnProperty.call(probe.remoteBundles, AUTH_BUNDLE_NAME)),
58
+ };
59
+ }
60
+ /** Production default: live SSH probe of reachability + `secrets list --json`. */
61
+ export function defaultAuthSyncProbe(device) {
62
+ return authPresenceFromProbe(probeDevice(device, { withSecrets: true }));
63
+ }
64
+ function defaultDevices() {
65
+ return Object.values(loadDevicesSync());
66
+ }
67
+ /**
68
+ * Push the local file-backed `auth` bundle to every pinned registered device
69
+ * that does not already have it. No-op when the local bundle is missing or on
70
+ * the wrong backend (doctor flags that separately).
71
+ */
72
+ export function syncReservedAuthBundle(deps = {}) {
73
+ const inspect = deps.inspectLocal ?? inspectReservedAuthBundle;
74
+ const local = inspect();
75
+ const localOk = local.exists && local.ok;
76
+ const localName = normalizeHost(deps.localName ?? machineId());
77
+ const devices = (deps.listDevices ?? defaultDevices)().filter((d) => normalizeHost(d.name) !== localName);
78
+ const pinned = deps.isPinned ?? ((name) => isHostPinned(name, managedKnownHostsPath()));
79
+ const probe = deps.probe ?? defaultAuthSyncProbe;
80
+ const plan = planAuthBundlePush(localOk, devices.map((d) => {
81
+ // No live probe until we know a push is even possible: missing local auth
82
+ // skips every device, and an unpinned host is refused before we SSH.
83
+ if (!localOk) {
84
+ return { name: d.name, reachable: true, pinned: pinned(d.name), remoteHasAuth: false };
85
+ }
86
+ const isPinnedDev = pinned(d.name);
87
+ if (!isPinnedDev) {
88
+ return { name: d.name, reachable: true, pinned: false, remoteHasAuth: false };
89
+ }
90
+ const p = probe(d);
91
+ return {
92
+ name: d.name,
93
+ reachable: p.reachable,
94
+ pinned: true,
95
+ remoteHasAuth: p.remoteHasAuth,
96
+ };
97
+ }));
98
+ const pushed = [];
99
+ const skipped = [];
100
+ const errors = [];
101
+ const push = deps.push ?? ((bundle, host) => pushBundleToHost(bundle, host, {
102
+ remoteBackend: 'file',
103
+ operation: 'auth-sync',
104
+ }));
105
+ const targetOf = deps.sshTarget ?? sshTargetFor;
106
+ for (const item of plan) {
107
+ if (item.action === 'skip') {
108
+ skipped.push({ device: item.device, reason: item.reason });
109
+ continue;
110
+ }
111
+ const profile = devices.find((d) => d.name === item.device);
112
+ if (!profile) {
113
+ skipped.push({ device: item.device, reason: 'not in registry' });
114
+ continue;
115
+ }
116
+ try {
117
+ const host = targetOf(profile);
118
+ const out = push(AUTH_BUNDLE_NAME, host);
119
+ if (out.ok)
120
+ pushed.push(item.device);
121
+ else
122
+ errors.push({ device: item.device, message: out.message });
123
+ }
124
+ catch (err) {
125
+ errors.push({ device: item.device, message: err.message });
126
+ }
127
+ }
128
+ return { pushed, skipped, errors };
129
+ }
@@ -0,0 +1,2 @@
1
+ import type { HealCheck } from '../types.js';
2
+ export declare const hookManifestCheck: HealCheck;
@@ -0,0 +1,56 @@
1
+ // hook-manifest check — detects manifest hooks whose `script:` resolves to
2
+ // nothing, so they are registered in config but silently never installed.
3
+ //
4
+ // Why this exists. resolveHookScriptPath only resolves a manifest script under
5
+ // <root>/hooks/, and resolveContainedHookPath rejects any candidate escaping
6
+ // that root. A manifest entry pointing anywhere else returns null and the hook
7
+ // is dropped — no error, no warning, no trace in `agents doctor`.
8
+ //
9
+ // That is not hypothetical. main-branch-guard was declared in agents.yaml as
10
+ // script: rules/subrules/truly-agentic-git-workflow/main-branch-guard.sh
11
+ // which resolves to hooks/rules/subrules/... — a path that does not exist. The
12
+ // guard was authored, tested with a 100+ case suite, and registered, yet
13
+ // reached zero of 25 settings files across three machines. Nothing surfaced it;
14
+ // it was found only after four agent sessions had written into a primary
15
+ // checkout that this exact hook exists to prevent.
16
+ //
17
+ // A guard that silently does not run is worse than a missing one, because the
18
+ // config says it is there. This check turns that silence into a finding.
19
+ import { resultOf } from '../types.js';
20
+ import { parseHookManifest, resolveHookScriptPath } from '../../hooks/install.js';
21
+ export const hookManifestCheck = {
22
+ id: 'hook-manifest',
23
+ title: 'Hook manifest scripts resolve',
24
+ cadence: 'periodic',
25
+ async run(_ctx) {
26
+ const needsAttention = [];
27
+ let manifest;
28
+ try {
29
+ // warn:false — this check reports, it does not double-log.
30
+ manifest = parseHookManifest({ warn: false });
31
+ }
32
+ catch (err) {
33
+ return resultOf([], [`hook manifest unreadable: ${err.message}`]);
34
+ }
35
+ for (const [name, def] of Object.entries(manifest)) {
36
+ if (!def || typeof def.script !== 'string' || def.script.length === 0)
37
+ continue;
38
+ if (def.enabled === false)
39
+ continue;
40
+ // An absolute script (a subrule-composed hook) is used as-is by the
41
+ // installer, so only relative manifest paths go through the hooks/ root
42
+ // resolver that can silently return null.
43
+ if (def.script.startsWith('/'))
44
+ continue;
45
+ if (resolveHookScriptPath(def.script) === null) {
46
+ needsAttention.push(`hook '${name}' declares script '${def.script}', which resolves to no file under any hooks/ root — ` +
47
+ `it is registered but silently never installed. Move the script under hooks/ (or point the manifest at ` +
48
+ `an entrypoint there) so the installer can find it.`);
49
+ }
50
+ }
51
+ // Report only. Repair would mean guessing where the author meant the script
52
+ // to live, and a wrong guess would wire the wrong file into a PreToolUse
53
+ // gate. Naming the broken entry is the fix that belongs here.
54
+ return resultOf([], needsAttention);
55
+ },
56
+ };
@@ -6,6 +6,7 @@
6
6
  // (all, or by id) — call runSelfHeal.
7
7
  import { resourcesCheck } from './checks/resources.js';
8
8
  import { hookRuntimeCheck } from './checks/hook-runtime.js';
9
+ import { hookManifestCheck } from './checks/hook-manifest.js';
9
10
  import { shimsCheck } from './checks/shims.js';
10
11
  import { shadowingCheck } from './checks/shadowing.js';
11
12
  import { pathCheck } from './checks/path.js';
@@ -17,6 +18,9 @@ export const HEAL_CHECKS = [
17
18
  shadowingCheck,
18
19
  pathCheck,
19
20
  hookRuntimeCheck,
21
+ // Detect-only, after the runtime shim repair: a shim can be healthy while the
22
+ // manifest entry that should have produced it resolves nowhere.
23
+ hookManifestCheck,
20
24
  resourcesCheck,
21
25
  ];
22
26
  /** Run the selected checks, isolating per-check failures. */
@@ -1,4 +1,4 @@
1
- export type HealCheckId = 'resources' | 'hook-runtime' | 'shims' | 'shadowing' | 'path';
1
+ export type HealCheckId = 'resources' | 'hook-runtime' | 'hook-manifest' | 'shims' | 'shadowing' | 'path';
2
2
  /** When the daemon schedules a check. */
3
3
  export type HealCadence = 'startup' | 'frequent' | 'periodic';
4
4
  export interface HealCtx {