@phnx-labs/agents-cli 1.22.25 → 1.22.26

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 (82) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +17 -2
  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/browser.js +35 -0
  7. package/dist/commands/doctor.js +8 -0
  8. package/dist/commands/insights.d.ts +25 -19
  9. package/dist/commands/insights.js +107 -33
  10. package/dist/commands/reconnect.d.ts +46 -0
  11. package/dist/commands/reconnect.js +109 -0
  12. package/dist/commands/routines.js +2 -2
  13. package/dist/commands/secrets.d.ts +2 -8
  14. package/dist/commands/secrets.js +29 -105
  15. package/dist/commands/sessions.js +4 -0
  16. package/dist/commands/setup-secrets.d.ts +1 -0
  17. package/dist/commands/setup-secrets.js +1 -1
  18. package/dist/commands/setup.d.ts +26 -3
  19. package/dist/commands/setup.js +105 -46
  20. package/dist/commands/teams.d.ts +6 -0
  21. package/dist/commands/teams.js +43 -0
  22. package/dist/commands/trends.d.ts +8 -0
  23. package/dist/commands/trends.js +10 -156
  24. package/dist/index.js +1 -1
  25. package/dist/lib/agents.d.ts +11 -0
  26. package/dist/lib/agents.js +29 -2
  27. package/dist/lib/analytics/dashboard.d.ts +10 -6
  28. package/dist/lib/analytics/dashboard.js +6 -4
  29. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  30. package/dist/lib/analytics/mix-commands.js +229 -0
  31. package/dist/lib/analytics/recipes.d.ts +19 -14
  32. package/dist/lib/analytics/recipes.js +4 -2
  33. package/dist/lib/browser/ipc.d.ts +26 -0
  34. package/dist/lib/browser/ipc.js +139 -24
  35. package/dist/lib/browser/profiles.d.ts +11 -0
  36. package/dist/lib/browser/profiles.js +1 -1
  37. package/dist/lib/browser/stream.d.ts +14 -0
  38. package/dist/lib/browser/stream.js +71 -0
  39. package/dist/lib/channels/owner-sink.d.ts +27 -0
  40. package/dist/lib/channels/owner-sink.js +93 -0
  41. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  42. package/dist/lib/devices/doctor-findings.js +33 -1
  43. package/dist/lib/fleet/apply.d.ts +59 -3
  44. package/dist/lib/fleet/apply.js +183 -6
  45. package/dist/lib/fleet/types.d.ts +21 -2
  46. package/dist/lib/hooks/cache.js +15 -0
  47. package/dist/lib/hosts/passthrough.d.ts +23 -0
  48. package/dist/lib/hosts/passthrough.js +45 -0
  49. package/dist/lib/hosts/ready.d.ts +2 -0
  50. package/dist/lib/hosts/ready.js +10 -1
  51. package/dist/lib/hosts/reconnect.d.ts +14 -12
  52. package/dist/lib/hosts/reconnect.js +41 -40
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  54. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  55. package/dist/lib/routines.js +14 -2
  56. package/dist/lib/runner.d.ts +0 -3
  57. package/dist/lib/runner.js +1 -14
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/push.d.ts +94 -0
  61. package/dist/lib/secrets/push.js +145 -0
  62. package/dist/lib/secrets/reaper.d.ts +15 -1
  63. package/dist/lib/secrets/reaper.js +30 -3
  64. package/dist/lib/session/db.d.ts +21 -3
  65. package/dist/lib/session/db.js +221 -13
  66. package/dist/lib/session/discover.d.ts +1 -0
  67. package/dist/lib/session/discover.js +115 -19
  68. package/dist/lib/session/insights.d.ts +18 -0
  69. package/dist/lib/session/insights.js +143 -1
  70. package/dist/lib/session/tool-index.js +133 -22
  71. package/dist/lib/session/tool-store.d.ts +26 -2
  72. package/dist/lib/session/tool-store.js +36 -17
  73. package/dist/lib/ssh-exec.js +8 -2
  74. package/dist/lib/startup/command-registry.d.ts +1 -0
  75. package/dist/lib/startup/command-registry.js +4 -0
  76. package/dist/lib/teams/agents.d.ts +13 -0
  77. package/dist/lib/teams/agents.js +75 -7
  78. package/dist/lib/teams/placement-probe.d.ts +21 -0
  79. package/dist/lib/teams/placement-probe.js +135 -0
  80. package/dist/lib/teams/scheduler.d.ts +74 -1
  81. package/dist/lib/teams/scheduler.js +187 -10
  82. package/package.json +1 -1
