@phnx-labs/agents-cli 1.22.57 → 1.22.58

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 (101) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/bootstrap.js +8 -1
  3. package/dist/commands/accounts.js +7 -3
  4. package/dist/commands/apply.js +10 -2
  5. package/dist/commands/fork.d.ts +23 -10
  6. package/dist/commands/fork.js +115 -58
  7. package/dist/commands/monitors.js +11 -0
  8. package/dist/commands/prune.js +5 -3
  9. package/dist/commands/routines.d.ts +8 -0
  10. package/dist/commands/routines.js +57 -3
  11. package/dist/commands/sessions-picker.d.ts +11 -0
  12. package/dist/commands/sessions-picker.js +16 -0
  13. package/dist/commands/sessions.js +1 -0
  14. package/dist/commands/share.d.ts +14 -0
  15. package/dist/commands/share.js +43 -2
  16. package/dist/commands/status.js +1 -1
  17. package/dist/commands/sync.js +83 -7
  18. package/dist/commands/traces.js +7 -0
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +6 -1
  21. package/dist/lib/account-registry.d.ts +5 -1
  22. package/dist/lib/account-registry.js +47 -14
  23. package/dist/lib/accounting/capacity.d.ts +18 -7
  24. package/dist/lib/accounting/capacity.js +19 -8
  25. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  26. package/dist/lib/accounting/usage-sync.js +76 -2
  27. package/dist/lib/accounting/usage.js +7 -1
  28. package/dist/lib/auth-mint.d.ts +11 -1
  29. package/dist/lib/auth-mint.js +21 -6
  30. package/dist/lib/browser/ipc.d.ts +8 -0
  31. package/dist/lib/browser/ipc.js +87 -0
  32. package/dist/lib/browser/service.d.ts +19 -0
  33. package/dist/lib/browser/service.js +96 -11
  34. package/dist/lib/browser/sessions-list.js +10 -1
  35. package/dist/lib/daemon/runner.d.ts +3 -0
  36. package/dist/lib/daemon/runner.js +86 -45
  37. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  38. package/dist/lib/daemon/usage-sync-service.js +14 -8
  39. package/dist/lib/daemon-services.js +1 -1
  40. package/dist/lib/devices/connect.d.ts +17 -8
  41. package/dist/lib/devices/connect.js +31 -14
  42. package/dist/lib/doctor-diff.js +77 -7
  43. package/dist/lib/fleet/manifest.d.ts +17 -0
  44. package/dist/lib/fleet/manifest.js +26 -0
  45. package/dist/lib/hooks/install.d.ts +27 -11
  46. package/dist/lib/hooks/install.js +42 -17
  47. package/dist/lib/hosts/reconnect.d.ts +52 -203
  48. package/dist/lib/hosts/reconnect.js +64 -284
  49. package/dist/lib/installations/migrate.d.ts +6 -120
  50. package/dist/lib/installations/migrate.js +27 -259
  51. package/dist/lib/installations/shims.d.ts +13 -95
  52. package/dist/lib/installations/shims.js +22 -139
  53. package/dist/lib/installations/store.js +1 -1
  54. package/dist/lib/installations/versions.d.ts +26 -133
  55. package/dist/lib/installations/versions.js +41 -204
  56. package/dist/lib/plugins/skills.d.ts +8 -1
  57. package/dist/lib/plugins/skills.js +18 -2
  58. package/dist/lib/refresh.d.ts +9 -0
  59. package/dist/lib/refresh.js +3 -1
  60. package/dist/lib/routine-readiness.d.ts +15 -1
  61. package/dist/lib/routine-readiness.js +41 -0
  62. package/dist/lib/sandbox.d.ts +4 -1
  63. package/dist/lib/sandbox.js +30 -1
  64. package/dist/lib/secrets/agent.d.ts +80 -225
  65. package/dist/lib/secrets/agent.js +139 -401
  66. package/dist/lib/secrets/bundles.d.ts +73 -222
  67. package/dist/lib/secrets/bundles.js +168 -467
  68. package/dist/lib/secrets/reaper.d.ts +28 -70
  69. package/dist/lib/secrets/reaper.js +30 -85
  70. package/dist/lib/secrets/remote.d.ts +42 -129
  71. package/dist/lib/secrets/remote.js +55 -173
  72. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  73. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  74. package/dist/lib/self-heal/registry.js +2 -0
  75. package/dist/lib/self-heal/types.d.ts +1 -1
  76. package/dist/lib/self-update.d.ts +23 -0
  77. package/dist/lib/self-update.js +50 -0
  78. package/dist/lib/session/active.d.ts +13 -1
  79. package/dist/lib/session/active.js +2 -0
  80. package/dist/lib/session/db.d.ts +20 -1
  81. package/dist/lib/session/db.js +139 -9
  82. package/dist/lib/session/fork.d.ts +45 -26
  83. package/dist/lib/session/fork.js +32 -95
  84. package/dist/lib/session/tool-calls.d.ts +43 -1
  85. package/dist/lib/session/tool-calls.js +74 -44
  86. package/dist/lib/session/tool-store.d.ts +33 -2
  87. package/dist/lib/session/tool-store.js +56 -3
  88. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  89. package/dist/lib/staleness/writers/sources.js +2 -1
  90. package/dist/lib/sync-status.d.ts +22 -0
  91. package/dist/lib/sync-status.js +27 -0
  92. package/dist/lib/sync-umbrella.d.ts +9 -0
  93. package/dist/lib/sync-umbrella.js +21 -2
  94. package/dist/lib/traces/insights.d.ts +47 -14
  95. package/dist/lib/traces/insights.js +92 -21
  96. package/dist/lib/traces/phenotype.d.ts +23 -3
  97. package/dist/lib/traces/phenotype.js +72 -24
  98. package/dist/lib/traces/sync.d.ts +15 -0
  99. package/dist/lib/traces/sync.js +104 -19
  100. package/dist/lib/traces/worker-template.js +154 -1
  101. package/package.json +1 -1
