@phnx-labs/agents-cli 1.20.63 → 1.20.65

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 (197) hide show
  1. package/CHANGELOG.md +56 -3
  2. package/README.md +46 -2
  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.js +149 -77
  10. package/dist/commands/feed.d.ts +4 -0
  11. package/dist/commands/feed.js +52 -19
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/lease.d.ts +23 -0
  14. package/dist/commands/lease.js +201 -0
  15. package/dist/commands/mailboxes.d.ts +20 -0
  16. package/dist/commands/mailboxes.js +390 -0
  17. package/dist/commands/mcp.js +55 -5
  18. package/dist/commands/monitors.d.ts +12 -0
  19. package/dist/commands/monitors.js +740 -0
  20. package/dist/commands/output.js +2 -2
  21. package/dist/commands/routines.js +43 -16
  22. package/dist/commands/secrets.d.ts +16 -0
  23. package/dist/commands/secrets.js +215 -64
  24. package/dist/commands/serve.js +31 -0
  25. package/dist/commands/sessions-export.d.ts +2 -0
  26. package/dist/commands/sessions-export.js +284 -0
  27. package/dist/commands/sessions-import.d.ts +2 -0
  28. package/dist/commands/sessions-import.js +230 -0
  29. package/dist/commands/sessions.d.ts +16 -0
  30. package/dist/commands/sessions.js +40 -6
  31. package/dist/commands/ssh.js +143 -5
  32. package/dist/commands/usage.d.ts +2 -0
  33. package/dist/commands/usage.js +7 -2
  34. package/dist/commands/versions.js +7 -3
  35. package/dist/commands/view.d.ts +27 -1
  36. package/dist/commands/view.js +32 -9
  37. package/dist/commands/webhook.js +10 -2
  38. package/dist/index.js +35 -14
  39. package/dist/lib/agents.d.ts +36 -0
  40. package/dist/lib/agents.js +80 -20
  41. package/dist/lib/auto-dispatch-provider.js +7 -2
  42. package/dist/lib/auto-dispatch.d.ts +3 -0
  43. package/dist/lib/auto-dispatch.js +3 -0
  44. package/dist/lib/browser/chrome.js +2 -2
  45. package/dist/lib/browser/drivers/ssh.js +19 -2
  46. package/dist/lib/cloud/antigravity.js +2 -2
  47. package/dist/lib/cloud/host.d.ts +59 -0
  48. package/dist/lib/cloud/host.js +224 -0
  49. package/dist/lib/cloud/registry.js +4 -0
  50. package/dist/lib/cloud/types.d.ts +6 -4
  51. package/dist/lib/comms-render.d.ts +37 -0
  52. package/dist/lib/comms-render.js +89 -0
  53. package/dist/lib/computer-rpc.js +3 -1
  54. package/dist/lib/crabbox/cli.d.ts +72 -0
  55. package/dist/lib/crabbox/cli.js +162 -9
  56. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  57. package/dist/lib/crabbox/runtimes.js +35 -2
  58. package/dist/lib/daemon.d.ts +20 -4
  59. package/dist/lib/daemon.js +68 -20
  60. package/dist/lib/devices/fleet.d.ts +3 -2
  61. package/dist/lib/devices/fleet.js +9 -0
  62. package/dist/lib/devices/health.d.ts +77 -0
  63. package/dist/lib/devices/health.js +186 -0
  64. package/dist/lib/devices/registry.d.ts +15 -0
  65. package/dist/lib/devices/registry.js +9 -0
  66. package/dist/lib/exec.d.ts +19 -2
  67. package/dist/lib/exec.js +41 -13
  68. package/dist/lib/fleet/apply.d.ts +63 -0
  69. package/dist/lib/fleet/apply.js +214 -0
  70. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  71. package/dist/lib/fleet/auth-sync.js +142 -0
  72. package/dist/lib/fleet/manifest.d.ts +29 -0
  73. package/dist/lib/fleet/manifest.js +127 -0
  74. package/dist/lib/fleet/types.d.ts +129 -0
  75. package/dist/lib/fleet/types.js +13 -0
  76. package/dist/lib/git.d.ts +27 -0
  77. package/dist/lib/git.js +34 -2
  78. package/dist/lib/hosts/dispatch.d.ts +29 -8
  79. package/dist/lib/hosts/dispatch.js +46 -18
  80. package/dist/lib/hosts/passthrough.js +2 -0
  81. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  82. package/dist/lib/hosts/providers/devices.js +98 -0
  83. package/dist/lib/hosts/registry.d.ts +10 -16
  84. package/dist/lib/hosts/registry.js +17 -50
  85. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  86. package/dist/lib/hosts/remote-cmd.js +71 -0
  87. package/dist/lib/hosts/run-target.d.ts +84 -0
  88. package/dist/lib/hosts/run-target.js +99 -0
  89. package/dist/lib/hosts/types.d.ts +23 -5
  90. package/dist/lib/hosts/types.js +22 -4
  91. package/dist/lib/linear-autoclose.d.ts +30 -0
  92. package/dist/lib/linear-autoclose.js +22 -0
  93. package/dist/lib/mailbox.d.ts +39 -0
  94. package/dist/lib/mailbox.js +112 -0
  95. package/dist/lib/mcp.d.ts +27 -1
  96. package/dist/lib/mcp.js +126 -12
  97. package/dist/lib/monitors/config.d.ts +161 -0
  98. package/dist/lib/monitors/config.js +372 -0
  99. package/dist/lib/monitors/dispatch.d.ts +28 -0
  100. package/dist/lib/monitors/dispatch.js +91 -0
  101. package/dist/lib/monitors/engine.d.ts +61 -0
  102. package/dist/lib/monitors/engine.js +201 -0
  103. package/dist/lib/monitors/sources/command.d.ts +11 -0
  104. package/dist/lib/monitors/sources/command.js +31 -0
  105. package/dist/lib/monitors/sources/device.d.ts +13 -0
  106. package/dist/lib/monitors/sources/device.js +35 -0
  107. package/dist/lib/monitors/sources/file.d.ts +14 -0
  108. package/dist/lib/monitors/sources/file.js +57 -0
  109. package/dist/lib/monitors/sources/http.d.ts +10 -0
  110. package/dist/lib/monitors/sources/http.js +34 -0
  111. package/dist/lib/monitors/sources/index.d.ts +14 -0
  112. package/dist/lib/monitors/sources/index.js +31 -0
  113. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  114. package/dist/lib/monitors/sources/poll.js +9 -0
  115. package/dist/lib/monitors/sources/types.d.ts +18 -0
  116. package/dist/lib/monitors/sources/types.js +9 -0
  117. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  118. package/dist/lib/monitors/sources/webhook.js +47 -0
  119. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  120. package/dist/lib/monitors/sources/ws.js +45 -0
  121. package/dist/lib/monitors/state.d.ts +69 -0
  122. package/dist/lib/monitors/state.js +144 -0
  123. package/dist/lib/paths.d.ts +13 -0
  124. package/dist/lib/paths.js +26 -4
  125. package/dist/lib/platform/exec.d.ts +16 -0
  126. package/dist/lib/platform/exec.js +17 -0
  127. package/dist/lib/plugins.js +101 -2
  128. package/dist/lib/redact.d.ts +14 -1
  129. package/dist/lib/redact.js +47 -1
  130. package/dist/lib/remote-agents-json.js +7 -1
  131. package/dist/lib/rotate.d.ts +6 -3
  132. package/dist/lib/rotate.js +0 -1
  133. package/dist/lib/routines.d.ts +37 -2
  134. package/dist/lib/routines.js +54 -12
  135. package/dist/lib/runner.d.ts +1 -0
  136. package/dist/lib/runner.js +348 -13
  137. package/dist/lib/sandbox.d.ts +9 -1
  138. package/dist/lib/sandbox.js +11 -2
  139. package/dist/lib/secrets/agent.d.ts +48 -10
  140. package/dist/lib/secrets/agent.js +123 -15
  141. package/dist/lib/secrets/bundles.d.ts +26 -0
  142. package/dist/lib/secrets/bundles.js +59 -8
  143. package/dist/lib/secrets/mcp.js +4 -2
  144. package/dist/lib/secrets/remote.d.ts +17 -0
  145. package/dist/lib/secrets/remote.js +40 -0
  146. package/dist/lib/self-update.d.ts +20 -0
  147. package/dist/lib/self-update.js +54 -1
  148. package/dist/lib/serve/control.d.ts +95 -0
  149. package/dist/lib/serve/control.js +260 -0
  150. package/dist/lib/serve/server.d.ts +35 -1
  151. package/dist/lib/serve/server.js +106 -76
  152. package/dist/lib/serve/stream.d.ts +43 -0
  153. package/dist/lib/serve/stream.js +116 -0
  154. package/dist/lib/serve/token.d.ts +35 -0
  155. package/dist/lib/serve/token.js +85 -0
  156. package/dist/lib/session/bundle.d.ts +164 -0
  157. package/dist/lib/session/bundle.js +200 -0
  158. package/dist/lib/session/remote-bundle.d.ts +12 -0
  159. package/dist/lib/session/remote-bundle.js +61 -0
  160. package/dist/lib/session/remote-list.js +5 -1
  161. package/dist/lib/session/state.d.ts +7 -25
  162. package/dist/lib/session/state.js +16 -6
  163. package/dist/lib/session/sync/agents.d.ts +54 -6
  164. package/dist/lib/session/sync/agents.js +0 -0
  165. package/dist/lib/session/sync/config.js +8 -2
  166. package/dist/lib/session/sync/manifest.d.ts +14 -3
  167. package/dist/lib/session/sync/manifest.js +4 -0
  168. package/dist/lib/session/sync/sync.d.ts +23 -2
  169. package/dist/lib/session/sync/sync.js +177 -74
  170. package/dist/lib/session/types.d.ts +30 -0
  171. package/dist/lib/ssh-tunnel.d.ts +19 -1
  172. package/dist/lib/ssh-tunnel.js +99 -8
  173. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  174. package/dist/lib/staleness/detectors/subagents.js +5 -192
  175. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  176. package/dist/lib/staleness/writers/subagents.js +11 -102
  177. package/dist/lib/startup/command-registry.d.ts +4 -0
  178. package/dist/lib/startup/command-registry.js +9 -0
  179. package/dist/lib/state.d.ts +5 -0
  180. package/dist/lib/state.js +12 -0
  181. package/dist/lib/subagents-registry.d.ts +85 -0
  182. package/dist/lib/subagents-registry.js +393 -0
  183. package/dist/lib/subagents.d.ts +8 -8
  184. package/dist/lib/subagents.js +32 -663
  185. package/dist/lib/sync-umbrella.d.ts +1 -0
  186. package/dist/lib/sync-umbrella.js +14 -3
  187. package/dist/lib/tmux/session.d.ts +7 -0
  188. package/dist/lib/tmux/session.js +3 -1
  189. package/dist/lib/triggers/webhook.d.ts +18 -0
  190. package/dist/lib/triggers/webhook.js +105 -0
  191. package/dist/lib/types.d.ts +35 -1
  192. package/dist/lib/usage.d.ts +42 -3
  193. package/dist/lib/usage.js +163 -21
  194. package/dist/lib/versions.js +14 -11
  195. package/dist/lib/workflows.d.ts +20 -0
  196. package/dist/lib/workflows.js +24 -0
  197. package/package.json +2 -1
