@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,129 @@
1
+ /**
2
+ * Shared types for the fleet profile-sync feature (`agents apply` / `ag apply`).
3
+ *
4
+ * `agents.yaml` gains an additive `fleet:` block that declares a *profile*: which
5
+ * agents every device should have installed, which config scopes to reconcile,
6
+ * and whether logins/tokens propagate. `apply` reconciles the live fleet to it.
7
+ *
8
+ * These types are the contract shared by the manifest parser (`manifest.ts`),
9
+ * the reconcile engine (`apply.ts`), the auth propagation (`auth-sync.ts`), and
10
+ * the command (`commands/apply.ts`). Runtime probe/diff shapes live here too so
11
+ * the pure diff can be unit-tested without SSH.
12
+ */
13
+ /**
14
+ * How login/token state propagates to a device.
15
+ * - `sync` (default): push portable credentials where possible, surface the rest
16
+ * as a manual login.
17
+ * - `skip`: probe/report only; take no login action.
18
+ * (A per-agent interactive `prompt` mode is intentionally not offered yet — it
19
+ * was removed rather than accepted as a silent no-op that behaves like `skip`.)
20
+ */
21
+ export type FleetLoginMode = 'sync' | 'skip';
22
+ /** Defaults applied to every targeted device unless a per-device entry overrides. */
23
+ export interface FleetDefaults {
24
+ /** Agent specs to ensure installed, e.g. `['claude@latest', 'codex@latest']`. */
25
+ agents?: string[];
26
+ /** Config sync scopes to reconcile on each device, e.g. `['user']`. */
27
+ sync?: string[];
28
+ /** Login propagation strategy. Default `'sync'`. */
29
+ login?: FleetLoginMode;
30
+ }
31
+ /** Per-device override; any omitted field inherits from `defaults`. */
32
+ export interface FleetDeviceOverride {
33
+ agents?: string[];
34
+ sync?: string[];
35
+ login?: FleetLoginMode;
36
+ }
37
+ /**
38
+ * The `fleet:` block as it appears in `agents.yaml` (or any `-f` file). `devices`
39
+ * is either the literal string `'all'` (every online registered device minus the
40
+ * source machine) or an explicit map of device-name -> override.
41
+ */
42
+ export interface FleetManifest {
43
+ defaults?: FleetDefaults;
44
+ devices: 'all' | Record<string, FleetDeviceOverride>;
45
+ }
46
+ /**
47
+ * A device's desired state after merging defaults with its override and
48
+ * expanding `devices: all`. This is what the reconcile engine drives toward.
49
+ */
50
+ export interface DeviceDesired {
51
+ /** Registered device name (from `agents devices`). */
52
+ device: string;
53
+ /** Resolved agent specs to ensure installed. */
54
+ agents: string[];
55
+ /** Config sync scopes. */
56
+ sync: string[];
57
+ /** Login propagation strategy for this device. */
58
+ login: FleetLoginMode;
59
+ }
60
+ /**
61
+ * What a probe found on one device. Populated from `readyProbe` plus an
62
+ * installed-agents listing; `reachable: false` short-circuits everything else.
63
+ */
64
+ export interface DeviceProbe {
65
+ device: string;
66
+ reachable: boolean;
67
+ /** Platform of the device (`linux` | `macos` | `windows`), for login classification. */
68
+ platform?: string;
69
+ /** agents-cli version present on the device (undefined if not installed). */
70
+ cliVersion?: string;
71
+ /** Agent ids currently installed on the device. */
72
+ installedAgents: string[];
73
+ /** Reason string when `reachable` is false or the probe partially failed. */
74
+ note?: string;
75
+ }
76
+ /** One planned action against a device, in a single reconcile dimension. */
77
+ export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login';
78
+ export interface FleetAction {
79
+ device: string;
80
+ kind: FleetActionKind;
81
+ /** Agent id for agent/login actions; undefined for cli/config actions. */
82
+ agent?: string;
83
+ /** Human, one-line description of the action. */
84
+ detail: string;
85
+ }
86
+ /**
87
+ * The full reconcile plan: per-device desired vs probed, plus the flat list of
88
+ * actions. Pure output of `diffFleet(desired, probes)` — drives both `--plan`
89
+ * rendering and the confirm prompt.
90
+ */
91
+ export interface FleetPlan {
92
+ devices: DeviceDiff[];
93
+ actions: FleetAction[];
94
+ }
95
+ /** Per-device diff row rendered in the plan matrix. */
96
+ export interface DeviceDiff {
97
+ device: string;
98
+ desired: DeviceDesired;
99
+ probe: DeviceProbe;
100
+ actions: FleetAction[];
101
+ /** Agents that must be logged in on the device but can't be propagated
102
+ * (source token is device-bound, e.g. macOS keychain). Surfaced, not faked. */
103
+ loginBlocked: string[];
104
+ }
105
+ /** A portable auth file captured from a source agent home, ready to propagate. */
106
+ export interface AuthFilePayload {
107
+ /** Agent id this file belongs to. */
108
+ agent: string;
109
+ /** Path relative to the agent's config dir (or $HOME), reconstructed on target. */
110
+ rel: string;
111
+ /** File contents, base64. */
112
+ contentB64: string;
113
+ /** POSIX mode to restore (e.g. 0o600 for credentials). */
114
+ mode: number;
115
+ }
116
+ /** The plaintext we encrypt before shipping auth over the wire. */
117
+ export interface AuthBundle {
118
+ /** Schema version for forward-compat. */
119
+ v: 1;
120
+ /** Source machine name the snapshot was taken on. */
121
+ source: string;
122
+ files: AuthFilePayload[];
123
+ }
124
+ /** Result of classifying one source agent's auth for propagation. */
125
+ export interface AuthSnapshotResult {
126
+ files: AuthFilePayload[];
127
+ /** Agent ids whose auth is device-bound (keychain) and cannot be captured. */
128
+ bound: string[];
129
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shared types for the fleet profile-sync feature (`agents apply` / `ag apply`).
3
+ *
4
+ * `agents.yaml` gains an additive `fleet:` block that declares a *profile*: which
5
+ * agents every device should have installed, which config scopes to reconcile,
6
+ * and whether logins/tokens propagate. `apply` reconciles the live fleet to it.
7
+ *
8
+ * These types are the contract shared by the manifest parser (`manifest.ts`),
9
+ * the reconcile engine (`apply.ts`), the auth propagation (`auth-sync.ts`), and
10
+ * the command (`commands/apply.ts`). Runtime probe/diff shapes live here too so
11
+ * the pure diff can be unit-tested without SSH.
12
+ */
13
+ export {};
package/dist/lib/git.d.ts CHANGED
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Git operations for the agents-cli system repo and package repositories.
3
+ *
4
+ * Handles cloning, pulling, syncing, and inspecting git repos used by
5
+ * the agents version management and plugin/package system. Includes
6
+ * source parsing for GitHub shorthand, SSH, HTTPS, and local paths.
7
+ */
8
+ import { SimpleGit } from 'simple-git';
1
9
  /**
2
10
  * Validate that a clone/pull source uses a safe git transport before it is
3
11
  * handed to `git`.
@@ -16,6 +24,25 @@
16
24
  * @throws Error if the source uses a disallowed transport.
17
25
  */
18
26
  export declare function assertSafeGitTransport(source: string): void;
27
+ /**
28
+ * Validate a branch name before it is passed to `git push` / `git pull`.
29
+ *
30
+ * A name beginning with `-` is parsed by git as a command-line option
31
+ * (e.g. `--mirror`, `--receive-pack=…`), not a ref. Pure string check —
32
+ * identical on every OS, no spawn.
33
+ *
34
+ * @throws Error if the name is empty or would be interpreted as a git option.
35
+ */
36
+ export declare function assertValidBranchName(branch: string): void;
37
+ /**
38
+ * `git push origin <branch>` with option-injection hardening:
39
+ * 1. {@link assertValidBranchName} rejects leading `-`
40
+ * 2. `--` ends option parsing so a hostile ref cannot be read as a flag
41
+ *
42
+ * Prefer this over `git.push(remote, branch)` whenever the branch comes from
43
+ * repo state rather than a hard-coded literal.
44
+ */
45
+ export declare function pushOrigin(git: SimpleGit, branch: string): Promise<void>;
19
46
  /** Parsed representation of a git source string (GitHub, generic URL, or local path). */
20
47
  export interface GitSource {
21
48
  type: 'github' | 'url' | 'local';
package/dist/lib/git.js CHANGED
@@ -51,6 +51,36 @@ export function assertSafeGitTransport(source) {
51
51
  }
52
52
  // No scheme -> SCP-style SSH ("git@host:path") or a local path; both safe.
53
53
  }
54
+ /**
55
+ * Validate a branch name before it is passed to `git push` / `git pull`.
56
+ *
57
+ * A name beginning with `-` is parsed by git as a command-line option
58
+ * (e.g. `--mirror`, `--receive-pack=…`), not a ref. Pure string check —
59
+ * identical on every OS, no spawn.
60
+ *
61
+ * @throws Error if the name is empty or would be interpreted as a git option.
62
+ */
63
+ export function assertValidBranchName(branch) {
64
+ const b = branch.trim();
65
+ if (!b) {
66
+ throw new Error(`Invalid branch name ${JSON.stringify(branch)}: branch name is empty.`);
67
+ }
68
+ if (b.startsWith('-')) {
69
+ throw new Error(`Invalid branch name ${JSON.stringify(branch)}: a name starting with "-" is interpreted as a git option.`);
70
+ }
71
+ }
72
+ /**
73
+ * `git push origin <branch>` with option-injection hardening:
74
+ * 1. {@link assertValidBranchName} rejects leading `-`
75
+ * 2. `--` ends option parsing so a hostile ref cannot be read as a flag
76
+ *
77
+ * Prefer this over `git.push(remote, branch)` whenever the branch comes from
78
+ * repo state rather than a hard-coded literal.
79
+ */
80
+ export async function pushOrigin(git, branch) {
81
+ assertValidBranchName(branch);
82
+ await git.raw(['push', '--', 'origin', branch]);
83
+ }
54
84
  /**
55
85
  * Whether installing a cloned/pulled repo's `.githooks/` is enabled.
56
86
  *
@@ -377,6 +407,7 @@ export async function commitAndPush(repoPath, message) {
377
407
  const git = simpleGit(repoPath);
378
408
  let status = await git.status();
379
409
  const branch = status.current || 'main';
410
+ assertValidBranchName(branch);
380
411
  let committed = false;
381
412
  if (status.files.length > 0) {
382
413
  await git.add('-A');
@@ -402,7 +433,7 @@ export async function commitAndPush(repoPath, message) {
402
433
  catch {
403
434
  /* origin/<branch> may not exist yet (first push) */
404
435
  }