@@ -1,19 +1,9 @@
1
1
  /**
2
2
  * Remote secrets — read and use `agents secrets` bundles that live on another
3
- * host, over the same hardened SSH path that `agents secrets export --device`
4
- * (the write inverse) already uses.
3
+ * host over the same SSH path that `agents secrets export --device` uses.
5
4
  *
6
- * This is the READ / USE direction:
7
- * - browse: drive the remote `agents secrets list|view` and stream its
8
- * stdout back verbatim (lossless, no parsing).
9
- * - use: resolve a remote bundle to an env map (JSON over ssh stdout) and
10
- * inject it ephemerally — never written to this machine's keychain.
11
- *
12
- * Trust model: relies on the operator's existing SSH access to the host (same
13
- * boundary as `export --device` / `run --device`). Bundle names are shell-quoted
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.
5
+ * Browse streams remote stdout verbatim; use resolves a bundle to an env map
6
+ * and injects it ephemerally without persisting it locally.
17
7
  */
18
8
  import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
9
  import { resolveHost } from '../hosts/registry.js';
@@ -29,21 +19,8 @@ function hostKeyLookupName(target) {
29
19
  return target.split('@').pop() ?? target;
30
20
  }
31
21
  /**
32
- * SSH options for a SECRET-carrying transport (RUSH-2527). Every `agents secrets
33
- * --host` operation moves credential bytes — over ssh stdin (push) or ssh stdout
34
- * (resolve/read-back) — so it MUST NOT ride the shared `accept-new` baseline
35
- * against the user's own `~/.ssh/known_hosts` with a reusable control socket.
36
- * Two hardenings over that baseline, matching the posture the `--copy-creds`
37
- * dispatch already uses (`hosts/dispatch.ts` -> `hostKeyCheckingOpts`):
38
- *
39
- * - **Managed pinned host keys.** Verify against the CLI-owned known_hosts
40
- * store (`known-hosts.ts`), not `~/.ssh/known_hosts`. A CHANGED key on a
41
- * known host is refused (`StrictHostKeyChecking` `yes` once pinned,
42
- * `accept-new` before that). Callers that copy durable provider credentials
43
- * must separately require an existing pin before invoking this transport.
44
- * - **No multiplex reuse.** `multiplex: false` — a credential channel never
45
- * leaves a persistent `ControlMaster` socket lingering (60s `ControlPersist`)
46
- * that any other `agents` invocation to that host would silently reuse.
22
+ * SSH options for secret-carrying transport. Pins the managed host key and
23
+ * disables multiplex so no reusable control socket lingers for other invocations.
47
24
  */
48
25
  export function credentialTransportSshOpts(target) {
49
26
  return { hostKeyOpts: hostKeyCheckingOpts(isHostPinned(hostKeyLookupName(target))), multiplex: false };
@@ -56,47 +33,34 @@ export function assertCredentialTransportHostPinned(target, pinned = isHostPinne
56
33
  `Connect once with 'agents ssh ${target}' and verify the host, then retry.`);
57
34
  }
58
35
  /**
59
- * Trust boundary for a remote-resolved env map. A peer's `secrets export` output
60
- * is untrusted input: a compromised or misconfigured host could return keys that
61
- * silently reshape THIS process's behavior once merged into the agent env
62
- * (bundles.ts:251 `sanitizeProcessEnv` only strips loader vars from process.env,
63
- * never the remote bundle). Block the dangerous-override classes here — at the
64
- * source — so every consumer (`run --secrets b@host`, `secrets exec --host`) is
65
- * protected, not just one call site:
66
- * - LD_* / DYLD_* / NODE_OPTIONS and the other loader/interpreter injections
67
- * (reuses the canonical bundles.ts predicate);
68
- * - GIT_* — GIT_SSH_COMMAND et al. hijack every git subprocess;
69
- * - *_PROXY — HTTP(S)_PROXY / ALL_PROXY reroute outbound traffic (MITM);
70
- * - *_BASE_URL — ANTHROPIC_BASE_URL / OPENAI_BASE_URL redirect the model API.
71
- * These keys are already rejected on the ADD side (validateEnvKey for loaders),
72
- * so a legitimate bundle never carries them — only a hostile peer would.
36
+ * Trust boundary for a remote-resolved env map. A peer's export output is
37
+ * untrusted input that could reshape this process once merged into the env, so
38
+ * block dangerous override classes here for every consumer:
39
+ * loader/interpreter vars, GIT_*, *_PROXY, and *_BASE_URL.
73
40
  */
