@phnx-labs/agents-cli 1.20.64 → 1.20.66

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 (188) hide show
  1. package/CHANGELOG.md +51 -3
  2. package/README.md +156 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.d.ts +48 -0
  10. package/dist/commands/exec.js +159 -49
  11. package/dist/commands/feed.js +25 -11
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/mcp.js +55 -5
  14. package/dist/commands/monitors.d.ts +12 -0
  15. package/dist/commands/monitors.js +748 -0
  16. package/dist/commands/output.js +2 -2
  17. package/dist/commands/plugins.js +28 -7
  18. package/dist/commands/routines.js +23 -2
  19. package/dist/commands/secrets.d.ts +16 -0
  20. package/dist/commands/secrets.js +215 -64
  21. package/dist/commands/serve.js +31 -0
  22. package/dist/commands/sessions-browser.d.ts +82 -0
  23. package/dist/commands/sessions-browser.js +320 -0
  24. package/dist/commands/sessions-export.js +8 -3
  25. package/dist/commands/sessions.d.ts +17 -0
  26. package/dist/commands/sessions.js +157 -10
  27. package/dist/commands/share.d.ts +2 -0
  28. package/dist/commands/share.js +150 -0
  29. package/dist/commands/ssh.js +54 -3
  30. package/dist/commands/versions.js +7 -3
  31. package/dist/commands/view.d.ts +26 -0
  32. package/dist/commands/view.js +32 -9
  33. package/dist/commands/webhook.js +10 -2
  34. package/dist/index.js +35 -14
  35. package/dist/lib/agents.d.ts +46 -0
  36. package/dist/lib/agents.js +121 -3
  37. package/dist/lib/auto-dispatch-provider.js +7 -2
  38. package/dist/lib/auto-dispatch.d.ts +3 -0
  39. package/dist/lib/auto-dispatch.js +3 -0
  40. package/dist/lib/browser/chrome.js +2 -2
  41. package/dist/lib/cloud/antigravity.js +2 -2
  42. package/dist/lib/cloud/host.d.ts +59 -0
  43. package/dist/lib/cloud/host.js +224 -0
  44. package/dist/lib/cloud/registry.js +4 -0
  45. package/dist/lib/cloud/types.d.ts +6 -4
  46. package/dist/lib/computer-rpc.js +3 -1
  47. package/dist/lib/crabbox/cli.js +5 -1
  48. package/dist/lib/crabbox/runtimes.js +11 -2
  49. package/dist/lib/daemon.d.ts +20 -4
  50. package/dist/lib/daemon.js +62 -19
  51. package/dist/lib/devices/connect.d.ts +18 -1
  52. package/dist/lib/devices/connect.js +10 -2
  53. package/dist/lib/devices/fleet.d.ts +3 -2
  54. package/dist/lib/devices/fleet.js +9 -0
  55. package/dist/lib/devices/known-hosts.d.ts +62 -0
  56. package/dist/lib/devices/known-hosts.js +137 -0
  57. package/dist/lib/devices/registry.d.ts +15 -0
  58. package/dist/lib/devices/registry.js +9 -0
  59. package/dist/lib/exec.d.ts +19 -2
  60. package/dist/lib/exec.js +41 -13
  61. package/dist/lib/fleet/apply.d.ts +63 -0
  62. package/dist/lib/fleet/apply.js +214 -0
  63. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  64. package/dist/lib/fleet/auth-sync.js +142 -0
  65. package/dist/lib/fleet/manifest.d.ts +29 -0
  66. package/dist/lib/fleet/manifest.js +127 -0
  67. package/dist/lib/fleet/types.d.ts +129 -0
  68. package/dist/lib/fleet/types.js +13 -0
  69. package/dist/lib/git.d.ts +27 -0
  70. package/dist/lib/git.js +34 -2
  71. package/dist/lib/hosts/dispatch.d.ts +29 -8
  72. package/dist/lib/hosts/dispatch.js +66 -20
  73. package/dist/lib/hosts/passthrough.js +2 -0
  74. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  75. package/dist/lib/hosts/providers/devices.js +98 -0
  76. package/dist/lib/hosts/registry.d.ts +10 -16
  77. package/dist/lib/hosts/registry.js +17 -50
  78. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  79. package/dist/lib/hosts/remote-cmd.js +79 -4
  80. package/dist/lib/hosts/run-target.d.ts +84 -0
  81. package/dist/lib/hosts/run-target.js +99 -0
  82. package/dist/lib/hosts/types.d.ts +23 -5
  83. package/dist/lib/hosts/types.js +22 -4
  84. package/dist/lib/linear-autoclose.d.ts +30 -0
  85. package/dist/lib/linear-autoclose.js +22 -0
  86. package/dist/lib/mcp.d.ts +27 -1
  87. package/dist/lib/mcp.js +126 -12
  88. package/dist/lib/monitors/config.d.ts +161 -0
  89. package/dist/lib/monitors/config.js +372 -0
  90. package/dist/lib/monitors/dispatch.d.ts +28 -0
  91. package/dist/lib/monitors/dispatch.js +91 -0
  92. package/dist/lib/monitors/engine.d.ts +61 -0
  93. package/dist/lib/monitors/engine.js +205 -0
  94. package/dist/lib/monitors/sources/command.d.ts +11 -0
  95. package/dist/lib/monitors/sources/command.js +31 -0
  96. package/dist/lib/monitors/sources/device.d.ts +13 -0
  97. package/dist/lib/monitors/sources/device.js +45 -0
  98. package/dist/lib/monitors/sources/file.d.ts +14 -0
  99. package/dist/lib/monitors/sources/file.js +57 -0
  100. package/dist/lib/monitors/sources/http.d.ts +10 -0
  101. package/dist/lib/monitors/sources/http.js +34 -0
  102. package/dist/lib/monitors/sources/index.d.ts +14 -0
  103. package/dist/lib/monitors/sources/index.js +31 -0
  104. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  105. package/dist/lib/monitors/sources/poll.js +9 -0
  106. package/dist/lib/monitors/sources/types.d.ts +18 -0
  107. package/dist/lib/monitors/sources/types.js +9 -0
  108. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  109. package/dist/lib/monitors/sources/webhook.js +47 -0
  110. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  111. package/dist/lib/monitors/sources/ws.js +45 -0
  112. package/dist/lib/monitors/state.d.ts +69 -0
  113. package/dist/lib/monitors/state.js +144 -0
  114. package/dist/lib/picker.d.ts +53 -0
  115. package/dist/lib/picker.js +214 -1
  116. package/dist/lib/platform/exec.d.ts +16 -0
  117. package/dist/lib/platform/exec.js +17 -0
  118. package/dist/lib/plugins.d.ts +31 -1
  119. package/dist/lib/plugins.js +175 -15
  120. package/dist/lib/redact.d.ts +14 -1
  121. package/dist/lib/redact.js +47 -1
  122. package/dist/lib/remote-agents-json.js +7 -1
  123. package/dist/lib/rotate.d.ts +6 -3
  124. package/dist/lib/rotate.js +0 -1
  125. package/dist/lib/routines.d.ts +16 -0
  126. package/dist/lib/routines.js +19 -0
  127. package/dist/lib/runner.d.ts +1 -0
  128. package/dist/lib/runner.js +102 -9
  129. package/dist/lib/secrets/agent.d.ts +83 -10
  130. package/dist/lib/secrets/agent.js +237 -70
  131. package/dist/lib/secrets/bundles.d.ts +26 -0
  132. package/dist/lib/secrets/bundles.js +59 -8
  133. package/dist/lib/secrets/filestore.d.ts +9 -0
  134. package/dist/lib/secrets/filestore.js +21 -8
  135. package/dist/lib/secrets/index.d.ts +7 -0
  136. package/dist/lib/secrets/index.js +26 -6
  137. package/dist/lib/secrets/mcp.js +4 -2
  138. package/dist/lib/secrets/remote.d.ts +17 -0
  139. package/dist/lib/secrets/remote.js +40 -0
  140. package/dist/lib/self-update.d.ts +20 -0
  141. package/dist/lib/self-update.js +54 -1
  142. package/dist/lib/serve/control.d.ts +95 -0
  143. package/dist/lib/serve/control.js +260 -0
  144. package/dist/lib/serve/server.d.ts +35 -1
  145. package/dist/lib/serve/server.js +106 -76
  146. package/dist/lib/serve/stream.d.ts +43 -0
  147. package/dist/lib/serve/stream.js +116 -0
  148. package/dist/lib/serve/token.d.ts +35 -0
  149. package/dist/lib/serve/token.js +85 -0
  150. package/dist/lib/session/bundle.d.ts +14 -0
  151. package/dist/lib/session/bundle.js +12 -1
  152. package/dist/lib/session/remote-list.js +5 -1
  153. package/dist/lib/session/state.d.ts +7 -25
  154. package/dist/lib/session/state.js +16 -6
  155. package/dist/lib/session/sync/config.js +8 -2
  156. package/dist/lib/session/types.d.ts +30 -0
  157. package/dist/lib/share/capture.d.ts +29 -0
  158. package/dist/lib/share/capture.js +140 -0
  159. package/dist/lib/share/config.d.ts +35 -0
  160. package/dist/lib/share/config.js +100 -0
  161. package/dist/lib/share/og.d.ts +25 -0
  162. package/dist/lib/share/og.js +84 -0
  163. package/dist/lib/share/provision.d.ts +10 -0
  164. package/dist/lib/share/provision.js +91 -0
  165. package/dist/lib/share/publish.d.ts +57 -0
  166. package/dist/lib/share/publish.js +145 -0
  167. package/dist/lib/share/worker-template.d.ts +2 -0
  168. package/dist/lib/share/worker-template.js +82 -0
  169. package/dist/lib/shims.d.ts +13 -0
  170. package/dist/lib/shims.js +42 -2
  171. package/dist/lib/ssh-exec.d.ts +24 -0
  172. package/dist/lib/ssh-exec.js +15 -3
  173. package/dist/lib/ssh-tunnel.d.ts +19 -1
  174. package/dist/lib/ssh-tunnel.js +86 -7
  175. package/dist/lib/startup/command-registry.d.ts +3 -0
  176. package/dist/lib/startup/command-registry.js +6 -0
  177. package/dist/lib/state.d.ts +5 -0
  178. package/dist/lib/state.js +12 -0
  179. package/dist/lib/tmux/session.d.ts +7 -0
  180. package/dist/lib/tmux/session.js +3 -1
  181. package/dist/lib/triggers/webhook.d.ts +18 -0
  182. package/dist/lib/triggers/webhook.js +105 -0
  183. package/dist/lib/types.d.ts +36 -1
  184. package/dist/lib/usage.js +7 -5
  185. package/dist/lib/versions.js +14 -11
  186. package/dist/lib/workflows.d.ts +20 -0
  187. package/dist/lib/workflows.js +24 -0
  188. package/package.json +2 -1