405
- await git.push('origin', branch);
436
+ await pushOrigin(git, branch);
406
437
  let after = '';
407
438
  try {
408
439
  after = (await git.raw(['rev-parse', '--short=8', 'HEAD'])).trim();
@@ -735,12 +766,13 @@ export async function syncRepoGit(dir, opts) {
735
766
  };
736
767
  }
737
768
  const branch = status.current || 'main';
769
+ assertValidBranchName(branch);
738
770
  await git.fetch('origin');
739
771
  await git.pull('origin', branch, { '--rebase': 'true' });
740
772
  installGithooksSymlinks(dir);
741
773
  let pushed = false;
742
774
  if (opts.push) {
743
- await git.push('origin', branch);
775
+ await pushOrigin(git, branch);
744
776
  pushed = true;
745
777
  }
746
778
  const log = await git.log({ maxCount: 1 });
@@ -62,24 +62,37 @@ export interface DispatchOptions {
62
62
  prompt: string;
63
63
  /** Explicit agent version pin (e.g. "2.1.207") to forward as `agent@version`. */
64
64
  version?: string;
65
- /** Explicit run strategy (e.g. "balanced") to forward as `--strategy <strategy>`. */
65
+ /** Run strategy (e.g. "balanced") the remote picks among ITS signed-in accounts. */
66
66
  strategy?: string;
67
+ balanced?: boolean;
68
+ fallback?: string;
67
69
  mode?: string;
68
70
  model?: string;
69
- /** Reasoning effort to forward as `--effort <effort>`. */
71
+ /** Reasoning effort forwarded unless 'auto' (the remote default). */
70
72
  effort?: string;
71
- /** Additional directories to grant, already made remote-portable. */
73
+ /** `--env k=v` pairs, forwarded verbatim (the remote CLI parses them). */
74
+ env?: string[];
75
+ /** `--add-dir` grants, already made remote-portable. */
72
76
  addDir?: string[];
73
- /** Stream events as JSON lines. */
77
+ /** Agent wall-clock cap, forwarded as `--timeout <duration>` for the REMOTE to enforce. */
78
+ timeout?: string;
79
+ /** Loop family — the loop driver runs on the host. */
80
+ loop?: boolean;
81
+ maxIterations?: string;
82
+ budget?: string;
83
+ until?: string;
84
+ interval?: string;
85
+ /** Remote emits ndjson into its log; the local follow streams it verbatim. */
74
86
  json?: boolean;
75
- /** Show detailed execution logs. */
76
87
  verbose?: boolean;
77
- /** Kill the agent after this duration, forwarded as `--timeout <duration>`. */
78
- timeout?: string;
79
- /** Skip the interactive budget-confirm prompt. */
88
+ /** Skip the budget-confirm prompt a detached remote run can't answer one. */
80
89
  yes?: boolean;
81
90
  /** Route through the Agent Client Protocol. */
82
91
  acp?: boolean;
92
+ /** False forwards --no-auto-secrets (workflow secrets resolve on the REMOTE keychain). */
93
+ autoSecrets?: boolean;
94
+ /** Native-CLI passthrough (everything after `--`), appended last. */
95
+ passthroughArgs?: string[];
83
96
  remoteCwd?: string;
84
97
  /**
85
98
  * Force the remote run's NEW session to use this exact id (Claude only, via
@@ -105,6 +118,10 @@ export interface DispatchOptions {
105
118
  * session-id / resume flag wiring is unit-testable without an SSH round-trip.
106
119
  * `--session-id` and `--resume` are mutually exclusive (the CLI rejects both);
107
120
  * resume wins when — defensively — both are set.
121
+ *
122
+ * Every field here is classified 'forward' in RUN_OPTION_FORWARDING
123
+ * (remote-cmd.ts) — keep the two in lockstep; run-forwarding.test.ts asserts
124
+ * the table side.
108
125
  */
109
126
  export declare function buildRunForwardedArgs(opts: DispatchOptions): string[];
110
127
  export interface InteractiveDispatchOptions {
@@ -139,6 +156,10 @@ export interface InteractiveDispatchOptions {
139
156
  raw?: boolean;
140
157
  /** Forward `--interactive` to the remote so a prompt-bearing run still starts the TUI. */
141
158
  forceInteractive?: boolean;
159
+ /** `--env k=v` pairs, forwarded verbatim (the remote CLI parses them). */
160
+ env?: string[];
161
+ balanced?: boolean;
162
+ fallback?: string;
142
163
  /** Copy runtime credentials to the host before the run and shred them after. */
143
164
  copyCreds?: HostCredentials;
144
165
  }
@@ -17,6 +17,7 @@ import { resolveRemoteOsSync } from './remote-os.js';
17
17
  import { saveTask, updateTask, terminalPatch } from './tasks.js';
18
18
  import { followHostTask } from './progress.js';
19
19
  import { wrapHostCommandWithCredentials } from './credentials.js';
20
+ import { hostKeyCheckingOpts } from '../devices/known-hosts.js';
20
21
  // Use $HOME (not ~) so the path is correct whether or not it's quoted and
21
22
  // regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
22
23
  // injection-safe to interpolate unquoted into remote commands.
@@ -180,10 +181,19 @@ async function launchDetached(host, target, opts) {
180
181
  if (opts.copyCreds) {
181
182
  inner = wrapHostCommandWithCredentials(inner, opts.copyCreds);
182
183
  }
184
+ // When credentials ride this launch, verify the host key strictly against the
185
+ // managed pin (the gate in exec.ts already required the host to be pinned) and
186
+ // force a fresh connection — reusing a control socket opened by an earlier
187
+ // accept-new connection would bypass the strict check (RUSH-1767).
188
+ const credHostKeyOpts = opts.copyCreds ? hostKeyCheckingOpts(true) : undefined;
183
189
  // Outer: ensure dir, launch the login-shell wrapper as a new process-group
184
190
  // leader, and print that leader PID.
185
191
  const launch = `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
186
- const res = sshExec(target, launch, { timeoutMs: 30000, multiplex: true });
192
+ const res = sshExec(target, launch, {
193
+ timeoutMs: 30000,
194
+ multiplex: !opts.copyCreds,
195
+ hostKeyOpts: credHostKeyOpts,
196
+ });
187
197
  if (res.code !== 0) {
188
198
  throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
189
199
  }
@@ -235,38 +245,61 @@ async function launchDetached(host, target, opts) {
235
245
  * session-id / resume flag wiring is unit-testable without an SSH round-trip.
236
246
  * `--session-id` and `--resume` are mutually exclusive (the CLI rejects both);
237
247
  * resume wins when — defensively — both are set.
248
+ *
249
+ * Every field here is classified 'forward' in RUN_OPTION_FORWARDING
250
+ * (remote-cmd.ts) — keep the two in lockstep; run-forwarding.test.ts asserts
251
+ * the table side.
238
252
  */
239
253
  export function buildRunForwardedArgs(opts) {
240
254
  const agentArg = opts.version ? `${opts.agent}@${opts.version}` : opts.agent;
241
255
  const args = ['run', agentArg, opts.prompt, '--quiet'];
242
- if (opts.strategy)
243
- args.push('--strategy', opts.strategy);
244
256
  if (opts.mode)
245
257
  args.push('--mode', opts.mode);
246
258
  if (opts.model)
247
259
  args.push('--model', opts.model);
248
- if (opts.effort)
260
+ // 'auto' is the remote default — forwarding it would only add noise.
261
+ if (opts.effort && opts.effort !== 'auto')
249
262
  args.push('--effort', opts.effort);
250
- if (opts.addDir) {
251
- for (const dir of opts.addDir)
252
- args.push('--add-dir', dir);
253
- }
263
+ for (const kv of opts.env ?? [])
264
+ args.push('--env', kv);
265
+ for (const dir of opts.addDir ?? [])
266
+ args.push('--add-dir', dir);
267
+ if (opts.timeout)
268
+ args.push('--timeout', opts.timeout);
269
+ if (opts.strategy)
270
+ args.push('--strategy', opts.strategy);
271
+ if (opts.balanced)
272
+ args.push('--balanced');
273
+ if (opts.fallback)
274
+ args.push('--fallback', opts.fallback);
275
+ if (opts.loop)
276
+ args.push('--loop');
277
+ if (opts.maxIterations)
278
+ args.push('--max-iterations', opts.maxIterations);
279
+ if (opts.budget)
280
+ args.push('--budget', opts.budget);
281
+ if (opts.until)
282
+ args.push('--until', opts.until);
283
+ if (opts.interval)
284
+ args.push('--interval', opts.interval);
254
285
  if (opts.json)
255
286
  args.push('--json');
256
287
  if (opts.verbose)
257
288
  args.push('--verbose');
258
- if (opts.timeout)
259
- args.push('--timeout', opts.timeout);
260
289
  if (opts.yes)
261
290
  args.push('--yes');
262
291
  if (opts.acp)
263
292
  args.push('--acp');
293
+ if (opts.autoSecrets === false)
294
+ args.push('--no-auto-secrets');
264
295
  if (opts.name)
265
296
  args.push('--name', opts.name);
266
297
  if (opts.resume)
267
298
  args.push('--resume', opts.resume);
268
299
  else if (opts.sessionId)
269
300
  args.push('--session-id', opts.sessionId);
301
+ if (opts.passthroughArgs && opts.passthroughArgs.length > 0)
302
+ args.push('--', ...opts.passthroughArgs);
270
303
  return args;
271
304
  }
272
305
  /**
@@ -283,24 +316,29 @@ export function buildInteractiveRunForwardedArgs(opts) {
283
316
  args.push(opts.prompt);
284
317
  if (opts.forceInteractive)
285
318
  args.push('--interactive');
286
- if (opts.strategy)
287
- args.push('--strategy', opts.strategy);
288
319
  if (opts.mode)
289
320
  args.push('--mode', opts.mode);
290
321
  if (opts.model)
291
322
  args.push('--model', opts.model);
292
- if (opts.effort)
323
+ // 'auto' is the remote default — forwarding it would only add noise.
324
+ if (opts.effort && opts.effort !== 'auto')
293
325
  args.push('--effort', opts.effort);
294
- if (opts.addDir) {
295
- for (const dir of opts.addDir)
296
- args.push('--add-dir', dir);
297
- }
326
+ for (const kv of opts.env ?? [])
327
+ args.push('--env', kv);
328
+ for (const dir of opts.addDir ?? [])
329
+ args.push('--add-dir', dir);
330
+ if (opts.timeout)
331
+ args.push('--timeout', opts.timeout);
332
+ if (opts.strategy)
333
+ args.push('--strategy', opts.strategy);
334
+ if (opts.balanced)
335
+ args.push('--balanced');
336
+ if (opts.fallback)
337
+ args.push('--fallback', opts.fallback);
298
338
  if (opts.json)
299
339
  args.push('--json');
300
340
  if (opts.verbose)
301
341
  args.push('--verbose');
302
- if (opts.timeout)
303
- args.push('--timeout', opts.timeout);
304
342
  if (opts.yes)
305
343
  args.push('--yes');
306
344
  if (opts.acp)
@@ -334,7 +372,15 @@ export async function runInteractiveOnHost(host, opts) {
334
372
  if (opts.copyCreds) {
335
373
  remoteCmd = wrapHostCommandWithCredentials(remoteCmd, opts.copyCreds);
336
374
  }
337
- return sshStream(target, remoteCmd, { tty: process.stdin.isTTY, multiplex: true });
375
+ // Credentials ride this stream when --copy-creds is set: verify the host key
376
+ // strictly against the managed pin and force a fresh connection so a stale
377
+ // accept-new control socket can't bypass the check (RUSH-1767).
378
+ const credHostKeyOpts = opts.copyCreds ? hostKeyCheckingOpts(true) : undefined;
379
+ return sshStream(target, remoteCmd, {
380
+ tty: process.stdin.isTTY,
381
+ multiplex: !opts.copyCreds,
382
+ hostKeyOpts: credHostKeyOpts,
383
+ });
338
384
  }
339
385
  /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
340
386
  export async function dispatchToHost(host, opts) {
@@ -107,6 +107,8 @@ const OWN_HOST_COMMANDS = new Set([
107
107
  'ssh',
108
108
  'devices',
109
109
  'fleet', // alias of devices
110
+ 'apply', // `--device` scopes the fleet reconcile to one device (it targets devices itself)
111
+ 'monitors', // `--device` names the OWNER machine (pin-to-one), not a routing target
110
112
  ]);
111
113
  /** `--no-tty` is stripped like the routing flags but carries no value. */
112
114
  const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Devices host provider: the Tailscale fleet as dispatch targets.
3
+ *
4
+ * Bridges the devices registry (`agents devices`, ~/.agents/.history/devices/
5
+ * registry.json) into the host pool behind the same `HostProvider` seam as
6
+ * `local` — the "tailscale provider" fast-follow named in docs/hosts.md. With
7
+ * it, a machine registered once via `agents devices sync` shows up in
8
+ * `agents hosts list`, participates in capability routing, and is enumerable
9
+ * by target pickers — not just resolvable by exact name.
10
+ *
11
+ * Password-auth devices are listed (the pool stays honest about what exists)
12
+ * but marked `dispatchable: false`; resolving one for dispatch throws the same
13
+ * typed `DeviceOffloadUnsupportedError` as before — offload rides `sshExec`,
14
+ * whose SSH_OPTS force `BatchMode=yes`.
15
+ *
16
+ * Precedence is unchanged: this provider registers AFTER `local`, so an
17
+ * enrolled host shadows a same-name device in both list dedup and resolve
18
+ * order, exactly like the old tier-2 devices fall-through in resolveHost.
19
+ */
20
+ import type { Host, HostProvider, HostProviderCapabilities, HostStatus } from '../types.js';
21
+ export declare class DevicesHostProvider implements HostProvider {
22
+ readonly id: "devices";
23
+ capabilities(): HostProviderCapabilities;
24
+ list(): Promise<Host[]>;
25
+ resolve(name: string): Promise<Host | null>;
26
+ presence(name: string): Promise<HostStatus>;
27
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Devices host provider: the Tailscale fleet as dispatch targets.
3
+ *
4
+ * Bridges the devices registry (`agents devices`, ~/.agents/.history/devices/
5
+ * registry.json) into the host pool behind the same `HostProvider` seam as
6
+ * `local` — the "tailscale provider" fast-follow named in docs/hosts.md. With
7
+ * it, a machine registered once via `agents devices sync` shows up in
8
+ * `agents hosts list`, participates in capability routing, and is enumerable
9
+ * by target pickers — not just resolvable by exact name.
10
+ *
11
+ * Password-auth devices are listed (the pool stays honest about what exists)
12
+ * but marked `dispatchable: false`; resolving one for dispatch throws the same
13
+ * typed `DeviceOffloadUnsupportedError` as before — offload rides `sshExec`,
14
+ * whose SSH_OPTS force `BatchMode=yes`.
15
+ *
16
+ * Precedence is unchanged: this provider registers AFTER `local`, so an
17
+ * enrolled host shadows a same-name device in both list dedup and resolve
18
+ * order, exactly like the old tier-2 devices fall-through in resolveHost.
19
+ */
20
+ import { loadDevices, getDevice, isControlDevice } from '../../devices/registry.js';
21
+ import { DeviceOffloadUnsupportedError } from '../types.js';
22
+ /** Tailscale's own presence bit, when the sync captured one. */
23
+ function statusOf(device) {
24
+ if (!device.tailscale)
25
+ return 'unknown';
26
+ return device.tailscale.online ? 'online' : 'offline';
27
+ }
28
+ /**
29
+ * Bridge a device profile into a `Host`. dnsName (stable across IP churn) is
30
+ * preferred over ip; `source: 'inline'` makes `sshTargetFor` emit `user@address`.
31
+ * Capability tags attach by enrolling the device (`agents hosts add <device>
32
+ * --cap …` sources the target from this profile) — the enrolled entry then
33
+ * shadows this row by provider precedence, carrying the caps.
34
+ */
35
+ function deviceToPoolHost(device) {
36
+ // A control device (a cockpit, e.g. a paired iPhone) drives the fleet but
37
+ // never runs agents — it must never enter the host pool or be resolvable as a
38
+ // dispatch target, whatever platform it reports (an iPhone syncs as `unknown`,
39
+ // which remoteShellFor would otherwise default to POSIX and try to SSH).
40
+ if (isControlDevice(device))
41
+ return null;
42
+ const address = device.address.dnsName ?? device.address.ip;
43
+ if (!address)
44
+ return null; // unreachable profile — nothing to dispatch to
45
+ return {
46
+ name: device.name,
47
+ provider: 'devices',
48
+ source: 'inline',
49
+ address,
50
+ user: device.user,
51
+ ...(device.platform !== 'unknown' ? { os: device.platform } : {}),
52
+ enrolled: true,
53
+ status: statusOf(device),
54
+ dispatchable: device.auth.method !== 'password',
55
+ };
56
+ }
57
+ export class DevicesHostProvider {
58
+ id = 'devices';
59
+ capabilities() {
60
+ // mutate stays false: `agents devices sync/add/set` own the registry.
61
+ return { directory: true, mutate: false, presence: true, relay: false, lease: false };
62
+ }
63
+ async list() {
64
+ const devices = await loadDevices();
65
+ const out = [];
66
+ for (const device of Object.values(devices)) {
67
+ const host = deviceToPoolHost(device);
68
+ if (host)
69
+ out.push(host);
70
+ }
71
+ return out.sort((a, b) => a.name.localeCompare(b.name));
72
+ }
73
+ async resolve(name) {
74
+ const device = await getDevice(name);
75
+ if (!device)
76
+ return null;
77
+ // Resolving is asking to dispatch. A control device can't run agents — fail
78
+ // loud with a clear message instead of attempting an SSH dispatch onto a
79
+ // phone (which remoteShellFor would treat as a POSIX host).
80
+ if (isControlDevice(device)) {
81
+ throw new Error(`Device "${device.name}" is a control device (a cockpit), not an executor — it can't run agents. Dispatch to a worker device instead.`);
82
+ }
83
+ // Keep the long-standing typed refusal for password auth (BatchMode=yes
84
+ // can't answer a prompt).
85
+ if (device.auth.method === 'password') {
86
+ throw new DeviceOffloadUnsupportedError(device.name);
87
+ }
88
+ const host = deviceToPoolHost(device);
89
+ if (!host) {
90
+ throw new Error(`Device "${device.name}" has no address (Tailscale DNS name or IP) to reach it by.`);
91
+ }
92
+ return host;
93
+ }
94
+ async presence(name) {
95
+ const device = await getDevice(name);
96
+ return device ? statusOf(device) : 'unknown';
97
+ }
98
+ }
@@ -2,30 +2,24 @@
2
2
  * Host provider registry.