@@ -0,0 +1,71 @@
1
+ import { createInterface } from 'readline';
2
+ import { connectBrowserIPC } from './ipc.js';
3
+ import { assertRemoteControlAllowed } from './remote-control.js';
4
+ function parseRequest(line) {
5
+ const parsed = JSON.parse(line);
6
+ if (!parsed ||
7
+ typeof parsed !== 'object' ||
8
+ Array.isArray(parsed) ||
9
+ typeof parsed.action !== 'string') {
10
+ throw new Error('Each input line must be a JSON object with an action');
11
+ }
12
+ return parsed;
13
+ }
14
+ function writeResponse(output, response) {
15
+ output.write(`${JSON.stringify(response)}\n`);
16
+ }
17
+ function writeErrorResponse(output, error) {
18
+ writeResponse(output, {
19
+ ok: false,
20
+ error: error instanceof Error ? error.message : String(error),
21
+ });
22
+ }
23
+ /**
24
+ * Read browser IPC requests as NDJSON and write one NDJSON response per line.
25
+ * The Node process and daemon connection stay alive until input closes.
26
+ */
27
+ export async function runBrowserIPCStream(options) {
28
+ const client = await connectBrowserIPC({ autoStartDaemon: options.autoStartDaemon });
29
+ const lines = createInterface({ input: options.input, crlfDelay: Infinity });
30
+ let defaultTask = options.task;
31
+ try {
32
+ for await (const line of lines) {
33
+ if (!line.trim())
34
+ continue;
35
+ let request;
36
+ try {
37
+ request = parseRequest(line);
38
+ }
39
+ catch (error) {
40
+ writeErrorResponse(options.output, error);
41
+ continue;
42
+ }
43
+ if (request.action === 'start') {
44
+ try {
45
+ assertRemoteControlAllowed();
46
+ }
47
+ catch (error) {
48
+ writeErrorResponse(options.output, error);
49
+ continue;
50
+ }
51
+ request = {
52
+ ...request,
53
+ taskName: request.taskName ?? defaultTask,
54
+ actor: request.actor ?? options.actor,
55
+ launchId: request.launchId ?? options.launchId,
56
+ };
57
+ }
58
+ else if (!request.task && defaultTask) {
59
+ request = { ...request, task: defaultTask };
60
+ }
61
+ const response = await client.request(request);
62
+ if (response.task)
63
+ defaultTask = response.task;
64
+ writeResponse(options.output, response);
65
+ }
66
+ }
67
+ finally {
68
+ lines.close();
69
+ await client.close();
70
+ }
71
+ }
@@ -0,0 +1,27 @@
1
+ import type { Meta } from '../types.js';
2
+ /** Why the owner sink cannot deliver from this box. */
3
+ export type OwnerSinkReason = 'rush-not-on-path' | 'rush-signed-out';
4
+ export interface OwnerSinkStatus {
5
+ /** Owner delivery is configured for this fleet (humans.yaml / notify.owner).
6
+ * When false, no finding is emitted — an un-opted-in box is not "broken". */
7
+ configured: boolean;
8
+ /** This box can actually deliver an owner notification right now. */
9
+ reachable: boolean;
10
+ /** Resolved owner channel (e.g. `imessage`). */
11
+ channel?: string;
12
+ /** Resolved transport after `notify.transports` mapping (usually === channel). */
13
+ transport?: string;
14
+ /** Set only when `reachable` is false. */
15
+ reason?: OwnerSinkReason;
16
+ }
17
+ /**
18
+ * Probe whether THIS box can deliver an owner notification right now. Returns
19
+ * `configured:false` (and the caller emits no finding) when the fleet has no
20
+ * owner channel configured — an un-opted-in box is not broken. When configured,
21
+ * reports whether the resolved transport can actually deliver from here.
22
+ *
23
+ * Only the concrete rush-backed failure the lane hits is reported as unreachable;
24
+ * non-rush transports (desktop / mailbox / …) deliver locally and are treated as
25
+ * reachable rather than probed, so this never invents a critical it cannot back.
26
+ */
27
+ export declare function probeOwnerSink(meta: Meta): Promise<OwnerSinkStatus>;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Owner-delivery-sink reachability probe (RUSH-2262).
3
+ *
4
+ * The feed/notify owner-delivery lane (`agents notify`, `agents feed post
5
+ * --level important` / `--blocked`) reaches the owner over the rush-backed owner
6
+ * channel (iMessage via `rush message send`). That transport can only deliver
7
+ * from a context that BOTH finds the `rush` CLI on PATH and can read its
8
+ * keychain-bound session. So a headless Linux fleet box (no rush) or a non-GUI
9
+ * SSH session on a mac (login keychain locked) structurally cannot escalate —
10
+ * and the failure is silent until a block is filed, surfacing only as the
11
+ * after-the-fact `owner failed: …` line. `agents doctor` had no signal for it,
12
+ * which is exactly the gap RUSH-2258 / RUSH-2262 flagged.
13
+ *
14
+ * This probes the SAME transport the lane uses, from the SAME context doctor runs
15
+ * in, so `agents doctor` can fail loud when this box cannot reach the owner. It is
16
+ * deliberately honest about context: `rush whoami` is what tells a real signed-in
17
+ * session apart from a keychain that is present but unreadable HERE. The session
18
+ * token is a keychain item, NOT `~/.rush/user.yaml` (RUSH-2262), so this never
19
+ * reads that file — checking it is the mistake that made a signed-in box look
20
+ * signed out.
21
+ *
22
+ * `agents notify --dry-run` is NOT this probe: it short-circuits before the
23
+ * `which rush` preflight (`providers/rush.ts`), so it reports `ok:true` on a box
24
+ * with no rush at all. Resolvability (does the envelope build?) and reachability
25
+ * (can this box actually deliver?) are different questions; this answers the
26
+ * second.
27
+ */
28
+ import { execFile } from 'child_process';
29
+ import { promisify } from 'util';
30
+ import { readOwnerDest } from './send.js';
31
+ import { RUSH_CHANNELS } from './providers/rush.js';
32
+ const execFileAsync = promisify(execFile);
33
+ async function rushOnPath() {
34
+ try {
35
+ await execFileAsync('which', ['rush'], { timeout: 4000 });
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /** Signed-in state from `rush whoami`, read with THIS context's keychain access.
43
+ * 'unknown' (a timeout or output we can't classify) is treated as reachable by
44
+ * the caller — a slow or unexpected probe must not cry wolf and paint a false
45
+ * critical. Only a definitive "not logged in" is reported as signed out. */
46
+ async function rushSignedIn() {
47
+ const classify = (s) => {
48
+ const out = s.toLowerCase();
49
+ if (/not (logged in|signed in)/.test(out))
50
+ return 'no';
51
+ if (/logged in as|session:\s*valid/.test(out))
52
+ return 'yes';
53
+ return 'unknown';
54
+ };
55
+ try {
56
+ const { stdout, stderr } = await execFileAsync('rush', ['whoami'], { timeout: 5000 });
57
+ return classify(`${stdout}\n${stderr}`);
58
+ }
59
+ catch (err) {
60
+ const e = err;
61
+ if (e.killed)
62
+ return 'unknown'; // timed out — do not conclude signed-out
63
+ // A signed-out rush commonly exits non-zero; trust an explicit message only.
64
+ return classify(`${e.stdout ?? ''}\n${e.stderr ?? ''}`);
65
+ }
66
+ }
67
+ /**
68
+ * Probe whether THIS box can deliver an owner notification right now. Returns
69
+ * `configured:false` (and the caller emits no finding) when the fleet has no
70
+ * owner channel configured — an un-opted-in box is not broken. When configured,
71
+ * reports whether the resolved transport can actually deliver from here.
72
+ *
73
+ * Only the concrete rush-backed failure the lane hits is reported as unreachable;
74
+ * non-rush transports (desktop / mailbox / …) deliver locally and are treated as
75
+ * reachable rather than probed, so this never invents a critical it cannot back.
76
+ */
77
+ export async function probeOwnerSink(meta) {
78
+ const dest = readOwnerDest(meta);
79
+ if (!dest)
80
+ return { configured: false, reachable: false };
81
+ const channel = dest.channel;
82
+ const transport = meta.notify?.transports?.[channel] ?? channel;
83
+ if (RUSH_CHANNELS.includes(transport)) {
84
+ if (!(await rushOnPath())) {
85
+ return { configured: true, reachable: false, channel, transport, reason: 'rush-not-on-path' };
86
+ }
87
+ if ((await rushSignedIn()) === 'no') {
88
+ return { configured: true, reachable: false, channel, transport, reason: 'rush-signed-out' };
89
+ }
90
+ return { configured: true, reachable: true, channel, transport };
91
+ }
92
+ return { configured: true, reachable: true, channel, transport };
93
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AgentId } from '../types.js';
2
2
  import type { DuplicateVersionHook } from '../hooks.js';
3
3
  import type { RcSecretFinding } from '../secrets/rc-hygiene.js';
4
+ import type { OwnerSinkStatus } from '../channels/owner-sink.js';
4
5
  import type { SyncStatusRow, OrphanRow } from '../drift.js';
5
6
  import type { FetchStatusMarker } from '../auto-pull.js';
6
7
  import type { VersionResourceReport } from '../doctor-diff.js';
@@ -10,7 +11,7 @@ export type FindingSeverity = 'critical' | 'warning';
10
11
  * the JSON consumer group by kind. */
11
12
  /** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
12
13
  * below owns it, and a second copy in these comments is a fourth place to drift. */
13
- export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "stale-cli"];
14
+ export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "stale-cli", "owner-sink-unreachable"];
14
15
  /**
15
16
  * The severity each kind is emitted with - the SINGLE source of truth, read by
16
17
  * the builders below and asserted against both prose rubrics by
@@ -106,6 +107,11 @@ export interface LocalFindingInputs {
106
107
  * sweep deliberately skips isolated copies, so a collapsed row would print a
107
108
  * remediation that does not fix them. */
