@phnx-labs/agents-cli 1.22.25 → 1.22.27

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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -9,7 +9,6 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import * as os from 'os';
12
- import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
13
12
  import { getRoutinesDir, getUserAgentsDir } from './state.js';
14
13
  import { safeJoin } from './paths.js';
15
14
  import { createLink } from './platform/index.js';
@@ -91,15 +90,14 @@ export function prepareJobHome(config) {
91
90
  const overlayHome = getJobHomePath(config.name);
92
91
  cleanJobHome(config.name);
93
92
  fs.mkdirSync(overlayHome, { recursive: true });
93
+ // gemini is hard-deprecated: runner.ts rejects a gemini routine before it
94
+ // ever reaches prepareJobHome, so there is deliberately no branch here.
94
95
  if (config.agent === 'claude') {
95
96
  generateClaudeConfig(overlayHome, config);
96
97
  }
97
98
  else if (config.agent === 'codex') {
98
99
  generateCodexConfig(overlayHome, config);
99
100
  }
100
- else if (config.agent === 'gemini') {
101
- generateGeminiConfig(overlayHome, config);
102
- }
103
101
  else if (config.agent === 'cursor') {
104
102
  generateCursorConfig(overlayHome);
105
103
  }
@@ -270,18 +268,3 @@ export function generateCodexConfig(overlayHome, config) {
270
268
  }
271
269
  fs.writeFileSync(path.join(codexDir, 'config.toml'), lines.join('\n') + '\n', 'utf-8');
272
270
  }
273
- /** Generate a Gemini settings.json in the overlay from the job's config block. */
274
- export function generateGeminiConfig(overlayHome, config) {
275
- const settingsPath = path.join(overlayHome, '.gemini', 'settings.json');
276
- updateGeminiSettings(settingsPath, (settings) => {
277
- if (config.config?.model) {
278
- settings.model = config.config.model;
279
- }
280
- if (config.config) {
281
- for (const [key, value] of Object.entries(config.config)) {
282
- settings[key] = value;
283
- }
284
- }
285
- setGeminiAutoUpdateDisabled(settings);
286
- });
287
- }
@@ -37,6 +37,7 @@ import { getCliLaunch } from '../cli-entry.js';
37
37
  import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
38
38
  import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
39
39
  import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
40
+ import { MAX_LEASE_MS, MIN_LEASE_MS } from './lease.js';
40
41
  // Re-exported so callers already reaching for agent.js keep one obvious home for
41
42
  // the scope vocabulary; the definitions live in the leaf module scope.ts because
42
43
  // agent.ts and session-store.ts import each other and a cyclic `const` read can