74
41
  export function isDangerousRemoteEnvKey(name) {
75
42
  const upper = name.toUpperCase();
76
43
  if (isLoaderOrInterpreterEnv(upper))
77
44
  return true;
78
45
  if (upper.startsWith('GIT_'))
79
- return true;
46
+ return true; // GIT_SSH_COMMAND hijacks git subprocesses
80
47
  if (upper.endsWith('_PROXY'))
81
- return true;
48
+ return true; // *_PROXY = MITM
82
49
  if (upper.endsWith('_BASE_URL'))
83
- return true;
50
+ return true; // *_BASE_URL = model API redirect
84
51
  return false;
85
52
  }
86
53
  /** Remote OS for a host name or target string. Prefer the original host name
87
- * because enrolled inline hosts resolve to `user@address`, while the OS
88
- * registry is keyed by the host name. */
54
+ * because enrolled inline hosts resolve to `user@address`, while the OS
55
+ * registry is keyed by the host name. */
89
56
  function osForTarget(target, lookupName) {
90
57
  const byName = lookupName ? resolveRemoteOsSync(lookupName) : undefined;
91
58
  return byName ?? resolveRemoteOsSync(target.split('@').pop() ?? target);
92
59
  }
93
60
  /**
94
- * Resolve a `--device` value to an ssh target STRING for the remote-secrets path.
95
- * Delegates to the single host/device resolver (`resolveHost`, RUSH-1967) so a
96
- * name here dials the exact same box `run --device` does; on a miss, treats the
97
- * value as a raw ssh target and validates it against injection. Named distinctly
98
- * from `../devices/resolve-target.ts` (which returns richer shapes) so importing
99
- * the wrong one can't silently change which machine you dial.
61
+ * Resolve a `--device` value to an ssh target string for the remote-secrets
62
+ * path. Delegates to the same resolver `run --device` uses; on a miss, treats
63
+ * the value as a raw ssh target and validates it.
100
64
  */
101
65
  export async function resolveHostSshTarget(nameOrAlias) {
102
66
  const host = await resolveHost(nameOrAlias);
@@ -106,11 +70,8 @@ export async function resolveHostSshTarget(nameOrAlias) {
106
70
  return nameOrAlias;
107
71
  }
108
72
  /**
109
- * Merge `--host <single>` / `--hosts <a,b,c>` (and their `--device` / `--devices`
110
- * aliases) into an ordered, de-duplicated list. All four flags compose; any alone
111
- * works. `--device`/`--devices` resolve identically to `--device`/`--hosts` so the
112
- * fleet-wide `--device` vocabulary (see `agents run --device`, `agents feed --host`)
113
- * works on the secrets remote commands too. Empty when none is set.
73
+ * Merge `--host` / `--hosts` (and their `--device` / `--devices` aliases) into
74
+ * an ordered, de-duplicated list. Empty when none is set.
114
75
  */
115
76
  export function parseHostsOption(opts) {
116
77
  const out = [];
@@ -135,11 +96,9 @@ export function parseHostsOption(opts) {
135
96
  return out;
136
97
  }
137
98
  /**
138
- * Split a `bundle@host` reference. No `@` → a local bundle (host undefined).
139
- * Bundle names can't contain `@` (BUNDLE_NAME_PATTERN), so the FIRST `@`
140
- * separates the bundle from the ssh target — and the target itself may be a
141
- * `user@host` (e.g. `r2.backups@muqsit@box` → bundle `r2.backups`, host
142
- * `muqsit@box`).
99
+ * Split a `bundle@host` reference. No `@` → a local bundle. Bundle names can't
100
+ * contain `@`, so the FIRST `@` separates bundle from ssh target; the target
101
+ * itself may be `user@host` (e.g. `r2.backups@muqsit@box`).
143
102
  */
144
103
  export function splitBundleRef(ref) {
145
104
  const at = ref.indexOf('@');
@@ -154,19 +113,13 @@ export function splitBundleRef(ref) {
154
113
  }
155
114
  /**
156
115
  * Run `agents secrets <args>` on a remote host over ssh and return the raw
157
- * result. Used by the browse commands — the remote's human-readable stdout is
158
- * streamed back unchanged. `tty` forces an interactive ssh session (`-tt`) so a
159
- * remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
116
+ * result. Used by browse commands; `tty` forces `-tt` so remote passphrase
117
+ * prompts can surface.
160
118
  */
161
119
  export function remoteSecretsRaw(target, args, opts = {}) {
162
120
  const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target, opts.osLookupName));
163
- // A secret-bearing call (`secret: true`) pins the managed host key and refuses
164
- // to multiplex — see `credentialTransportSshOpts` (RUSH-2527). A `-tt` session
165
- // (a remote reveal/passphrase prompt) additionally allocates a PTY and never
166
- // multiplexes, and it COMPOSES with the secret posture: a `view --reveal` over
167
- // `--device` both prompts AND streams the plaintext value back over ssh stdout,
168
- // so it needs the managed host-key pin too — `tty` must not short-circuit past
169
- // `secret`. A plain browse `list` passes neither and keeps the shared baseline.
121
+ // `secret: true` pins the managed host key and refuses multiplex; `-tt`
122
+ // additionally allocates a PTY and never multiplexes. The two compose.
170
123
  const posture = opts.secret ? credentialTransportSshOpts(target) : {};
171
124
  const conn = opts.tty
172
125
  ? { ...posture, extraSshArgs: ['-tt'], multiplex: false }
@@ -178,49 +131,28 @@ export function remoteSecretsRaw(target, args, opts = {}) {
178
131
  });
179
132
  }