108
109
  isolatedVersions?: string[];
110
+ /** Whether the feed/notify owner-delivery lane can reach the owner from this box
111
+ * (RUSH-2262). Collected by `probeOwnerSink` in the command (it spawns the real
112
+ * `rush` transport, so it stays out of this pure module). Absent → no probe ran
113
+ * → no finding. */
114
+ ownerSink?: OwnerSinkStatus;
109
115
  }
110
116
  /**
111
117
  * Fold this machine's signals into findings. Missing hooks/plugins and unwired
@@ -18,7 +18,8 @@
18
18
  * with. Keep this list exhaustive; a kind missing from it is a doc that lies.
19
19
  * CRITICAL — logged-out (provable) · missing-hook · missing-plugin ·
20
20
  * unwired-hook (a hook on disk that settings.json never fires) ·
21
- * cli-missing.
21
+ * cli-missing · owner-sink-unreachable (the feed/notify owner lane
22
+ * cannot reach the owner from this box).
22
23
  * WARNING — logout-unprovable (hedged) · missing-resource · content-drift ·
23
24
  * never-synced · stale · repo-behind · repo-drift · version-skew ·
24
25
  * fleet-resource-gap · orphan · duplicate-hook ·
@@ -97,6 +98,7 @@ export const ALL_FINDING_KINDS = [
97
98
  'env-secret-export', // the file-store master key live in THIS process's env
98
99
  'exec-policy', // Windows execution policy blocks agents.ps1
99
100
  'stale-cli',
101
+ 'owner-sink-unreachable', // the feed/notify owner-delivery lane can't reach the owner from this box
100
102
  ];
101
103
  /**
102
104
  * The severity each kind is emitted with - the SINGLE source of truth, read by
@@ -116,6 +118,9 @@ export const FINDING_SEVERITY = {
116
118
  'missing-plugin': 'critical',
117
119
  'unwired-hook': 'critical',
118
120
  'cli-missing': 'critical',
121
+ // A factory that cannot escalate a blocked agent to the owner is not healthy,
122
+ // and the failure is otherwise silent until a block is filed (RUSH-2262/2258).
123
+ 'owner-sink-unreachable': 'critical',
119
124
  // Everything else is resolvable by a routine sync/cleanup and does not block
120
125
  // the harness right now. RUSH-2162 moved never-synced and duplicate-hook-drift
121
126
  // here: both are stale-sync states that one `agents sync` resolves.
@@ -251,6 +256,13 @@ export function remediationFor(finding) {
251
256
  return 'Set-ExecutionPolicy -Scope CurrentUser RemoteSigned';
252
257
  case 'stale-cli':
253
258
  return 'upgrade';
259
+ case 'owner-sink-unreachable':
260
+ // The lane delivers over the rush-backed owner channel, which needs rush on
261
+ // PATH AND a usable session in THIS context. Non-interactive shells miss a
262
+ // ~/.zshrc export (RUSH-2258), and the session is keychain-bound, not in
263
+ // ~/.rush/user.yaml (RUSH-2262) — so `rush login` here, or the Rush App for
264
+ // a GUI keychain, is what makes it reachable.
265
+ return "put rush on PATH for non-interactive shells (~/.zshenv) and run 'rush login'";
254
266
  }
255
267
  }
256
268
  function finding(f) {
@@ -293,6 +305,22 @@ export function buildLocalFindings(input) {
293
305
  message: `${agentName(agent)} binary not found`,
294
306
  }));
295
307
  }
308
+ // owner-sink-unreachable — the feed/notify owner-delivery lane can't reach the
309
+ // owner from this box (RUSH-2262). Only when owner delivery is CONFIGURED for the
310
+ // fleet; an un-opted-in box is not broken. The message names the concrete reason.
311
+ const sink = input.ownerSink;
312
+ if (sink?.configured && !sink.reachable) {
313
+ const chan = sink.channel ?? 'owner';
314
+ const why = sink.reason === 'rush-not-on-path'
315
+ ? `rush CLI not on this box's PATH`
316
+ : sink.reason === 'rush-signed-out'
317
+ ? 'rush has no usable session here'
318
+ : 'transport unreachable';
319
+ out.push(finding({
320
+ severity: FINDING_SEVERITY['owner-sink-unreachable'], kind: 'owner-sink-unreachable', device,
321
+ message: `${chan} → owner unreachable: ${why}`,
322
+ }));
323
+ }
296
324
  // Per-version resource reports → missing hook/plugin (critical), unwired hook
297
325
  // (critical), other missing kinds (warning), content drift (warning).
298
326
  for (const report of input.reports) {
@@ -782,6 +810,10 @@ function subjectLabel(f) {
782
810
  return f.version ? `${f.agent} @${f.version}` : f.agent;
783
811
  }
784
812
  function critLabel(f) {
813
+ // Machine-level criticals with no agent get a category subject so the left
814
+ // column is not blank; the owner-sink row reads `owner …`, not an empty label.
815
+ if (f.kind === 'owner-sink-unreachable')
816
+ return { left: 'owner', account: '', message: f.message };
785
817
  return {
786
818
  left: subjectLabel(f),
787
819
  account: f.account ?? '',
@@ -8,6 +8,7 @@
8
8
  * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
9
  */