@@ -127,9 +127,43 @@ export interface AccountInfo {
127
127
  } | null;
128
128
  lastActive: Date | null;
129
129
  signedIn: boolean;
130
+ organizationType?: string | null;
131
+ organizationName?: string | null;
130
132
  }
133
+ /**
134
+ * Human-readable label for a Claude account's organizationType as read from
135
+ * .claude.json's oauthAccount ("claude_team" -> "Team"). Unrecognized values
136
+ * (future tiers) are rendered by stripping the "claude_" prefix and
137
+ * title-casing the rest — an unfamiliar-but-visible label beats silence.
138
+ * Returns null for missing input.
139
+ */
140
+ export declare function formatClaudeOrgLabel(orgType: string | null | undefined): string | null;
141
+ /**
142
+ * Short badge identifying which org an account belongs to: "Turing Labs · Team"
143
+ * for multi-seat orgs, just the tier label ("Max") for personal plans — a
144
+ * personal org's name is auto-generated boilerplate ("<email>'s Organization"),
145
+ * not identity. Returns null when the account carries no organizationType
146
+ * (signed out, non-Claude agents, configs predating the field).
147
+ */
148
+ export declare function accountOrgBadge(info?: Pick<AccountInfo, 'organizationType' | 'organizationName'> | null): string | null;
131
149
  /** Return the email address associated with the agent's auth config, or null. */