@@ -1120,8 +1121,8 @@ export function secretsAgentDurable() {
1120
1121
  /** Minimum / maximum bounds for the configurable hold window. A too-small value
1121
1122
  * would defeat the broker (constant re-prompts); a too-large one pins secrets in
1122
1123
  * memory far longer than intended. */
1123
- export const MIN_HOLD_MS = 60 * 1000; // 1m
1124
- export const MAX_HOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30d
1124
+ export const MIN_HOLD_MS = MIN_LEASE_MS;
1125
+ export const MAX_HOLD_MS = MAX_LEASE_MS;
1125
1126
  /**
1126
1127
  * How long an unlocked / auto-cached bundle is held before the next read
1127
1128
  * re-prompts. Defaults to DEFAULT_TTL_MS (7d); override with
@@ -0,0 +1,25 @@
1
+ export declare const MIN_LEASE_MS: number;
2
+ export declare const MAX_LEASE_MS: number;
3
+ export interface SecretLease {
4
+ id: string;
5
+ bundle: string;
6
+ keys: string[];
7
+ createdAt: number;
8
+ expiresAt: number;
9
+ harness: string;
10
+ sleepPersist: boolean;
11
+ }
12
+ export declare function clampLeaseTtlMs(ttlMs: number): number;
13
+ export declare function normalizeLeaseKeys(keys: readonly string[], availableKeys: readonly string[]): string[];
14
+ export declare function createSecretLease(input: {
15
+ bundle: string;
16
+ keys: readonly string[];
17
+ availableKeys: readonly string[];
18
+ ttlMs: number;
19
+ now?: number;
20
+ id?: string;
21
+ harness?: string;
22
+ sleepPersist?: boolean;
23
+ }): SecretLease;
24
+ export declare function leaseIsActive(lease: SecretLease, now?: number): boolean;
25
+ export declare function selectLeasedEnv(lease: SecretLease, env: Record<string, string>, now?: number): Record<string, string>;
@@ -0,0 +1,44 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { GLOBAL_HARNESS } from './scope.js';
3
+ export const MIN_LEASE_MS = 60 * 1000;
4
+ export const MAX_LEASE_MS = 30 * 24 * 60 * 60 * 1000;
5
+ export function clampLeaseTtlMs(ttlMs) {
6
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
7
+ throw new Error('Lease duration must be a positive finite number.');
8
+ }
9
+ return Math.min(Math.max(Math.floor(ttlMs), MIN_LEASE_MS), MAX_LEASE_MS);
10
+ }
11
+ export function normalizeLeaseKeys(keys, availableKeys) {
12
+ const requested = [...new Set(keys.map((key) => key.trim()).filter(Boolean))].sort();
13
+ if (requested.length === 0)
14
+ throw new Error('A secret lease requires at least one key.');
15
+ const available = new Set(availableKeys);
16
+ const missing = requested.filter((key) => !available.has(key));
17
+ if (missing.length > 0)
18
+ throw new Error(`Unknown secret lease key(s): ${missing.join(', ')}`);
19
+ return requested;
20
+ }
21
+ export function createSecretLease(input) {
22
+ const createdAt = input.now ?? Date.now();
23
+ const ttlMs = clampLeaseTtlMs(input.ttlMs);
24
+ return {
25
+ id: input.id ?? randomBytes(12).toString('hex'),
26
+ bundle: input.bundle,
27
+ keys: normalizeLeaseKeys(input.keys, input.availableKeys),
28
+ createdAt,
29
+ expiresAt: createdAt + ttlMs,
30
+ harness: input.harness ?? GLOBAL_HARNESS,
31
+ sleepPersist: input.sleepPersist ?? false,
32
+ };
33
+ }
34
+ export function leaseIsActive(lease, now = Date.now()) {
35
+ return now < lease.expiresAt;
36
+ }
37
+ export function selectLeasedEnv(lease, env, now = Date.now()) {
38
+ if (!leaseIsActive(lease, now))
39
+ throw new Error(`Secret lease '${lease.id}' has expired.`);
40
+ const missing = lease.keys.filter((key) => typeof env[key] !== 'string');
41
+ if (missing.length > 0)
42
+ throw new Error(`Secret lease '${lease.id}' is missing value(s): ${missing.join(', ')}`);
43
+ return Object.fromEntries(lease.keys.map((key) => [key, env[key]]));
44
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Serialize a resolved env map to `.env` lines that round-trip losslessly through
3
+ * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
4
+ * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
5
+ * value survives unchanged with no escaping. Newlines would break its line-based
6
+ * parse, so multi-line values are rejected rather than silently corrupted.
7
+ */
8
+ export declare function bundleEnvToDotenv(env: Record<string, string>): string;
9
+ /** Where the bundle should live ON THE REMOTE. */
10
+ export type RemoteBackend = 'keychain' | 'file';
11
+ /** A bundle read once, ready to push to any number of hosts. */
12
+ export interface ResolvedBundleForPush {
13
+ /** key -> value. Never logged; only its KEY NAMES are ever surfaced. */
14
+ env: Record<string, string>;
15
+ /** The same values as a dotenv blob, shipped over ssh stdin (never argv). */
16
+ dotenv: string;
17
+ keyCount: number;
18
+ }
19
+ export interface PushBundleOptions {
20
+ remoteBackend: RemoteBackend;
21
+ /** Overwrite a key that already exists on the remote. */
22
+ force?: boolean;
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).
32
+ */
33
+ passphrase?: string;
34
+ /** Label for the audit trail — `export --host` vs `fleet apply`. */
35
+ operation: string;
36
+ }
37
+ export interface PushBundleResult {
38
+ ok: boolean;
39
+ host: string;
40
+ bundle: string;
41
+ keyCount: number;
42
+ /** One line for the caller to render. Never contains a secret value. */
43
+ message: string;
44
+ }
45
+ /**
46
+ * Read and resolve a bundle once, for pushing to one or more hosts.
47
+ *
48
+ * `agentOnly` + `keyMode: 'storage'` match what `export --host` has always
49
+ * passed: storage-shaped values, and the headless guard that fails fast rather
50
+ * than popping Touch ID inside an automated run.
51
+ */
52
+ export declare function resolveBundleForPush(bundle: string, caller: string): ResolvedBundleForPush;
53
+ /**
54
+ * WHICH transport a (backend, target-OS) pair selects, and the exact bytes it
55
+ * will send. One of four outcomes, and picking the wrong one is silent: a
56
+ * Windows target handed a POSIX `bash -lc` produces garbage, and a Windows
57
+ * target handed `--from -` hangs forever on a stdin the `agents.ps1` shim never
58
+ * forwards. Neither shows up as a failed ssh, so the selection is the thing
59
+ * worth pinning.
60
+ *
61
+ * Separated from execution so it is decidable without a network: the branch is
62
+ * chosen from the device registry alone, and `pushResolvedBundleToHost` below
63
+ * does nothing but run what this returns.
64
+ */
65
+ export type PushTransport =
66
+ /** No supported command exists for this pair — fail loud, never a wrong path. */
67
+ {
68
+ kind: 'refuse';
69
+ message: string;
70
+ }
71
+ /** A command sent over the raw ssh engine, with the .env on stdin. */
72
+ | {
73
+ kind: 'ssh';
74
+ remoteCmd: string;
75
+ input: string;
76
+ }
77
+ /** The OS-aware `agents secrets` wrapper, the READ inverse's own path. */
78
+ | {
79
+ kind: 'remote-secrets';
80
+ args: string[];
81
+ input: string;
82
+ };
83
+ /** Choose the transport for one push. Pure: registry read in, plan out. */
84
+ export declare function planPushTransport(resolved: ResolvedBundleForPush, bundle: string, host: string, opts: PushBundleOptions): PushTransport;
85
+ /**
86
+ * Push an already-resolved bundle to ONE host.
87
+ *
88
+ * Drives the remote's own `agents secrets import --from -`, so the values land
89
+ * in the remote's chosen backend and the .env is read off ssh stdin rather than
90
+ * parsed by a remote shell. `import` auto-creates the bundle.
91
+ */
92
+ export declare function pushResolvedBundleToHost(resolved: ResolvedBundleForPush, bundle: string, host: string, opts: PushBundleOptions): PushBundleResult;
93
+ /** Resolve and push in one call — for a single host. */
94
+ export declare function pushBundleToHost(bundle: string, host: string, opts: PushBundleOptions): PushBundleResult;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Push one bundle's values to a remote host over SSH — the provisioning
3
+ * primitive behind `agents secrets export --host` and, from RUSH-1968, behind
4
+ * `agents fleet apply --provision-secrets`.
5
+ *
6
+ * This logic used to live inline in the `export --host` command action. It moved
7
+ * here because `lib/fleet/apply.ts` needs it and a lib MUST NOT import a command
8
+ * module — and because the absence of a callable primitive is part of why
9
+ * `fleet apply` never provisioned secrets at all, leaving an operator to
10
+ * hand-export the file store's master key across the fleet.
11
+ *
12
+ * Two rules shape the shape of this module:
13
+ *
14
+ * - **It never prints.** The lib layer stays `console.*`-free (SEC-14/SEC-16), so
15
+ * every outcome is returned as data and the CALLER renders it. That is also
16
+ * what lets `fleet apply` fold a push into its own per-device report instead of
17
+ * interleaving stray lines into it.
18
+ * - **Resolve once, push N times.** `resolveBundleForPush` is separate because
19
+ * reading a bundle can prompt (Touch ID); doing it per host would prompt per
20
+ * host. `export --host a,b,c` resolves once and pushes three times.
21
+ */
22
+ import { sshExec } from '../ssh-exec.js';
23
+ import { remoteShellFor, buildWindowsStdinImportCommand } from '../hosts/remote-cmd.js';
24
+ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
25
+ import { remoteSecretsRaw, verifyRemoteKeychainPush, keychainWriteFailureMessage, buildRemoteFileImportCommand, } from './remote.js';
26
+ import { readAndResolveBundleEnv } from './bundles.js';
27
+ /**
28
+ * Serialize a resolved env map to `.env` lines that round-trip losslessly through
29
+ * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
30
+ * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
31
+ * value survives unchanged with no escaping. Newlines would break its line-based
32
+ * parse, so multi-line values are rejected rather than silently corrupted.
33
+ */
34
+ export function bundleEnvToDotenv(env) {
35
+ const lines = [];
36
+ for (const [k, v] of Object.entries(env)) {
37
+ if (/[\r\n]/.test(v)) {
38
+ throw new Error(`Key '${k}' has a multi-line value; the SSH .env transport can't carry newlines. ` +
39
+ `Set it directly on the remote with 'agents secrets add ${k} --value-stdin'.`);
40
+ }
41
+ lines.push(`${k}="${v}"`);
42
+ }
43
+ return lines.join('\n') + '\n';
44
+ }
45
+ /**
46
+ * Read and resolve a bundle once, for pushing to one or more hosts.
47
+ *
48
+ * `agentOnly` + `keyMode: 'storage'` match what `export --host` has always
49
+ * passed: storage-shaped values, and the headless guard that fails fast rather
50
+ * than popping Touch ID inside an automated run.
51
+ */
52
+ export function resolveBundleForPush(bundle, caller) {
53
+ const { env } = readAndResolveBundleEnv(bundle, { caller, keyMode: 'storage', agentOnly: true });
54
+ return { env, dotenv: bundleEnvToDotenv(env), keyCount: Object.keys(env).length };
55
+ }
56
+ function isPowershellTarget(host) {
57
+ return remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell';
58
+ }
59
+ /** Choose the transport for one push. Pure: registry read in, plan out. */
60
+ export function planPushTransport(resolved, bundle, host, opts) {
61
+ const powershell = isPowershellTarget(host);
62
+ if (opts.remoteBackend === 'file') {
63
+ // Both file-backend paths build a POSIX `bash -lc` command. Refuse a Windows
64
+ // target cleanly rather than emit broken PowerShell (fail loud at the
65
+ // boundary, never a silent wrong path).
66
+ if (powershell) {
67
+ return { kind: 'refuse', message: 'file backend export to a Windows target is not yet supported' };
68
+ }
69
+ const { remoteCmd, input } = buildRemoteFileImportCommand(bundle, resolved.dotenv, {
70
+ passphrase: opts.passphrase ?? '',
71
+ force: opts.force,
72
+ });
73
+ return { kind: 'ssh', remoteCmd, input };
74
+ }
75
+ if (powershell) {
76
+ // Keychain on a Windows target: the `agents.ps1` shim doesn't forward
77
+ // ssh-piped stdin to node, so `--from -` would hang. Bridge the piped .env
78
+ // through PowerShell into a temp file and import `--from <file>` (deleted
79
+ // afterwards). Same hardened ssh engine; the .env still only ever crosses
80
+ // the wire over ssh stdin.
81
+ return {
82
+ kind: 'ssh',
83
+ remoteCmd: buildWindowsStdinImportCommand(bundle, { force: opts.force }),
84
+ input: resolved.dotenv,
85
+ };
86
+ }
87
+ // Keychain on a POSIX target: OS-aware wrapping + the hardened ssh engine
88
+ // (BatchMode, ConnectTimeout, keepalive, control-socket reuse) via the same
89
+ // path the READ inverse (`remoteResolveEnv`) uses.
90
+ return {
91
+ kind: 'remote-secrets',
92
+ args: ['import', bundle, '--from', '-', ...(opts.force ? ['--force'] : [])],
93
+ input: resolved.dotenv,
94
+ };
95
+ }
96
+ /**
97
+ * Push an already-resolved bundle to ONE host.
98
+ *
99
+ * Drives the remote's own `agents secrets import --from -`, so the values land
100
+ * in the remote's chosen backend and the .env is read off ssh stdin rather than
101
+ * parsed by a remote shell. `import` auto-creates the bundle.
102
+ */
103
+ export function pushResolvedBundleToHost(resolved, bundle, host, opts) {
104
+ const fail = (message) => ({ ok: false, host, bundle, keyCount: resolved.keyCount, message });
105
+ const plan = planPushTransport(resolved, bundle, host, opts);
106
+ if (plan.kind === 'refuse')
107
+ return fail(plan.message);
108
+ const res = plan.kind === 'ssh'
109
+ ? sshExec(host, plan.remoteCmd, { input: plan.input })
110
+ : remoteSecretsRaw(host, plan.args, { input: plan.input, osLookupName: host });
111
+ if (res.code === null) {
112
+ return fail(res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed'));
113
+ }
114
+ if (res.code !== 0) {
115
+ const msg = (res.stderr || res.stdout || '').trim();
116
+ return fail(`remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`);
117
+ }
118
+ // A keychain-backed push to a macOS remote over headless SSH can land the
119
+ // bundle metadata but no READABLE value items: the remote login keychain is
120
+ // locked in the non-interactive SSH context, so Security accepts the write but
121
+ // the biometry-ACL'd item is unreadable — and the remote `import` still exits
122
+ // 0. Read it back the way a release will and FAIL LOUDLY, rather than leave a
123
+ // metadata-only bundle that breaks later with "stored item not found". The
124
+ // file backend is headless-readable by construction, so it is skipped.
125
+ if (opts.remoteBackend === 'keychain') {
126
+ const verdict = verifyRemoteKeychainPush(host, bundle, Object.keys(resolved.env), { osLookupName: host });
127
+ if (!verdict.ok) {
128
+ return fail(verdict.kind === 'locked-keychain'
129
+ ? keychainWriteFailureMessage(host, bundle, verdict.reason)
130
+ : `pushed '${bundle}' but could not verify it on the remote: ${verdict.reason}`);
131
+ }
132
+ }
133
+ const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
134
+ return {
135
+ ok: true,
136
+ host,
137
+ bundle,
138
+ keyCount: resolved.keyCount,
139
+ message: remoteMsg || `${resolved.keyCount} key(s) exported`,
140
+ };
141
+ }
142
+ /** Resolve and push in one call — for a single host. */
143
+ export function pushBundleToHost(bundle, host, opts) {
144
+ return pushResolvedBundleToHost(resolveBundleForPush(bundle, opts.operation), bundle, host, opts);
145
+ }
@@ -27,7 +27,12 @@ export interface KeychainProcessSnapshot {
27
27
  * `null` means "could not capture" — the planner must fail closed.
28
28
  */
29
29
  startTime: string | null;
30
- /** True when this process's executable path matches the installed helper. */
30
+ /**
31
+ * True when this process is a REAP-ELIGIBLE helper invocation — the installed
32
+ * helper binary running a short-lived keychain verb. False for a non-helper
33
+ * process AND for the long-lived `watch-lock` watcher (see
34
+ * {@link isReapableHelperCommand}), which must never be reaped.
35
+ */
31
36
  isHelper: boolean;
32
37
  }
33
38
  /**
@@ -78,6 +83,15 @@ export declare function planKeychainReap(snapshots: KeychainProcessSnapshot[], n
78
83
  * Returns null for an unparseable value so the caller drops the row.
79
84
  */
80
85
  export declare function parseEtimeToSeconds(raw: string): number | null;
86
+ /**
87
+ * Whether a `ps` command line is a REAP-ELIGIBLE helper invocation: the installed
88
+ * helper binary running a short-lived keychain verb (get/has/list/set/delete/
89
+ * migrate-*) that a wedged `coreauthd` can hang. Returns false for a non-helper
90
+ * command AND for the deliberately long-lived `watch-lock` watcher — matching by
91
+ * the full argv (`ps … command=`), so a live-parent `watch-lock` child is never
92
+ * mistaken for a stuck read and killed. Pure; unit-tested.
93
+ */
94
+ export declare function isReapableHelperCommand(command: string, helperPath: string): boolean;
81
95
  /** Test seam: reset the persisted candidate state. */
82
96
  export declare function resetKeychainReaperCandidatesForTest(): void;
83
97
  /**
@@ -134,6 +134,31 @@ function parsePsLine(line) {
134
134
  return null;
135
135
  return { pid, ppid, elapsedSec, command };
136
136
  }
137
+ /**
138
+ * The one helper verb that is DELIBERATELY long-lived: the broker's auto-lock
139
+ * sleep/lock watcher (`spawn(getKeychainHelperPath(), ['watch-lock'], …)` in
140
+ * `agent.ts`). It lives for the broker's whole hold — potentially days — as a
141
+ * healthy child of the live broker/daemon, emitting LOCK/SLEEP lines that wipe
142
+ * the in-memory secret store on sleep. It is NOT a stuck keychain read, so the
143
+ * reaper must never target it: killing it silently disables auto-lock-on-sleep.
144
+ */
145
+ const HELPER_WATCH_LOCK_VERB = 'watch-lock';
146
+ /**
147
+ * Whether a `ps` command line is a REAP-ELIGIBLE helper invocation: the installed
148
+ * helper binary running a short-lived keychain verb (get/has/list/set/delete/
149
+ * migrate-*) that a wedged `coreauthd` can hang. Returns false for a non-helper
150
+ * command AND for the deliberately long-lived `watch-lock` watcher — matching by
151
+ * the full argv (`ps … command=`), so a live-parent `watch-lock` child is never
152
+ * mistaken for a stuck read and killed. Pure; unit-tested.
153
+ */
154
+ export function isReapableHelperCommand(command, helperPath) {
155
+ if (command === helperPath)
156
+ return true; // bare exec, no verb — never watch-lock
157
+ if (!command.startsWith(`${helperPath} `))
158
+ return false; // not our helper
159
+ const firstArg = command.slice(helperPath.length + 1).trimStart().split(/\s+/)[0];
160
+ return firstArg !== HELPER_WATCH_LOCK_VERB;
161
+ }
137
162
  /** Module-state for the two-sweep stuck-parent debounce. */
138
163
  let stuckParentCandidates = new Map();
139
164
  /** Test seam: reset the persisted candidate state. */
@@ -178,9 +203,11 @@ export function reapOrphanedKeychainProcesses() {
178
203
  if (!parsed)
179
204
  continue;
180
205
  const { pid, ppid, elapsedSec, command } = parsed;
181
- // Exact path-match: the helper invocation's command line begins with the
182
- // absolute helper path, followed by a space and its arguments (or nothing).
183
- const isHelper = command === helperPath || command.startsWith(`${helperPath} `);
206
+ // Reap-eligible = the helper binary running a short-lived keychain verb. The
207
+ // full-argv match excludes the deliberately long-lived `watch-lock` watcher,
208
+ // whose live-parent child would otherwise be killed as if it were stuck
209
+ // (RUSH-2232 — that silently disabled auto-lock-on-sleep).
210
+ const isHelper = isReapableHelperCommand(command, helperPath);
184
211
  rows.push({ pid, ppid, elapsedSec, isHelper, startTime: null });
185
212
  }
186
213
  const rowByPid = new Map(rows.map((r) => [r.pid, r]));
@@ -136,15 +136,30 @@ export declare function verifyInstalledVersion(packageRoot: string, expectedVers
136
136
  */
137
137
  export declare function refreshAliasShims(packageRoot: string): void;
138
138
  export interface AgentsCliInstall {
139
- /** The PATH entry (`<dir>/agents`) that resolves to this install. */
140
- binPath: string;
139
+ /** The PATH entry (`<dir>/agents`) that resolves to this install, when found through PATH. */
140
+ binPath?: string;
141
141
  /** Package root containing package.json and dist/. */
142
142
  packageRoot: string;
143
143
  version: string;
144
+ /** Whether this copy uses the serialized, atomic helper-bundle installer. */
145
+ atomicHelperInstall: boolean;
144
146
  }
147
+ export interface FindAgentsCliInstallsOptions {
148
+ homeDir?: string;
149
+ fnmDir?: string;
150
+ npmCacheDir?: string;
151
+ globalNodeModulesDirs?: string[];
152
+ }
153
+ export interface MultiInstallInventoryEntry {
154
+ packageRoot: string;
155
+ version: string;
156
+ note: string;
157
+ }
158
+ export declare function buildMultiInstallInventory(runningRoot: string, runningVersion: string, installs: AgentsCliInstall[]): MultiInstallInventoryEntry[];
145
159
  /**
146
- * Scan PATH for `agents` entrypoints and resolve each to the agents-cli
147
- * package root it executes. More than one distinct root means upgrades,
160
+ * Resolve every `agents` entrypoint on PATH, then inspect the bounded global
161
+ * install layouts used by NVM, fnm, Volta, Bun, npm, and npx. More than one
162
+ * distinct package root means upgrades,
148
163
  * shims, and the command the user types can act on different copies — the
149
164
  * divergence behind silently-failing self-updates.
150
165
  *
@@ -158,4 +173,4 @@ export interface AgentsCliInstall {
158
173
  * @phnx-labs/agents-cli is some other tool and is skipped.
159
174
  * POSIX-only: Windows npm bins are .cmd wrappers, not symlinks.
160
175
  */
161
- export declare function findAgentsCliInstalls(pathEnv: string): AgentsCliInstall[];
176
+ export declare function findAgentsCliInstalls(pathEnv: string, opts?: FindAgentsCliInstallsOptions): AgentsCliInstall[];
@@ -327,9 +327,89 @@ function packageRootForEntry(real) {
327
327
  }
328
328
  return null;
329
329
  }
330
+ export function buildMultiInstallInventory(runningRoot, runningVersion, installs) {
331
+ const byRoot = new Map();
332
+ byRoot.set(runningRoot, { packageRoot: runningRoot, version: runningVersion, note: 'running' });
333
+ for (const install of installs) {
334
+ const notes = [install.packageRoot === runningRoot
335
+ ? 'running'
336
+ : install.binPath
337
+ ? `agents on PATH: ${install.binPath}`
338
+ : 'discovered install'];
339
+ if (!install.atomicHelperInstall)
340
+ notes.push('unsafe legacy helper installer — remove this copy');
341
+ byRoot.set(install.packageRoot, {
342
+ packageRoot: install.packageRoot,
343
+ version: install.version,
344
+ note: notes.join('; '),
345
+ });
346
+ }
347
+ return [...byRoot.values()];
348
+ }
349
+ function childDirectories(parent) {
350
+ try {
351
+ return fs.readdirSync(parent, { withFileTypes: true })
352
+ .filter((entry) => entry.isDirectory())
353
+ .map((entry) => path.join(parent, entry.name));
354
+ }
355
+ catch {
356
+ return [];
357
+ }
358
+ }
359
+ function knownPackageRoots(opts) {
360
+ const homeDir = opts.homeDir ?? os.homedir();
361
+ const fnmRoots = opts.fnmDir !== undefined
362
+ ? [opts.fnmDir]
363
+ : [process.env.FNM_DIR, path.join(homeDir, '.local', 'share', 'fnm')]
364
+ .filter((value) => Boolean(value));
365
+ const roots = [];
366
+ const packageTail = path.join('lib', 'node_modules', ...NPM_PACKAGE_NAME.split('/'));
367
+ for (const nodeDir of childDirectories(path.join(homeDir, '.nvm', 'versions', 'node'))) {
368
+ roots.push(path.join(nodeDir, packageTail));
369
+ }
370
+ for (const fnmRoot of fnmRoots) {
371
+ for (const versionDir of childDirectories(path.join(fnmRoot, 'node-versions'))) {
372
+ roots.push(path.join(versionDir, 'installation', packageTail));
373
+ }
374
+ }
375
+ roots.push(path.join(homeDir, '.volta', 'tools', 'image', 'packages', ...NPM_PACKAGE_NAME.split('/'), packageTail), path.join(homeDir, '.local', packageTail), path.join(homeDir, '.bun', 'install', 'global', 'node_modules', ...NPM_PACKAGE_NAME.split('/')));
376
+ const npmCacheDir = opts.npmCacheDir ?? process.env.npm_config_cache ?? path.join(homeDir, '.npm');
377
+ for (const npxRunDir of childDirectories(path.join(npmCacheDir, '_npx'))) {
378
+ roots.push(path.join(npxRunDir, 'node_modules', ...NPM_PACKAGE_NAME.split('/')));
379
+ }
380
+ const globalNodeModulesDirs = opts.globalNodeModulesDirs ?? [
381
+ '/opt/homebrew/lib/node_modules',
382
+ '/usr/local/lib/node_modules',
383
+ '/usr/lib/node_modules',
384
+ ];
385
+ for (const nodeModulesDir of globalNodeModulesDirs) {
386
+ roots.push(path.join(nodeModulesDir, ...NPM_PACKAGE_NAME.split('/')));
387
+ }
388
+ return roots;
389
+ }
390
+ function readAgentsCliInstall(packageRoot, binPath) {
391
+ let canonicalRoot;
392
+ let pkg;
393
+ try {
394
+ canonicalRoot = fs.realpathSync(packageRoot);
395
+ pkg = JSON.parse(fs.readFileSync(path.join(canonicalRoot, 'package.json'), 'utf-8'));
396
+ }
397
+ catch {
398
+ return null;
399
+ }
400
+ if (pkg.name !== NPM_PACKAGE_NAME || typeof pkg.version !== 'string')
401
+ return null;
402
+ return {
403
+ ...(binPath ? { binPath } : {}),
404
+ packageRoot: canonicalRoot,
405
+ version: pkg.version,
406
+ atomicHelperInstall: fs.existsSync(path.join(canonicalRoot, 'dist', 'lib', 'app-bundle-install.js')),
407
+ };
408
+ }
330
409
  /**
331
- * Scan PATH for `agents` entrypoints and resolve each to the agents-cli
332
- * package root it executes. More than one distinct root means upgrades,
410
+ * Resolve every `agents` entrypoint on PATH, then inspect the bounded global
411
+ * install layouts used by NVM, fnm, Volta, Bun, npm, and npx. More than one
412
+ * distinct package root means upgrades,
333
413
  * shims, and the command the user types can act on different copies — the
334
414
  * divergence behind silently-failing self-updates.
335
415
  *
@@ -343,11 +423,17 @@ function packageRootForEntry(real) {
343
423
  * @phnx-labs/agents-cli is some other tool and is skipped.
344
424
  * POSIX-only: Windows npm bins are .cmd wrappers, not symlinks.
345
425
  */
346
- export function findAgentsCliInstalls(pathEnv) {
426
+ export function findAgentsCliInstalls(pathEnv, opts = {}) {
347
427
  if (process.platform === 'win32')
348
428
  return [];
349
429
  const installs = [];
350
430
  const seenRoots = new Set();
431
+ const addInstall = (install) => {
432
+ if (!install || seenRoots.has(install.packageRoot))
433
+ return;
434
+ seenRoots.add(install.packageRoot);
435
+ installs.push(install);
436
+ };
351
437
  for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) {
352
438
  const candidate = path.join(dir, 'agents');
353
439
  let real;
@@ -360,19 +446,10 @@ export function findAgentsCliInstalls(pathEnv) {
360
446
  const packageRoot = packageRootForEntry(real);
361
447
  if (!packageRoot)
362
448
  continue;
363
- if (seenRoots.has(packageRoot))
364
- continue;
365
- let pkg;
366
- try {
367
- pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
368
- }
369
- catch {
370
- continue;
371
- }
372
- if (pkg.name !== NPM_PACKAGE_NAME || typeof pkg.version !== 'string')
373
- continue;
374
- seenRoots.add(packageRoot);
375
- installs.push({ binPath: candidate, packageRoot, version: pkg.version });
449
+ addInstall(readAgentsCliInstall(packageRoot, candidate));
450
+ }
451
+ for (const packageRoot of knownPackageRoots(opts)) {
452
+ addInstall(readAgentsCliInstall(packageRoot));
376
453
  }
377
454
  return installs;
378
455
  }