@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,274 @@
1
+ /**
2
+ * `agents apply` (alias `ag apply`) — reconcile the whole fleet to a declared
3
+ * profile in one command: install agents-cli + agents, sync config, and
4
+ * propagate login so a machine that's signed in once seeds every device. Kills
5
+ * the "6 hosts x ~8 harnesses = ~48 OAuth flows" slog.
6
+ *
7
+ * The manifest is the `fleet:` block of any `-f` file (default `agents.yaml`).
8
+ */
9
+ import * as fs from 'fs';
10
+ import * as os from 'os';
11
+ import * as path from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ import { Option } from 'commander';
14
+ import chalk from 'chalk';
15
+ import { setHelpSections } from '../lib/help.js';
16
+ import { machineId } from '../lib/session/sync/config.js';
17
+ import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
18
+ import { readFleetFile, resolveDesired } from '../lib/fleet/manifest.js';
19
+ import { snapshotAuth, materializeAuth, parseAuthBundle, KEYCHAIN_BOUND_ON_MAC } from '../lib/fleet/auth-sync.js';
20
+ import { agentIdOf, diffFleet, probeDevice, runFleetApply, pool, sourceHome, } from '../lib/fleet/apply.js';
21
+ /** Version of the running agents-cli — the fleet target version. */
22
+ function localCliVersion() {
23
+ try {
24
+ const here = path.dirname(fileURLToPath(import.meta.url));
25
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', '..', 'package.json'), 'utf-8'));
26
+ return String(pkg.version ?? '');
27
+ }
28
+ catch {
29
+ return '';
30
+ }
31
+ }
32
+ async function readStdin() {
33
+ const chunks = [];
34
+ for await (const c of process.stdin)
35
+ chunks.push(c);
36
+ return Buffer.concat(chunks).toString('utf-8');
37
+ }
38
+ /** Hidden path: receive an auth bundle on stdin and materialize it locally. */
39
+ async function runRecvAuth() {
40
+ const raw = await readStdin();
41
+ const bundle = parseAuthBundle(raw);
42
+ const res = materializeAuth(bundle, { home: os.homedir() });
43
+ if (res.errors.length > 0) {
44
+ console.error(`recv-auth: ${res.errors.length} error(s): ${res.errors.join('; ')}`);
45
+ process.exit(1);
46
+ }
47
+ console.log(`recv-auth: wrote login for ${res.written.join(', ') || '(nothing)'}`);
48
+ }
49
+ function confirm(question) {
50
+ return new Promise((resolve) => {
51
+ process.stdout.write(question);
52
+ const onData = (d) => {
53
+ process.stdin.pause();
54
+ process.stdin.off('data', onData);
55
+ resolve(/^y(es)?$/i.test(d.toString().trim()));
56
+ };
57
+ process.stdin.resume();
58
+ process.stdin.once('data', onData);
59
+ });
60
+ }
61
+ const ONLY_KINDS = {
62
+ agents: new Set(['install-cli', 'upgrade-cli', 'add-agent']),
63
+ config: new Set(['sync-config']),
64
+ login: new Set(['push-login', 'needs-login']),
65
+ };
66
+ /** Render the device x dimension matrix (cribbed from `doctor --devices`). */
67
+ function renderPlan(plan) {
68
+ const rows = plan.devices;
69
+ const nameWidth = Math.max('device'.length, ...rows.map((r) => r.device.length));
70
+ const cell = (row, kinds, okLabel) => {
71
+ if (!row.probe.reachable)
72
+ return chalk.gray('- offline');
73
+ const acts = row.actions.filter((a) => kinds.includes(a.kind));
74
+ if (acts.length === 0)
75
+ return chalk.green(`ok ${okLabel}`);
76
+ if (acts.some((a) => a.kind === 'needs-login')) {
77
+ const push = acts.filter((a) => a.kind === 'push-login').length;
78
+ const need = acts.filter((a) => a.kind === 'needs-login').length;
79
+ return chalk.yellow(`${push} push · ${need} manual`);
80
+ }
81
+ return chalk.cyan('↑ ' + acts.map((a) => a.agent ?? a.kind.replace('-cli', '')).join(','));
82
+ };
83
+ const header = ` ${'device'.padEnd(nameWidth)} ${'agents-cli'.padEnd(12)}${'agents'.padEnd(20)}${'config'.padEnd(10)}login`;
84
+ console.log(chalk.gray(header));
85
+ for (const row of rows) {
86
+ const cli = row.probe.reachable
87
+ ? (row.actions.find((a) => a.kind === 'install-cli') ? chalk.cyan('install')
88
+ : row.actions.find((a) => a.kind === 'upgrade-cli') ? chalk.cyan('upgrade')
89
+ : chalk.green(`ok ${row.probe.cliVersion ?? ''}`))
90
+ : chalk.gray('- offline');
91
+ const agentsCell = row.probe.reachable
92
+ ? (() => {
93
+ const add = row.actions.filter((a) => a.kind === 'add-agent');
94
+ return add.length === 0 ? chalk.green(`ok ${row.desired.agents.length}/${row.desired.agents.length}`) : chalk.cyan('+ ' + add.map((a) => a.agent).join(','));
95
+ })()
96
+ : chalk.gray('-');
97
+ const configCell = row.probe.reachable
98
+ ? (row.actions.some((a) => a.kind === 'sync-config') ? chalk.cyan('↑ sync') : chalk.green('ok'))
99
+ : chalk.gray('-');
100
+ const loginCell = cell(row, ['push-login', 'needs-login'], `${row.desired.agents.length}/${row.desired.agents.length}`);
101
+ console.log(` ${row.device.padEnd(nameWidth)} ${stripPad(cli, 12)}${stripPad(agentsCell, 20)}${stripPad(configCell, 10)}${loginCell}`);
102
+ }
103
+ console.log();
104
+ console.log(chalk.gray(` ${plan.actions.length} action(s) across ${rows.filter((r) => r.probe.reachable).length} reachable device(s)`));
105
+ // Distinguish *why* a login can't be propagated: a macOS keychain-bound token
106
+ // vs. the source simply not being signed in to that agent (no portable file).
107
+ const bound = [];
108
+ const noToken = [];
109
+ for (const r of rows) {
110
+ for (const a of r.loginBlocked) {
111
+ const tag = `${a}@${r.device}`;
112
+ if (r.probe.platform === 'macos' && KEYCHAIN_BOUND_ON_MAC.has(a))
113
+ bound.push(tag);
114
+ else
115
+ noToken.push(tag);
116
+ }
117
+ }
118
+ if (bound.length > 0) {
119
+ console.log(chalk.yellow(` manual login needed (macOS keychain-bound): ${bound.join(', ')}`));
120
+ }
121
+ if (noToken.length > 0) {
122
+ console.log(chalk.yellow(` manual login needed (no portable token on source): ${noToken.join(', ')}`));
123
+ }
124
+ }
125
+ /** padEnd on the visible width, ignoring chalk color codes. Exported for tests. */
126
+ export function stripPad(s, width) {
127
+ // Strip the whole SGR sequence (ESC `[` ... `m`). Matching only the `[...m`
128
+ // tail leaves each leading ESC byte counted as visible, so every colored
129
+ // cell over-pads and the plan table misaligns in a real TTY.
130
+ // eslint-disable-next-line no-control-regex
131
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, '').length;
132
+ return s + ' '.repeat(Math.max(1, width - visible));
133
+ }
134
+ async function runApply(opts) {
135
+ const file = opts.file ?? 'agents.yaml';
136
+ const manifest = readFleetFile(path.resolve(file));
137
+ const source = machineId();
138
+ const registry = await loadDevices();
139
+ // Control devices (a cockpit) never run agents — exclude them from the
140
+ // reconcile set entirely so `agents apply` doesn't try to install/sync/login
141
+ // onto a paired phone.
142
+ const all = Object.values(registry).filter((d) => !isControlDevice(d));
143
+ const online = all.filter((d) => d.tailscale?.online === true).map((d) => d.name);
144
+ const registered = all.map((d) => d.name);
145
+ let desired = resolveDesired(manifest, { onlineDevices: online, registeredDevices: registered, source });
146
+ if (opts.device) {
147
+ desired = desired.filter((d) => d.device === opts.device);
148
+ if (desired.length === 0)
149
+ throw new Error(`Device '${opts.device}' is not a target in this manifest.`);
150
+ }
151
+ if (opts.login === false)
152
+ desired = desired.map((d) => ({ ...d, login: 'skip' }));
153
+ if (desired.length === 0) {
154
+ console.log(chalk.gray('No target devices — nothing to apply.'));
155
+ return;
156
+ }
157
+ // Snapshot source auth once for every agent named anywhere in the profile.
158
+ const allAgents = [...new Set(desired.flatMap((d) => d.agents.map(agentIdOf)))];
159
+ const snap = snapshotAuth(allAgents, { home: sourceHome(), platform: process.platform });
160
+ const filesByAgent = new Map();
161
+ for (const f of snap.files) {
162
+ const arr = filesByAgent.get(f.agent) ?? [];
163
+ arr.push(f);
164
+ filesByAgent.set(f.agent, arr);
165
+ }
166
+ const sourceAuth = {
167
+ available: new Set(snap.files.map((f) => f.agent)),
168
+ bound: new Set(snap.bound),
169
+ filesByAgent,
170
+ };
171
+ // Probe every target device in parallel.
172
+ const nameToProfile = new Map(desired.map((d) => [d.device, registry[d.device]]));
173
+ console.log(chalk.gray(`Probing ${desired.length} device(s)…`));
174
+ const probeList = await pool(desired, 6, async (d) => probeDevice(nameToProfile.get(d.device)));
175
+ const probes = new Map(probeList.map((p) => [p.device, p]));
176
+ const targetCliVersion = localCliVersion();
177
+ let plan = diffFleet(desired, probes, { targetCliVersion, sourceAuth });
178
+ // --only filter.
179
+ if (opts.only) {
180
+ const keep = new Set();
181
+ for (const k of opts.only.split(',').map((s) => s.trim()))
182
+ for (const x of ONLY_KINDS[k] ?? [])
183
+ keep.add(x);
184
+ plan = {
185
+ devices: plan.devices.map((r) => ({ ...r, actions: r.actions.filter((a) => keep.has(a.kind)) })),
186
+ actions: plan.actions.filter((a) => keep.has(a.kind)),
187
+ };
188
+ }
189
+ console.log();
190
+ console.log(chalk.bold(`Fleet profile · ${desired.length} device(s) · ${allAgents.length} agent(s) (${allAgents.join(', ')})`));
191
+ renderPlan(plan);
192
+ const isDry = opts.plan || opts.dryRun;
193
+ if (isDry)
194
+ return;
195
+ if (plan.actions.filter((a) => a.kind !== 'needs-login').length === 0) {
196
+ console.log(chalk.green('\nNothing to do — fleet already matches the profile.'));
197
+ return;
198
+ }
199
+ if (!opts.yes) {
200
+ const ok = await confirm(chalk.bold(`\nApply this plan? (${plan.actions.length} action(s)) [y/N] `));
201
+ if (!ok) {
202
+ console.log(chalk.gray('Aborted.'));
203
+ return;
204
+ }
205
+ }
206
+ console.log();
207
+ const results = await runFleetApply(plan.devices, nameToProfile, {
208
+ targetCliVersion,
209
+ source,
210
+ sourceAuth,
211
+ });
212
+ reportResults(results);
213
+ }
214
+ function reportResults(results) {
215
+ let failures = 0;
216
+ for (const r of results) {
217
+ if (r.note && r.steps.length === 0) {
218
+ console.log(` ${chalk.gray(r.device.padEnd(16))} ${chalk.yellow('skipped')} ${chalk.gray(r.note)}`);
219
+ continue;
220
+ }
221
+ const badge = r.ok ? chalk.green('ok ') : chalk.red('fail');
222
+ if (!r.ok)
223
+ failures++;
224
+ const summary = r.steps.map((s) => `${s.ok ? '✓' : '✗'} ${s.kind}`).join(' ');
225
+ console.log(` ${chalk.bold(r.device.padEnd(16))} ${badge} ${chalk.gray(summary)}`);
226
+ for (const s of r.steps.filter((x) => x.kind === 'needs-login')) {
227
+ console.log(` ${chalk.yellow('→')} ${chalk.gray(s.detail)}`);
228
+ }
229
+ }
230
+ console.log();
231
+ if (failures > 0) {
232
+ console.error(chalk.red(`${failures} device(s) had failures.`));
233
+ process.exit(1);
234
+ }
235
+ console.log(chalk.green('Fleet reconciled.'));
236
+ }
237
+ export function registerApplyCommand(program) {
238
+ const applyCmd = program
239
+ .command('apply')
240
+ .description('Reconcile the fleet to a declared profile: install agents, sync config, propagate login.')
241
+ .option('-f, --file <path>', 'Manifest file carrying a fleet: block (default: agents.yaml)')
242
+ .option('--plan', 'Show the reconcile plan and exit (no changes)')
243
+ .option('--dry-run', 'Alias for --plan')
244
+ .option('-y, --yes', 'Skip the confirmation prompt')
245
+ .option('--device <name>', 'Scope the apply to a single device')
246
+ .option('--only <dims>', 'Limit to dimensions: comma list of agents,config,login')
247
+ .option('--no-login', 'Do not propagate logins')
248
+ .addOption(new Option('--recv-auth', 'internal: receive an auth bundle on stdin').hideHelp())
249
+ .action(async (opts) => {
250
+ try {
251
+ if (opts.recvAuth) {
252
+ await runRecvAuth();
253
+ return;
254
+ }
255
+ await runApply(opts);
256
+ }
257
+ catch (e) {
258
+ console.error(chalk.red(e.message));
259
+ process.exit(1);
260
+ }
261
+ });
262
+ setHelpSections(applyCmd, {
263
+ examples: `
264
+ # Preview what would change across the fleet
265
+ agents apply --plan -f agents.yaml
266
+
267
+ # Bring every device to the profile (installs, syncs, propagates login)
268
+ ag apply -f agents.yaml
269
+
270
+ # One device only
271
+ agents apply --device yosemite-s1 -y
272
+ `,
273
+ });
274
+ }
@@ -4,7 +4,7 @@ import { listProfiles, getProfile, createProfile, deleteProfile, ensureDefaultBr
4
4
  import { updateMeta } from '../lib/state.js';
5
5
  import { loginsForProfile, profilesLoggedInto, serviceForUrl, loginsWithAccountsForProfile, accountsForProfile, credKeysForService, AUTH_SIGNATURES, } from '../lib/browser/login-detection.js';
6
6
  import { parseSecretRef } from '../lib/browser/secret-ref.js';
7
- import { readAndResolveBundleEnv, bundleExists, readBundle, describeBundle } from '../lib/secrets/bundles.js';
7
+ import { readAndResolveBundleEnv, isHeadlessSecretsContext, bundleExists, readBundle, describeBundle } from '../lib/secrets/bundles.js';
8
8
  import { findBrowserPath, getPortOccupant, isLauncherScript } from '../lib/browser/chrome.js';
9
9
  import { listProfileCacheDirs, removeProfileCache, listAllProfileSnapshots, } from '../lib/browser/runtime-state.js';
10
10
  import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
@@ -1420,7 +1420,7 @@ function registerTaskCommands(browser) {
1420
1420
  process.exit(1);
1421
1421
  }
1422
1422
  try {
1423
- const { env } = readAndResolveBundleEnv(parsed.bundle, { caller: 'browser type', keys: [parsed.key] });
1423
+ const { env } = readAndResolveBundleEnv(parsed.bundle, { caller: 'browser type', keys: [parsed.key], agentOnly: isHeadlessSecretsContext() });
1424
1424
  if (!(parsed.key in env)) {
1425
1425
  console.error(`Key "${parsed.key}" not in bundle "${parsed.bundle}".`);
1426
1426
  process.exit(1);
@@ -8,6 +8,7 @@ import { insertTask, updateTaskStatus, getTaskById, listTasks as listStoredTasks
8
8
  import { renderStream } from '../lib/cloud/stream.js';
9
9
  import { MissingTargetError, MAX_IMAGES_PER_DISPATCH } from '../lib/cloud/types.js';
10
10
  import { normalizeTriggerEvent, validateTrigger, writeJob, jobExists, GITHUB_TRIGGER_EVENTS } from '../lib/routines.js';
11
+ import { machineId } from '../lib/machine-id.js';
11
12
  import { emit } from '../lib/events.js';
12
13
  /** Map a supported image file extension to its wire mimeType. Rejects anything else. */
13
14
  function imageMimeFromPath(file) {
@@ -145,7 +146,7 @@ Examples:
145
146
  cloud
146
147
  .command('run [prompt]')
147
148
  .description('Dispatch a task to a cloud agent.')
148
- .option('--provider <id>', 'Cloud backend: rush, codex, factory, antigravity (overrides agent auto-routing)')
149
+ .option('--provider <id>', 'Cloud backend: rush, codex, factory, antigravity, host (overrides agent auto-routing)')
149
150
  .option('--agent <name>', 'Agent to run: claude, codex, droid, antigravity (auto-routes to its native cloud)')
150
151
  .option('--repo <owner/repo>', 'GitHub repository. Repeatable for multi-repo dispatch (Rush Cloud only).', (value, previous) => {
151
152
  const acc = Array.isArray(previous) ? previous : [];
@@ -160,6 +161,9 @@ Examples:
160
161
  .option('--model <model>', 'Model override')
161
162
  .option('--env <id>', 'Codex Cloud environment ID')
162
163
  .option('--computer <name>', 'Factory/Droid computer target')
164
+ .option('--host <name>', 'One of your machines as the target (a registered host, device, capability tag, or user@host). Implies --provider host.')
165
+ .option('--remote-cwd <dir>', 'Working directory on the host (--provider host only)')
166
+ .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match')
163
167
  .option('--autonomy <level>', 'Factory/Droid autonomy: low, medium, high (default high)')
164
168
  .option('--mode <mode>', 'Execution mode (e.g., plan, edit, full)')
165
169
  .option('--image <path>', `Attach an image (.png/.jpg/.webp) for vision dispatch. Repeatable, up to ${MAX_IMAGES_PER_DISPATCH} (Rush Cloud only).`, (value, previous) => {
@@ -189,6 +193,10 @@ Examples:
189
193
  # Codex Cloud
190
194
  agents cloud run "add auth tests" --provider codex --env env_abc123
191
195
 
196
+ # One of your own machines (agents hosts / agents devices), over SSH
197
+ agents cloud run "run the nightly benchmark" --host gpu-box --agent claude
198
+ agents cloud run "rebuild the index" --host gpu --any --remote-cwd ~/proj
199
+
192
200
  # Default provider (set in ~/.agents/agents.yaml)
193
201
  agents cloud run "refactor auth module" --repo user/repo
194
202
  `)
@@ -208,9 +216,16 @@ Examples:
208
216
  process.stderr.write(chalk.dim(`Reading prompt from ${filePath} (${sizeKB} KB)\n`));
209
217
  }
210
218
  }
219
+ // --host names one of YOUR machines as the target — that only means
220
+ // something to the host provider, so it implies --provider host rather
221
+ // than silently riding along to a cloud backend that would ignore it.
222
+ if (options.host && options.provider && options.provider !== 'host') {
223
+ die(`--host targets your own machines (--provider host), not ${options.provider}. Drop --host, or use --provider host.`);
224
+ }
225
+ const explicitProvider = options.provider ?? (options.host ? 'host' : undefined);
211
226
  // Agent-aware: with no --provider, the agent routes to its native cloud
212
227
  // (claude→rush, codex→codex, droid→factory, antigravity→antigravity).
213
- const provider = resolveProvider(options.provider, options.agent);
228
+ const provider = resolveProvider(explicitProvider, options.agent);
214
229
  // --repo is repeatable: commander gives us an array via our collector.
215
230
  // A single --repo value arrives as a one-element array; keep the legacy
216
231
  // singular `repo` field in sync so providers that only know that field
@@ -234,6 +249,12 @@ Examples:
234
249
  dispatchOptions.providerOptions.env = options.env;
235
250
  if (options.computer)
236
251
  dispatchOptions.providerOptions.computer = options.computer;
252
+ if (options.host)
253
+ dispatchOptions.providerOptions.host = options.host;
254
+ if (options.remoteCwd)
255
+ dispatchOptions.providerOptions.remoteCwd = options.remoteCwd;
256
+ if (options.any)
257
+ dispatchOptions.providerOptions.any = true;
237
258
  if (options.autonomy)
238
259
  dispatchOptions.providerOptions.autonomy = options.autonomy;
239
260
  if (options.mode)
@@ -279,6 +300,15 @@ Examples:
279
300
  };
280
301
  if (repoValues[0])
281
302
  routine.repo = repoValues[0];
303
+ // --host with --on: the webhook-fired run places on that machine (the
304
+ // routine carries the placement, and firing pins to THIS device so a
305
+ // fleet of receivers can't each dispatch a duplicate).
306
+ if (options.host) {
307
+ routine.host = options.host;
308
+ if (options.remoteCwd)
309
+ routine.remoteCwd = options.remoteCwd;
310
+ routine.devices = [machineId()];
311
+ }
282
312
  writeJob(routine);
283
313
  if (json) {
284
314
  console.log(JSON.stringify({ ok: true, registered: routineName, trigger }, null, 2));
@@ -1,6 +1,6 @@
1
1
  import { addHostOption } from '../lib/hosts/option.js';
2
2
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
3
- import { loadDevices } from '../lib/devices/registry.js';
3
+ import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
4
4
  import { resolveHost } from '../lib/hosts/registry.js';
5
5
  import { sshExec } from '../lib/ssh-exec.js';
6
6
  import { sshTargetFor } from '../lib/hosts/types.js';
@@ -249,6 +249,9 @@ async function resolveFleetTargets(opts) {
249
249
  const localName = machineId();
250
250
  return Object.values(registry)
251
251
  .filter((d) => d.name.toLowerCase() !== localName)
252
+ // Control devices (a cockpit) never run agents — skip them in the fleet
253
+ // fan-out (an explicit --device <name> still resolves above).
254
+ .filter((d) => !isControlDevice(d))
252
255
  .map((d) => ({
253
256
  name: d.name,
254
257
  sshTarget: d.name,
@@ -7,6 +7,54 @@
7
7
  */
8
8
  import { type Command } from 'commander';
9
9
  import type { ExecEffort } from '../lib/exec.js';
10
+ import { type SshGResult } from '../lib/hosts/ssh-config.js';
11
+ /** The host descriptor fields the `--copy-creds` security gate reads. */
12
+ export interface CopyCredsGateHost {
13
+ name: string;
14
+ /** Concrete SSH target for inline/provider hosts; unset for ssh-config aliases. */
15
+ address?: string;
16
+ /** `devices` = registered Tailscale fleet host; `local` = ssh-config/inline. */
17
+ provider?: string;
18
+ }
19
+ /** Outcome of the `--copy-creds` security gate (RUSH-1767). */
20
+ export interface CopyCredsGateDecision {
21
+ /** Ship credentials? True only when the host key is pinned in the managed store. */
22
+ allowed: boolean;
23
+ /** The address whose key is checked/pinned — an ssh-config alias resolved to its real HostName. */
24
+ pinTarget: string;
25
+ /** True when the non-device self-pin path pinned the target during this call. */
26
+ selfPinned: boolean;
27
+ }
28
+ /** Injectable network seams so the gate decision is testable without ssh/keyscan. */
29
+ export interface CopyCredsGateDeps {
30
+ /** Managed known_hosts store file (defaults to the real cache path). */
31
+ file?: string;
32
+ /** Resolve an ssh-config alias to its effective HostName/Port (defaults to real `ssh -G`). */
33
+ resolve?: (name: string) => SshGResult | undefined;
34
+ /** Self-pin `target` via ssh-keyscan; returns whether it's pinned afterward (defaults to real `pinHostKey`). */
35
+ selfPin?: (target: string, port: number | undefined, file: string) => boolean;
36
+ }
37
+ /**
38
+ * Decide whether `--copy-creds` may ship credentials (and the Claude OAuth
39
+ * token) to `host` (RUSH-1767). Credentials ship only to a host whose SSH host
40
+ * key is pinned in the managed known_hosts store, so the offload never rides an
41
+ * accept-new (TOFU) connection a machine-in-the-middle could intercept.
42
+ *
43
+ * Resolution:
44
+ * - The checked target is the host's concrete `address`, else its ssh-config
45
+ * HostName (`ssh -G`), else its name — so an alias is pinned/verified against
46
+ * the SAME real host the strict dispatch later connects to.
47
+ * - An unpinned NON-device (a bare `~/.ssh/config` `Host` alias or literal,
48
+ * which `agents ssh <name>` can't reach — "Unknown device") is self-pinned in
49
+ * place via ssh-keyscan. A registered device is left unpinned here — it earns
50
+ * its pin through the normal `agents ssh <name>` accept-new connect, so the
51
+ * caller steers there instead.
52
+ *
53
+ * The network seams (`resolve`, `selfPin`) default to the real ssh -G /
54
+ * ssh-keyscan implementations and are injectable so the ship/refuse decision is
55
+ * unit-testable against real known_hosts fixtures with no network.
56
+ */
57
+ export declare function decideCopyCredsGate(host: CopyCredsGateHost, deps?: CopyCredsGateDeps): CopyCredsGateDecision;
10
58
  /**
11
59
  * Build the LoopConfig the driver consumes from CLI flags and/or a workflow's
12
60
  * `loop:` frontmatter block (issue #332). Returns undefined when neither source