10
10
  import type { DeviceProfile } from '../devices/registry.js';
11
+ import { type RemoteBackend } from '../secrets/push.js';
11
12
  import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
12
13
  /** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
13
14
  export declare function agentIdOf(spec: string): string;
@@ -54,21 +55,76 @@ export interface DiffContext {
54
55
  /** agents-cli version the source is on — the fleet target version. */
55
56
  targetCliVersion: string;
56
57
  sourceAuth: SourceAuth;
57
- /** Secrets-bundle names the profile declares. Values are keychain-local and
58
- * can't be pushed, so each reachable device surfaces them as a manual recreate
59
- * (`needs-secret`) — informational, never an executed mutation. */
58
+ /** Secrets-bundle names the profile declares. */
60
59
  secretsBundles?: string[];
60
+ /** `--provision-secrets`. OFF by default: pushing a bundle moves credential
61
+ * VALUES to another machine, so it is opted into per invocation and never
62
+ * defaulted from the shared `agents.yaml` (RUSH-1968). */
63
+ provisionSecrets?: boolean;
64
+ /** Is this device's host key pinned? Injected so `decideSecretPush` stays pure
65
+ * and its refusals are testable against real known_hosts fixtures with no
66
+ * network. Absent = treated as unpinned, i.e. refuse. */
67
+ isHostPinned?: (device: string) => boolean;
68
+ /** `--force`: push a declared bundle even when the device already has it. */
69
+ forceSecrets?: boolean;
61
70
  }