132
150
  export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
151
+ /** Decrypted contents of Droid's auth.v2.file (subset we consume). */
152
+ export interface DroidAuthPayload {
153
+ access_token?: string;
154
+ active_organization_id?: string | null;
155
+ }
156
+ /**
157
+ * Factory Droid stores its OAuth credential encrypted at ~/.factory/auth.v2.file
158
+ * (AES-256-GCM, format `ivB64:tagB64:ctB64`) with the 32-byte key base64-stored
159
+ * in ~/.factory/auth.v2.key. On the keyfile-v2 source there is no OS-keychain /
160
+ * device binding — the key is on disk — so we can decrypt locally with no
161
+ * network call. Every failure (missing key file — e.g. a keyring-v2/legacy
162
+ * login with no on-disk key, a bad GCM tag, or malformed JSON) returns null.
163
+ * Never throws. Shared by account identity below and the Droid usage fetcher
164
+ * in usage.ts.
165
+ */
166
+ export declare function decryptDroidAuthPayload(base: string): DroidAuthPayload | null;
133
167
  /**
134
168
  * Antigravity (`agy`) stores its OAuth token via the Go keyring library
135
169
  * (zalando/go-keyring), which is platform-split:
@@ -173,6 +207,8 @@ export declare function resolveLastActive(agentId: AgentId, base: string, config
173
207
  * Used during init to show approximate session count to user.
174
208
  */
175
209
  export declare function countSessionFiles(agentId: AgentId): number;
210
+ /** Decode the payload section of a JWT token without verifying its signature. */
211
+ export declare function decodeJwtPayload(token: string): Record<string, any> | null;
176
212
  /** Register an MCP server with an agent's CLI via `mcp add`. */