@@ -0,0 +1,62 @@
1
+ /** Path to the CLI-managed known_hosts store (created lazily, mode 0600). */
2
+ export declare function managedKnownHostsPath(): string;
3
+ /** Ensure the parent directory of the managed store exists (mode 0700). */
4
+ export declare function ensureManagedKnownHostsDir(file?: string): void;
5
+ /** Read the managed store, or '' when it does not exist yet. */
6
+ export declare function readManagedKnownHosts(file?: string): string;
7
+ /**
8
+ * True if `host` has at least one pinned key line in `content`. Pure (content
9
+ * in) so the match logic is unit-testable without touching disk. Matching is
10
+ * case-insensitive on the hostname, as OpenSSH does.
11
+ */
12
+ export declare function isHostPinnedIn(content: string, host: string): boolean;
13
+ /** True if `host` is pinned in the managed store on disk. */
14
+ export declare function isHostPinned(host: string, file?: string): boolean;
15
+ /**
16
+ * The host-key-checking ssh options for a connection.
17
+ *
18
+ * Always points `UserKnownHostsFile` at the managed store so learned and pinned
19
+ * keys live in exactly one CLI-owned file. `StrictHostKeyChecking` is `yes` once
20
+ * the host is pinned (a key swap is refused) and `accept-new` before that
21
+ * (genuine first enrollment learns the key into the managed store, which pins it
22
+ * for every subsequent connect). Pure given `pinned`, so the policy is testable.
23
+ */
24
+ export declare function hostKeyCheckingOpts(pinned: boolean, file?: string): string[];
25
+ /**
26
+ * The key lines in `scanned` (ssh-keyscan output) not already present in
27
+ * `existing`. Comments and blank lines are dropped; whitespace is normalized so
28
+ * a re-scan of an already-pinned key is a no-op. Pure, so the idempotent-append
29
+ * contract is unit-testable without spawning ssh-keyscan.
30
+ */
31
+ export declare function newKnownHostsLines(existing: string, scanned: string): string[];
32
+ export interface PinResult {
33
+ /** True if the host is pinned in the managed store after this call. */
34
+ pinned: boolean;
35
+ /** How many new key lines were appended. */
36
+ added: number;
37
+ }
38
+ /**
39
+ * Merge `ssh-keyscan` output for `host` into the managed store at `file`,
40
+ * idempotently, and report whether `host` is pinned afterward. Split out from
41
+ * {@link pinHostKey} so the store-write half — the part that decides a scanned
42
+ * key now counts as pinned — is unit-testable with real keyscan text and no
43
+ * network (the spawn stays in `pinHostKey`).
44
+ */
45
+ export declare function recordScannedKeys(host: string, scanned: string, file?: string): PinResult;
46
+ /**
47
+ * `ssh-keyscan` a host at a trusted moment and append any new key lines to the
48
+ * managed store, idempotently. This is the explicit pin path; the implicit one
49
+ * is a normal `accept-new` connection whose learned key lands in the same store.
50
+ * Returns whether the host is pinned afterward.
51
+ *
52
+ * Host-name-agnostic: it scans whatever address it is handed, so it also pins a
53
+ * host `agents ssh` can't reach — notably a bare `~/.ssh/config` `Host` alias,
54
+ * which is not a registered device. The `--copy-creds` gate (commands/exec.ts)
55
+ * calls this for exactly that case so the credential copy is usable for
56
+ * ssh-config-alias hosts (RUSH-1767).
57
+ */
58
+ export declare function pinHostKey(host: string, opts?: {
59
+ file?: string;
60
+ timeoutMs?: number;
61
+ port?: number;
62
+ }): PinResult;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Managed known_hosts pinning for the device fleet (RUSH-1767).
3
+ *
4
+ * The shared SSH baseline uses `StrictHostKeyChecking=accept-new`
5
+ * (trust-on-first-use): it silently accepts whatever key answers on the FIRST
6
+ * connect, so a machine-in-the-middle present in that window is trusted forever
7
+ * and never re-checked. This module gives the CLI its own known_hosts store,
8
+ * kept apart from the user's `~/.ssh/known_hosts`, so a device's host key can be
9
+ * *pinned*: once a key is recorded here, connections verify against it with
10
+ * `StrictHostKeyChecking=yes`, so a later key swap is refused instead of
11
+ * silently re-accepted.
12
+ *
13
+ * The learn-then-pin flow: the first `agents ssh`/fleet connection to a host is
14
+ * still `accept-new`, but it writes the learned key into THIS store, which pins
15
+ * it for every subsequent connect. Credential copies (`run --host --copy-creds`)
16
+ * refuse to run against a host that isn't pinned here — see
17
+ * `commands/exec.ts` — so tokens never ride an unverified first connect.
18
+ */
19
+ import * as fs from 'fs';
20
+ import * as path from 'path';
21
+ import { spawnSync } from 'child_process';
22
+ import { getCacheDir } from '../state.js';
23
+ import { assertValidSshTarget } from '../ssh-exec.js';
24
+ import { parseKnownHosts } from '../hosts/ssh-config.js';
25
+ /** Path to the CLI-managed known_hosts store (created lazily, mode 0600). */
26
+ export function managedKnownHostsPath() {
27
+ return path.join(getCacheDir(), 'devices', 'known_hosts');
28
+ }
29
+ /** Ensure the parent directory of the managed store exists (mode 0700). */
30
+ export function ensureManagedKnownHostsDir(file = managedKnownHostsPath()) {
31
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
32
+ }
33
+ /** Read the managed store, or '' when it does not exist yet. */
34
+ export function readManagedKnownHosts(file = managedKnownHostsPath()) {
35
+ try {
36
+ return fs.readFileSync(file, 'utf-8');
37
+ }
38
+ catch {
39
+ return '';
40
+ }
41
+ }
42
+ /**
43
+ * True if `host` has at least one pinned key line in `content`. Pure (content
44
+ * in) so the match logic is unit-testable without touching disk. Matching is
45
+ * case-insensitive on the hostname, as OpenSSH does.
46
+ */
47
+ export function isHostPinnedIn(content, host) {
48
+ const needle = host.trim().toLowerCase();
49
+ if (!needle)
50
+ return false;
51
+ return parseKnownHosts(content).some((h) => h.toLowerCase() === needle);
52
+ }
53
+ /** True if `host` is pinned in the managed store on disk. */
54
+ export function isHostPinned(host, file = managedKnownHostsPath()) {
55
+ return isHostPinnedIn(readManagedKnownHosts(file), host);
56
+ }
57
+ /**
58
+ * The host-key-checking ssh options for a connection.
59
+ *
60
+ * Always points `UserKnownHostsFile` at the managed store so learned and pinned
61
+ * keys live in exactly one CLI-owned file. `StrictHostKeyChecking` is `yes` once
62
+ * the host is pinned (a key swap is refused) and `accept-new` before that
63
+ * (genuine first enrollment learns the key into the managed store, which pins it
64
+ * for every subsequent connect). Pure given `pinned`, so the policy is testable.
65
+ */
66
+ export function hostKeyCheckingOpts(pinned, file = managedKnownHostsPath()) {
67
+ return [
68
+ '-o', `UserKnownHostsFile=${file}`,
69
+ '-o', `StrictHostKeyChecking=${pinned ? 'yes' : 'accept-new'}`,
70
+ ];
71
+ }
72
+ /**
73
+ * The key lines in `scanned` (ssh-keyscan output) not already present in
74
+ * `existing`. Comments and blank lines are dropped; whitespace is normalized so
75
+ * a re-scan of an already-pinned key is a no-op. Pure, so the idempotent-append
76
+ * contract is unit-testable without spawning ssh-keyscan.
77
+ */
78
+ export function newKnownHostsLines(existing, scanned) {
79
+ const have = new Set(existing.split('\n').map((l) => l.trim()).filter(Boolean));
80
+ const seen = new Set();
81
+ const fresh = [];
82
+ for (const raw of scanned.split('\n')) {
83
+ const line = raw.trim();
84
+ if (!line || line.startsWith('#') || have.has(line) || seen.has(line))
85
+ continue;
86
+ seen.add(line);
87
+ fresh.push(line);
88
+ }
89
+ return fresh;
90
+ }
91
+ /**
92
+ * Merge `ssh-keyscan` output for `host` into the managed store at `file`,
93
+ * idempotently, and report whether `host` is pinned afterward. Split out from
94
+ * {@link pinHostKey} so the store-write half — the part that decides a scanned
95
+ * key now counts as pinned — is unit-testable with real keyscan text and no
96
+ * network (the spawn stays in `pinHostKey`).
97
+ */
98
+ export function recordScannedKeys(host, scanned, file = managedKnownHostsPath()) {
99
+ ensureManagedKnownHostsDir(file);
100
+ const existing = readManagedKnownHosts(file);
101
+ const fresh = newKnownHostsLines(existing, scanned);
102
+ if (fresh.length > 0) {
103
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
104
+ fs.appendFileSync(file, prefix + fresh.join('\n') + '\n', { mode: 0o600 });
105
+ }
106
+ return { pinned: isHostPinned(host, file), added: fresh.length };
107
+ }
108
+ /**
109
+ * `ssh-keyscan` a host at a trusted moment and append any new key lines to the
110
+ * managed store, idempotently. This is the explicit pin path; the implicit one
111
+ * is a normal `accept-new` connection whose learned key lands in the same store.
112
+ * Returns whether the host is pinned afterward.
113
+ *
114
+ * Host-name-agnostic: it scans whatever address it is handed, so it also pins a
115
+ * host `agents ssh` can't reach — notably a bare `~/.ssh/config` `Host` alias,
116
+ * which is not a registered device. The `--copy-creds` gate (commands/exec.ts)
117
+ * calls this for exactly that case so the credential copy is usable for
118
+ * ssh-config-alias hosts (RUSH-1767).
119
+ */
120
+ export function pinHostKey(host, opts = {}) {
121
+ // Same injection guard as sshExec: a host starting with `-` (or carrying shell
122
+ // metacharacters) must never reach ssh-keyscan as a bare argv where it could
123
+ // be parsed as a flag.
124
+ assertValidSshTarget(host);
125
+ const file = opts.file ?? managedKnownHostsPath();
126
+ const timeoutMs = opts.timeoutMs ?? 8000;
127
+ ensureManagedKnownHostsDir(file);
128
+ const args = ['-T', String(Math.max(1, Math.ceil(timeoutMs / 1000)))];
129
+ if (opts.port)
130
+ args.push('-p', String(opts.port));
131
+ args.push(host);
132
+ const res = spawnSync('ssh-keyscan', args, { encoding: 'utf-8', timeout: timeoutMs });
133
+ if (res.status !== 0 || !res.stdout) {
134
+ return { pinned: isHostPinned(host, file), added: 0 };
135
+ }
136
+ return recordScannedKeys(host, res.stdout, file);
137
+ }
@@ -1,5 +1,13 @@
1
1
  /** Operating-system family of a device, used to pick the remote shell. */
