@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
@@ -12,6 +12,8 @@ import { parseLoopInterval } from '../lib/loop.js';
12
12
  import { AGENTS } from '../lib/agents.js';
13
13
  import { recordDispatchedRun } from '../lib/audit/log.js';
14
14
  import { warnUnpushedWork, shouldWarnUnpushed } from '../lib/warn-unpushed.js';
15
+ import { isHostPinned, pinHostKey, managedKnownHostsPath } from '../lib/devices/known-hosts.js';
16
+ import { sshResolve } from '../lib/hosts/ssh-config.js';
15
17
  import * as fs from 'fs';
16
18
  import * as path from 'path';
17
19
  import * as os from 'os';
@@ -27,6 +29,46 @@ function formatRotationBanner(result, verb = 'balanced') {
27
29
  const ratio = `${healthy.length} of ${healthy.length + excluded.length} healthy`;
28
30
  return `[agents] ${verb} picked ${label} (${ratio})`;
29
31
  }
32
+ /**
33
+ * Decide whether `--copy-creds` may ship credentials (and the Claude OAuth
34
+ * token) to `host` (RUSH-1767). Credentials ship only to a host whose SSH host
35
+ * key is pinned in the managed known_hosts store, so the offload never rides an
36
+ * accept-new (TOFU) connection a machine-in-the-middle could intercept.
37
+ *
38
+ * Resolution:
39
+ * - The checked target is the host's concrete `address`, else its ssh-config
40
+ * HostName (`ssh -G`), else its name — so an alias is pinned/verified against
41
+ * the SAME real host the strict dispatch later connects to.
42
+ * - An unpinned NON-device (a bare `~/.ssh/config` `Host` alias or literal,
43
+ * which `agents ssh <name>` can't reach — "Unknown device") is self-pinned in
44
+ * place via ssh-keyscan. A registered device is left unpinned here — it earns
45
+ * its pin through the normal `agents ssh <name>` accept-new connect, so the
46
+ * caller steers there instead.
47
+ *
48
+ * The network seams (`resolve`, `selfPin`) default to the real ssh -G /
49
+ * ssh-keyscan implementations and are injectable so the ship/refuse decision is
50
+ * unit-testable against real known_hosts fixtures with no network.
51
+ */
52
+ export function decideCopyCredsGate(host, deps = {}) {
53
+ const file = deps.file ?? managedKnownHostsPath();
54
+ const resolve = deps.resolve ?? sshResolve;
55
+ const selfPin = deps.selfPin ?? ((target, port, f) => pinHostKey(target, { file: f, ...(port ? { port } : {}) }).pinned);
56
+ // For an ssh-config host `host.address` is unset; ssh -G gives the real
57
+ // HostName (and Port) the strict dispatch will verify against.
58
+ const cfg = host.address ? undefined : resolve(host.name);
59
+ const pinTarget = host.address ?? cfg?.hostname ?? host.name;
60
+ // Not pinned yet? A registered device earns its pin through the normal
61
+ // accept-new connect (`agents ssh <name>` / the fleet sweep), so leave it and
62
+ // let the caller steer there. A bare ssh-config alias / literal is NOT a
63
+ // registered device — that flow can never pin it — so pin it right here.
64
+ let selfPinned = false;
65
+ if (!isHostPinned(pinTarget, file) && host.provider !== 'devices') {
66
+ const cfgPort = cfg?.port;
67
+ const port = cfgPort && cfgPort !== '22' ? Number(cfgPort) : undefined;
68
+ selfPinned = selfPin(pinTarget, port, file);
69
+ }
70
+ return { allowed: isHostPinned(pinTarget, file), pinTarget, selfPinned };
71
+ }
30
72
  /**
31
73
  * Build the LoopConfig the driver consumes from CLI flags and/or a workflow's
32
74
  * `loop:` frontmatter block (issue #332). Returns undefined when neither source
@@ -507,33 +549,45 @@ export function registerRunCommand(program) {
507
549
  process.exit(1);
508
550
  }
509
551
  const hostName = hostGiven[0];
510
- const { resolveHost, resolveHostByCap } = await import('../lib/hosts/registry.js');
511
- const { dispatchToHost, runInteractiveOnHost } = await import('../lib/hosts/dispatch.js');
512
- const { registerHostSession, registerInteractiveHostSession } = await import('../lib/hosts/session-index.js');
552
+ const { resolveHostRunTarget, dispatchPromptToHost, HostResolutionError } = await import('../lib/hosts/run-target.js');
553
+ const { runInteractiveOnHost } = await import('../lib/hosts/dispatch.js');
554
+ const { registerInteractiveHostSession } = await import('../lib/hosts/session-index.js');
555
+ const { RUN_OPTION_REJECT_MESSAGES } = await import('../lib/hosts/remote-cmd.js');
513
556
  const { normalizeRunStrategy, RUN_STRATEGIES } = await import('../lib/rotate.js');
514
- // A password-auth device throws DeviceOffloadUnsupportedError here; it's
515
- // printed cleanly by the top-level catch in index.ts (covers every
516
- // resolveHost caller), so it never falls through to capability routing.
517
- let host = await resolveHost(hostName);
518
- if (!host) {
519
- // Not a host name — try capability routing (e.g. --host gpu). A
520
- // "Multiple hosts tagged…" error is actionable and must surface;
521
- // only "no host tagged" falls through to the generic unknown-host msg.
522
- try {
523
- host = await resolveHostByCap(hostName, options.any);
524
- }
525
- catch (e) {
526
- const msg = e.message ?? '';
527
- if (msg.startsWith('Multiple hosts')) {
528
- console.error(chalk.red(msg));
529
- process.exit(1);
530
- }
531
- }
532
- }
533
- if (!host) {
534
- console.error(chalk.red(`Unknown host "${hostName}". List hosts: agents hosts list`));
557
+ // The forwarding contract (RUN_OPTION_FORWARDING): options that cannot
558
+ // cross the SSH boundary fail loud BEFORE dispatch never a silent
559
+ // drop. Value-aware: only reject what was actually passed.
560
+ const hostRejects = [];
561
+ if (options.secrets.length > 0)
562
+ hostRejects.push(RUN_OPTION_REJECT_MESSAGES.secrets);
563
+ if (options.secretsKeys)
564
+ hostRejects.push(RUN_OPTION_REJECT_MESSAGES.secretsKeys);
565
+ if (options.allowExpired)
566
+ hostRejects.push(RUN_OPTION_REJECT_MESSAGES.allowExpired);
567
+ if (options.resumeCheckpoint)
568
+ hostRejects.push(RUN_OPTION_REJECT_MESSAGES.resumeCheckpoint);
569
+ if (options.resume === true)
570
+ hostRejects.push(RUN_OPTION_REJECT_MESSAGES.resumeBare);
571
+ if (hostRejects.length > 0) {
572
+ for (const msg of hostRejects)
573
+ console.error(chalk.red(msg));
535
574
  process.exit(1);
536
575
  }
576
+ // Shared resolution (name → capability tag → error). A password-auth
577
+ // device throws DeviceOffloadUnsupportedError inside the helper and
578
+ // propagates untouched — it's printed cleanly by the top-level catch in
579
+ // index.ts (covers every resolveHost caller).
580
+ let host;
581
+ try {
582
+ host = await resolveHostRunTarget(hostName, { any: options.any });
583
+ }
584
+ catch (e) {
585
+ if (e instanceof HostResolutionError) {
586
+ console.error(chalk.red(e.message));
587
+ process.exit(1);
588
+ }
589
+ throw e;
590
+ }
537
591
  try {
538
592
  const [runAgent, rawRunVersion] = agentSpec.split('@');
539
593
  // Forward the explicit @version pin verbatim. Resolving aliases like
@@ -569,6 +623,29 @@ export function registerRunCommand(program) {
569
623
  // --lease, a host is persistent, so this is strictly opt-in per run.
570
624
  let hostCopyCreds;
571
625
  if (options.copyCreds) {
626
+ // Refuse to copy credentials (and the Claude OAuth token) to a host
627
+ // whose SSH host key isn't pinned in the managed known_hosts store:
628
+ // the offload transport would otherwise ride an accept-new (TOFU)
629
+ // connection, so a machine-in-the-middle on an unverified first
630
+ // connect could capture the tokens. The dispatch itself then verifies
631
+ // strictly against that pin (see lib/hosts/dispatch.ts) (RUSH-1767).
632
+ // The ship/refuse decision — pinTarget resolution, the non-device
633
+ // self-pin, and the final pinned check — lives in decideCopyCredsGate
634
+ // (unit-tested against real known_hosts fixtures).
635
+ const gate = decideCopyCredsGate(host);
636
+ if (gate.selfPinned) {
637
+ console.error(chalk.gray(`Pinned "${host.name}" (${gate.pinTarget}) into the managed known_hosts store.`));
638
+ }
639
+ if (!gate.allowed) {
640
+ console.error(chalk.red(`Refusing --copy-creds to "${host.name}": its SSH host key isn't pinned, so the ` +
641
+ `credentials could be exposed to a machine-in-the-middle on an unverified first connect.`));
642
+ console.error(chalk.gray(host.provider === 'devices'
643
+ ? `Pin the key first by connecting once so agents records and verifies it: agents ssh ${host.name}\n` +
644
+ `Then re-run with --copy-creds.`
645
+ : `Couldn't reach "${gate.pinTarget}" to pin its key (ssh-keyscan returned nothing). ` +
646
+ `Make sure the host is reachable over SSH, then re-run with --copy-creds.`));
647
+ process.exit(1);
648
+ }
572
649
  const { detectSignedInRuntimes, pickRuntimes, resolveClaudeCredentialsBlob } = await import('../lib/crabbox/runtimes.js');
573
650
  const { confirm } = await import('@inquirer/prompts');
574
651
  const detected = await detectSignedInRuntimes();
@@ -636,10 +713,12 @@ export function registerRunCommand(program) {
636
713
  agent: runAgent,
637
714
  version: resumeId ? undefined : runVersion,
638
715
  strategy: resumeId ? undefined : runStrategy,
716
+ fallback: options.fallback,
639
717
  prompt,
640
718
  mode: options.mode,
641
719
  model: options.model,
642
720
  effort: options.effort,
721
+ env: options.env,
643
722
  addDir: hostAddDirs,
644
723
  json: options.json,
645
724
  verbose: options.verbose,
@@ -663,32 +742,37 @@ export function registerRunCommand(program) {
663
742
  console.error(chalk.red('A prompt is required for headless host runs: agents run <agent> "<task>" --host <name>'));
664
743
  process.exit(1);
665
744
  }
666
- const hostSessionId = runAgent === 'claude' && !resumeId ? randomUUID() : undefined;
667
- const { task, exitCode } = await dispatchToHost(host, {
745
+ // Session-id mint, detached dispatch, and local session-index
746
+ // registration all live in the shared helper (lib/hosts/run-target.ts).
747
+ const { task, exitCode } = await dispatchPromptToHost(host, {
668
748
  agent: runAgent,
669
749
  version: resumeId ? undefined : runVersion,
670
750
  strategy: resumeId ? undefined : runStrategy,
751
+ fallback: options.fallback,
671
752
  prompt,
672
753
  mode: options.mode,
673
754
  model: options.model,
674
755
  effort: options.effort,
756
+ env: options.env,
675
757
  addDir: hostAddDirs,
758
+ timeout: options.timeout,
759
+ loop: options.loop,
760
+ maxIterations: options.maxIterations,
761
+ budget: options.budget,
762
+ until: options.until,
763
+ interval: options.interval,
676
764
  json: options.json,
677
765
  verbose: options.verbose,
678
- timeout: options.timeout,
679
766
  yes: options.yes,
680
767
  acp: options.acp,
768
+ autoSecrets: options.autoSecrets,
681
769
  remoteCwd: hostCwd,
682
- sessionId: hostSessionId,
683
770
  name: options.name,
684
771
  resume: resumeId,
685
772
  follow: options.follow !== false,
773
+ passthroughArgs,
686
774
  copyCreds: hostCopyCreds,
687
775
  });
688
- // Register the dispatched run in the LOCAL session index so it shows
689
- // up in `agents sessions` and resolves by id/name, even though its
690
- // transcript lives on the host. No-op when no session id was captured.
691
- registerHostSession(task, { cwd: process.cwd(), prompt });
692
776
  if (options.follow === false) {
693
777
  // The handle the caller uses to check on the run: the name if given,
694
778
  // else the real host-task id (never the old literal `<id>`). Steer
@@ -803,7 +887,7 @@ export function registerRunCommand(program) {
803
887
  await warnUnpushedWork(resumeExec.cwd ?? process.cwd());
804
888
  process.exit(resumeExit);
805
889
  }
806
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
890
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported, isHeadlessSecretsContext }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
807
891
  import('../lib/exec.js'),
808
892
  import('../lib/agents.js'),
809
893
  import('../lib/profiles.js'),
@@ -841,6 +925,11 @@ export function registerRunCommand(program) {
841
925
  // produced item, and drives the teams supervisor to drain — no `teams`
842
926
  // subcommands needed.
843
927
  let workflowForEach;
928
+ // True once this run copies ≥1 dispatchable subagent into the shared agents
929
+ // dir. Used below to keep the `Task` tool in a `tools:`-restricted workflow —
930
+ // an orchestrator handed subagents but denied `Task` cannot reach them and
931
+ // degenerates to a no-op ("I'll wait for the completion notification").
932
+ let workflowHasSubagents = false;
844
933
  const cwd = options.cwd ?? process.cwd();
845
934
  if (isValidAgent(rawAgent)) {
846
935
  agent = rawAgent;
@@ -922,6 +1011,8 @@ export function registerRunCommand(program) {
922
1011
  workflowSubagentTargets.push(dest);
923
1012
  copied++;
924
1013
  }
1014
+ if (copied > 0)
1015
+ workflowHasSubagents = true;
925
1016
  if (allowedAgents !== undefined) {
926
1017
  // Surface any allowedAgents entry with no matching subagent file, and
927
1018
  // report how many were filtered out, so the scope is auditable.
@@ -965,7 +1056,9 @@ export function registerRunCommand(program) {
965
1056
  }
966
1057
  // Auto-inject secrets bundles declared in the workflow's frontmatter `secrets:` field.
967
1058
  // Union with any --secrets flags the user passed; dedupe. Skip when --no-auto-secrets is set.
968
- if (!options.noAutoSecrets) {
1059
+ // (Commander stores the negated flag as `autoSecrets: false` — the old
1060
+ // `noAutoSecrets` read was never populated, making the flag a no-op.)
1061
+ if (options.autoSecrets !== false) {
969
1062
  const declared = workflowFrontmatter?.secrets ?? [];
970
1063
  if (declared.length > 0) {
971
1064
  const existing = new Set(options.secrets);
@@ -1002,8 +1095,14 @@ export function registerRunCommand(program) {
1002
1095
  }
1003
1096
  else if (hasScoping) {
1004
1097
  if (tools && tools.length > 0) {
1005
- workflowToolsRestrict = tools;
1006
- process.stderr.write(chalk.gray(`[workflow] restricting available tools to: ${tools.join(', ')} (Write/Bash/Edit unavailable unless listed)\n`));
1098
+ // An orchestrator with dispatchable subagents MUST retain the `Task`
1099
+ // tool, or it can't reach the subagents this run just installed for it
1100
+ // — the run silently no-ops ("I'll wait for the completion notification").
1101
+ workflowToolsRestrict = ensureSubagentDispatchTool(tools, workflowHasSubagents);
1102
+ process.stderr.write(chalk.gray(`[workflow] restricting available tools to: ${workflowToolsRestrict.join(', ')} (Write/Bash/Edit unavailable unless listed)\n`));
1103
+ if (workflowToolsRestrict.length !== tools.length) {
1104
+ process.stderr.write(chalk.gray(`[workflow] kept Task tool: workflow ships subagents to dispatch\n`));
1105
+ }
1007
1106
  }
1008
1107
  if (mcpServerNames && mcpServerNames.length > 0) {
1009
1108
  const servers = getMcpServersByName(mcpServerNames, { cwd });
@@ -1186,8 +1285,12 @@ export function registerRunCommand(program) {
1186
1285
  process.exit(1);
1187
1286
  }
1188
1287
  const strategy = options.balanced ? 'balanced' : explicitStrategy ?? configuredStrategy;
1189
- // Strategy only applies to bare agent invocations. Explicit @version,
1190
- // profiles, and fallback chains already define their execution target.
1288
+ // Strategy only applies to bare agent invocations. Explicit @version and
1289
+ // profiles already define their execution target. A --fallback chain does
1290
+ // NOT pin the primary: it only names where to cascade on a rate limit, so
1291
+ // the bare primary still resolves through the strategy — otherwise every
1292
+ // `agents run claude --fallback codex` run lands on the pinned default
1293
+ // account and account rotation silently stops (the gh-monitor heal bug).
1191
1294
  if (strategy !== 'pinned' || options.balanced || explicitStrategy) {
1192
1295
  if (version) {
1193
1296
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
@@ -1195,9 +1298,6 @@ export function registerRunCommand(program) {
1195
1298
  else if (fromProfile) {
1196
1299
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: profile pins its own version/auth\n`));
1197
1300
  }
1198
- else if (options.fallback) {
1199
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: --fallback pins versions directly\n`));
1200
- }
1201
1301
  else {
1202
1302
  try {
1203
1303
  const resolved = await resolveRunVersion(agent, strategy, cwd);
@@ -1350,6 +1450,10 @@ export function registerRunCommand(program) {
1350
1450
  caller: `agent ${agent}`,
1351
1451
  keys: secretsKeysSubset,
1352
1452
  allowExpired: options.allowExpired,
1453
+ // A headless/background run (routine, teammate, detached) must not
1454
+ // pop a Touch ID sheet nobody can answer — resolve broker-only and
1455
+ // fail fast with an actionable error. Interactive runs still prompt.
1456
+ agentOnly: isHeadlessSecretsContext(),
1353
1457
  });
1354
1458
  const entries = describeBundle(bundle);
1355
1459
  const counts = {};
@@ -1470,17 +1574,23 @@ export function registerRunCommand(program) {
1470
1574
  // path (continuing the session via /continue). Because this injects into
1471
1575
  // the same `fallback` array `--fallback` uses, it must only arm for run
1472
1576
  // shapes that accept a fallback chain — shouldArmRotationFailover excludes
1473
- // acp/loop/resume-checkpoint (which reject a non-empty fallback below),
1474
- // interactive/no-prompt runs, and runs that already have an explicit or
1475
- // profile fallback. Pinned/single-account runs stay unchanged because
1476
- // rotationResult is null or rotationFailoverChain returns []. version is
1477
- // set here because rotationResult is only populated when resolveRunVersion
1478
- // picked one.
1577
+ // acp/loop/resume-checkpoint (which reject a non-empty fallback below)
1578
+ // and interactive/no-prompt runs. Pinned/single-account runs stay
1579
+ // unchanged because rotationResult is null or rotationFailoverChain
1580
+ // returns []. version is set here because rotationResult is only
1581
+ // populated when resolveRunVersion picked one.
1582
+ //
1583
+ // Composes with an explicit --fallback chain: the same-agent accounts are
1584
+ // UNSHIFTED ahead of the user's cross-agent entries, so a rate limit
1585
+ // first tries the other accounts of the same agent (cheapest recovery —
1586
+ // same CLI, session continues) and only then cascades to codex/gemini/etc.
1587
+ // Profiles never compose: strategy is skipped for them, rotationResult
1588
+ // stays null. (fromProfile's model-swap unshift above is therefore never
1589
+ // displaced by this one.)
1479
1590
  if (shouldArmRotationFailover({
1480
1591
  hasRotation: !!rotationResult,
1481
1592
  hasVersion: !!version,
1482
1593
  hasPrompt: prompt !== undefined,
1483
- explicitFallback: fallback.length > 0,
1484
1594
  interactive: !!options.interactive,
1485
1595
  acp: !!options.acp,
1486
1596
  loop: !!options.loop,
@@ -1488,7 +1598,7 @@ export function registerRunCommand(program) {
1488
1598
  })) {
1489
1599
  const failover = rotationFailoverChain(rotationResult, version);
1490
1600
  if (failover.length > 0) {
1491
- fallback.push(...failover);
1601
+ fallback.unshift(...failover);
1492
1602
  if (!options.quiet) {
1493
1603
  const accounts = failover.map(f => `${f.agent}@${f.version}`).join(', ');
1494
1604
  process.stderr.write(chalk.gray(`[agents] rate-limit failover armed: ${accounts}\n`));
@@ -8,6 +8,7 @@ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
8
8
  import { loadPolicy, applyPolicyToBlock, isPhoneUrgent } from '../lib/feed-policy.js';
9
9
  import { notifyUrgentBlock } from '../lib/notify.js';
10
10
  import { gcMailbox } from '../lib/mailbox-gc.js';
11
+ import { isValidMailboxId } from '../lib/mailbox.js';
11
12
  import { getActiveSessions } from '../lib/session/active.js';
12
13
  import { mailboxIdForActiveSession } from '../lib/mailbox-target.js';
13
14
  import { GLYPH, masthead } from '../lib/comms-render.js';
@@ -38,6 +39,11 @@ export function parseRemoteFeed(stdout, machine) {
38
39
  const block = item;
39
40
  if (!block.blockId || !block.sessionId || !block.mailboxId || !block.questions?.length)
40
41
  continue;
42
+ // A crafted mailboxId (path separators, `.`/`..`) would throw inside
43
+ // mailboxDir() when policy runs against the block, aborting the whole
44
+ // dispatch loop — drop it here so a malicious peer can't smuggle one in.
45
+ if (!isValidMailboxId(block.mailboxId))
46
+ continue;
41
47
  blocks.push({ ...block, host: machine });
42
48
  }
43
49
  return blocks;
@@ -240,20 +246,28 @@ export function registerFeedCommand(program) {
240
246
  const policy = loadPolicy();
241
247
  const now = new Date();
242
248
  for (const b of blocks) {
243
- const result = applyPolicyToBlock(b, policy, now);
244
- if (result.action !== 'none') {
245
- console.log(`${chalk.yellow('policy')} ${b.blockId}: ${result.action}`);
246
- }
247
- if (isPhoneUrgent(b, policy)) {
248
- const notifyResult = await notifyUrgentBlock(b, { dryRun: opts.json });
249
- if (notifyResult.ok && !notifyResult.skipped) {
250
- recordNotified(b.blockId);
251
- console.log(`${chalk.green('notified')} ${b.blockId}`);
249
+ // Wrap per-block policy so one malformed block (e.g. a crafted
250
+ // mailboxId that throws in mailboxDir) can't abort the whole loop and
251
+ // strand every remaining block's dispatch.
252
+ try {
253
+ const result = applyPolicyToBlock(b, policy, now);
254
+ if (result.action !== 'none') {
255
+ console.log(`${chalk.yellow('policy')} ${b.blockId}: ${result.action}`);
252
256
  }
253
- else if (notifyResult.error) {
254
- console.error(chalk.yellow(`Notification failed for ${b.blockId}: ${notifyResult.error}`));
257
+ if (isPhoneUrgent(b, policy)) {
258
+ const notifyResult = await notifyUrgentBlock(b, { dryRun: opts.json });
259
+ if (notifyResult.ok && !notifyResult.skipped) {
260
+ recordNotified(b.blockId);
261
+ console.log(`${chalk.green('notified')} ${b.blockId}`);
262
+ }
263
+ else if (notifyResult.error) {
264
+ console.error(chalk.yellow(`Notification failed for ${b.blockId}: ${notifyResult.error}`));
265
+ }
255
266
  }
256
267
  }
268
+ catch (err) {
269
+ console.error(chalk.yellow(`Skipped block ${b.blockId}: ${err.message}`));
270
+ }
257
271
  }
258
272
  }
259
273
  for (const warning of setupWarnings) {
@@ -11,6 +11,7 @@ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/widt
11
11
  import { checkbox, confirm } from '@inquirer/prompts';
12
12
  import { assertValidSshTarget } from '../lib/ssh-exec.js';
13
13
  import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
14
+ import { getDevice } from '../lib/devices/registry.js';
14
15
  import { sshTargetFor } from '../lib/hosts/types.js';
15
16
  import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
16
17
  import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
@@ -93,7 +94,7 @@ async function doAdd(name, target, opts) {
93
94
  return;
94
95
  }
95
96
  let spec;
96
- let sshTarget;
97
+ let sshTarget = '';
97
98
  if (target) {
98
99
  assertValidSshTarget(target);
99
100
  const { address, user } = parseTarget(target);
@@ -105,9 +106,36 @@ async function doAdd(name, target, opts) {
105
106
  sshTarget = name;
106
107
  }
107
108
  else {
108
- console.log(chalk.red(`"${name}" is not in ~/.ssh/config. Pass a target: agents hosts add ${name} <user@host>`));
109
- process.exitCode = 1;
110
- return;
109
+ // A registered device enrolls with no target connection details come from
110
+ // the device profile. The main reason to enroll one at all is the overlay
111
+ // metadata (capability tags); plain dispatch already works via the devices
112
+ // provider.
113
+ const device = await getDevice(name);
114
+ if (device && device.auth.method !== 'password') {
115
+ const address = device.address.dnsName ?? device.address.ip;
116
+ if (address) {
117
+ spec = {
118
+ name,
119
+ provider: 'local',
120
+ source: 'inline',
121
+ address,
122
+ user: device.user,
123
+ caps: opts.cap,
124
+ os: opts.os ?? (device.platform !== 'unknown' ? device.platform : undefined),
125
+ };
126
+ sshTarget = device.user ? `${device.user}@${address}` : address;
127
+ }
128
+ }
129
+ if (!spec) {
130
+ if (device?.auth.method === 'password') {
131
+ console.log(chalk.red(`Device "${name}" uses password auth — dispatch needs key auth. Switch it first: agents devices set ${name} --auth key`));
132
+ }
133
+ else {
134
+ console.log(chalk.red(`"${name}" is not in ~/.ssh/config or the devices registry. Pass a target: agents hosts add ${name} <user@host>`));
135
+ }
136
+ process.exitCode = 1;
137
+ return;
138
+ }
111
139
  }
112
140
  const probe = probeHost(sshTarget);
113
141
  if (!spec.os && probe.os)
@@ -133,8 +161,12 @@ async function doList(json) {
133
161
  // Cap TARGET at its column so a long user@host can't shove CAPS out of alignment.
134
162
  const tgtW = stringWidth(tgt);
135
163
  const tgtCol = tgtW > 28 ? truncateToWidth(tgt, 28) : tgt + ' '.repeat(28 - tgtW);
136
- const mark = h.enrolled ? '' : chalk.gray(' ·available');
137
- console.log(h.name.padEnd(20) + h.source.padEnd(13) + tgtCol + (h.caps?.join(',') ?? '') + mark);
164
+ // Devices surface their registry as SOURCE (they're synced, not enrolled here).
165
+ const source = h.provider === 'devices' ? 'devices' : h.source;
166
+ const mark = h.dispatchable === false
167
+ ? chalk.yellow(' ·password-auth (no dispatch)')
168
+ : h.enrolled ? '' : chalk.gray(' ·available');
169
+ console.log(h.name.padEnd(20) + source.padEnd(13) + tgtCol + (h.caps?.join(',') ?? '') + mark);
138
170
  }
139
171
  }
140
172
  async function doCheck(name) {
@@ -163,6 +195,12 @@ async function doRemove(name) {
163
195
  console.log(chalk.yellow(`"${name}" is not enrolled (nothing to remove).`));
164
196
  return;
165
197
  }
198
+ if (host.provider === 'devices') {
199
+ // The devices registry owns this entry — hosts remove would silently no-op.
200
+ console.log(chalk.yellow(`"${name}" comes from the devices registry. Remove it there: agents devices rm ${name}`));
201
+ process.exitCode = 1;
202
+ return;
203
+ }
166
204
  await getProvider('local').remove(name);
167
205
  console.log(chalk.green(`Removed ${name}`));
168
206
  }
@@ -6,9 +6,9 @@ import { capableAgents, isCapable } from '../lib/capabilities.js';
6
6
  import { emit } from '../lib/events.js';
7
7
  import { AGENTS, getAllCliStates, resolveAgentName, formatAgentError, registerMcpToTargets, unregisterMcpFromTargets, listInstalledMcpsWithScope, parseMcpConfig, getMcpConfigPathForHome, agentLabel, } from '../lib/agents.js';
8
8
  import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
9
- import { listMcpServerConfigs, discoverMcpConfigsFromRepo, installMcpConfigCentrally, } from '../lib/mcp.js';
9
+ import { listMcpServerConfigs, discoverMcpConfigsFromRepo, installMcpConfigCentrally, isProjectMcpTrusted, trustProjectMcp, untrustProjectMcp, getMcpTrustStorePath, } from '../lib/mcp.js';
10
10
  import { cloneRepo } from '../lib/git.js';
11
- import { getMcpDir } from '../lib/state.js';
11
+ import { getMcpDir, getProjectAgentsDir } from '../lib/state.js';
12
12
  import { getEffectiveHome, getGlobalDefault, listInstalledVersions, getVersionHomePath, resolveInstalledAgentTargets, resolveConfiguredAgentTargets, syncResourcesToVersion, } from '../lib/versions.js';
13
13
  import { getUserAgentsDir } from '../lib/state.js';
14
14
  import { isPromptCancelled, isInteractiveTerminal, requireInteractiveSelection, promptRemovalTargets, parseCommaSeparatedList, ensureAgentVersionsInstalled, resolveAgentTargetsAutoInstalling, resolveInstalledAgentTargetsAutoInstalling, VersionNotInstalledError, resolveListFilterOrExit, } from './utils.js';
@@ -178,6 +178,43 @@ When to use:
178
178
  filterVersion,
179
179
  });
180
180
  });
181
+ mcpCmd
182
+ .command('trust')
183
+ .description('Trust this project so its .agents/mcp/ servers may be registered and spawned')
184
+ .action(() => {
185
+ const projectAgentsDir = getProjectAgentsDir();
186
+ if (!projectAgentsDir) {
187
+ console.log(chalk.red('Not inside a project (no .agents/ directory found) — nothing to trust.'));
188
+ process.exit(1);
189
+ }
190
+ const servers = listMcpServerConfigs().filter((s) => s.scope === 'project');
191
+ if (servers.length > 0) {
192
+ console.log(chalk.bold('Project-scoped MCP servers that will be trusted to run:'));
193
+ for (const s of servers) {
194
+ console.log(' ' + chalk.cyan(s.name) + ' ' + chalk.white(formatCentralCommand(s)));
195
+ }
196
+ }
197
+ const trusted = trustProjectMcp();
198
+ console.log(chalk.green(`Trusted project MCP servers for: ${trusted}`));
199
+ console.log(chalk.gray(`Recorded in ${getMcpTrustStorePath()} — revoke with: agents mcp untrust`));
200
+ });
201
+ mcpCmd
202
+ .command('untrust')
203
+ .description('Revoke MCP trust for this project (its .agents/mcp/ servers stop auto-applying)')
204
+ .action(() => {
205
+ const projectAgentsDir = getProjectAgentsDir();
206
+ if (!projectAgentsDir) {
207
+ console.log(chalk.red('Not inside a project (no .agents/ directory found).'));
208
+ process.exit(1);
209
+ }
210
+ const removed = untrustProjectMcp();
211
+ if (removed) {
212
+ console.log(chalk.green('Revoked MCP trust for this project.'));
213
+ }
214
+ else {
215
+ console.log(chalk.gray('This project was not trusted — nothing to revoke.'));
216
+ }
217
+ });
181
218
  mcpCmd
182
219
  .command('add <name> [command_or_url...]')
183
220
  .description('Add an MCP server to the manifest (run "agents mcp register" afterward to apply)')
@@ -797,9 +834,15 @@ function formatCentralCommand(server) {
797
834
  function formatMcpDetail(name, source, centralConfig, manifestConfig, command, targets) {
798
835
  const lines = [];
799
836
  lines.push(chalk.bold.cyan(name));
800
- const tag = source === 'central' ? chalk.green('declared in ~/.agents/mcp/') :
801
- source === 'manifest' ? chalk.gray('declared in agents.yaml') :
802
- chalk.yellow('unmanaged (not in central or manifest)');
837
+ const isUntrustedProject = centralConfig?.scope === 'project'
838
+ && !isProjectMcpTrusted(getProjectAgentsDir() ?? '');
839
+ const tag = centralConfig?.scope === 'project'
840
+ ? (isUntrustedProject
841
+ ? chalk.yellow('declared in <repo>/.agents/mcp/ (untrusted project — will NOT auto-apply)')
842
+ : chalk.green('declared in <repo>/.agents/mcp/ (trusted project)')) :
843
+ source === 'central' ? chalk.green('declared in ~/.agents/mcp/') :
844
+ source === 'manifest' ? chalk.gray('declared in agents.yaml') :
845
+ chalk.yellow('unmanaged (not in central or manifest)');
803
846
  lines.push(' ' + tag);
804
847
  lines.push('');
805
848
  if (centralConfig) {
@@ -814,6 +857,13 @@ function formatMcpDetail(name, source, centralConfig, manifestConfig, command, t
814
857
  if (command) {
815
858
  lines.push(` ${chalk.gray('command:')} ${chalk.white(command)}`);
816
859
  }
860
+ // A project-scoped server is an arbitrary command from a (possibly cloned)
861
+ // repo. Show exactly what would run and how to opt in (RUSH-1776).
862
+ if (isUntrustedProject) {
863
+ lines.push('');
864
+ lines.push(' ' + chalk.yellow('This project is untrusted — its MCP servers are skipped on sync and workflow runs.'));
865
+ lines.push(' ' + chalk.gray('Review the command above, then opt in with: ') + chalk.white('agents mcp trust'));
866
+ }
817
867
  lines.push('');
818
868
  lines.push(chalk.bold(' Synced to'));
819
869
  lines.push(buildTargetsSection(targets));
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Monitors — durable event-triggered watchers.
3
+ *
4
+ * Registers the `agents monitors` command tree: create, list, view, dry-run test,
5
+ * edit, logs, fire history, pause/resume, (re)pin the owner device, and remove.
6
+ * Mirrors `agents routines` (commands/routines.ts) and shares the same daemon +
7
+ * dispatch engine underneath — a monitor is a routine whose trigger is a watched
8
+ * source instead of a clock.
9
+ */
10
+ import type { Command } from 'commander';
11
+ /** Register the `agents monitors` command tree. */
12
+ export declare function registerMonitorsCommands(program: Command): void;