@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,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[];
@@ -0,0 +1,127 @@
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 * as fs from 'fs';
11
+ import * as yaml from 'yaml';
12
+ const LOGIN_MODES = ['sync', 'skip'];
13
+ function isStringArray(v) {
14
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
15
+ }
16
+ function validateLogin(v, where) {
17
+ if (v === undefined)
18
+ return undefined;
19
+ if (typeof v !== 'string' || !LOGIN_MODES.includes(v)) {
20
+ throw new Error(`fleet: ${where}.login must be one of ${LOGIN_MODES.join(' | ')} (got ${JSON.stringify(v)}).`);
21
+ }
22
+ return v;
23
+ }
24
+ function validateDefaults(v, where) {
25
+ if (v === undefined)
26
+ return {};
27
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) {
28
+ throw new Error(`fleet: ${where} must be a mapping.`);
29
+ }
30
+ const o = v;
31
+ if (o.agents !== undefined && !isStringArray(o.agents)) {
32
+ throw new Error(`fleet: ${where}.agents must be a list of agent specs (e.g. [claude@latest]).`);
33
+ }
34
+ if (o.sync !== undefined && !isStringArray(o.sync)) {
35
+ throw new Error(`fleet: ${where}.sync must be a list of scope names (e.g. [user]).`);
36
+ }
37
+ return {
38
+ agents: o.agents,
39
+ sync: o.sync,
40
+ login: validateLogin(o.login, where),
41
+ };
42
+ }
43
+ /** Validate a raw `fleet:` object (already YAML-parsed) into a typed manifest. */
44
+ export function parseFleetManifest(raw) {
45
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
46
+ throw new Error('fleet: block must be a mapping with a `devices:` key.');
47
+ }
48
+ const o = raw;
49
+ const defaults = validateDefaults(o.defaults, 'defaults');
50
+ if (o.devices === undefined) {
51
+ throw new Error('fleet: a `devices:` key is required (use `devices: all` or an explicit map).');
52
+ }
53
+ let devices;
54
+ if (o.devices === 'all') {
55
+ devices = 'all';
56
+ }
57
+ else if (typeof o.devices === 'object' && o.devices !== null && !Array.isArray(o.devices)) {
58
+ const map = {};
59
+ for (const [name, ov] of Object.entries(o.devices)) {
60
+ // An empty entry (`device: {}`) inherits defaults — represent as {}.
61
+ const merged = ov == null ? {} : validateDefaults(ov, `devices.${name}`);
62
+ map[name] = merged;
63
+ }
64
+ devices = map;
65
+ }
66
+ else {
67
+ throw new Error(`fleet: devices must be the string 'all' or a mapping of device -> overrides (got ${JSON.stringify(o.devices)}).`);
68
+ }
69
+ return { defaults, devices };
70
+ }
71
+ /** Read a YAML file and extract + validate its `fleet:` block. */
72
+ export function readFleetFile(filePath) {
73
+ if (!fs.existsSync(filePath)) {
74
+ throw new Error(`Manifest not found: ${filePath}`);
75
+ }
76
+ let doc;
77
+ try {
78
+ doc = yaml.parse(fs.readFileSync(filePath, 'utf-8'));
79
+ }
80
+ catch (e) {
81
+ throw new Error(`Failed to parse YAML in ${filePath}: ${e.message}`);
82
+ }
83
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
84
+ throw new Error(`${filePath} is not a mapping — no fleet: block to apply.`);
85
+ }
86
+ const fleet = doc.fleet;
87
+ if (fleet === undefined) {
88
+ throw new Error(`${filePath} has no fleet: block. Add one to declare the profile (see \`agents apply --help\`).`);
89
+ }
90
+ return parseFleetManifest(fleet);
91
+ }
92
+ /** Merge a per-device override over defaults into a concrete desired state. */
93
+ function mergeDesired(device, defaults, override) {
94
+ return {
95
+ device,
96
+ agents: override.agents ?? defaults.agents ?? [],
97
+ sync: override.sync ?? defaults.sync ?? [],
98
+ login: override.login ?? defaults.login ?? 'sync',
99
+ };
100
+ }
101
+ /**
102
+ * Expand a manifest into concrete per-device desired states. `devices: all`
103
+ * expands to every online registered device minus the source; an explicit map
104
+ * validates each name against the registry. Devices with no desired agents,
105
+ * sync scopes, and `login: skip` are still returned (probe/report only).
106
+ */
107
+ export function resolveDesired(manifest, ctx) {
108
+ const defaults = manifest.defaults ?? {};
109
+ const out = [];
110
+ if (manifest.devices === 'all') {
111
+ for (const name of ctx.onlineDevices) {
112
+ if (name === ctx.source)
113
+ continue;
114
+ out.push(mergeDesired(name, defaults, {}));
115
+ }
116
+ return out;
117
+ }
118
+ for (const [name, override] of Object.entries(manifest.devices)) {
119
+ if (name === ctx.source)
120
+ continue;
121
+ if (!ctx.registeredDevices.includes(name)) {
122
+ throw new Error(`fleet: device '${name}' is not a registered device. Run \`agents devices add ${name}\` or fix the manifest.`);
123
+ }
124
+ out.push(mergeDesired(name, defaults, override));
125
+ }
126
+ return out;
127
+ }