2
2
  export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
3
+ /**
4
+ * What a device is *for*. A `worker` (the default) can run agents — it's dialed
5
+ * over SSH for sessions and eligible for run/team placement. A `control` device
6
+ * is a cockpit that drives the fleet but never executes agents itself (an
7
+ * iPhone/iPad running the companion app): it appears in the fleet but is never
8
+ * dialed for SSH nor scheduled work. Absent `role` means `worker`.
9
+ */
10
+ export type DeviceRole = 'worker' | 'control';
3
11
  /** Remote shell dialect derived from the platform. */
4
12
  export type DeviceShell = 'powershell' | 'posix';
5
13
  /** How `agents ssh` authenticates to a device. Both are first-class, fully
@@ -41,9 +49,15 @@ export interface DeviceProfile {
41
49
  address: DeviceAddress;
42
50
  auth: DeviceAuth;
43
51
  tailscale?: DeviceTailscale;
52
+ /** What the device is for. Absent means `worker` (see {@link DeviceRole}). */
53
+ role?: DeviceRole;
44
54
  createdAt: string;
45
55
  updatedAt: string;
46
56
  }
57
+ /** A device's effective role, defaulting to `worker` when unset. */
58
+ export declare function deviceRole(d: DeviceProfile): DeviceRole;
59
+ /** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
60
+ export declare function isControlDevice(d: DeviceProfile): boolean;
47
61
  /** Map of device name to profile. */