62
71
  /** Pure: desired vs probed -> per-device diff + flat action list. */
63
72
  export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
73
+ /** Why a declared bundle is or is not pushed to one device. */
74
+ export interface SecretPushDecision {
75
+ push: boolean;
76
+ /** Where it would land on the remote. Only meaningful when `push`. */
77
+ backend: RemoteBackend;
78
+ /** Set when `push` is false — rendered as the `needs-secret` reminder. */
79
+ reason: string;
80
+ }
81
+ /**
82
+ * Decide whether `fleet apply` may push one declared bundle to one device.
83
+ *
84
+ * PURE — no ssh, no keychain, no filesystem beyond the injectable pin check — so
85
+ * every branch is unit-testable with no live fleet. Three gates, in order, and
86
+ * each REFUSAL still yields a `needs-secret` reminder rather than silence:
87
+ *
88
+ * 1. `--provision-secrets` must be set. Off by default, and deliberately a
89
+ * flag rather than an `agents.yaml` field: the manifest is shared, so a
90
+ * file-level default means someone else's `apply -y` ships credential
91
+ * values without deciding to (RUSH-1968's shape of accident).
92
+ * 2. The device must be reachable — nothing to push to otherwise.
93
+ * 3. The host key must be PINNED. This moves credential values to another
94
+ * machine, so it reuses the same bar `agents exec --copy-creds` already
95
+ * sets (EXEC-34): an unpinned device earns its pin through a normal
96
+ * `agents ssh <device>` first.
97
+ *
98
+ * Backend follows the platform, and this is the load-bearing default of the
99
+ * whole feature: **file on Linux, keychain on macOS/Windows**. A headless Linux
100
+ * box has no keychain (`lib/secrets/linux.ts`), and the file store there
101
+ * auto-provisions its OWN machine-local key — so each box ends up with an
102
+ * unshared at-rest key and NO passphrase is forwarded. That is the direct
103
+ * alternative to the fleet-wide shared secret this ticket exists to remove.
104
+ */
105
+ export declare function decideSecretPush(bundle: string, desired: DeviceDesired, probe: DeviceProbe, ctx: DiffContext): SecretPushDecision;
64
106
  export interface ProbeOptions {
65
107
  /** Also fetch per-agent installed versions (one extra `agents view --json`
66
108
  * round-trip). Enable only when the plan has a version-pinned spec. */
67
109
  withVersions?: boolean;
110
+ /** Also fetch which secrets bundles the device already has (one extra
111
+ * `agents secrets list --json`). Enable only when the manifest declares
112
+ * bundles and provisioning is on — same cost discipline as `withVersions`. */
113
+ withSecrets?: boolean;
68
114
  }
69
115
  /** Probe one device: reachability + agents-cli version + installed agent ids
70
116
  * (and, when `withVersions`, the installed version strings per agent). */
71
117
  export declare function probeDevice(device: DeviceProfile, opts?: ProbeOptions): DeviceProbe;