180
133
  /**
181
- * Run a remote `agents secrets <args>` FOREGROUND, with the local stdio wired
182
- * straight through (`stdio: 'inherit'` + `-tt`), and return its exit code.
183
- *
184
- * Unlike `remoteSecretsRaw` — which pipes stdin, so even with `-tt` the remote
185
- * process's `process.stdin.isTTY` is false and a passphrase prompt refuses to
186
- * appear (the macOS file-store guard then hard-errors "needs
187
- * AGENTS_SECRETS_PASSPHRASE") — this inherits the caller's real terminal, so the
188
- * remote sees a genuine TTY and its hidden passphrase prompt surfaces and reads
189
- * the keystrokes. This is the transport for `unlock --device`: you type the remote
190
- * bundle's passphrase at your own terminal. Output is NOT captured (it streams
191
- * to the terminal); only the exit code is returned.
134
+ * Run a remote `agents secrets <args>` foreground with local stdio inherited,
135
+ * so the remote sees a real TTY and its passphrase prompt surfaces and reads
136
+ * keystrokes. Output streams to the terminal; only the exit code is returned.
192
137
  */
193
138
  export function remoteSecretsStream(target, args, opts = {}) {
194
139
  const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target, opts.osLookupName));
195
- // `unlock --device` carries the remote bundle's passphrase to the destination over
196
- // this interactive channel, so it is secret-bearing: pin the managed host key
197
- // (a changed key is refused) and never multiplex (RUSH-2527).
198
140
  return sshStream(target, remoteCmd, { tty: true, ...credentialTransportSshOpts(target) });
199
141
  }
200
142
  /**
201
143
  * Resolve a remote bundle to a plaintext env map by driving the remote's
202
144
  * `agents secrets export <bundle> --plaintext --format json`. Values cross over
203
- * ssh stdout (encrypted in transit), parsed in memory, never persisted.
145
+ * ssh stdout, parsed in memory, never persisted.
204
146
  *
205
- * The remote unlocks the bundle with ITS OWN credentials — the owner host's
206
- * keychain/secrets-agent, or its own `AGENTS_SECRETS_PASSPHRASE` (in the login
207
- * env) for a file-backed bundle. We deliberately do NOT forward this machine's
208
- * passphrase: the remote bundle is encrypted with the remote's passphrase, so
209
- * overriding it would break the read. (A macOS remote under non-interactive
210
- * SSH will block on Touch-ID — use `view`/`exec` with a remote `file` bundle,
211
- * an already-unlocked remote secrets-agent, or an interactive `-tt` session.)
147
+ * The remote unlocks the bundle with its own credentials; this machine does NOT
148
+ * forward its passphrase because the remote bundle is encrypted with the
149
+ * remote's passphrase.
212
150
  */
213
151
  export async function remoteResolveEnv(target, bundle, opts = {}) {
214
152
  assertValidSshTarget(target);
215
- // AGENTS_SECRETS_REMOTE_TRANSPORT is the marker that lets the remote's
216
- // `export --plaintext --format json` emit at all — the public shell-eval
217
- // export mode was removed (RUSH-2774), and this machine-to-machine resolve is
218
- // the only surviving caller of the JSON emitter. Riding the legacy argv keeps
219
- // a new driver compatible with an old remote during a fleet rollout.
153
+ // AGENTS_SECRETS_REMOTE_TRANSPORT marks this as the machine-to-machine JSON
154
+ // resolve path (the public shell-eval export mode was removed).
220
155
  const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName), { AGENTS_SECRETS_REMOTE_TRANSPORT: '1' });
