@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
package/dist/lib/exec.js CHANGED
@@ -147,12 +147,13 @@ export function resolveInteractive(options) {
147
147
  * @param piped true when the parent's stdout is NOT a TTY (output piped)
148
148
  * @param capsActive true when a budget watcher is attached (caps configured)
149
149
  */
150
- export function shouldTapStdout(interactive, piped, capsActive) {
150
+ export function shouldTapStdout(interactive, piped, capsActive, captureTail = false) {
151
151
  if (interactive)
152
152
  return false;
153
153
  // Always pipe when the caller pipes us downstream (preserve composability),
154
- // OR when caps are active so the watcher can read the stream at a TTY.
155
- return piped || capsActive;
154
+ // when caps are active so the watcher can read the stream at a TTY, or when
155
+ // a fallback chain needs a stdout tail for rate-limit detection.
156
+ return piped || capsActive || captureTail;
156
157
  }
157
158
  /** Pattern for valid environment variable names (C identifier rules). */
158
159
  const EXEC_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -866,11 +867,17 @@ export function shouldWrapInTmux(ctx) {
866
867
  * `#{pane_pid}`, clean signal delivery on detach/kill).
867
868
  * Keys are filtered to valid identifiers so exported shell functions
868
869
  * (`BASH_FUNC_*%%`) can't make `env` choke.
870
+ *
871
+ * `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
872
+ * the KEY names. The env map here carries resolved secrets bundles (options.env),
873
+ * so the real string would embed secret VALUES — which get persisted verbatim
874
+ * into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
875
+ * real values; the stored/informational copy uses the redacted form (RUSH-1758).
869
876
  */
870
- export function buildTmuxAgentCommand(executable, args, env) {
877
+ export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
871
878
  const envPrefix = Object.entries(env)
872
879
  .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
873
- .map(([k, v]) => `${k}=${shellQuote(String(v))}`)
880
+ .map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
874
881
  .join(' ');
875
882
  const agentCmd = [executable, ...args].map(shellQuote).join(' ');
876
883
  return `exec env ${envPrefix} ${agentCmd}`;
@@ -917,11 +924,16 @@ async function runInTmux(options, executable, args) {
917
924
  const cwd = options.cwd || process.cwd();
918
925
  const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
919
926
  const name = slugifyName(`ag-${options.agent}-${idSeed}`);
920
- const cmd = buildTmuxAgentCommand(executable, args, buildExecEnv(options));
927
+ const execEnv = buildExecEnv(options);
928
+ // Launch with the real env (secret VALUES materialized into the pane); persist
929
+ // only a value-redacted copy in SessionMeta.cmd so resolved secrets never hit
930
+ // disk via the informational cmd field (RUSH-1758).
931
+ const cmd = buildTmuxAgentCommand(executable, args, execEnv);
932
+ const metaCmd = buildTmuxAgentCommand(executable, args, execEnv, { redactEnvValues: true });
921
933
  const labels = { agent: options.agent };
922
934
  if (options.sessionId)
923
935
  labels.sessionId = options.sessionId;
924
- const meta = await createSession({ name, cmd, cwd, socket, source: 'cli', labels });
936
+ const meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
925
937
  const pane = meta.pane;
926
938
  if (pane) {
927
939
  // When the AGENT pane dies, detach the client (don't kill) so the session
@@ -990,7 +1002,7 @@ async function runInTmux(options, executable, args) {
990
1002
  await surfacePaneFailure(before.status, `${options.agent} exited before it could start`);
991
1003
  }
992
1004
  await killSession(name, socket).catch(() => { });
993
- return { exitCode: before.status ?? 0, stderr: '' };
1005
+ return { exitCode: before.status ?? 0, stderr: '', stdout: '' };
994
1006
  }
995
1007
  await attachTmux({ socket, args: ['attach-session', '-t', name] });
996
1008
  const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
@@ -1003,10 +1015,10 @@ async function runInTmux(options, executable, args) {
1003
1015
  await surfacePaneFailure(after.status, `${options.agent} exited`);
1004
1016
  }
1005
1017
  await killSession(name, socket).catch(() => { });
1006
- return { exitCode: after.status ?? 0, stderr: '' };
1018
+ return { exitCode: after.status ?? 0, stderr: '', stdout: '' };
1007
1019
  }
1008
1020
  // Pane still alive → the user detached; keep the session for `agents focus`.
1009
- return { exitCode: 0, stderr: '' };
1021
+ return { exitCode: 0, stderr: '', stdout: '' };
1010
1022
  }
1011
1023
  /**
1012
1024
  * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
@@ -1092,7 +1104,7 @@ async function spawnAgent(options) {
1092
1104
  // PIPE (and later tee) stdout whenever the live budget watcher must read it
1093
1105
  // — for ALL non-interactive runs when caps are active, regardless of TTY.
1094
1106
  // See shouldTapStdout() for the rationale (FIX 3, issue #346).
1095
- const tapStdout = shouldTapStdout(interactive, piped, watcherState !== null);
1107
+ const tapStdout = shouldTapStdout(interactive, piped, watcherState !== null, options.captureStdoutTail);
1096
1108
  const stdio = interactive
1097
1109
  ? ['inherit', 'inherit', 'inherit']
1098
1110
  : ['inherit', tapStdout ? 'pipe' : 'inherit', 'pipe'];
@@ -1127,10 +1139,17 @@ async function spawnAgent(options) {
1127
1139
  timer.mark('startup');
1128
1140
  let budgetKilled = false;
1129
1141
  let budgetKillTimer;
1142
+ let stdoutTail = '';
1143
+ const STDOUT_TAIL_CAP = 16 * 1024;
1130
1144
  if (!interactive && tapStdout && child.stdout) {
1131
1145
  // TEE the child's stdout back to the parent's so the user still sees
1132
1146
  // output (mirrors stdio:'inherit') while we tap the same stream for usage.
1133
1147
  child.stdout.pipe(process.stdout);
1148
+ // Keep a rolling TAIL (billing refusals arrive at the very end of a run)
1149
+ // for the fallback chain's rate-limit scan.
1150
+ child.stdout.on('data', (chunk) => {
1151
+ stdoutTail = (stdoutTail + chunk.toString('utf-8')).slice(-STDOUT_TAIL_CAP);
1152
+ });
1134
1153
  // Tap the same stream for budget usage events without consuming the pipe
1135
1154
  // (a 'data' listener and .pipe() both receive every chunk). Kill on breach.
1136
1155
  if (watcherState) {
@@ -1200,7 +1219,7 @@ async function spawnAgent(options) {
1200
1219
  // teams/cloud can tell a budget termination apart from a normal failure.
1201
1220
  const exitCode = budgetKilled ? BUDGET_KILL_EXIT_CODE : (code ?? 0);
1202
1221
  timer.end({ exitCode, status: budgetKilled ? 'budget_killed' : code === 0 ? 'success' : 'failed' });
1203
- resolve({ exitCode, stderr: stderrBuffer });
1222
+ resolve({ exitCode, stderr: stderrBuffer, stdout: stdoutTail });
1204
1223
  });
1205
1224
  });
1206
1225
  }
@@ -1282,6 +1301,12 @@ export const RATE_LIMIT_PATTERNS = [
1282
1301
  /too many requests/i,
1283
1302
  /api[\s_-]?overloaded/i,
1284
1303
  /\boverloaded\b/i,
1304
+ // Claude billing refusals — "You've hit your org's monthly spend limit" and
1305
+ // "You're out of usage credits". Both end the run with exit 1 and are exactly
1306
+ // the condition a fallback chain exists to recover from. Printed to STDOUT,
1307
+ // hence the stdout tail in SpawnResult.
1308
+ /spend[\s-]?limit/i,
1309
+ /out of (?:usage )?credits/i,
1285
1310
  ];
1286
1311
  /** Return true if the text contains any known rate-limit or overload indicator. */
1287
1312
  export function detectRateLimit(text) {
@@ -1370,6 +1395,9 @@ export async function runWithFallback(options) {
1370
1395
  prompt,
1371
1396
  env: envOverride ? { ...(options.env ?? {}), ...envOverride } : options.env,
1372
1397
  sessionId: pinnedSessionId ?? (i === 0 ? options.sessionId : undefined),
1398
+ // Claude prints billing refusals (spend limit / out of credits) to
1399
+ // stdout; tail it so the cascade check below can see them.
1400
+ captureStdoutTail: true,
1373
1401
  };
1374
1402
  const label = version ? `${agent}@${version}` : agent;
1375
1403
  const modelSwapNote = sameHostRetry && envOverride
@@ -1397,7 +1425,7 @@ export async function runWithFallback(options) {
1397
1425
  const isLast = i === chain.length - 1;
1398
1426
  if (isLast)
1399
1427
  return result.exitCode;
1400
- if (!detectRateLimit(result.stderr)) {
1428
+ if (!detectRateLimit(result.stderr) && !detectRateLimit(result.stdout)) {
1401
1429
  return result.exitCode;
1402
1430
  }
1403
1431
  const next = chain[i + 1];
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Reconcile engine for `agents apply`. The diff (`diffFleet`) is pure and unit-
3
+ * tested; execution (`probeDevice`, `reconcileDevice`, `runFleetApply`) drives
4
+ * the real fleet over SSH and is verified end-to-end against live devices.
5
+ *
6
+ * Flow per device: probe → install/upgrade agents-cli → add missing agents →
7
+ * sync config → propagate login. Every step reuses an existing primitive
8
+ * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
+ */
10
+ import type { DeviceProfile } from '../devices/registry.js';
11
+ import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
12
+ /** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
13
+ export declare function agentIdOf(spec: string): string;
14
+ /** Source-side auth availability, computed once from `snapshotAuth`. */
15
+ export interface SourceAuth {
16
+ /** Agent ids the source has a readable, propagatable credential file for. */
17
+ available: Set<string>;
18
+ /** Agent ids whose source auth is device-bound (macOS keychain). */
19
+ bound: Set<string>;
20
+ /** The captured file payloads, keyed by agent. */
21
+ filesByAgent: Map<string, AuthFilePayload[]>;
22
+ }
23
+ /**
24
+ * Can we propagate `agent`'s login to a target on `targetPlatform`? False when
25
+ * the agent has no portable file, the source can't provide it (bound / not
26
+ * signed in), or the target consumes credentials from its own keychain.
27
+ */
28
+ export declare function canPushLogin(agent: string, targetPlatform: string | undefined, src: SourceAuth): boolean;
29
+ export interface DiffContext {
30
+ /** agents-cli version the source is on — the fleet target version. */
31
+ targetCliVersion: string;
32
+ sourceAuth: SourceAuth;
33
+ }
34
+ /** Pure: desired vs probed -> per-device diff + flat action list. */
35
+ export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
36
+ /** Probe one device: reachability + agents-cli version + installed agent ids. */
37
+ export declare function probeDevice(device: DeviceProfile): DeviceProbe;
38
+ export interface ApplyStep {
39
+ kind: FleetAction['kind'];
40
+ ok: boolean;
41
+ detail: string;
42
+ }
43
+ export interface DeviceApplyResult {
44
+ device: string;
45
+ ok: boolean;
46
+ steps: ApplyStep[];
47
+ note?: string;
48
+ }
49
+ export interface ExecContext {
50
+ targetCliVersion: string;
51
+ source: string;
52
+ sourceAuth: SourceAuth;
53
+ /** Set for a dry run — probe + plan only, execute nothing. */
54
+ dryRun?: boolean;
55
+ }
56
+ /** Execute one device's planned actions in order. Real SSH — no mocks. */
57
+ export declare function reconcileDevice(row: DeviceDiff, device: DeviceProfile, ctx: ExecContext): DeviceApplyResult;
58
+ /** Run a pool of async tasks with a concurrency cap, preserving input order. */
59
+ export declare function pool<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
60
+ /** Reconcile every device row in parallel (capped). */
61
+ export declare function runFleetApply(rows: DeviceDiff[], nameToProfile: Map<string, DeviceProfile>, ctx: ExecContext, concurrency?: number): Promise<DeviceApplyResult[]>;
62
+ /** Default home for source snapshots (overridable in tests). */
63
+ export declare function sourceHome(): string;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Reconcile engine for `agents apply`. The diff (`diffFleet`) is pure and unit-
3
+ * tested; execution (`probeDevice`, `reconcileDevice`, `runFleetApply`) drives
4
+ * the real fleet over SSH and is verified end-to-end against live devices.
5
+ *
6
+ * Flow per device: probe → install/upgrade agents-cli → add missing agents →
7
+ * sync config → propagate login. Every step reuses an existing primitive
8
+ * (`readyProbe`, `bootstrapAgentsCli`, `buildRemoteAgentsInvocation`, `sshExec`).
9
+ */
10
+ import * as os from 'os';
11
+ import { sshTargetFor } from '../devices/connect.js';
12
+ import { readyProbe, bootstrapAgentsCli } from '../hosts/ready.js';
13
+ import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
14
+ import { sshExec } from '../ssh-exec.js';
15
+ import { isPropagatableAgent, KEYCHAIN_BOUND_ON_MAC, buildAuthBundle, } from './auth-sync.js';
16
+ /** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
17
+ export function agentIdOf(spec) {
18
+ return spec.split('@')[0].trim();
19
+ }
20
+ /**
21
+ * Can we propagate `agent`'s login to a target on `targetPlatform`? False when
22
+ * the agent has no portable file, the source can't provide it (bound / not
23
+ * signed in), or the target consumes credentials from its own keychain.
24
+ */
25
+ export function canPushLogin(agent, targetPlatform, src) {
26
+ if (!isPropagatableAgent(agent))
27
+ return false;
28
+ if (src.bound.has(agent))
29
+ return false;
30
+ if (targetPlatform === 'macos' && KEYCHAIN_BOUND_ON_MAC.has(agent))
31
+ return false;
32
+ return src.available.has(agent);
33
+ }
34
+ /** Pure: desired vs probed -> per-device diff + flat action list. */
35
+ export function diffFleet(desired, probes, ctx) {
36
+ const devices = [];
37
+ const actions = [];
38
+ for (const d of desired) {
39
+ const probe = probes.get(d.device) ?? {
40
+ device: d.device,
41
+ reachable: false,
42
+ installedAgents: [],
43
+ note: 'not probed',
44
+ };
45
+ const rowActions = [];
46
+ const loginBlocked = [];
47
+ if (probe.reachable) {
48
+ // agents-cli presence.
49
+ if (!probe.cliVersion) {
50
+ rowActions.push({ device: d.device, kind: 'install-cli', detail: `install agents-cli ${ctx.targetCliVersion}` });
51
+ }
52
+ else if (probe.cliVersion !== ctx.targetCliVersion) {
53
+ rowActions.push({ device: d.device, kind: 'upgrade-cli', detail: `agents-cli ${probe.cliVersion} -> ${ctx.targetCliVersion}` });
54
+ }
55
+ // agents.
56
+ for (const spec of d.agents) {
57
+ const id = agentIdOf(spec);
58
+ if (!probe.installedAgents.includes(id)) {
59
+ rowActions.push({ device: d.device, kind: 'add-agent', agent: id, detail: `install ${spec}` });
60
+ }
61
+ }
62
+ // config.
63
+ if (d.sync.length > 0) {
64
+ rowActions.push({ device: d.device, kind: 'sync-config', detail: `sync config (${d.sync.join(', ')})` });
65
+ }
66
+ // login.
67
+ if (d.login === 'sync') {
68
+ for (const spec of d.agents) {
69
+ const id = agentIdOf(spec);
70
+ if (canPushLogin(id, probe.platform, ctx.sourceAuth)) {
71
+ rowActions.push({ device: d.device, kind: 'push-login', agent: id, detail: `propagate ${id} login` });
72
+ }
73
+ else if (isPropagatableAgent(id) &&
74
+ (ctx.sourceAuth.bound.has(id) || (probe.platform === 'macos' && KEYCHAIN_BOUND_ON_MAC.has(id)))) {
75
+ // A login-propagation candidate we still can't push: the source token
76
+ // is keychain-bound (unextractable), or the macOS target consumes its
77
+ // own keychain. Surface those as manual — don't fake. Agents that were
78
+ // never propagation candidates (no portable file), or a source that
79
+ // simply isn't signed in, are silently skipped the same on every OS.
80
+ loginBlocked.push(id);
81
+ rowActions.push({ device: d.device, kind: 'needs-login', agent: id, detail: `${id} needs a manual login` });
82
+ }
83
+ }
84
+ }
85
+ }
86
+ devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked });
87
+ actions.push(...rowActions);
88
+ }
89
+ return { devices, actions };
90
+ }
91
+ // ---- execution (real SSH; verified end-to-end, not unit-mocked) ----
92
+ function osHint(platform) {
93
+ return platform === 'windows' ? 'windows' : undefined;
94
+ }
95
+ /** POSIX login shells often miss the shims dir; inject it (mirrors doctor). */
96
+ function remoteEnv(platform) {
97
+ return platform === 'windows' ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
98
+ }
99
+ /** Probe one device: reachability + agents-cli version + installed agent ids. */
100
+ export function probeDevice(device) {
101
+ let target;
102
+ try {
103
+ target = sshTargetFor(device);
104
+ }
105
+ catch (e) {
106
+ return { device: device.name, reachable: false, platform: device.platform, installedAgents: [], note: e.message };
107
+ }
108
+ const hint = osHint(device.platform);
109
+ const ready = readyProbe(target, hint);
110
+ if (!ready.reachable) {
111
+ return { device: device.name, reachable: false, platform: device.platform, installedAgents: [], note: 'unreachable' };
112
+ }
113
+ let installed = [];
114
+ const remoteCmd = buildRemoteAgentsInvocation(['teams', 'doctor', '--json'], undefined, hint, remoteEnv(device.platform));
115
+ const res = sshExec(target, remoteCmd, { timeoutMs: 30000, multiplex: true });
116
+ if (res.code === 0) {
117
+ try {
118
+ const map = JSON.parse(res.stdout);
119
+ installed = Object.entries(map).filter(([, e]) => e?.installed).map(([k]) => k);
120
+ }
121
+ catch {
122
+ /* agents-cli present but doctor output unparsable — treat as no agents */
123
+ }
124
+ }
125
+ return {
126
+ device: device.name,
127
+ reachable: true,
128
+ platform: device.platform,
129
+ cliVersion: ready.version ?? undefined,
130
+ installedAgents: installed,
131
+ };
132
+ }
133
+ /** Execute one device's planned actions in order. Real SSH — no mocks. */
134
+ export function reconcileDevice(row, device, ctx) {
135
+ if (!row.probe.reachable) {
136
+ return { device: row.device, ok: false, steps: [], note: row.probe.note ?? 'unreachable' };
137
+ }
138
+ const steps = [];
139
+ let target;
140
+ try {
141
+ target = sshTargetFor(device);
142
+ }
143
+ catch (e) {
144
+ return { device: row.device, ok: false, steps: [], note: e.message };
145
+ }
146
+ const hint = osHint(device.platform);
147
+ const env = remoteEnv(device.platform);
148
+ let ok = true;
149
+ const sshAgents = (args, input) => sshExec(target, buildRemoteAgentsInvocation(args, undefined, hint, env), { timeoutMs: 300000, multiplex: true, input });
150
+ // 1. agents-cli install/upgrade.
151
+ const cliAction = row.actions.find((a) => a.kind === 'install-cli' || a.kind === 'upgrade-cli');
152
+ if (cliAction) {
153
+ const r = bootstrapAgentsCli(target, ctx.targetCliVersion, hint);
154
+ steps.push({ kind: cliAction.kind, ok: r.ok, detail: cliAction.detail });
155
+ ok = ok && r.ok;
156
+ }
157
+ // 2. agents.
158
+ for (const a of row.actions.filter((x) => x.kind === 'add-agent')) {
159
+ const spec = a.detail.replace(/^install\s+/, '');
160
+ const r = sshAgents(['add', spec, '--yes']);
161
+ steps.push({ kind: 'add-agent', ok: r.code === 0, detail: a.detail });
162
+ ok = ok && r.code === 0;
163
+ }
164
+ // 3. config sync.
165
+ if (row.actions.some((a) => a.kind === 'sync-config')) {
166
+ const r = sshAgents(['sync']);
167
+ steps.push({ kind: 'sync-config', ok: r.code === 0, detail: `sync config` });
168
+ ok = ok && r.code === 0;
169
+ }
170
+ // 4. login propagation — one bundle for all pushable agents on this device.
171
+ const pushAgents = [...new Set(row.actions.filter((a) => a.kind === 'push-login').map((a) => a.agent))];
172
+ if (pushAgents.length > 0) {
173
+ const files = [];
174
+ for (const agent of pushAgents)
175
+ files.push(...(ctx.sourceAuth.filesByAgent.get(agent) ?? []));
176
+ const bundle = buildAuthBundle(ctx.source, files);
177
+ const r = sshAgents(['apply', '--recv-auth'], JSON.stringify(bundle));
178
+ steps.push({ kind: 'push-login', ok: r.code === 0, detail: `propagate login: ${pushAgents.join(', ')}` });
179
+ ok = ok && r.code === 0;
180
+ }
181
+ // Surface blocked logins as (non-fatal) informational steps.
182
+ for (const blocked of row.loginBlocked) {
183
+ steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
184
+ }
185
+ return { device: row.device, ok, steps };
186
+ }
187
+ /** Run a pool of async tasks with a concurrency cap, preserving input order. */
188
+ export async function pool(items, limit, fn) {
189
+ const results = new Array(items.length);
190
+ let next = 0;
191
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
192
+ for (;;) {
193
+ const i = next++;
194
+ if (i >= items.length)
195
+ return;
196
+ results[i] = await fn(items[i], i);
197
+ }
198
+ });
199
+ await Promise.all(workers);
200
+ return results;
201
+ }
202
+ /** Reconcile every device row in parallel (capped). */
203
+ export async function runFleetApply(rows, nameToProfile, ctx, concurrency = 6) {
204
+ return pool(rows, concurrency, async (row) => {
205
+ const profile = nameToProfile.get(row.device);
206
+ if (!profile)
207
+ return { device: row.device, ok: false, steps: [], note: 'no device profile' };
208
+ return reconcileDevice(row, profile, ctx);
209
+ });
210
+ }
211
+ /** Default home for source snapshots (overridable in tests). */
212
+ export function sourceHome() {
213
+ return os.homedir();
214
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Login/token propagation — the "one login instead of ~48 OAuth flows" core of
3
+ * `agents apply`.
4
+ *
5
+ * A source machine that is already signed in to a set of harnesses has portable
6
+ * credential files on disk (verified per-agent locations below). `snapshotAuth`
7
+ * captures those; `materializeAuth` writes them into the corresponding paths on
8
+ * a target device. Transport is the caller's job — `apply` streams the bundle
9
+ * over the existing (encrypted, authenticated) SSH channel via `sshExec`'s
10
+ * stdin `input`, so no app-layer crypto is layered on top of SSH here.
11
+ *
12
+ * Honest boundary: on macOS, claude and antigravity keep their tokens in the
13
+ * login keychain, ACL-bound to the harness process — unreadable by us. Those are
14
+ * classified `bound` and surfaced for a one-time manual login, never faked.
15
+ */
16
+ import type { AuthFilePayload, AuthBundle, AuthSnapshotResult } from './types.js';
17
+ /** A portable credential file location, relative to $HOME. */
18
+ interface AuthFileSpec {
19
+ rel: string;
20
+ mode: number;
21
+ }
22
+ /**
23
+ * Verified portable auth-file locations per agent (home-relative). Sourced from
24
+ * live inspection of a Linux fleet box + the agent registry. Agents absent here
25
+ * have no portable credential file we can propagate.
26
+ */
27
+ export declare const FLEET_AUTH_FILES: Record<string, AuthFileSpec[]>;
28
+ /** Agents whose macOS credentials live in the ACL-bound login keychain. */
29
+ export declare const KEYCHAIN_BOUND_ON_MAC: ReadonlySet<string>;
30
+ /** Which agents `apply` can propagate auth for at all. */
31
+ export declare function isPropagatableAgent(agent: string): boolean;
32
+ export interface SnapshotOptions {
33
+ /** Home directory to read credential files from. */
34
+ home: string;
35
+ /** Platform of the source machine (`process.platform`). */
36
+ platform: NodeJS.Platform;
37
+ }
38
+ /**
39
+ * Capture portable credential files for the given agents from a source home.
40
+ * Returns the readable file payloads plus the list of agents whose auth is
41
+ * device-bound (macOS keychain) and therefore cannot be captured. Agents that
42
+ * are simply not signed in (no file on disk) are silently omitted — nothing to
43
+ * propagate, not an error.
44
+ */
45
+ export declare function snapshotAuth(agents: string[], opts: SnapshotOptions): AuthSnapshotResult;
46
+ /** Assemble the plaintext bundle shipped to a target. */
47
+ export declare function buildAuthBundle(source: string, files: AuthFilePayload[]): AuthBundle;
48
+ export interface MaterializeOptions {
49
+ /** Home directory to write credential files into. */
50
+ home: string;
51
+ }
52
+ export interface MaterializeResult {
53
+ /** Agent ids that received at least one file. */
54
+ written: string[];
55
+ /** `rel: reason` for any file that failed. */
56
+ errors: string[];
57
+ }
58
+ /**
59
+ * Write a captured auth bundle into a target home. Parent dirs are created; the
60
+ * captured POSIX mode is restored (0600 for credentials). Writing through an
61
+ * existing symlink lands in the agent's active version home, which is exactly
62
+ * what per-version credential carry-forward expects.
63
+ */
64
+ export declare function materializeAuth(bundle: AuthBundle, opts: MaterializeOptions): MaterializeResult;
65
+ /** Parse + validate a bundle received on stdin (the remote `--_recv-auth` path). */
66
+ export declare function parseAuthBundle(raw: string): AuthBundle;
67
+ export {};
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Login/token propagation — the "one login instead of ~48 OAuth flows" core of
3
+ * `agents apply`.
4
+ *
5
+ * A source machine that is already signed in to a set of harnesses has portable
6
+ * credential files on disk (verified per-agent locations below). `snapshotAuth`
7
+ * captures those; `materializeAuth` writes them into the corresponding paths on
8
+ * a target device. Transport is the caller's job — `apply` streams the bundle
9
+ * over the existing (encrypted, authenticated) SSH channel via `sshExec`'s
10
+ * stdin `input`, so no app-layer crypto is layered on top of SSH here.
11
+ *
12
+ * Honest boundary: on macOS, claude and antigravity keep their tokens in the
13
+ * login keychain, ACL-bound to the harness process — unreadable by us. Those are
14
+ * classified `bound` and surfaced for a one-time manual login, never faked.
15
+ */
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ /**
19
+ * Verified portable auth-file locations per agent (home-relative). Sourced from
20
+ * live inspection of a Linux fleet box + the agent registry. Agents absent here
21
+ * have no portable credential file we can propagate.
22
+ */
23
+ export const FLEET_AUTH_FILES = {
24
+ claude: [{ rel: '.claude/.credentials.json', mode: 0o600 }],
25
+ codex: [{ rel: '.codex/auth.json', mode: 0o600 }],
26
+ gemini: [{ rel: '.gemini/oauth_creds.json', mode: 0o600 }],
27
+ grok: [{ rel: '.grok/auth.json', mode: 0o600 }],
28
+ kimi: [{ rel: '.kimi-code/credentials/kimi-code.json', mode: 0o600 }],
29
+ opencode: [{ rel: '.local/share/opencode/auth.json', mode: 0o600 }],
30
+ droid: [
31
+ { rel: '.factory/auth.v2.file', mode: 0o600 },
32
+ { rel: '.factory/auth.v2.key', mode: 0o600 },
33
+ ],
34
+ antigravity: [{ rel: '.gemini/antigravity-cli/antigravity-oauth-token', mode: 0o600 }],
35
+ };
36
+ /** Agents whose macOS credentials live in the ACL-bound login keychain. */
37
+ export const KEYCHAIN_BOUND_ON_MAC = new Set(['claude', 'antigravity']);
38
+ /** Which agents `apply` can propagate auth for at all. */
39
+ export function isPropagatableAgent(agent) {
40
+ return agent in FLEET_AUTH_FILES;
41
+ }
42
+ /**
43
+ * Capture portable credential files for the given agents from a source home.
44
+ * Returns the readable file payloads plus the list of agents whose auth is
45
+ * device-bound (macOS keychain) and therefore cannot be captured. Agents that
46
+ * are simply not signed in (no file on disk) are silently omitted — nothing to
47
+ * propagate, not an error.
48
+ */
49
+ export function snapshotAuth(agents, opts) {
50
+ const files = [];
51
+ const bound = [];
52
+ for (const agent of agents) {
53
+ const specs = FLEET_AUTH_FILES[agent];
54
+ if (!specs)
55
+ continue; // not propagatable — caller surfaces separately if desired
56
+ if (opts.platform === 'darwin' && KEYCHAIN_BOUND_ON_MAC.has(agent)) {
57
+ bound.push(agent);
58
+ continue;
59
+ }
60
+ for (const spec of specs) {
61
+ const abs = path.join(opts.home, spec.rel);
62
+ let stat;
63
+ try {
64
+ stat = fs.statSync(abs);
65
+ }
66
+ catch {
67
+ continue; // not signed in for this agent — nothing to carry
68
+ }
69
+ if (!stat.isFile())
70
+ continue;
71
+ const content = fs.readFileSync(abs); // follows symlinks into version homes
72
+ files.push({
73
+ agent,
74
+ rel: spec.rel,
75
+ contentB64: content.toString('base64'),
76
+ mode: (stat.mode & 0o777) || spec.mode,
77
+ });
78
+ }
79
+ }
80
+ return { files, bound };
81
+ }
82
+ /** Assemble the plaintext bundle shipped to a target. */
83
+ export function buildAuthBundle(source, files) {
84
+ return { v: 1, source, files };
85
+ }
86
+ /**
87
+ * Write a captured auth bundle into a target home. Parent dirs are created; the
88
+ * captured POSIX mode is restored (0600 for credentials). Writing through an
89
+ * existing symlink lands in the agent's active version home, which is exactly
90
+ * what per-version credential carry-forward expects.
91
+ */
92
+ export function materializeAuth(bundle, opts) {
93
+ const written = new Set();
94
+ const errors = [];
95
+ for (const f of bundle.files) {
96
+ const abs = path.join(opts.home, f.rel);
97
+ try {
98
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
99
+ fs.writeFileSync(abs, Buffer.from(f.contentB64, 'base64'));
100
+ fs.chmodSync(abs, f.mode || 0o600);
101
+ written.add(f.agent);
102
+ }
103
+ catch (e) {
104
+ errors.push(`${f.rel}: ${e.message}`);
105
+ }
106
+ }
107
+ return { written: [...written], errors };
108
+ }
109
+ /** Parse + validate a bundle received on stdin (the remote `--_recv-auth` path). */
110
+ export function parseAuthBundle(raw) {
111
+ let doc;
112
+ try {
113
+ doc = JSON.parse(raw);
114
+ }
115
+ catch (e) {
116
+ throw new Error(`auth bundle is not valid JSON: ${e.message}`);
117
+ }
118
+ if (typeof doc !== 'object' || doc === null)
119
+ throw new Error('auth bundle must be an object.');
120
+ const o = doc;
121
+ if (o.v !== 1)
122
+ throw new Error(`unsupported auth bundle version ${JSON.stringify(o.v)}.`);
123
+ if (typeof o.source !== 'string')
124
+ throw new Error('auth bundle missing source.');
125
+ if (!Array.isArray(o.files))
126
+ throw new Error('auth bundle missing files[].');
127
+ for (const f of o.files) {
128
+ if (typeof f !== 'object' || f === null ||
129
+ typeof f.agent !== 'string' ||
130
+ typeof f.rel !== 'string' ||
131
+ typeof f.contentB64 !== 'string' ||
132
+ typeof f.mode !== 'number') {
133
+ throw new Error('auth bundle has a malformed file entry.');
134
+ }
135
+ // Reject path traversal — rel must stay under $HOME.
136
+ const rel = f.rel;
137
+ if (rel.startsWith('/') || rel.split('/').includes('..')) {
138
+ throw new Error(`auth bundle rejected unsafe path: ${rel}`);
139
+ }
140
+ }
141
+ return doc;
142
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Parse + validate the `fleet:` block of a profile manifest and resolve it into
3
+ * per-device desired state.
4
+ *
5
+ * `fleet:` is additive to the `Meta` schema (`types.ts`). Any `-f <file>` that
6
+ * carries a `fleet:` block is a valid manifest, so `ag apply -f agents.yaml`
7
+ * works against the project file. The functions here are pure (no SSH, no
8
+ * registry I/O) so they're fully unit-testable; device enumeration is injected.
9
+ */
10
+ import type { FleetManifest, DeviceDesired } from './types.js';
11
+ /** Validate a raw `fleet:` object (already YAML-parsed) into a typed manifest. */
12
+ export declare function parseFleetManifest(raw: unknown): FleetManifest;
13
+ /** Read a YAML file and extract + validate its `fleet:` block. */
14
+ export declare function readFleetFile(filePath: string): FleetManifest;
15
+ export interface ResolveContext {
16
+ /** Device names currently online (used to expand `devices: all`). */
17
+ onlineDevices: string[];
18
+ /** All registered device names (used to validate explicit entries). */
19
+ registeredDevices: string[];
20
+ /** The source machine, always excluded from the target set. */
21
+ source: string;
22
+ }
23
+ /**
24
+ * Expand a manifest into concrete per-device desired states. `devices: all`
25
+ * expands to every online registered device minus the source; an explicit map
26
+ * validates each name against the registry. Devices with no desired agents,
27
+ * sync scopes, and `login: skip` are still returned (probe/report only).
28
+ */
29
+ export declare function resolveDesired(manifest: FleetManifest, ctx: ResolveContext): DeviceDesired[];