118
+ /**
119
+ * Narrow a remote `secrets list --json` payload to `name -> updated_at`.
120
+ *
121
+ * Exported and pure so the parse is unit-tested against real payload shapes with
122
+ * no live fleet. Returns `{}` rather than throwing on anything unexpected: the
123
+ * remote runs its own agents-cli version, and a parse failure must degrade to
124
+ * "unknown, so push" — never to "present, so skip", which would silently leave a
125
+ * device unprovisioned.
126
+ */
127
+ export declare function parseRemoteBundles(stdout: string): Record<string, string>;
72
128
  export interface ApplyStep {
73
129
  kind: FleetAction['kind'];
74
130
  ok: boolean;
@@ -8,6 +8,7 @@
8
8
  * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
9
  */
10
10
  import * as os from 'os';
11
+ import { pushBundleToHost } from '../secrets/push.js';
11
12
  import { sshTargetFor } from '../devices/connect.js';
12
13
  import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
13
14
  import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
@@ -167,14 +168,33 @@ export function diffFleet(desired, probes, ctx) {
167
168
  }
168
169
  }
169
170
  }
170
- // secrets surfaced, never pushed (values are keychain-local). Declared
171
- // once at the manifest level, so every reachable device gets the same
172
- // manual-recreate reminder. Not an executable action (see idempotence
173
- // check in commands/apply.ts, which excludes needs-* kinds).
171
+ // secrets. Declared once at the manifest level, so every reachable device
172
+ // is considered. Historically this was ALWAYS a manual reminder "surfaced,
173
+ // never pushed" and that gap is a direct cause of RUSH-1968: an operator
174
+ // who needed secrets on a worker box had no supported path, so they
175
+ // hand-exported the file store's master key across the fleet instead.
176
+ //
177
+ // It is now pushable, but only deliberately. `--provision-secrets` is off by
178
+ // default and is a FLAG, not a manifest field: `agents.yaml` is shared, and
179
+ // a file-level default would mean someone else's `apply -y` silently ships
180
+ // credential values — the same shape of accident this ticket is about.
181
+ // Everything the gate refuses stays a `needs-secret` reminder, so nothing
182
+ // is ever silently skipped.
174
183
  if (ctx.secretsBundles && ctx.secretsBundles.length > 0) {
175
184
  for (const bundle of ctx.secretsBundles) {
176
- secretsNeeded.push(bundle);
177
- rowActions.push({ device: d.device, kind: 'needs-secret', detail: `recreate secrets bundle '${bundle}' (\`agents secrets create ${bundle}\`)` });
185
+ const decision = decideSecretPush(bundle, d, probe, ctx);
186
+ if (decision.push) {
187
+ rowActions.push({
188
+ device: d.device,
189
+ kind: 'push-secret',
190
+ bundle,
191
+ detail: `push secrets bundle '${bundle}' (${decision.backend} backend)`,
192
+ });
193
+ }
194
+ else {
195
+ secretsNeeded.push(bundle);
196
+ rowActions.push({ device: d.device, kind: 'needs-secret', bundle, detail: decision.reason });
197
+ }
178
198
  }
179
199
  }
180
200
  }
@@ -183,6 +203,66 @@ export function diffFleet(desired, probes, ctx) {
183
203
  }
184
204
  return { devices, actions };
185
205
  }
206
+ /**
207
+ * Decide whether `fleet apply` may push one declared bundle to one device.
208
+ *
209
+ * PURE — no ssh, no keychain, no filesystem beyond the injectable pin check — so
210
+ * every branch is unit-testable with no live fleet. Three gates, in order, and
211
+ * each REFUSAL still yields a `needs-secret` reminder rather than silence:
212
+ *
213
+ * 1. `--provision-secrets` must be set. Off by default, and deliberately a
214
+ * flag rather than an `agents.yaml` field: the manifest is shared, so a
215
+ * file-level default means someone else's `apply -y` ships credential
216
+ * values without deciding to (RUSH-1968's shape of accident).
217
+ * 2. The device must be reachable — nothing to push to otherwise.
218
+ * 3. The host key must be PINNED. This moves credential values to another
219
+ * machine, so it reuses the same bar `agents exec --copy-creds` already
220
+ * sets (EXEC-34): an unpinned device earns its pin through a normal
221
+ * `agents ssh <device>` first.
222
+ *
223
+ * Backend follows the platform, and this is the load-bearing default of the
224
+ * whole feature: **file on Linux, keychain on macOS/Windows**. A headless Linux
225
+ * box has no keychain (`lib/secrets/linux.ts`), and the file store there
226
+ * auto-provisions its OWN machine-local key — so each box ends up with an
227
+ * unshared at-rest key and NO passphrase is forwarded. That is the direct
228
+ * alternative to the fleet-wide shared secret this ticket exists to remove.
229
+ */
230
+ export function decideSecretPush(bundle, desired, probe, ctx) {
231
+ const device = desired.device;
232
+ const backend = probe.platform === 'linux' ? 'file' : 'keychain';
233
+ const manual = `recreate secrets bundle '${bundle}' (\`agents ssh ${device} -- secrets create ${bundle}\`)`;
234
+ if (!ctx.provisionSecrets) {
235
+ return { push: false, backend, reason: `${manual} — or re-run with --provision-secrets to push it` };
236
+ }
237
+ if (!probe.reachable) {
238
+ return { push: false, backend, reason: manual };
239
+ }
240
+ // Already there? Skip — otherwise every `apply` re-resolves the bundle, and a
241
+ // resolve can prompt for Touch ID, so a converged fleet would nag on every run.
242
+ //
243
+ // Known limitation, stated rather than hidden: this compares PRESENCE (and
244
+ // carries `updated_at` for a future content check). It is a timestamp
245
+ // heuristic, not a content hash — a bundle whose VALUES changed locally still
246
+ // reads as present. `--force` is the way to overwrite regardless.
247
+ // hasOwnProperty, NOT `in`: `in` walks the prototype chain, so a bundle named
248
+ // `toString` / `constructor` / `valueOf` would read as present on an EMPTY map
249
+ // and be silently skipped — leaving that device unprovisioned, the worse of the
250
+ // two errors this gate can make.
251
+ if (!ctx.forceSecrets && probe.remoteBundles
252
+ && Object.prototype.hasOwnProperty.call(probe.remoteBundles, bundle)) {
253
+ return { push: false, backend, reason: `secrets bundle '${bundle}' already present on ${device} — pass --force to overwrite` };
254
+ }
255
+ if (!ctx.isHostPinned?.(device)) {
256
+ // Same bar as `exec --copy-creds`: never ship credential values to a host
257
+ // whose key we have not pinned.
258
+ return {
259
+ push: false,
260
+ backend,
261
+ reason: `${manual} — host key not pinned; run \`agents ssh ${device}\` once to pin it, then re-apply`,
262
+ };
263
+ }
264
+ return { push: true, backend, reason: '' };
265
+ }
186
266
  // ---- execution (real SSH; verified end-to-end, not unit-mocked) ----