3
3
  *
4
4
  * Mirrors the cloud provider registry: a Map of provider id → implementation,
5
- * instantiated once. v1 registers only `local`; adding `rush`/`tailscale`/
6
- * `crabbox` later is a one-line `providers.set(...)` with no caller changes.
5
+ * instantiated once. Registers `local` then `devices` (order is precedence:
6
+ * an enrolled host shadows a same-name device); adding `rush`/`crabbox` later
7
+ * is a one-line `providers.set(...)` with no caller changes.
7
8
  */
8
9
  import type { Host, HostProvider, HostProviderId } from './types.js';
10
+ import { DeviceOffloadUnsupportedError } from './types.js';
11
+ export { DeviceOffloadUnsupportedError };
9
12
  export declare function getProvider(id: HostProviderId): HostProvider;
10
13
  export declare function getAllProviders(): HostProvider[];
11
14
  /** Every host across all registered providers, deduped by name (first wins). */
12
15
  export declare function listAllHosts(): Promise<Host[]>;
13
- /**
14
- * Thrown when a device resolves but can't be used as an offload target because
15
- * it authenticates with a password. The offload path runs over `sshExec`, whose
16
- * `SSH_OPTS` force `BatchMode=yes` (no password prompts), so only key / ssh-config
17
- * auth can carry a `--host` run. Named so the top-level catch prints the message
18
- * cleanly instead of a stack trace.
19
- */
20
- export declare class DeviceOffloadUnsupportedError extends Error {
21
- constructor(name: string);
22
- }
23
16
  /**
24
17
  * Resolve a host name to a single host, or null if unknown. Resolution order:
25
- * 1. host providers — the `agents hosts` registry (agents.yaml overlay + ssh-config)
26
- * 2. the devices registry (`agents devices`) a machine registered once with
27
- * `agents devices sync` is reachable by `--host`/`--device` with no second enroll
28
- * 3. an ad-hoc `user@host` (must contain `@`, validated) — nothing to register
18
+ * 1. host providers, in registration order — the `agents hosts` registry
19
+ * (`local`: agents.yaml overlay + ssh-config), then the devices registry
20
+ * (`devices`: a machine registered once with `agents devices sync` is
21
+ * reachable by `--host`/`--device` with no second enroll)
22
+ * 2. an ad-hoc `user@host` (must contain `@`, validated) — nothing to register
29
23
  *
30
24
  * A bare unknown name returns null so capability-tag routing (`resolveHostByCap`)
31
25
  * stays reachable: `--host gpu` must fall through to a cap lookup, not be misread