221
- // Resolving a remote bundle streams its plaintext values back over ssh stdout,
222
- // so this read is secret-bearing: pin the managed host key and never leave a
223
- // reusable control master to the source (RUSH-2527, `credentialTransportSshOpts`).
224
156
  const res = sshExec(target, remoteCmd, {
225
157
  timeoutMs: REMOTE_TIMEOUT_MS,
226
158
  ...credentialTransportSshOpts(target),
@@ -249,8 +181,7 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
249
181
  const env = {};
250
182
  const blocked = [];
251
183
  for (const [k, v] of Object.entries(parsed)) {
252
- // Drop dangerous-override keys returned by the (untrusted) peer before they
253
- // can reshape this process — see isDangerousRemoteEnvKey.
184
+ // Drop dangerous-override keys returned by the (untrusted) peer.
254
185
  if (isDangerousRemoteEnvKey(k)) {
255
186
  blocked.push(k);
256
187
  continue;
@@ -261,10 +192,7 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
261
192
  process.stderr.write(`[secrets] Dropped ${blocked.length} dangerous key(s) from '${bundle}'@${target} ` +
262
193
  `(remote override blocked): ${blocked.join(', ')}\n`);
263
194
  }
264
- // The remote host audits its own `secrets export` read; this emit records the
265
- // event on the INITIATING host too (values were pulled into this process and
266
- // injected locally). Covers `secrets exec --host` and `run --secrets b@host`.
267
- // Values never enter the payload — only the bundle, target host, and count.
195
+ // Audit the event on the initiating host too; values never enter the payload.
268
196
  emitSecretAudit({
269
197
  event: 'secrets.get',
270
198
  bundle,
@@ -277,56 +205,32 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
277
205
  return env;
278
206
  }
279
207
  /**
280
- * The remote's headless read-back raises one of these when a keychain-backed bundle
281
- * has metadata but no readable value items — the exact locked-login-keychain
282
- * signature. Anything else (connection refused, timeout, host key error) is a
283
- * transient/unrelated failure and must NOT be mislabeled as a locked keychain.
208
+ * True when the remote's headless read-back raised the locked-login-keychain
209
+ * signature. Anything else must NOT be mislabeled as a locked keychain.
284
210
  */
285
211
  function isLockedKeychainReadBackError(stderr) {
286
212
  const s = stderr.toLowerCase();
287
- return (
288
- // bundles.ts agentOnly guard: "…is not unlocked in the secrets agent…"
289
- s.includes('not unlocked') ||
213
+ return (s.includes('not unlocked') ||
290
214
  s.includes('secrets agent') ||
291
- // resolveBundleEnv: "Bundle '<b>' key '<k>': stored item '<item>' not found."
292
215
  s.includes('stored item') ||
293
216
  s.includes('not found'));
294
217
  }
295
218
  /**
296
- * Decide whether a keychain-backed push to a remote actually PERSISTED its secret
297
- * value items, given the read-back of that bundle from the remote's own store.
298
- *
299
- * The silent-failure this guards: pushing `--remote-backend keychain` (default) to
300
- * a macOS host over headless SSH lands the bundle METADATA but not readable value
301
- * items — the remote login keychain is locked in the non-interactive SSH context,
302
- * so Security accepts the item WRITE at the DB level but the biometry-ACL'd item is
303
- * unreadable, and the remote `import` still reports success (values written first,
304
- * metadata `noAcl` last — bundles.ts writeBundleWithItems). The metadata-only bundle
305
- * then fails every later read with the confusing `Bundle '<b>' key '<k>': stored
306
- * item '<item>' not found` (bundles.ts resolveBundleEnv). We catch it by reading
307
- * the bundle back the same way a later `secrets exec`/resolve will (the marker-gated
308
- * json transport, driven headlessly on the remote so its `agentOnly` guard FAILS FAST
309
- * before any keychain read — no Touch ID prompt) and confirming every pushed key
310
- * returned.
311
- *
312
- * Pure so both branches are unit-testable without a real locked keychain: inject the
313
- * "read-back failed / key absent" condition through `readBack`.
219
+ * Decide whether a keychain-backed push to a remote actually persisted its
220
+ * secret value items by reading the bundle back the same way a later resolve
221
+ * will. Catches the silent failure where headless SSH writes metadata but not
222
+ * readable value items to a locked macOS login keychain.
314
223
  */
315
224
  export function evaluateKeychainWriteVerification(pushedKeys, readBack) {
316
225
  if (!readBack.ok) {
317
226
  const stderr = readBack.stderr.trim();
318
227
  if (isLockedKeychainReadBackError(stderr)) {
319
- // The remote's own headless read raised the not-unlocked / not-found signal —
320
- // exactly the confusing error the user hits later. Surface it now, at push
321
- // time, with the fix.
322
228
  return {
323
229
  ok: false,
324
230
  kind: 'locked-keychain',
325
231
  reason: `the remote could not read it back${stderr ? ` (${stderr})` : ''}`,
326
232
  };
327
233
  }
328
- // A transient / unrelated failure (flaky SSH, timeout, bad payload). Re-surface
329
- // verbatim — do NOT diagnose a locked keychain from a connection error.
330
234
  return {
331
235
  ok: false,
332
236
  kind: 'error',
@@ -336,8 +240,6 @@ export function evaluateKeychainWriteVerification(pushedKeys, readBack) {
336
240
  const present = new Set(readBack.keys);
337
241
  const missing = pushedKeys.filter((k) => !present.has(k));
338
242
  if (missing.length > 0) {
339
- // Read-back succeeded but some pushed keys are absent — the value items didn't
340
- // persist. Same locked-keychain cause and fix.
341
243
  return {
342
244
  ok: false,
343
245
  kind: 'locked-keychain',
@@ -348,10 +250,8 @@ export function evaluateKeychainWriteVerification(pushedKeys, readBack) {
348
250
  return { ok: true };
349
251
  }
350
252
  /**
351
- * The actionable error message for a failed keychain-over-SSH push verification.
352
- * Names the cause (locked remote login keychain) and steers to the two real fixes:
353
- * re-run with the headless-readable file backend, or unlock the remote keychain.
354
- * Pure + exported so the exact guidance is asserted in tests.
253
+ * Actionable error message for a failed keychain-over-SSH push. Names the cause
254
+ * (locked remote login keychain) and steers to the two real fixes.
355
255
  */
356
256
  export function keychainWriteFailureMessage(host, bundle, reason) {
357
257
  return (`${host}: pushed '${bundle}' but the keychain items did not persist — ${reason}. ` +
@@ -363,22 +263,12 @@ export function keychainWriteFailureMessage(host, bundle, reason) {
363
263
  `(e.g. an interactive login / \`agents secrets unlock\` on ${host}) and retry.`);
364
264
  }
365
265
  /**
366
- * Read a bundle back from a remote over SSH (headlessly, so it fails fast rather
367
- * than prompting Touch ID) and confirm the pushed keys materialized. Drives the
368
- * remote's marker-gated json transport (`secrets export <bundle> --plaintext
369
- * --format json` under AGENTS_SECRETS_REMOTE_TRANSPORT) but keeps only the KEY
370
- * NAMES; the plaintext values are dropped immediately and never retained or
371
- * logged. Returns a verification verdict; the caller renders
372
- * `keychainWriteFailureMessage` on failure.
266
+ * Read a bundle back from a remote over SSH and confirm the pushed keys
267
+ * materialized. Drives the marker-gated json transport but keeps only KEY NAMES;
268
+ * plaintext values are discarded immediately. Returns a verification verdict.
373
269
  */
374
270
  export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {}) {
375
- const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName),
376
- // Same transport marker as remoteResolveEnv — see the comment there (RUSH-2774).
377
- { AGENTS_SECRETS_REMOTE_TRANSPORT: '1' });
378
- // This read-back streams the just-pushed plaintext over ssh stdout, so it is
379
- // as secret-bearing as the push itself — the push path passes `secret: true`
380
- // so it too pins the managed host key and leaves no reusable control master to
381
- // the destination (RUSH-2527).
271
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName), { AGENTS_SECRETS_REMOTE_TRANSPORT: '1' });
382
272
  const res = sshExec(target, remoteCmd, {
383
273
  timeoutMs: REMOTE_TIMEOUT_MS,
384
274
  ...(opts.secret ? credentialTransportSshOpts(target) : {}),
@@ -388,8 +278,7 @@ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {})
388
278
  const stderr = `${why}${(res.stderr || res.stdout || '').trim() ? `: ${(res.stderr || res.stdout).trim()}` : ''}`;
389
279
  return evaluateKeychainWriteVerification(pushedKeys, { ok: false, stderr });
390
280
  }
391
- // Take the outer { … } object (tolerate login-shell banner noise), read the key
392
- // names, and immediately discard the values — we only need presence here.
281
+ // Tolerate login-shell banner noise; keep only key names and discard values.
393
282
  const raw = res.stdout;
394
283
  const start = raw.indexOf('{');
395
284
  const end = raw.lastIndexOf('}');
@@ -414,24 +303,17 @@ export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {})
414
303
  return evaluateKeychainWriteVerification(pushedKeys, { ok: true, keys });
415
304
  }
416
305
  /**
417
- * The `bash -lc` command + stdin payload that drives a **file-backed** remote
418
- * import for `secrets export --device … --remote-backend file`.
306
+ * `bash -lc` command + stdin payload for a file-backed remote import.
419
307
  *
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
308
+ * The file store is passphrase-free: the remote auto-provisions its own machine-
309
+ * local key, so reads are headless. AGENTS_SECRETS_PASSPHRASE must NOT be
424
310
  * forwarded (PHNX-2371): a remote keyed to a secret its daemon does not hold
425
311
  * reports "Imported N key(s)" then fails every later decrypt.
426
- *
427
- * Pure — no I/O — so the exact command string and stdin ordering are unit-testable
428
- * against the SSH boundary the same way `remoteSecretsRaw` is.
429
312
  */
430
313
  export function buildRemoteFileImportCommand(bundle, dotenv, opts = {}) {
431
314
  const force = opts.force ? ' --force' : '';
432
315
  const policy = opts.policyNever ? ' --policy never --i-understand' : '';
433
316
  const importCmd = `agents secrets import ${shellQuote(bundle)} --from - --backend file${force}${policy}`;
434
- // No prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the remote, so the
435
- // file store falls back to its machine-local key (headless).
317
+ // No prologue — AGENTS_SECRETS_PASSPHRASE stays unset on the remote.
436
318
  return { remoteCmd: `bash -lc ${shellQuote(importCmd)}`, input: dotenv };
437
319
  }
@@ -0,0 +1,4 @@
1
+ import type { HealCheck } from '../types.js';
2
+ /** A concurrent upgrade must be long finished before an unattended sweep may touch its staging dir. */
3
+ export declare const STALE_INSTALL_STAGING_AGE_MS: number;
4
+ export declare const installStagingCheck: HealCheck;
@@ -0,0 +1,96 @@
1
+ // install-staging check — fleet-wide self-heal for the orphaned npm reify
2
+ // staging dir that dead-ends `agents upgrade` forever (PHNX-3393).
3
+ //
4
+ // `agents upgrade` itself sweeps this orphan proactively before every reify
5
+ // (sweepStaleInstallStaging in self-update.ts), so a box that runs `agents
6
+ // upgrade` regularly never accumulates one. This check exists for the box
7
+ // that does NOT: one that stopped upgrading after the crash that left the
8
+ // orphan behind, so the next manual `agents upgrade` would still hit the same
9
+ // ENOTEMPTY the sweep exists to prevent, and nothing runs `agents upgrade` to
10
+ // trigger that sweep in the meantime. Periodic cadence closes that gap
11
+ // fleet-wide (via the daemon's self-heal service) and on demand via
12
+ // `agents doctor --fix`.
13
+ //
14
+ // The age guard is load-bearing: a staging dir can be legitimately mid-write
15
+ // by a CONCURRENT upgrade this instant. Only a dir older than the guard is
16
+ // touched, so an unattended periodic run can never delete a live reify.
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { fileURLToPath } from 'url';
20
+ import { resultOf } from '../types.js';
21
+ import { resolveRunningPackageRoot } from '../../self-update.js';
22
+ const __installStagingDirname = path.dirname(fileURLToPath(import.meta.url));
23
+ /** A concurrent upgrade must be long finished before an unattended sweep may touch its staging dir. */
24
+ export const STALE_INSTALL_STAGING_AGE_MS = 10 * 60 * 1000;
25
+ /**
26
+ * Find the retire-path staging dir(s) for `packageRoot` older than
27
+ * `maxAgeMs`. Reimplements the same matching self-update.ts's
28
+ * sweepStaleInstallStaging uses, so the age guard can be checked BEFORE
29
+ * anything is deleted — the sweep helper itself removes unconditionally,
30
+ * which is correct for the upgrade hot path (nothing else touches that
31
+ * path while an upgrade you just started is running) but wrong for an
32
+ * unattended periodic sweep that could race a concurrent upgrade.
33
+ */
34
+ function findAgedInstallStaging(packageRoot, maxAgeMs, now) {
35
+ const resolved = path.resolve(packageRoot);
36
+ const dir = path.dirname(resolved);
37
+ const base = path.basename(resolved);
38
+ const escapedBase = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
39
+ const stagingPattern = new RegExp(`^\\.${escapedBase}-[a-zA-Z0-9]+$`);
40
+ let entries;
41
+ try {
42
+ entries = fs.readdirSync(dir);
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ const aged = [];
48
+ for (const entry of entries) {
49
+ if (!stagingPattern.test(entry))
50
+ continue;
51
+ const full = path.join(dir, entry);
52
+ let mtimeMs;
53
+ try {
54
+ mtimeMs = fs.statSync(full).mtimeMs;
55
+ }
56
+ catch {
57
+ continue; // vanished between readdir and stat — nothing to sweep
58
+ }
59
+ if (now - mtimeMs >= maxAgeMs)
60
+ aged.push(full);
61
+ }
62
+ return aged;
63
+ }
64
+ export const installStagingCheck = {
65
+ id: 'install-staging',
66
+ title: 'Orphaned npm reify staging dir',
67
+ cadence: 'periodic',
68
+ async run(ctx) {
69
+ let packageRoot;
70
+ try {
71
+ packageRoot = resolveRunningPackageRoot(__installStagingDirname);
72
+ }
73
+ catch {
74
+ // Not an npm/bun-managed install (source checkout) — nothing to sweep.
75
+ return resultOf([], []);
76
+ }
77
+ const aged = findAgedInstallStaging(packageRoot, STALE_INSTALL_STAGING_AGE_MS, Date.now());
78
+ if (aged.length === 0)
79
+ return resultOf([], []);
80
+ if (ctx.dryRun) {
81
+ return resultOf(aged.map((p) => `orphaned reify staging dir: ${p}`), []);
82
+ }
83
+ const fixed = [];
84
+ const needsAttention = [];
85
+ for (const stagingPath of aged) {
86
+ try {
87
+ fs.rmSync(stagingPath, { recursive: true, force: true });
88
+ fixed.push(`removed orphaned reify staging dir ${stagingPath} — the next 'agents upgrade' can reify cleanly`);
89
+ }
90
+ catch (err) {
91
+ needsAttention.push(`could not remove orphaned reify staging dir ${stagingPath}: ${err.message}`);
92
+ }
93
+ }
94
+ return resultOf(fixed, needsAttention);
95
+ },
96
+ };
@@ -10,6 +10,7 @@ import { hookManifestCheck } from './checks/hook-manifest.js';
10
10
  import { shimsCheck } from './checks/shims.js';
11
11
  import { shadowingCheck } from './checks/shadowing.js';
12
12
  import { pathCheck } from './checks/path.js';
13
+ import { installStagingCheck } from './checks/install-staging.js';
13
14
  // Order matters: cheap structural fixes (shims, shadow adoption, PATH, generated
14
15
  // hook wrappers) before the heavier resource reconciliation, so a freshly-
15
16
  // repaired shim is in place first.
@@ -22,6 +23,7 @@ export const HEAL_CHECKS = [
22
23
  // manifest entry that should have produced it resolves nowhere.
23
24
  hookManifestCheck,
24
25
  resourcesCheck,
26
+ installStagingCheck,
25
27
  ];
26
28
  /** Run the selected checks, isolating per-check failures. */
27
29
  export async function runSelfHeal(opts = {}) {
@@ -1,4 +1,4 @@
1
- export type HealCheckId = 'resources' | 'hook-runtime' | 'hook-manifest' | 'shims' | 'shadowing' | 'path';
1
+ export type HealCheckId = 'resources' | 'hook-runtime' | 'hook-manifest' | 'shims' | 'shadowing' | 'path' | 'install-staging';
2
2
  /** When the daemon schedules a check. */
3
3
  export type HealCadence = 'startup' | 'frequent' | 'periodic';
4
4
  export interface HealCtx {
@@ -130,6 +130,29 @@ export declare function resolveRunningPackageRoot(dirname: string, execPath?: st
130
130
  * one is exactly the bug this module exists to prevent.
131
131
  */
132
132
  export declare function deriveGlobalPrefix(packageRoot: string): string;
133
+ /**
134
+ * Sweep npm arborist's "retired" staging dir for `packageRoot` before a
135
+ * reify (PHNX-3393).
136
+ *
137
+ * npm (@npmcli/arborist) reifies an install by first renaming the tree it is
138
+ * about to replace out of the way into a sibling directory —
139
+ * `retirePath(from)` in arborist's own source names it
140
+ * `.<basename>-<8-char sha1 hash of the full path>`, sibling to `from` — then
141
+ * stages the new tree and renames it into place. Because the hash is a pure
142
+ * function of `packageRoot`'s path, that staging path is IDENTICAL on every
143
+ * reify of this install. A crash between the retire-rename and the final
144
+ * rename (SIGKILL, a killed terminal, a box that lost power mid-upgrade)
145
+ * leaves that exact directory behind, non-empty. `rename(2)` cannot replace a
146
+ * non-empty directory, so every subsequent upgrade's reify fails ENOTEMPTY at
147
+ * the same path forever — nothing about a plain retry ever clears it.
148
+ *
149
+ * Removing any stale `.<basename>-*` sibling before install self-heals this:
150
+ * npm re-stages cleanly once the collision is gone. Matches only the
151
+ * retire-path shape (a dot-prefixed sibling starting with the package's own
152
+ * basename), so an unrelated dotfile in the same directory is left alone.
153
+ * Best-effort per entry: one unremovable sibling must not block the rest.
154
+ */
155
+ export declare function sweepStaleInstallStaging(packageRoot: string): string[];
133
156
  /**
134
157
  * Install `spec` into an explicit global prefix. `--prefix` pins the
135
158
  * destination no matter which npm binary PATH resolves. `--ignore-scripts`
@@ -292,6 +292,56 @@ export function deriveGlobalPrefix(packageRoot) {
292
292
  const parent = path.dirname(nodeModulesDir);
293
293
  return path.basename(parent) === 'lib' ? path.dirname(parent) : parent;
294
294
  }
295
+ /**
296
+ * Sweep npm arborist's "retired" staging dir for `packageRoot` before a
297
+ * reify (PHNX-3393).
298
+ *
299
+ * npm (@npmcli/arborist) reifies an install by first renaming the tree it is
300
+ * about to replace out of the way into a sibling directory —
301
+ * `retirePath(from)` in arborist's own source names it
302
+ * `.<basename>-<8-char sha1 hash of the full path>`, sibling to `from` — then
303
+ * stages the new tree and renames it into place. Because the hash is a pure
304
+ * function of `packageRoot`'s path, that staging path is IDENTICAL on every
305
+ * reify of this install. A crash between the retire-rename and the final
306
+ * rename (SIGKILL, a killed terminal, a box that lost power mid-upgrade)
307
+ * leaves that exact directory behind, non-empty. `rename(2)` cannot replace a
308
+ * non-empty directory, so every subsequent upgrade's reify fails ENOTEMPTY at
309
+ * the same path forever — nothing about a plain retry ever clears it.
310
+ *
311
+ * Removing any stale `.<basename>-*` sibling before install self-heals this:
312
+ * npm re-stages cleanly once the collision is gone. Matches only the
313
+ * retire-path shape (a dot-prefixed sibling starting with the package's own
314
+ * basename), so an unrelated dotfile in the same directory is left alone.
315
+ * Best-effort per entry: one unremovable sibling must not block the rest.
316
+ */
317
+ export function sweepStaleInstallStaging(packageRoot) {
318
+ const resolved = path.resolve(packageRoot);
319
+ const dir = path.dirname(resolved);
320
+ const base = path.basename(resolved);
321
+ const escapedBase = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
322
+ const stagingPattern = new RegExp(`^\\.${escapedBase}-[a-zA-Z0-9]+$`);
323
+ let entries;
324
+ try {
325
+ entries = fs.readdirSync(dir);
326
+ }
327
+ catch {
328
+ return [];
329
+ }
330
+ const swept = [];
331
+ for (const entry of entries) {
332
+ if (!stagingPattern.test(entry))
333
+ continue;
334
+ const full = path.join(dir, entry);
335
+ try {
336
+ fs.rmSync(full, { recursive: true, force: true });
337
+ swept.push(full);
338
+ }
339
+ catch {
340
+ /* best-effort — one unremovable stager must not block the rest */
341
+ }
342
+ }
343
+ return swept;
344
+ }
295
345
  /**
296
346
  * Install `spec` into an explicit global prefix. `--prefix` pins the
297
347
  * destination no matter which npm binary PATH resolves. `--ignore-scripts`
@@ -55,7 +55,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
55
55
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
56
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
57
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
58
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
59
59
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
60
60
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
61
61
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
@@ -201,6 +201,18 @@ export interface ActiveSession {
201
201
  * id (RUSH-2205), never asserted by a source.
202
202
  */
203
203
  version?: string;
204
+ /**
205
+ * Email of the account that produced the session (display-only). Like
206
+ * {@link version}, a running process does not report which account a
207
+ * `--strategy balanced` launch selected, so it is backfilled at render time
208
+ * from the indexed {@link SessionMeta} by session id (PHNX-3184). This is what
209
+ * the AGI EXT status bar renders as the session's account — it reads it off the
210
+ * `sessions watch --json` row instead of spawning a per-tab `agents sessions
211
+ * <id> --device <host> --json` (the 2026-08-25 CPU incident, agi-cli#3019).
212
+ * Never group on this — two orgs can share one email; group on the index's
213
+ * `accountKey`.
214
+ */
215
+ account?: string;
204
216
  /**
205
217
  * Last-activity epoch — the transcript's last write (mtime). Distinct from
206
218
  * {@link startedAtMs} (session START): a session begun 3h ago but last touched