187
267
  function osHint(platform) {
188
268
  return platform === 'windows' ? 'windows' : undefined;
@@ -225,6 +305,15 @@ export function probeDevice(device, opts) {
225
305
  if (vres.code === 0)
226
306
  installedVersions = parseInstalledVersions(vres.stdout);
227
307
  }
308
+ let remoteBundles;
309
+ if (opts?.withSecrets) {
310
+ // Metadata only — `secrets list --json` returns names + timestamps and never
311
+ // values, which is why this is safe to run across the fleet.
312
+ const listCmd = buildRemoteAgentsInvocation(['secrets', 'list', '--json'], undefined, hint, remoteEnv(device.platform));
313
+ const lres = sshExec(target, listCmd, { timeoutMs: 30000, multiplex: true });
314
+ if (lres.code === 0)
315
+ remoteBundles = parseRemoteBundles(lres.stdout);
316
+ }
228
317
  return {
229
318
  device: device.name,
230
319
  reachable: true,
@@ -232,8 +321,52 @@ export function probeDevice(device, opts) {
232
321
  cliVersion: ready.version ?? undefined,
233
322
  installedAgents: installed,
234
323
  installedVersions,
324
+ remoteBundles,
235
325
  };
236
326
  }
327
+ /**
328
+ * Narrow a remote `secrets list --json` payload to `name -> updated_at`.
329
+ *
330
+ * Exported and pure so the parse is unit-tested against real payload shapes with
331
+ * no live fleet. Returns `{}` rather than throwing on anything unexpected: the
332
+ * remote runs its own agents-cli version, and a parse failure must degrade to
333
+ * "unknown, so push" — never to "present, so skip", which would silently leave a
334
+ * device unprovisioned.
335
+ */
336
+ export function parseRemoteBundles(stdout) {
337
+ try {
338
+ const parsed = JSON.parse(stdout);
339
+ const rows = Array.isArray(parsed)
340
+ ? parsed
341
+ : Array.isArray(parsed?.bundles)
342
+ ? parsed.bundles
343
+ : [];
344
+ // Null-prototype: a remote-supplied name is used as a KEY here, so `{}` would
345
+ // let `__proto__` hit the prototype setter instead of becoming an own
346
+ // property (and then read back as absent). It also means the presence check
347
+ // cannot see inherited names.
348
+ const out = Object.create(null);
349
+ for (const row of rows) {
350
+ if (!row || typeof row !== 'object')
351
+ continue;
352
+ const r = row;
353
+ const name = typeof r.name === 'string' ? r.name : undefined;
354
+ if (!name)
355
+ continue;
356
+ // `updatedAt` is the real field name in `secrets list --json` — verified
357
+ // against a live payload, not assumed. `updated_at` is accepted too so an
358
+ // older remote is not silently recorded with an empty timestamp.
359
+ const ts = typeof r.updatedAt === 'string' ? r.updatedAt
360
+ : typeof r.updated_at === 'string' ? r.updated_at
361
+ : '';
362
+ out[name] = ts;
363
+ }
364
+ return out;
365
+ }
366
+ catch {
367
+ return {};
368
+ }
369
+ }
237
370
  /** Execute one device's planned actions in order. Real SSH — no mocks. */
238
371
  export function reconcileDevice(row, device, ctx) {
239
372
  if (!row.probe.reachable) {
@@ -290,6 +423,50 @@ export function reconcileDevice(row, device, ctx) {
290
423
  steps.push({ kind: 'push-login', ok: r.code === 0, detail: `propagate login: ${pushAgents.join(', ')}` });
291
424
  ok = ok && r.code === 0;
292
425
  }
426
+ // 5. secrets provisioning — LAST, and deliberately so. It is the most
427
+ // sensitive mutation apply performs (credential VALUES crossing to another
428
+ // machine), so every lower-risk step above is already recorded before we
429
+ // touch it: a failure here never obscures what did land.
430
+ //
431
+ // Resolve ONCE per device even for several bundles is not possible (a resolve
432
+ // is per bundle), but each bundle resolves once and pushes once — the read can
433
+ // prompt, so it must not repeat.
434
+ const pushSecrets = row.actions.filter((a) => a.kind === 'push-secret');
435
+ for (const action of pushSecrets) {
436
+ const bundle = action.bundle;
437
+ if (!bundle) {
438
+ // A push-secret action without a bundle name is a planner bug, not a
439
+ // recoverable state — fail loud rather than push nothing and report ok.
440
+ steps.push({ kind: 'push-secret', ok: false, detail: 'push-secret action carried no bundle name' });
441
+ ok = false;
442
+ continue;
443
+ }
444
+ const backend = device.platform === 'linux' ? 'file' : 'keychain';
445
+ try {
446
+ const out = pushBundleToHost(bundle, target, {
447
+ remoteBackend: backend,
448
+ operation: `fleet apply ${row.device}`,
449
+ // No passphrase, ever, from this path. On the file backend the remote
450
+ // auto-provisions its OWN machine-local key, which is the entire point:
451
+ // each box gets an unshared at-rest key instead of the fleet-wide shared
452
+ // secret RUSH-1968 is about.
453
+ });
454
+ steps.push({
455
+ kind: 'push-secret',
456
+ ok: out.ok,
457
+ detail: out.ok
458
+ ? `secrets '${bundle}' -> ${row.device} (${backend}): ${out.message}`
459
+ : `secrets '${bundle}' -> ${row.device}: ${out.message}`,
460
+ });
461
+ ok = ok && out.ok;
462
+ }
463
+ catch (e) {
464
+ // A local resolve failure (locked store, missing bundle, multi-line value)
465
+ // is reported per bundle rather than aborting the whole device.
466
+ steps.push({ kind: 'push-secret', ok: false, detail: `secrets '${bundle}': ${e.message}` });
467
+ ok = false;
468
+ }
469
+ }
293
470
  // Surface blocked logins as (non-fatal) informational steps.
294
471
  for (const blocked of row.loginBlocked) {
295
472
  steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
@@ -95,13 +95,28 @@ export interface DeviceProbe {
95
95
  * undefined, version-pinned specs fall back to id-level presence.
96
96
  */
97
97
  installedVersions?: Record<string, string[]>;
98
+ /**
99
+ * Secrets bundles already present on the device: name -> `updated_at` (or ''
100
+ * when the remote reports none). Only populated when the manifest declares
101
+ * bundles AND `--provision-secrets` is set — a fleet that uses no bundles
102
+ * never pays for the extra round trip.
103
+ *
104
+ * METADATA ONLY. `agents secrets list --json` returns names and timestamps and
105
+ * explicitly never values, which is what makes this probe safe to run.
106
+ */
107
+ remoteBundles?: Record<string, string>;
98
108
  /** Reason string when `reachable` is false or the probe partially failed. */
99
109
  note?: string;
100
110
  }
101
111
  /** One planned action against a device, in a single reconcile dimension. */
102
112
  export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
103
- /** Secrets bundles the profile declares but that can't be pushed (values are
104
- * keychain-local) surfaced as a manual recreate, like `needs-login`. */
113
+ /** Push a declared secrets bundle to the device over SSH. Opt-in only
114
+ * (`--provision-secrets`) and gated on a pinned host key, because this moves
115
+ * credential VALUES to another machine (RUSH-1968). */
116
+ | 'push-secret'
117
+ /** A declared secrets bundle that could NOT be pushed — the flag is off, the
118
+ * host key isn't pinned, or the bundle is already current. Surfaced as a manual
119
+ * recreate, like `needs-login`. */
105
120
  | 'needs-secret';
106
121
  export interface FleetAction {
107
122
  device: string;
@@ -111,6 +126,10 @@ export interface FleetAction {
111
126
  /** Full agent spec for `add-agent` (e.g. `claude@2.1.170`) so the plan can show
112
127
  * the exact version being installed; equals the id for a bare/latest spec. */
113
128
  spec?: string;
129
+ /** Bundle name for `push-secret` / `needs-secret`, so the executor pushes the
130
+ * bundle the planner decided on rather than re-deriving it from the detail
131
+ * string. */
132
+ bundle?: string;
114
133
  /** Human, one-line description of the action. */
115
134
  detail: string;
116
135
  }