48
62
  export type DeviceRegistry = Record<string, DeviceProfile>;
49
63
  /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
@@ -74,6 +88,7 @@ export interface DeviceInput {
74
88
  address?: DeviceAddress;
75
89
  auth?: DeviceAuth;
76
90
  tailscale?: DeviceTailscale;
91
+ role?: DeviceRole;
77
92
  }
78
93
  /**
79
94
  * Create the device if absent, otherwise merge the supplied fields into the
@@ -19,6 +19,14 @@ import * as path from 'path';
19
19
  import { randomBytes } from 'crypto';
20
20
  import lockfile from 'proper-lockfile';
21
21
  import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
22
+ /** A device's effective role, defaulting to `worker` when unset. */
23
+ export function deviceRole(d) {
24
+ return d.role ?? 'worker';
25
+ }
26
+ /** True for a control-only device (a cockpit) that must never be dialed/scheduled. */
27
+ export function isControlDevice(d) {
28
+ return deviceRole(d) === 'control';
29
+ }
22
30
  function registryPath() {
23
31
  return getDevicesRegistryPath();
24
32
  }
@@ -170,6 +178,7 @@ export async function upsertDevice(name, input) {
170
178
  address: input.address ?? prev?.address ?? { via: 'manual' },
171
179
  auth: input.auth ?? prev?.auth ?? { method: 'key' },
172
180
  tailscale: input.tailscale ?? prev?.tailscale,
181
+ role: input.role ?? prev?.role,
173
182
  createdAt: prev?.createdAt ?? now,
174
183
  updatedAt: now,
175
184
  };