177
213
  export declare function registerMcp(agentId: AgentId, name: string, command: string, scope?: 'user' | 'project', transport?: string, options?: {
178
214
  home?: string;
@@ -17,7 +17,7 @@ import * as os from 'os';
17
17
  import * as TOML from 'smol-toml';
18
18
  import * as yaml from 'yaml';
19
19
  import chalk from 'chalk';
20
- import { needsWindowsShell } from './platform/index.js';
20
+ import { execFileShellSpec } from './platform/index.js';
21
21
  import { latestFileMtimeMs } from './fs-walk.js';
22
22
  import { damerauLevenshtein } from './fuzzy.js';
23
23
  import { getCacheDir, getVersionsDir, getShimsDir, getCliVersionCachePath } from './state.js';
@@ -943,6 +943,48 @@ export function ensureSkillsDir(agentId) {
943
943
  export function agentConfigDirName(agentId) {
944
944
  return path.relative(HOME, AGENTS[agentId].configDir);
945
945
  }
946
+ /**
947
+ * Human-readable label for a Claude account's organizationType as read from
948
+ * .claude.json's oauthAccount ("claude_team" -> "Team"). Unrecognized values
949
+ * (future tiers) are rendered by stripping the "claude_" prefix and
950
+ * title-casing the rest — an unfamiliar-but-visible label beats silence.
951
+ * Returns null for missing input.
952
+ */
953
+ export function formatClaudeOrgLabel(orgType) {
954
+ if (!orgType)
955
+ return null;
956
+ const known = {
957
+ claude_max: 'Max',
958
+ claude_pro: 'Pro',
959
+ claude_team: 'Team',
960
+ claude_enterprise: 'Enterprise',
961
+ claude_free: 'Free',
962
+ };
963
+ if (known[orgType])
964
+ return known[orgType];
965
+ return orgType
966
+ .replace(/^claude_/, '')
967
+ .split('_')
968
+ .filter(Boolean)
969
+ .map((w) => w[0].toUpperCase() + w.slice(1))
970
+ .join(' ');
971
+ }
972
+ /**
973
+ * Short badge identifying which org an account belongs to: "Turing Labs · Team"
974
+ * for multi-seat orgs, just the tier label ("Max") for personal plans — a
975
+ * personal org's name is auto-generated boilerplate ("<email>'s Organization"),
976
+ * not identity. Returns null when the account carries no organizationType
977
+ * (signed out, non-Claude agents, configs predating the field).
978
+ */
979
+ export function accountOrgBadge(info) {
980
+ const label = formatClaudeOrgLabel(info?.organizationType);
981
+ if (!label)
982
+ return null;
983
+ const isMultiSeat = info?.organizationType === 'claude_team' || info?.organizationType === 'claude_enterprise';
984
+ if (isMultiSeat && info?.organizationName)
985
+ return `${info.organizationName} · ${label}`;
986
+ return label;
987
+ }
946
988
  /** Return the email address associated with the agent's auth config, or null. */
947
989
  export async function getAccountEmail(agentId, home) {
948
990
  const info = await getAccountInfo(agentId, home);
@@ -984,15 +1026,12 @@ function resolveAccountCredentialPath(base, ...segments) {
984
1026
  * (AES-256-GCM, format `ivB64:tagB64:ctB64`) with the 32-byte key base64-stored
985
1027
  * in ~/.factory/auth.v2.key. On the keyfile-v2 source there is no OS-keychain /
986
1028
  * device binding — the key is on disk — so we can decrypt locally with no
987
- * network call. The decrypted JSON credential's `access_token` is a WorkOS JWT
988
- * carrying an `email` claim (plus org_id / role). We decode the claim WITHOUT
989
- * verifying `exp`: the email is stable identity for display, not an
990
- * authorization decision, so an expired token still yields the right address.
991
- * Every failure (missing key file — e.g. a keyring-v2/legacy login with no
992
- * on-disk key, a bad GCM tag, malformed JSON, or no email claim) returns null so
993
- * the caller falls back to the file-presence signed-in signal. Never throws.
1029
+ * network call. Every failure (missing key file e.g. a keyring-v2/legacy
1030
+ * login with no on-disk key, a bad GCM tag, or malformed JSON) returns null.
1031
+ * Never throws. Shared by account identity below and the Droid usage fetcher
1032
+ * in usage.ts.
994
1033
  */
995
- function decryptDroidCredential(base) {
1034
+ export function decryptDroidAuthPayload(base) {
996
1035
  const filePath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.file');
997
1036
  const keyPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.key');
998
1037
  if (!filePath || !keyPath)
@@ -1012,19 +1051,32 @@ function decryptDroidCredential(base) {
1012
1051
  decipher.final(),
1013
1052
  ]).toString('utf-8');
1014
1053
  const cred = JSON.parse(plaintext);
1015
- const claims = typeof cred?.access_token === 'string' ? decodeJwtPayload(cred.access_token) : null;
1016
- if (!claims)
1017
- return null;
1018
- return {
1019
- email: typeof claims.email === 'string' ? claims.email : null,
1020
- orgId: normalizeIdentityPart(claims.org_id ?? cred.active_organization_id),
1021
- role: typeof claims.role === 'string' ? claims.role : null,
1022
- };
1054
+ return cred && typeof cred === 'object' ? cred : null;
1023
1055
  }
1024
1056
  catch {
1025
1057
  return null;
1026
1058
  }
1027
1059
  }
1060
+ /**
1061
+ * Derive Droid account identity from the decrypted credential. The
1062
+ * `access_token` is a WorkOS JWT carrying an `email` claim (plus org_id /
1063
+ * role). We decode the claim WITHOUT verifying `exp`: the email is stable
1064
+ * identity for display, not an authorization decision, so an expired token
1065
+ * still yields the right address. Returns null when the credential can't be
1066
+ * decrypted or has no decodable claims, so the caller falls back to the
1067
+ * file-presence signed-in signal.
1068
+ */
1069
+ function decryptDroidCredential(base) {
1070
+ const cred = decryptDroidAuthPayload(base);
1071
+ const claims = typeof cred?.access_token === 'string' ? decodeJwtPayload(cred.access_token) : null;
1072
+ if (!claims)
1073
+ return null;
1074
+ return {
1075
+ email: typeof claims.email === 'string' ? claims.email : null,
1076
+ orgId: normalizeIdentityPart(claims.org_id ?? cred?.active_organization_id),
1077
+ role: typeof claims.role === 'string' ? claims.role : null,
1078
+ };
1079
+ }
1028
1080
  let cachedAgyKeychainSignedIn;
1029
1081
  /**
1030
1082
  * Antigravity (`agy`) stores its OAuth token via the Go keyring library
@@ -1238,6 +1290,8 @@ export async function getAccountInfo(agentId, home) {
1238
1290
  overageCredits,
1239
1291
  lastActive,
1240
1292
  signedIn: !!email,
1293
+ organizationType: oa?.organizationType ?? null,
1294
+ organizationName: oa?.organizationName ?? null,
1241
1295
  };
1242
1296
  }
1243
1297
  case 'codex': {
@@ -1564,7 +1618,7 @@ export function countSessionFiles(agentId) {
1564
1618
  return count;
1565
1619
  }
1566
1620
  /** Decode the payload section of a JWT token without verifying its signature. */
1567
- function decodeJwtPayload(token) {
1621
+ export function decodeJwtPayload(token) {
1568
1622
  const payload = token.split('.')[1];
1569
1623
  if (!payload)
1570
1624
  return null;
@@ -1649,7 +1703,10 @@ export async function registerMcp(agentId, name, command, scope = 'user', transp
1649
1703
  // On Windows a bare command name / `.cmd` wrapper (the npm-installed agent
1650
1704
  // CLI) can't be exec'd directly — it needs shell:true for PATHEXT/cmd. Off
1651
1705
  // Windows this is always false, so the no-shell argv path is unchanged.
1652
- await execFileAsync(bin, args, { ...(env ? { env } : {}), shell: needsWindowsShell(bin) });
1706
+ // RUSH-1752: when shell is needed, compose a fully-quoted command line and
1707
+ // pass EMPTY argv so user-controlled MCP command/args never reach cmd.exe unescaped.
1708
+ const spec = execFileShellSpec(bin, args);
1709
+ await execFileAsync(spec.command, spec.args, { ...(env ? { env } : {}), shell: spec.shell });
1653
1710
  return { success: true };
1654
1711
  }
1655
1712
  catch (err) {
@@ -1677,7 +1734,10 @@ export async function unregisterMcp(agentId, name, options) {
1677
1734
  try {
1678
1735
  const bin = options?.binary || agent.cliCommand;
1679
1736
  const env = options?.home ? { ...process.env, HOME: options.home } : undefined;
1680
- await execFileAsync(bin, ['mcp', 'remove', name], { ...(env ? { env } : {}), shell: needsWindowsShell(bin) });
1737
+ // RUSH-1752: same shell-safe path as registerMcp attacker-controlled MCP
1738
+ // `name` must not reach cmd.exe unescaped when shell:true is required.
1739
+ const spec = execFileShellSpec(bin, ['mcp', 'remove', name]);
1740
+ await execFileAsync(spec.command, spec.args, { ...(env ? { env } : {}), shell: spec.shell });
1681
1741
  return { success: true };
1682
1742
  }
1683
1743
  catch (err) {
@@ -9,7 +9,7 @@
9
9
  import { resolveProvider } from './cloud/registry.js';
10
10
  export function createProviderDispatcher() {
11
11
  return {
12
- async dispatch({ agent, prompt, repo, provider }) {
12
+ async dispatch({ agent, prompt, repo, provider, host }) {
13
13
  const p = resolveProvider(provider, agent);
14
14
  const caps = p.capabilities();
15
15
  if (!caps.available) {
@@ -18,7 +18,12 @@ export function createProviderDispatcher() {
18
18
  if (!caps.dispatch) {
19
19
  throw new Error(`cloud provider '${p.id}' does not support dispatch`);
20
20
  }
21
- const task = await p.dispatch({ agent, prompt, repo });
21
+ // The host provider clones nothing a repo target is meaningless there
22
+ // and its dispatch() rejects it; a ticket project pinned to 'host' relies
23
+ // on the machine's own checkout (the project's `host` names the machine).
24
+ const task = p.id === 'host'
25
+ ? await p.dispatch({ agent, prompt, providerOptions: { host } })
26
+ : await p.dispatch({ agent, prompt, repo });
22
27
  return { id: task.id };
23
28
  },
24
29
  };
@@ -22,6 +22,7 @@ export interface AutoDispatchProject {
22
22
  autoDispatch?: boolean;
23
23
  maxAgents?: number;
24
24
  provider?: string;
25
+ host?: string;
25
26
  }
26
27
  /** A Linear issue that is a candidate for dispatch. */
27
28
  export interface DelegatedIssue {
@@ -36,6 +37,7 @@ export interface PlannedDispatch {
36
37
  projectId: string;
37
38
  repoSlug?: string;
38
39
  provider?: string;
40
+ host?: string;
39
41
  issueId: string;
40
42
  identifier: string;
41
43
  title: string;
@@ -73,6 +75,7 @@ export interface Dispatcher {
73
75
  prompt: string;
74
76
  repo?: string;
75
77
  provider?: string;
78
+ host?: string;
76
79
  }): Promise<{
77
80
  id: string;
78
81
  }>;
@@ -46,6 +46,7 @@ export function readAutoDispatchProjects() {
46
46
  autoDispatch: o.autoDispatch === true,
47
47
  maxAgents: typeof o.maxAgents === 'number' ? o.maxAgents : undefined,
48
48
  provider: typeof o.provider === 'string' ? o.provider : undefined,
49
+ host: typeof o.host === 'string' ? o.host : undefined,
49
50
  });
50
51
  }
51
52
  return out;
@@ -76,6 +77,7 @@ export function planAutoDispatch(projects, inFlightByProject, delegatedTodoByPro
76
77
  projectId: p.id,
77
78
  repoSlug: p.repoSlug,
78
79
  provider: p.provider,
80
+ host: p.host,
79
81
  issueId: issue.id,
80
82
  identifier: issue.identifier,
81
83
  title: issue.title,
@@ -122,6 +124,7 @@ export async function autoDispatchTick(deps) {
122
124
  prompt: dispatchPrompt(d.identifier, d.title),
123
125
  repo: d.repoSlug,
124
126
  provider: d.provider,
127
+ host: d.host,
125
128
  });
126
129
  // Bookkeeping only — dispatch already happened; moving to Doing keeps the
127
130
  // ticket out of the next Todo poll. A failure here is non-fatal.
@@ -4,7 +4,7 @@ import * as path from 'path';
4
4
  import * as os from 'os';
5
5
  import { getProfileRuntimeDir } from './profiles.js';
6
6
  import { discoverBrowserWsUrl, registerPipeTransport } from './cdp.js';
7
- import { readAndResolveBundleEnv, bundleExists } from '../secrets/bundles.js';
7
+ import { readAndResolveBundleEnv, bundleExists, isHeadlessSecretsContext } from '../secrets/bundles.js';
8
8
  import { writeProfileRuntime, readProfileRuntime } from './runtime-state.js';
9
9
  // Windows install roots. Resolve from the environment (fall back to the usual
10
10
  // defaults) so per-user installs under %LOCALAPPDATA% and 64-bit Program Files
@@ -260,7 +260,7 @@ isElectron = false) {
260
260
  let env = { ...process.env };
261
261
  if (secrets && bundleExists(secrets)) {
262
262
  try {
263
- const { env: bundleEnv } = readAndResolveBundleEnv(secrets, { caller: 'browser profile' });
263
+ const { env: bundleEnv } = readAndResolveBundleEnv(secrets, { caller: 'browser profile', agentOnly: isHeadlessSecretsContext() });
264
264
  env = { ...env, ...bundleEnv };
265
265
  }
266
266
  catch {
@@ -9,7 +9,7 @@ import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
9
9
  // is the shared hardened baseline (BatchMode + ConnectTimeout + keepalive) —
10
10
  // reuse it so the raw-ssh spawns below fail fast on an unreachable host instead
11
11
  // of hanging on the default ~127s TCP timeout, rather than re-listing options.
12
- import { shellQuote, SSH_OPTS } from '../../ssh-exec.js';
12
+ import { shellQuote, SSH_OPTS, assertValidSshTarget } from '../../ssh-exec.js';
13
13
  export { shellQuote };
14
14
  // The `ssh -L` tunnel spawn is shared with `agents computer --host`; it lives in
15
15
  // the single ssh-tunnel helper. Calling it with no options preserves this
@@ -292,6 +292,12 @@ export function buildKillCmd(remoteOs, port) {
292
292
  return `pids=$(lsof -ti ${shellQuote(`:${port}`)} 2>/dev/null); [ -z "$pids" ] || kill -9 $pids 2>/dev/null || true`;
293
293
  }
294
294
  export async function ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
295
+ // `user`/`host` derive from an ssh:// profile endpoint (git-tracked user
296
+ // config). Validate the composed target before it reaches the raw `ssh` spawn:
297
+ // an endpoint like `ssh://-Fattack@victim` would otherwise place `-Fattack` at
298
+ // the target position, where OpenSSH parses it as `-F <file>` and an
299
+ // attacker-supplied ssh config's ProxyCommand yields local code execution.
300
+ assertValidSshTarget(`${user}@${host}`);
295
301
  const remoteCmd = buildLaunchCmd(remoteOs, browserType, port, customBinary);
296
302
  return new Promise((resolve, reject) => {
297
303
  // Capture stderr so a real launch failure (missing exe, auth denied, bad
@@ -362,7 +368,18 @@ export function killRemoteBrowser(user, host, remoteOs, port) {
362
368
  return runSSHCommand(user, host, buildKillCmd(remoteOs, port));
363
369
  }
364
370
  function runSSHCommand(user, host, cmd) {
365
- return new Promise((resolve) => {
371
+ return new Promise((resolve, reject) => {
372
+ // Reject an option-injecting target (e.g. a `-`-leading user from a crafted
373
+ // ssh:// profile) before it reaches the raw `ssh` spawn. Reject rather than
374
+ // throw synchronously so `killRemoteBrowser(...).catch()` stays best-effort.
375
+ // See ensureRemoteBrowser.
376
+ try {
377
+ assertValidSshTarget(`${user}@${host}`);
378
+ }
379
+ catch (err) {
380
+ reject(err);
381
+ return;
382
+ }
366
383
  // Reuse the shared hardened baseline so a hung TCP SYN to an unreachable
367
384
  // host is bounded by ConnectTimeout (the local 3s kill below only bounds a
368
385
  // connected-but-slow command, not the connect itself). Options precede the
@@ -20,7 +20,7 @@
20
20
  * back to GEMINI_API_KEY / GOOGLE_API_KEY in the environment.
21
21
  */
22
22
  import { resolveDispatchRepos, normalizeProviderStatus } from './types.js';
23
- import { readAndResolveBundleEnv } from '../secrets/bundles.js';
23
+ import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../secrets/bundles.js';
24
24
  const INTERACTIONS_URL = 'https://generativelanguage.googleapis.com/v1beta/interactions';
25
25
  const DEFAULT_MODEL = 'antigravity-preview-05-2026';
26
26
  const KEY_NAMES = ['GEMINI_API_KEY', 'GOOGLE_API_KEY'];
@@ -65,7 +65,7 @@ export class AntigravityCloudProvider {
65
65
  resolveApiKey() {
66
66
  if (this.secretsBundle) {
67
67
  try {
68
- const { env } = readAndResolveBundleEnv(this.secretsBundle, { caller: 'cloud:antigravity' });
68
+ const { env } = readAndResolveBundleEnv(this.secretsBundle, { caller: 'cloud:antigravity', agentOnly: isHeadlessSecretsContext() });
69
69
  for (const k of KEY_NAMES) {
70
70
  if (env[k])
71
71
  return env[k];
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Host cloud provider — your own machines as a task-execution backend.
3
+ *
4
+ * A thin adapter over the hosts subsystem (lib/hosts/*): `agents cloud run
5
+ * --provider host --host <name>` dispatches through the SAME detached-SSH
6
+ * launch as `agents run --host`, and the resulting task shows up in BOTH
7
+ * `agents cloud ps` and `agents hosts ps` — one store (the host-task sidecars
8
+ * under ~/.agents/.cache/hosts/), two views. No new transport, no relay: SSH
9
+ * is the only wire, exactly like the rest of the hosts design (docs/hosts.md).
10
+ *
11
+ * Status semantics follow reconcile.ts's prime rule: completion is only ever
12
+ * CONFIRMED from the remote `.exit` file — an unreachable host leaves a task
13
+ * `running`, never guessed as failed. The cloud SQLite store row is a cached
14
+ * index (the `cloud list` refresh loop upserts what `status()` returns); the
15
+ * sidecar stays the source of truth. Reachability is memoized per target for
16
+ * the life of the process so a down host costs ONE short timeout, not one per
17
+ * task ("care around the cloud status-refresh path", lib/hosts/tasks.ts).
18
+ */
19
+ import type { CloudEvent, CloudProvider, CloudTarget, CloudTask, CloudTaskStatus, DispatchOptions, ProviderCapabilities } from './types.js';
20
+ import type { HostTask } from '../hosts/tasks.js';
21
+ /** Project a host-task sidecar into the cloud task shape. */
22
+ export declare function hostTaskToCloudTask(task: HostTask): CloudTask;
23
+ export declare class HostCloudProvider implements CloudProvider {
24
+ readonly id: "host";
25
+ readonly name = "Host (your machines)";
26
+ readonly targetKind: "host";
27
+ /**
28
+ * Reachability memo, per target, for the life of this process. The cloud
29
+ * refresh loop calls `status()` once per active task; without the memo a
30
+ * down host would cost one ~6s ssh timeout PER task instead of one total.
31
+ * CLI processes are short-lived, so staleness is bounded by the invocation.
32
+ */
33
+ private reachable;
34
+ capabilities(): ProviderCapabilities;
35
+ dispatch(options: DispatchOptions): Promise<CloudTask>;
36
+ status(taskId: string): Promise<CloudTask>;
37
+ list(filter?: {
38
+ status?: CloudTaskStatus;
39
+ }): Promise<CloudTask[]>;
40
+ /**
41
+ * `reconcileTask` with the per-process reachability memo folded in: probe a
42
+ * target at most once per process; a down host leaves its tasks `running`.
43
+ */
44
+ private reconcileMemoized;
45
+ /**
46
+ * Offset-tail the remote log (the same one-round-trip fetch the `run --host`
47
+ * follow uses) and yield it as `text` events until the `.exit` file lands.
48
+ */
49
+ stream(taskId: string): AsyncIterable<CloudEvent>;
50
+ cancel(taskId: string): Promise<void>;
51
+ /**
52
+ * Follow-up message = resume the run's session on the same host. Only runs
53
+ * that captured a session id (Claude — the one agent that takes
54
+ * `--session-id`) can be resumed; others get an actionable refusal.
55
+ */
56
+ message(taskId: string, content: string): Promise<void>;
57
+ /** The unified host pool (enrolled hosts ∪ devices), dispatchable ones only. */
58
+ listTargets(): Promise<CloudTarget[]>;
59
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Host cloud provider — your own machines as a task-execution backend.
3
+ *
4
+ * A thin adapter over the hosts subsystem (lib/hosts/*): `agents cloud run
5
+ * --provider host --host <name>` dispatches through the SAME detached-SSH
6
+ * launch as `agents run --host`, and the resulting task shows up in BOTH
7
+ * `agents cloud ps` and `agents hosts ps` — one store (the host-task sidecars
8
+ * under ~/.agents/.cache/hosts/), two views. No new transport, no relay: SSH
9
+ * is the only wire, exactly like the rest of the hosts design (docs/hosts.md).
10
+ *
11
+ * Status semantics follow reconcile.ts's prime rule: completion is only ever
12
+ * CONFIRMED from the remote `.exit` file — an unreachable host leaves a task
13
+ * `running`, never guessed as failed. The cloud SQLite store row is a cached
14
+ * index (the `cloud list` refresh loop upserts what `status()` returns); the
15
+ * sidecar stays the source of truth. Reachability is memoized per target for
16
+ * the life of the process so a down host costs ONE short timeout, not one per
17
+ * task ("care around the cloud status-refresh path", lib/hosts/tasks.ts).
18
+ */
19
+ import { MissingTargetError, resolveDispatchRepos } from './types.js';
20
+ import { listTasks, loadTask, terminalPatch, updateTask } from '../hosts/tasks.js';
21
+ import { readRemoteExit } from '../hosts/reconcile.js';
22
+ import { sshReachable } from '../ssh-exec.js';
23
+ import { fetchProgress } from '../hosts/progress.js';
24
+ import { dispatchPromptToHost, resolveHostRunTarget } from '../hosts/run-target.js';
25
+ import { listAllHosts } from '../hosts/registry.js';
26
+ import { terminateDispatchedTask } from '../hosts/dispatch.js';
27
+ /** Host-task lifecycle → canonical cloud enum. `unknown` stays `running`
28
+ * (completion is confirmed, never guessed — reconcile.ts's rule). */
29
+ function toCloudStatus(status) {
30
+ switch (status) {
31
+ case 'completed': return 'completed';
32
+ case 'failed': return 'failed';
33
+ case 'running':
34
+ case 'unknown':
35
+ default:
36
+ return 'running';
37
+ }
38
+ }
39
+ /** Project a host-task sidecar into the cloud task shape. */
40
+ export function hostTaskToCloudTask(task) {
41
+ return {
42
+ id: task.id,
43
+ provider: 'host',
44
+ status: toCloudStatus(task.status),
45
+ agent: task.agent,
46
+ prompt: task.prompt,
47
+ createdAt: task.createdAt,
48
+ updatedAt: task.finishedAt ?? task.createdAt,
49
+ summary: `on ${task.host}${task.name ? ` as "${task.name}"` : ''}`,
50
+ };
51
+ }
52
+ export class HostCloudProvider {
53
+ id = 'host';
54
+ name = 'Host (your machines)';
55
+ targetKind = 'host';
56
+ /**
57
+ * Reachability memo, per target, for the life of this process. The cloud
58
+ * refresh loop calls `status()` once per active task; without the memo a
59
+ * down host would cost one ~6s ssh timeout PER task instead of one total.
60
+ * CLI processes are short-lived, so staleness is bounded by the invocation.
61
+ */
62
+ reachable = new Map();
63
+ capabilities() {
64
+ return {
65
+ available: true, // ssh is the only dependency; per-host reachability is probed at dispatch
66
+ dispatch: true,
67
+ status: true,
68
+ list: true,
69
+ stream: true,
70
+ cancel: true,
71
+ message: true, // gated per task: needs the sessionId only Claude runs carry
72
+ multiRepo: false,
73
+ skills: false,
74
+ images: false,
75
+ };
76
+ }
77
+ async dispatch(options) {
78
+ const hostName = options.providerOptions?.host;
79
+ if (!hostName) {
80
+ throw new MissingTargetError('host', 'No host given. Pass --host <name> (a registered host, a device, a capability tag, or user@host).', 'List your machines: agents hosts list · register more: agents devices sync / agents hosts add');
81
+ }
82
+ if (resolveDispatchRepos(options).length > 0) {
83
+ throw new Error('--repo has no meaning for --provider host — the run executes in a directory on the machine, not a cloned repo. ' +
84
+ 'Pass the working directory via providerOptions.remoteCwd (CLI: --remote-cwd) instead.');
85
+ }
86
+ if (options.branch) {
87
+ throw new Error('--branch has no meaning for --provider host (no clone step). Check out the branch on the host, or use --remote-cwd.');
88
+ }
89
+ // DeviceOffloadUnsupportedError / HostResolutionError propagate — both carry
90
+ // actionable messages the CLI prints verbatim.
91
+ const host = await resolveHostRunTarget(hostName, {
92
+ any: options.providerOptions?.any === true,
93
+ });
94
+ const { task } = await dispatchPromptToHost(host, {
95
+ agent: options.agent ?? 'claude',
96
+ prompt: options.prompt,
97
+ mode: options.providerOptions?.mode,
98
+ model: options.model,
99
+ timeout: options.timeout,
100
+ remoteCwd: options.providerOptions?.remoteCwd,
101
+ name: options.providerOptions?.name,
102
+ follow: false, // the cloud pipeline streams via stream(); never block dispatch
103
+ });
104
+ return hostTaskToCloudTask(task);
105
+ }
106
+ async status(taskId) {
107
+ const task = loadTask(taskId);
108
+ if (!task)
109
+ throw new Error(`Unknown host task: ${taskId}`);
110
+ return hostTaskToCloudTask(this.reconcileMemoized(task));
111
+ }
112
+ async list(filter) {
113
+ const tasks = listTasks().map((t) => hostTaskToCloudTask(this.reconcileMemoized(t)));
114
+ return filter?.status ? tasks.filter((t) => t.status === filter.status) : tasks;
115
+ }
116
+ /**
117
+ * `reconcileTask` with the per-process reachability memo folded in: probe a
118
+ * target at most once per process; a down host leaves its tasks `running`.
119
+ */
120
+ reconcileMemoized(task) {
121
+ if (task.status !== 'running')
122
+ return task;
123
+ if (!this.reachable.has(task.target)) {
124
+ this.reachable.set(task.target, sshReachable(task.target, 6000));
125
+ }
126
+ if (!this.reachable.get(task.target))
127
+ return task;
128
+ const st = readRemoteExit(task.target, task.remoteExit);
129
+ if (st.state !== 'done')
130
+ return task;
131
+ return updateTask(task.id, terminalPatch(st.code)) ?? task;
132
+ }
133
+ /**
134
+ * Offset-tail the remote log (the same one-round-trip fetch the `run --host`
135
+ * follow uses) and yield it as `text` events until the `.exit` file lands.
136
+ */
137
+ async *stream(taskId) {
138
+ const task = loadTask(taskId);
139
+ if (!task)
140
+ throw new Error(`Unknown host task: ${taskId}`);
141
+ if (task.status !== 'running') {
142
+ yield { type: 'status', status: toCloudStatus(task.status) };
143
+ yield { type: 'done', status: toCloudStatus(task.status) };
144
+ return;
145
+ }
146
+ let offset = 0;
147
+ const fastPollMs = 1500;
148
+ const maxPollMs = 6000;
149
+ let pollMs = fastPollMs;
150
+ for (;;) {
151
+ const fetched = fetchProgress(task.target, {
152
+ remoteLog: task.remoteLog,
153
+ remoteExit: task.remoteExit,
154
+ taskId: task.id,
155
+ offset,
156
+ });
157
+ if (fetched) {
158
+ if (fetched.logChunk.length > 0) {
159
+ offset += fetched.logChunk.length;
160
+ pollMs = fastPollMs; // output is flowing — snap back to the fast poll
161
+ yield { type: 'text', content: fetched.logChunk.toString('utf8') };
162
+ }
163
+ else {
164
+ pollMs = Math.min(Math.round(pollMs * 1.5), maxPollMs); // idle backoff
165
+ }
166
+ const exit = fetched.exit.trim();
167
+ if (exit !== '') {
168
+ const code = Number.parseInt(exit, 10);
169
+ const finished = updateTask(task.id, terminalPatch(Number.isFinite(code) ? code : 0));
170
+ const status = toCloudStatus(finished?.status ?? (code === 0 ? 'completed' : 'failed'));
171
+ yield { type: 'done', status };
172
+ return;
173
+ }
174
+ }
175
+ await new Promise((r) => setTimeout(r, pollMs));
176
+ }
177
+ }
178
+ async cancel(taskId) {
179
+ const task = loadTask(taskId);
180
+ if (!task)
181
+ throw new Error(`Unknown host task: ${taskId}`);
182
+ terminateDispatchedTask(task);
183
+ }
184
+ /**
185
+ * Follow-up message = resume the run's session on the same host. Only runs
186
+ * that captured a session id (Claude — the one agent that takes
187
+ * `--session-id`) can be resumed; others get an actionable refusal.
188
+ */
189
+ async message(taskId, content) {
190
+ const task = loadTask(taskId);
191
+ if (!task)
192
+ throw new Error(`Unknown host task: ${taskId}`);
193
+ if (!task.sessionId) {
194
+ throw new Error(`Host task ${taskId} has no session id to resume (only Claude runs capture one). ` +
195
+ `Start a follow-up run instead: agents run ${task.agent} "<prompt>" --host ${task.host}`);
196
+ }
197
+ const host = await resolveHostRunTarget(task.host);
198
+ await dispatchPromptToHost(host, {
199
+ agent: task.agent,
200
+ prompt: content,
201
+ resume: task.sessionId,
202
+ name: task.name,
203
+ follow: false,
204
+ });
205
+ }
206
+ /** The unified host pool (enrolled hosts ∪ devices), dispatchable ones only. */
207
+ async listTargets() {
208
+ const hosts = await listAllHosts();
209
+ return hosts
210
+ .filter((h) => h.dispatchable !== false)
211
+ .map((h) => ({
212
+ id: h.name,
213
+ label: [
214
+ h.provider === 'devices' ? 'device' : h.source,
215
+ h.os,
216
+ h.status && h.status !== 'unknown' ? h.status : undefined,
217
+ h.caps?.length ? `caps: ${h.caps.join(',')}` : undefined,
218
+ ]
219
+ .filter(Boolean)
220
+ .join(' · '),
221
+ kind: 'host',
222
+ }));
223
+ }
224
+ }