@@ -120,6 +120,15 @@ export interface ExecOptions {
120
120
  mcpConfigPath?: string;
121
121
  /** Raw args captured after `--` on the command line, forwarded verbatim to the underlying agent CLI. */
122
122
  passthroughArgs?: string[];
123
+ /**
124
+ * Tee-and-tail the child's stdout even when no budget cap is active, so the
125
+ * caller can scan it for rate/usage-limit messages. Claude prints billing
126
+ * refusals ("monthly spend limit", "out of usage credits") to STDOUT, not
127
+ * stderr — a fallback chain that only inspects stderr never cascades on
128
+ * them. Set by runWithFallback for every chain entry; harmless elsewhere
129
+ * (output is mirrored to the parent's stdout exactly like stdio:'inherit').
130
+ */
131
+ captureStdoutTail?: boolean;
123
132
  /**
124
133
  * Escape hatch for the interactive tmux spawn-wrap (see shouldWrapInTmux):
125
134
  * when true, spawn the agent directly instead of inside a shared-socket tmux
@@ -150,7 +159,7 @@ export declare function resolveInteractive(options: Pick<ExecOptions, 'interacti
150
159
  * @param piped true when the parent's stdout is NOT a TTY (output piped)
151
160
  * @param capsActive true when a budget watcher is attached (caps configured)
152
161
  */
153
- export declare function shouldTapStdout(interactive: boolean, piped: boolean, capsActive: boolean): boolean;
162
+ export declare function shouldTapStdout(interactive: boolean, piped: boolean, capsActive: boolean, captureTail?: boolean): boolean;
154
163
  /** Parse an array of KEY=VALUE strings into an env record. Returns undefined for empty input. */
155
164
  export declare function parseExecEnv(entries: string[]): Record<string, string> | undefined;
156
165
  /**
@@ -274,8 +283,16 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
274
283
  * `#{pane_pid}`, clean signal delivery on detach/kill).
275
284
  * Keys are filtered to valid identifiers so exported shell functions
276
285
  * (`BASH_FUNC_*%%`) can't make `env` choke.
286
+ *
287
+ * `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
288
+ * the KEY names. The env map here carries resolved secrets bundles (options.env),
289
+ * so the real string would embed secret VALUES — which get persisted verbatim
290
+ * into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
291
+ * real values; the stored/informational copy uses the redacted form (RUSH-1758).
277
292
  */
278
- export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string;
293
+ export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
294
+ redactEnvValues?: boolean;
295
+ }): string;
279
296
  /**
280
297
  * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
281
298
  * (right-stripping each). Used by runInTmux to recap a fast-failed agent's
package/dist/lib/exec.js CHANGED
@@ -147,12 +147,13 @@ export function resolveInteractive(options) {
147
147
  * @param piped true when the parent's stdout is NOT a TTY (output piped)
148
148
  * @param capsActive true when a budget watcher is attached (caps configured)
149
149
  */
150
- export function shouldTapStdout(interactive, piped, capsActive) {
150
+ export function shouldTapStdout(interactive, piped, capsActive, captureTail = false) {
151
151
  if (interactive)
152
152
  return false;
153
153
  // Always pipe when the caller pipes us downstream (preserve composability),
154
- // OR when caps are active so the watcher can read the stream at a TTY.
155
- return piped || capsActive;
154
+ // when caps are active so the watcher can read the stream at a TTY, or when
155
+ // a fallback chain needs a stdout tail for rate-limit detection.
156
+ return piped || capsActive || captureTail;
156
157
  }
157
158
  /** Pattern for valid environment variable names (C identifier rules). */
158
159
  const EXEC_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -866,11 +867,17 @@ export function shouldWrapInTmux(ctx) {
866
867
  * `#{pane_pid}`, clean signal delivery on detach/kill).
867
868
  * Keys are filtered to valid identifiers so exported shell functions
868
869
  * (`BASH_FUNC_*%%`) can't make `env` choke.
870
+ *
871
+ * `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
872
+ * the KEY names. The env map here carries resolved secrets bundles (options.env),
873
+ * so the real string would embed secret VALUES — which get persisted verbatim
874
+ * into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
875
+ * real values; the stored/informational copy uses the redacted form (RUSH-1758).
869
876
  */
870
- export function buildTmuxAgentCommand(executable, args, env) {
877
+ export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
871
878
  const envPrefix = Object.entries(env)
872
879
  .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
873
- .map(([k, v]) => `${k}=${shellQuote(String(v))}`)
880
+ .map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
874
881
  .join(' ');
875
882
  const agentCmd = [executable, ...args].map(shellQuote).join(' ');
876
883
  return `exec env ${envPrefix} ${agentCmd}`;
@@ -917,11 +924,16 @@ async function runInTmux(options, executable, args) {
917
924
  const cwd = options.cwd || process.cwd();
918
925
  const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
919
926
  const name = slugifyName(`ag-${options.agent}-${idSeed}`);
920
- const cmd = buildTmuxAgentCommand(executable, args, buildExecEnv(options));
927
+ const execEnv = buildExecEnv(options);
928
+ // Launch with the real env (secret VALUES materialized into the pane); persist
929
+ // only a value-redacted copy in SessionMeta.cmd so resolved secrets never hit
930
+ // disk via the informational cmd field (RUSH-1758).
931
+ const cmd = buildTmuxAgentCommand(executable, args, execEnv);
932
+ const metaCmd = buildTmuxAgentCommand(executable, args, execEnv, { redactEnvValues: true });
921
933
  const labels = { agent: options.agent };
922
934
  if (options.sessionId)
923
935
  labels.sessionId = options.sessionId;
924
- const meta = await createSession({ name, cmd, cwd, socket, source: 'cli', labels });
936
+ const meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
925
937
  const pane = meta.pane;
926
938
  if (pane) {
927
939
  // When the AGENT pane dies, detach the client (don't kill) so the session
@@ -990,7 +1002,7 @@ async function runInTmux(options, executable, args) {
990
1002
  await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
991
1003
  }
992
1004
  await killSession(name, socket).catch(() => { });
993
- return { exitCode: before.status ?? 0, stderr: '' };
1005
+ return { exitCode: before.status ?? 0, stderr: '', stdout: '' };
994
1006
  }
995
1007
  await attachTmux({ socket, args: ['attach-session', '-t', name] });
996
1008
  const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
@@ -1003,10 +1015,10 @@ async function runInTmux(options, executable, args) {
1003
1015
  await surfacePaneFailure(after.status, `${options.agent} exited`);
1004
1016
  }
1005
1017
  await killSession(name, socket).catch(() => { });
1006
- return { exitCode: after.status ?? 0, stderr: '' };
1018
+ return { exitCode: after.status ?? 0, stderr: '', stdout: '' };
1007
1019
  }
1008
1020
  // Pane still alive → the user detached; keep the session for `agents focus`.
1009
- return { exitCode: 0, stderr: '' };
1021
+ return { exitCode: 0, stderr: '', stdout: '' };
1010
1022
  }
1011
1023
  /**
1012
1024
  * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
@@ -1092,7 +1104,7 @@ async function spawnAgent(options) {
1092
1104
  // PIPE (and later tee) stdout whenever the live budget watcher must read it
1093
1105
  // — for ALL non-interactive runs when caps are active, regardless of TTY.
1094
1106
  // See shouldTapStdout() for the rationale (FIX 3, issue #346).
1095
- const tapStdout = shouldTapStdout(interactive, piped, watcherState !== null);
1107
+ const tapStdout = shouldTapStdout(interactive, piped, watcherState !== null, options.captureStdoutTail);
1096
1108
  const stdio = interactive
1097
1109
  ? ['inherit', 'inherit', 'inherit']
1098
1110
  : ['inherit', tapStdout ? 'pipe' : 'inherit', 'pipe'];
@@ -1127,10 +1139,17 @@ async function spawnAgent(options) {
1127
1139
  timer.mark('startup');
1128
1140
  let budgetKilled = false;
1129
1141
  let budgetKillTimer;
1142
+ let stdoutTail = '';
1143
+ const STDOUT_TAIL_CAP = 16 * 1024;
1130
1144
  if (!interactive && tapStdout && child.stdout) {
1131
1145
  // TEE the child's stdout back to the parent's so the user still sees
1132
1146
  // output (mirrors stdio:'inherit') while we tap the same stream for usage.
1133
1147
  child.stdout.pipe(process.stdout);
1148
+ // Keep a rolling TAIL (billing refusals arrive at the very end of a run)
1149
+ // for the fallback chain's rate-limit scan.
1150
+ child.stdout.on('data', (chunk) => {
1151
+ stdoutTail = (stdoutTail + chunk.toString('utf-8')).slice(-STDOUT_TAIL_CAP);
1152
+ });
1134
1153
  // Tap the same stream for budget usage events without consuming the pipe
1135
1154
  // (a 'data' listener and .pipe() both receive every chunk). Kill on breach.
1136
1155
  if (watcherState) {
@@ -1200,7 +1219,7 @@ async function spawnAgent(options) {
1200
1219
  // teams/cloud can tell a budget termination apart from a normal failure.
1201
1220
  const exitCode = budgetKilled ? BUDGET_KILL_EXIT_CODE : (code ?? 0);
1202
1221
  timer.end({ exitCode, status: budgetKilled ? 'budget_killed' : code === 0 ? 'success' : 'failed' });
1203
- resolve({ exitCode, stderr: stderrBuffer });
1222
+ resolve({ exitCode, stderr: stderrBuffer, stdout: stdoutTail });
1204
1223
  });
1205
1224
  });
1206
1225
  }
@@ -1282,6 +1301,12 @@ export const RATE_LIMIT_PATTERNS = [
1282
1301
  /too many requests/i,
1283
1302
  /api[\s_-]?overloaded/i,
1284
1303
  /\boverloaded\b/i,
1304
+ // Claude billing refusals — "You've hit your org's monthly spend limit" and
1305
+ // "You're out of usage credits". Both end the run with exit 1 and are exactly
1306
+ // the condition a fallback chain exists to recover from. Printed to STDOUT,
1307
+ // hence the stdout tail in SpawnResult.
1308
+ /spend[\s-]?limit/i,
1309
+ /out of (?:usage )?credits/i,
1285
1310
  ];
1286
1311
  /** Return true if the text contains any known rate-limit or overload indicator. */
1287
1312
  export function detectRateLimit(text) {
@@ -1370,6 +1395,9 @@ export async function runWithFallback(options) {
1370
1395
  prompt,
1371
1396
  env: envOverride ? { ...(options.env ?? {}), ...envOverride } : options.env,
1372
1397
  sessionId: pinnedSessionId ?? (i === 0 ? options.sessionId : undefined),
1398
+ // Claude prints billing refusals (spend limit / out of credits) to
1399
+ // stdout; tail it so the cascade check below can see them.
1400
+ captureStdoutTail: true,
1373
1401
  };
1374
1402
  const label = version ? `${agent}@${version}` : agent;
1375
1403
  const modelSwapNote = sameHostRetry && envOverride
@@ -1397,7 +1425,7 @@ export async function runWithFallback(options) {
1397
1425
  const isLast = i === chain.length - 1;
1398
1426
  if (isLast)
1399
1427
  return result.exitCode;
1400
- if (!detectRateLimit(result.stderr)) {
1428
+ if (!detectRateLimit(result.stderr) && !detectRateLimit(result.stdout)) {
1401
1429
  return result.exitCode;
1402
1430
  }
1403
1431
  const next = chain[i + 1];
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Reconcile engine for `agents apply`. The diff (`diffFleet`) is pure and unit-
3
+ * tested; execution (`probeDevice`, `reconcileDevice`, `runFleetApply`) drives
4
+ * the real fleet over SSH and is verified end-to-end against live devices.
5
+ *
6
+ * Flow per device: probe → install/upgrade agents-cli → add missing agents →
7
+ * sync config → propagate login. Every step reuses an existing primitive
8
+ * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
+ */
10
+ import type { DeviceProfile } from '../devices/registry.js';
11
+ import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
12
+ /** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
13
+ export declare function agentIdOf(spec: string): string;
14
+ /** Source-side auth availability, computed once from `snapshotAuth`. */
15
+ export interface SourceAuth {
16
+ /** Agent ids the source has a readable, propagatable credential file for. */
17
+ available: Set<string>;
18
+ /** Agent ids whose source auth is device-bound (macOS keychain). */
19
+ bound: Set<string>;
20
+ /** The captured file payloads, keyed by agent. */
21
+ filesByAgent: Map<string, AuthFilePayload[]>;
22
+ }
23
+ /**
24
+ * Can we propagate `agent`'s login to a target on `targetPlatform`? False when
25
+ * the agent has no portable file, the source can't provide it (bound / not
26
+ * signed in), or the target consumes credentials from its own keychain.
27
+ */
28
+ export declare function canPushLogin(agent: string, targetPlatform: string | undefined, src: SourceAuth): boolean;
29
+ export interface DiffContext {
30
+ /** agents-cli version the source is on — the fleet target version. */
31
+ targetCliVersion: string;
32
+ sourceAuth: SourceAuth;
33
+ }
34
+ /** Pure: desired vs probed -> per-device diff + flat action list. */
35
+ export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
36
+ /** Probe one device: reachability + agents-cli version + installed agent ids. */
37
+ export declare function probeDevice(device: DeviceProfile): DeviceProbe;
38
+ export interface ApplyStep {
39
+ kind: FleetAction['kind'];
40
+ ok: boolean;
41
+ detail: string;
42
+ }
43
+ export interface DeviceApplyResult {
44
+ device: string;
45
+ ok: boolean;
46
+ steps: ApplyStep[];
47
+ note?: string;
48
+ }
49
+ export interface ExecContext {
50
+ targetCliVersion: string;
51
+ source: string;
52
+ sourceAuth: SourceAuth;
53
+ /** Set for a dry run — probe + plan only, execute nothing. */
54
+ dryRun?: boolean;
55
+ }
56
+ /** Execute one device's planned actions in order. Real SSH — no mocks. */
57
+ export declare function reconcileDevice(row: DeviceDiff, device: DeviceProfile, ctx: ExecContext): DeviceApplyResult;
58
+ /** Run a pool of async tasks with a concurrency cap, preserving input order. */
59
+ export declare function pool<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
60
+ /** Reconcile every device row in parallel (capped). */
61
+ export declare function runFleetApply(rows: DeviceDiff[], nameToProfile: Map<string, DeviceProfile>, ctx: ExecContext, concurrency?: number): Promise<DeviceApplyResult[]>;
62
+ /** Default home for source snapshots (overridable in tests). */
63
+ export declare function sourceHome(): string;