@phnx-labs/agents-cli 1.22.115 → 1.22.116

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 (88) hide show
  1. package/CHANGELOG.md +149 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.js +212 -141
  5. package/dist/commands/feed.js +65 -14
  6. package/dist/commands/sessions-picker.d.ts +11 -0
  7. package/dist/commands/sessions-picker.js +88 -7
  8. package/dist/commands/sessions.d.ts +21 -2
  9. package/dist/commands/sessions.js +157 -3
  10. package/dist/commands/setup-secrets.d.ts +2 -2
  11. package/dist/commands/setup-term.d.ts +24 -0
  12. package/dist/commands/setup-term.js +70 -0
  13. package/dist/commands/setup.d.ts +1 -1
  14. package/dist/commands/setup.js +12 -4
  15. package/dist/commands/ssh.js +1 -69
  16. package/dist/lib/accounting/rotate.d.ts +63 -1
  17. package/dist/lib/accounting/rotate.js +56 -0
  18. package/dist/lib/accounts/add.js +2 -2
  19. package/dist/lib/accounts/slots.js +32 -2
  20. package/dist/lib/answer-router.d.ts +11 -2
  21. package/dist/lib/answer-router.js +26 -2
  22. package/dist/lib/auth-mint.d.ts +5 -4
  23. package/dist/lib/auth-mint.js +4 -3
  24. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  25. package/dist/lib/browser/service.d.ts +10 -0
  26. package/dist/lib/browser/service.js +208 -36
  27. package/dist/lib/browser/types.d.ts +18 -0
  28. package/dist/lib/config-keys.d.ts +1 -1
  29. package/dist/lib/config-keys.js +5 -0
  30. package/dist/lib/device-config.js +61 -0
  31. package/dist/lib/devices/doctor-findings.js +2 -6
  32. package/dist/lib/feed/answer.d.ts +153 -4
  33. package/dist/lib/feed/answer.js +716 -105
  34. package/dist/lib/feed/feed.d.ts +61 -1
  35. package/dist/lib/feed/feed.js +226 -14
  36. package/dist/lib/feed/hub-server.d.ts +58 -3
  37. package/dist/lib/feed/hub-server.js +306 -54
  38. package/dist/lib/feed/pr-status.d.ts +8 -0
  39. package/dist/lib/feed/pr-status.js +9 -1
  40. package/dist/lib/feed-outcome.d.ts +1 -1
  41. package/dist/lib/feed-outcome.js +9 -2
  42. package/dist/lib/feed-policy.js +9 -3
  43. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  44. package/dist/lib/fleet/auth-sync.js +2 -89
  45. package/dist/lib/harness-auth-capabilities.js +7 -2
  46. package/dist/lib/hosts/dispatch.d.ts +20 -1
  47. package/dist/lib/hosts/dispatch.js +52 -30
  48. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  49. package/dist/lib/hosts/remote-cmd.js +26 -2
  50. package/dist/lib/mailbox.d.ts +12 -0
  51. package/dist/lib/mailbox.js +16 -2
  52. package/dist/lib/menubar/snapshot.d.ts +51 -0
  53. package/dist/lib/menubar/snapshot.js +42 -3
  54. package/dist/lib/open-url.js +2 -2
  55. package/dist/lib/projects.d.ts +23 -0
  56. package/dist/lib/projects.js +78 -0
  57. package/dist/lib/secrets-cli.d.ts +3 -3
  58. package/dist/lib/secrets-cli.js +1 -1
  59. package/dist/lib/session/active.d.ts +1 -0
  60. package/dist/lib/session/active.js +8 -0
  61. package/dist/lib/session/db.d.ts +67 -3
  62. package/dist/lib/session/db.js +381 -126
  63. package/dist/lib/session/prompt.d.ts +23 -7
  64. package/dist/lib/session/prompt.js +46 -8
  65. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  66. package/dist/lib/session/remote/remote-list.js +22 -6
  67. package/dist/lib/session/remote/watch.d.ts +12 -0
  68. package/dist/lib/session/remote/watch.js +9 -0
  69. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  70. package/dist/lib/session/remote-preview-cache.js +373 -0
  71. package/dist/lib/session/tail.d.ts +50 -0
  72. package/dist/lib/session/tail.js +219 -0
  73. package/dist/lib/setup-tool-install.js +2 -1
  74. package/dist/lib/setup-tool-status.d.ts +1 -1
  75. package/dist/lib/setup-tool-status.js +6 -1
  76. package/dist/lib/signin-badge.d.ts +19 -4
  77. package/dist/lib/signin-badge.js +29 -11
  78. package/dist/lib/term-driver.d.ts +24 -0
  79. package/dist/lib/term-driver.js +36 -0
  80. package/dist/lib/terminal/index.d.ts +1 -1
  81. package/dist/lib/terminal/index.js +1 -1
  82. package/dist/lib/terminal/inject.d.ts +38 -0
  83. package/dist/lib/terminal/inject.js +55 -9
  84. package/dist/lib/terminal/transport.d.ts +15 -5
  85. package/dist/lib/terminal/transport.js +61 -11
  86. package/package.json +1 -1
  87. package/dist/lib/fleet/remote-login.d.ts +0 -170
  88. package/dist/lib/fleet/remote-login.js +0 -568
@@ -1,568 +0,0 @@
1
- /**
2
- * `agents fleet login` — remote device-code OAuth orchestration.
3
- *
4
- * WHY this exists: copying one OAuth credential file across N boxes is fatal — a
5
- * shared refresh token rotates server-side on first refresh and invalidates the
6
- * other copies (droid collapsed 10 boxes -> 1 overnight). The durable fix is a
7
- * per-machine credential: an interactive login per (agent x box). This module
8
- * makes that bearable by driving every box's device-code flow over SSH, scraping
9
- * each verification URL + user code, and surfacing them all in ONE local browser
10
- * page so the human enters codes back-to-back instead of babysitting N terminals.
11
- *
12
- * The propagation gate lives in `auth-sync.ts`: {@link isCredentialSafeToPropagate}
13
- * and {@link SINGLE_USE_ROTATING_REFRESH_AGENTS} are the single place where the
14
- * "is this credential safe to copy?" decision is made, shared by `agents apply`
15
- * and any other propagation path.
16
- *
17
- * Layering — pure vs I/O, so the valuable logic is unit-testable without SSH:
18
- * - `scrapeLogin` / `classifyLoginFlow` / `selectLoginTargets` /
19
- * `buildRemoteLoginSshCommand` / `buildDashboardHtml` are PURE.
20
- * - `driveRemoteLogin` drives a terminal through an injectable {@link TermDriver}
21
- * (fake in tests; real over the standalone `term` CLI in prod — see
22
- * `../term-client.js`).
23
- * - `detectPending` / `runFleetLogin` do the real fleet I/O.
24
- *
25
- * The device codes have a ~15 min TTL, so the default (bulk) mode requests every
26
- * code concurrently and shows them at once, while `--interactive` drives one box
27
- * at a time — requesting each code just-in-time so it can't expire while the
28
- * human works through the earlier ones.
29
- */
30
- import * as http from 'http';
31
- import { loadDevices } from '../devices/registry.js';
32
- import { deviceIdentityArgs, fleetDialTarget } from '../devices/connect.js';
33
- import { planFleetTargets } from '../devices/fleet.js';
34
- import { assertValidSshTarget, shellQuote, sshExecAsync } from '../ssh-exec.js';
35
- import { machineId } from '../session/sync/config.js';
36
- import { termStart, termExec, termWrite, termScreen, termStop } from '../term-client.js';
37
- import { showUrl } from '../open-url.js';
38
- import { readAuthHealthCache, } from '../auth-health.js';
39
- import { FLEET_LOGIN_FLOWS, KEYCHAIN_BOUND_ON_MAC, } from './auth-sync.js';
40
- /**
41
- * Extract the verification URL and user code from an ANSI-stripped login screen
42
- * using the flow's regexes. Pure — no I/O, so it is the highest-value unit test
43
- * target (a wrong code boundary or a missed URL is exactly the bug class this
44
- * whole feature turns on). Group 1 is preferred over the whole match so a
45
- * surrounding phrase ("enter code XXXX-XXXX") never leaks into the captured
46
- * value. A flow with no regex for a field yields `undefined` for that field.
47
- */
48
- export function scrapeLogin(screenText, flow) {
49
- const out = {};
50
- if (flow.verificationUrlRegex) {
51
- const m = screenText.match(flow.verificationUrlRegex);
52
- if (m)
53
- out.url = (m[1] ?? m[0]).trim();
54
- }
55
- if (flow.userCodeRegex) {
56
- const m = screenText.match(flow.userCodeRegex);
57
- if (m)
58
- out.code = (m[1] ?? m[0]).trim();
59
- }
60
- return out;
61
- }
62
- /**
63
- * Decide whether `agent`'s login can be driven on a box of `platform`. Only a
64
- * `device-code` flow is remotable; loopback (browser + 127.0.0.1 on the box),
65
- * api-key, unknown, or a macOS keychain-bound token are surfaced with an honest
66
- * reason instead of a mis-drive. Pure.
67
- */
68
- export function classifyLoginFlow(agent, platform) {
69
- const flow = FLEET_LOGIN_FLOWS[agent] ?? null;
70
- if (!flow)
71
- return { flow: null, remotable: false, reason: 'no login flow defined' };
72
- if (platform === 'macos' && KEYCHAIN_BOUND_ON_MAC.has(agent)) {
73
- return { flow, remotable: false, reason: 'keychain-bound on macOS (log in on the box itself)' };
74
- }
75
- switch (flow.flowType) {
76
- case 'device-code':
77
- return { flow, remotable: true };
78
- case 'loopback':
79
- return { flow, remotable: false, reason: 'loopback OAuth — needs a browser on the box' };
80
- case 'api-key':
81
- return { flow, remotable: false, reason: 'api-key login — no device code' };
82
- case 'unknown':
83
- default:
84
- return { flow, remotable: false, reason: 'login flow not yet characterized' };
85
- }
86
- }
87
- /**
88
- * Does a cached auth verdict mean "this (host, agent) needs an interactive
89
- * login"? A missing row (never signed in / credential absent) and a `revoked`
90
- * token do; `live`, `unverified` (signed in, no probe), `expired`, and
91
- * `rate_limited` are treated as already-logged-in (expired self-heals on next
92
- * launch — never re-login on it). Pure.
93
- */
94
- export function isPendingVerdict(verdict) {
95
- return verdict === undefined || verdict === 'revoked';
96
- }
97
- /** The worst (most-actionable) verdict cached for a host+agent across versions. */
98
- function worstCachedVerdict(cache, host, agent) {
99
- const prefix = `${host}:${agent}:`;
100
- let best;
101
- for (const [key, health] of Object.entries(cache)) {
102
- if (!key.startsWith(prefix))
103
- continue;
104
- // A revoked row always wins (most actionable); otherwise the first live/other.
105
- if (health.verdict === 'revoked')
106
- return 'revoked';
107
- if (best === undefined)
108
- best = health.verdict;
109
- }
110
- return best;
111
- }
112
- /**
113
- * From a device list, requested agents, and the auth-health cache, compute the
114
- * pending logins — partitioned (via {@link classifyLoginFlow}) into remotable
115
- * device-code flows and non-remotable pairs with a reason. Pure: the caller
116
- * supplies the already-loaded devices + cache, so this is fully unit-testable.
117
- *
118
- * A pair is pending when its cached verdict {@link isPendingVerdict} (or
119
- * `includeLoggedIn` forces it). Agents with no defined login flow are dropped
120
- * silently (nothing to log into).
121
- */
122
- export function selectLoginTargets(devices, agents, cache, opts = {}) {
123
- const out = [];
124
- for (const device of devices) {
125
- for (const agent of agents) {
126
- const { flow, remotable, reason } = classifyLoginFlow(agent, device.platform);
127
- if (!flow)
128
- continue; // agent has no login flow — nothing to do
129
- const verdict = worstCachedVerdict(cache, device.name, agent);
130
- if (!opts.includeLoggedIn && !isPendingVerdict(verdict))
131
- continue;
132
- out.push({
133
- device: device.name,
134
- agent,
135
- platform: device.platform,
136
- target: fleetDialTarget(device),
137
- extraSshArgs: deviceIdentityArgs(device),
138
- flow,
139
- remotable,
140
- reason,
141
- });
142
- }
143
- }
144
- return out;
145
- }
146
- /**
147
- * Build the `ssh -tt <target> <loginCommand>` command string driven through the
148
- * `term` CLI. `-tt` forces a tty so the remote CLI enters its interactive
149
- * device-code flow; `accept-new` learns a first-seen host key without a prompt.
150
- * Both the target and the login command are shell-quoted. Pure/testable.
151
- *
152
- * NOTE: this uses key-based ssh auth (the tailnet fleet's norm). Password-bundle
153
- * devices need the askpass shim env, which the PTY env allowlist strips — those
154
- * are a follow-up (TODO), surfaced as an error rather than silently mis-driven.
155
- */
156
- export function buildRemoteLoginSshCommand(target, flow, extraSshArgs = []) {
157
- assertValidSshTarget(target);
158
- // The agent CLIs (kimi/droid/codex/…) are agents-cli shims that live in
159
- // ~/.agents/.cache/shims and are only on PATH in an interactive/login shell.
160
- // `ssh <box> <cmd>` runs a NON-login shell, so a bare `kimi` is "command not
161
- // found" and the login program never launches (the scrape then times out).
162
- // Prepend the shim dir — resolved on the REMOTE via $HOME, single-quoted so
163
- // $HOME/$PATH expand on the box, not locally — so the login command resolves.
164
- const remoteCmd = `PATH="$HOME/.agents/.cache/shims:$PATH" ${flow.loginCommand}`;
165
- return [
166
- 'ssh', '-tt',
167
- '-o', 'StrictHostKeyChecking=accept-new',
168
- '-o', 'ConnectTimeout=10',
169
- ...extraSshArgs.map(shellQuote),
170
- shellQuote(target),
171
- shellQuote(remoteCmd),
172
- ].join(' ');
173
- }
174
- function esc(s) {
175
- return s
176
- .replace(/&/g, '&amp;')
177
- .replace(/</g, '&lt;')
178
- .replace(/>/g, '&gt;')
179
- .replace(/"/g, '&quot;');
180
- }
181
- /**
182
- * Render the self-contained dashboard page. Inlines all CSS/JS; the page polls
183
- * `/api/status` and flips rows to their live state. `bulk` shows a grid of every
184
- * pending login at once; `interactive` shows a one-at-a-time wizard. Pure — a
185
- * string in, a string out — so it renders (and is asserted) without a server.
186
- * Terminal-coded per the agents-cli brand (#0a0a0a bg, #a3e635 lime), with a
187
- * light variant via `prefers-color-scheme`.
188
- */
189
- export function buildDashboardHtml(pending, mode) {
190
- const seed = pending.map((p) => ({
191
- device: p.device,
192
- agent: p.agent,
193
- remotable: p.remotable,
194
- reason: p.reason,
195
- state: (p.remotable ? 'pending' : 'skipped'),
196
- }));
197
- const seedJson = JSON.stringify(seed);
198
- const remotableCount = pending.filter((p) => p.remotable).length;
199
- const subtitle = mode === 'interactive'
200
- ? 'Guided login — one box at a time, codes requested just-in-time.'
201
- : 'All pending logins — enter each code in a browser tab, back to back.';
202
- return `<!doctype html>
203
- <html lang="en">
204
- <head>
205
- <meta charset="utf-8">
206
- <meta name="viewport" content="width=device-width, initial-scale=1">
207
- <title>agents fleet login</title>
208
- <style>
209
- :root {
210
- --bg: #0a0a0a; --panel: #141414; --border: #262626; --fg: #e5e5e5;
211
- --muted: #8a8a8a; --accent: #a3e635; --ready: #38bdf8; --ok: #22c55e;
212
- --warn: #f59e0b; --err: #ef4444; --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
213
- }
214
- @media (prefers-color-scheme: light) {
215
- :root { --bg: #fafafa; --panel: #fff; --border: #e5e5e5; --fg: #171717; --muted: #6b7280; }
216
- }
217
- * { box-sizing: border-box; }
218
- body { margin: 0; background: var(--bg); color: var(--fg); font-family: Inter, system-ui, sans-serif; }
219
- .wrap { max-width: 920px; margin: 0 auto; padding: 32px 20px 64px; }
220
- header h1 { font-family: var(--mono); font-size: 20px; margin: 0 0 4px; }
221
- header h1 .lime { color: var(--accent); }
222
- header p { color: var(--muted); margin: 0 0 6px; font-size: 14px; }
223
- .meta { color: var(--muted); font-size: 12px; font-family: var(--mono); margin-bottom: 24px; }
224
- .grid { display: grid; gap: 12px; }
225
- .card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
226
- .card.done { border-color: var(--ok); }
227
- .card.err { border-color: var(--err); }
228
- .card.skip { opacity: 0.6; }
229
- .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
230
- .who { font-family: var(--mono); font-size: 14px; }
231
- .who .agent { color: var(--accent); }
232
- .who .at { color: var(--muted); }
233
- .state { font-family: var(--mono); font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }
234
- .state.pending { color: var(--muted); }
235
- .state.driving { color: var(--warn); border-color: var(--warn); }
236
- .state.ready { color: var(--ready); border-color: var(--ready); }
237
- .state.authorized { color: var(--ok); border-color: var(--ok); }
238
- .state.error { color: var(--err); border-color: var(--err); }
239
- .code-line { margin-top: 12px; display: none; align-items: center; gap: 12px; flex-wrap: wrap; }
240
- .code-line.show { display: flex; }
241
- .code { font-family: var(--mono); font-size: 22px; letter-spacing: 2px; color: var(--fg); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 6px 14px; }
242
- a.auth-btn { text-decoration: none; background: var(--accent); color: #0a0a0a; font-weight: 600; font-family: var(--mono); font-size: 13px; padding: 8px 14px; border-radius: 8px; }
243
- button.copy { font-family: var(--mono); font-size: 12px; background: transparent; color: var(--fg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; cursor: pointer; }
244
- .ttl { font-family: var(--mono); font-size: 12px; color: var(--muted); }
245
- .reason { color: var(--muted); font-size: 12px; margin-top: 6px; }
246
- .empty { color: var(--muted); font-family: var(--mono); }
247
- </style>
248
- </head>
249
- <body>
250
- <div class="wrap">
251
- <header>
252
- <h1>agents <span class="lime">fleet login</span></h1>
253
- <p>${esc(subtitle)}</p>
254
- <div class="meta">mode: ${mode} &middot; ${remotableCount} remotable &middot; <span id="tick"></span></div>
255
- </header>
256
- <div id="grid" class="grid"></div>
257
- </div>
258
- <script>
259
- const SEED = ${seedJson};
260
- const MODE = ${JSON.stringify(mode)};
261
- const keyOf = (s) => s.device + '::' + s.agent;
262
- let statuses = {};
263
- for (const s of SEED) statuses[keyOf(s)] = s;
264
-
265
- function fmtTtl(expiresAt) {
266
- if (!expiresAt) return '';
267
- const ms = expiresAt - Date.now();
268
- if (ms <= 0) return 'expired';
269
- const m = Math.floor(ms / 60000), sec = Math.floor((ms % 60000) / 1000);
270
- return 'expires in ' + m + 'm ' + String(sec).padStart(2, '0') + 's';
271
- }
272
-
273
- function render() {
274
- const grid = document.getElementById('grid');
275
- const items = Object.values(statuses);
276
- if (items.length === 0) { grid.innerHTML = '<div class="empty">Nothing pending — every requested account is already logged in.</div>'; return; }
277
- grid.innerHTML = '';
278
- for (const s of items) {
279
- const card = document.createElement('div');
280
- card.className = 'card' + (s.state === 'authorized' ? ' done' : s.state === 'error' ? ' err' : s.state === 'skipped' ? ' skip' : '');
281
- const stateCls = s.state;
282
- const stateText = s.state === 'skipped' ? 'not remotable' : s.state;
283
- card.innerHTML =
284
- '<div class="row">' +
285
- '<span class="who"><span class="agent">' + s.agent + '</span><span class="at"> @ </span>' + s.device + '</span>' +
286
- '<span class="state ' + stateCls + '">' + stateText + '</span>' +
287
- '</div>' +
288
- (s.reason && !s.remotable ? '<div class="reason">' + s.reason + '</div>' : '') +
289
- (s.detail ? '<div class="reason">' + s.detail + '</div>' : '');
290
- if (s.url && s.code && s.state !== 'authorized') {
291
- const line = document.createElement('div');
292
- line.className = 'code-line show';
293
- const sep = s.url.indexOf('?') === -1 ? '?' : '&';
294
- const deep = s.url + sep + 'user_code=' + encodeURIComponent(s.code);
295
- line.innerHTML =
296
- '<span class="code">' + s.code + '</span>' +
297
- '<button class="copy" data-code="' + s.code + '">copy code</button>' +
298
- '<a class="auth-btn" href="' + deep + '" target="_blank" rel="noopener">Authorize &rarr;</a>' +
299
- '<span class="ttl" data-exp="' + (s.expiresAt || '') + '">' + fmtTtl(s.expiresAt) + '</span>';
300
- card.appendChild(line);
301
- }
302
- grid.appendChild(card);
303
- }
304
- grid.querySelectorAll('button.copy').forEach((b) => {
305
- b.addEventListener('click', () => { navigator.clipboard.writeText(b.getAttribute('data-code')); b.textContent = 'copied'; });
306
- });
307
- }
308
-
309
- async function poll() {
310
- try {
311
- const res = await fetch('/api/status');
312
- const data = await res.json();
313
- for (const s of data.statuses) statuses[keyOf(s)] = s;
314
- } catch (e) { /* transient */ }
315
- render();
316
- }
317
-
318
- function tick() {
319
- document.getElementById('tick').textContent = new Date().toLocaleTimeString();
320
- document.querySelectorAll('.ttl[data-exp]').forEach((el) => {
321
- const exp = Number(el.getAttribute('data-exp'));
322
- if (exp) el.textContent = fmtTtl(exp);
323
- });
324
- }
325
-
326
- render();
327
- poll();
328
- setInterval(poll, 2000);
329
- setInterval(tick, 1000);
330
- tick();
331
- </script>
332
- </body>
333
- </html>`;
334
- }
335
- /** Real driver over the standalone `term` CLI (`../term-client.js`). */
336
- export function defaultTermDriver() {
337
- const expectOk = (res, what) => {
338
- if (!res.ok)
339
- throw new Error(`term ${what} failed: ${res.error ?? 'unknown'}`);
340
- };
341
- return {
342
- async start(opts) {
343
- const res = await termStart({ rows: opts?.rows ?? 40, cols: opts?.cols ?? 120 });
344
- expectOk(res, 'start');
345
- return res.id;
346
- },
347
- async exec(id, command) {
348
- expectOk(await termExec(id, command), 'exec');
349
- },
350
- async write(id, input) {
351
- expectOk(await termWrite(id, input), 'write');
352
- },
353
- async screen(id) {
354
- const res = await termScreen(id);
355
- expectOk(res, 'screen');
356
- return { screen: res.screen ?? '', exited: Boolean(res.exited) };
357
- },
358
- async stop(id) {
359
- await termStop(id).catch(() => undefined);
360
- },
361
- };
362
- }
363
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
364
- /**
365
- * Drive one remote device-code login: launch `ssh -tt <target> <loginCommand>`
366
- * in a `term` session, send the flow's `deviceCodeSelect` keystrokes (if any) to reach the
367
- * device-code path, then poll the screen until {@link scrapeLogin} yields both a
368
- * URL and a code (or the deadline / session-exit). The human completes the code
369
- * in the browser; the caller polls the credential file for completion. Testable
370
- * against a fake {@link TermDriver}.
371
- */
372
- export async function driveRemoteLogin(target, flow, driver, opts = {}, extraSshArgs = []) {
373
- const initialDelayMs = opts.initialDelayMs ?? 4000;
374
- const pollMs = opts.pollMs ?? 1000;
375
- const timeoutMs = opts.timeoutMs ?? 90000;
376
- const id = await driver.start();
377
- // Everything past start() must tear the session down on a mid-flight throw —
378
- // otherwise the caller never receives a sessionId and the term session (and
379
- // the `ssh -tt` process it drives) leaks until term's own idle reaper. On the
380
- // normal return paths the caller owns stop() via the returned sessionId.
381
- try {
382
- await driver.exec(id, buildRemoteLoginSshCommand(target, flow, extraSshArgs));
383
- await sleep(initialDelayMs);
384
- if (flow.deviceCodeSelect) {
385
- await driver.write(id, flow.deviceCodeSelect);
386
- }
387
- const deadline = Date.now() + timeoutMs;
388
- let last = {};
389
- for (;;) {
390
- const { screen, exited } = await driver.screen(id);
391
- const scraped = scrapeLogin(screen, flow);
392
- if (scraped.url)
393
- last.url = scraped.url;
394
- if (scraped.code)
395
- last.code = scraped.code;
396
- if (last.url && last.code)
397
- return { ...last, exited, sessionId: id };
398
- if (exited)
399
- return { ...last, exited: true, sessionId: id };
400
- if (Date.now() >= deadline)
401
- return { ...last, exited: false, sessionId: id };
402
- await sleep(pollMs);
403
- }
404
- }
405
- catch (e) {
406
- await driver.stop(id).catch(() => { });
407
- throw e;
408
- }
409
- }
410
- /**
411
- * Resolve the online fleet devices, then compute pending logins
412
- * from the shared auth-health cache (populated by `agents fleet ping`). Thin I/O
413
- * over the pure {@link selectLoginTargets}. Devices/agents can be narrowed by the
414
- * caller's flags; the default agent set is every agent with a defined flow.
415
- */
416
- async function detectPending(opts = {}) {
417
- const reg = await loadDevices();
418
- const self = machineId();
419
- const online = planFleetTargets(reg)
420
- .filter((t) => !t.skip && t.device.name !== self)
421
- .map((t) => t.device);
422
- const deviceFilter = opts.devices && opts.devices.length > 0 ? new Set(opts.devices) : null;
423
- const devices = deviceFilter ? online.filter((d) => deviceFilter.has(d.name)) : online;
424
- const agents = opts.agents && opts.agents.length > 0
425
- ? opts.agents
426
- : Object.keys(FLEET_LOGIN_FLOWS);
427
- const cache = readAuthHealthCache();
428
- return selectLoginTargets(devices, agents, cache, { includeLoggedIn: opts.includeLoggedIn });
429
- }
430
- /**
431
- * Best-effort epoch-seconds mtime of a home-relative file on a remote box, or 0
432
- * when it is absent/unreadable. Used to detect the credential file appearing /
433
- * bumping after the human completes the browser step. POSIX `stat` only (the
434
- * device-code agents are all POSIX-fleet).
435
- */
436
- async function remoteFileMtime(target, homeRel, extraSshArgs = []) {
437
- const cmd = `stat -c %Y "$HOME/${homeRel}" 2>/dev/null || stat -f %m "$HOME/${homeRel}" 2>/dev/null || echo 0`;
438
- const res = await sshExecAsync(target, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs }).catch(() => null);
439
- if (!res || res.code !== 0)
440
- return 0;
441
- const n = parseInt(res.stdout.trim(), 10);
442
- return Number.isFinite(n) ? n : 0;
443
- }
444
- const DEVICE_CODE_TTL_MS = 15 * 60 * 1000;
445
- /**
446
- * Orchestrate `agents fleet login`: detect pending -> serve the local dashboard
447
- * + open the browser -> drive each box's device-code flow (concurrent in bulk,
448
- * sequential just-in-time in interactive) -> poll the credential file for
449
- * completion -> print a final matrix. The pure pieces above carry the logic;
450
- * this wires them to the live fleet.
451
- */
452
- export async function runFleetLogin(opts = {}) {
453
- const pending = await detectPending({ agents: opts.agents, devices: opts.devices, includeLoggedIn: opts.all });
454
- if (pending.length === 0)
455
- return [];
456
- const mode = opts.interactive ? 'interactive' : 'bulk';
457
- const codeTtl = opts.codeTtlMs ?? DEVICE_CODE_TTL_MS;
458
- const key = (device, agent) => `${device}::${agent}`;
459
- const statuses = new Map();
460
- for (const p of pending) {
461
- statuses.set(key(p.device, p.agent), {
462
- device: p.device,
463
- agent: String(p.agent),
464
- remotable: p.remotable,
465
- reason: p.reason,
466
- state: p.remotable ? 'pending' : 'skipped',
467
- });
468
- }
469
- // Local dashboard server (loopback only; read-only status endpoint).
470
- const server = http.createServer((req, res) => {
471
- const host = (req.headers.host ?? '').replace(/:\d+$/, '').toLowerCase();
472
- if (host && host !== 'localhost' && host !== '127.0.0.1' && host !== '[::1]' && host !== '::1') {
473
- res.writeHead(403, { 'content-type': 'text/plain' });
474
- res.end('forbidden');
475
- return;
476
- }
477
- if (req.method !== 'GET') {
478
- res.writeHead(405).end('method not allowed');
479
- return;
480
- }
481
- const url = (req.url ?? '/').split('?')[0];
482
- if (url === '/' || url === '/index.html') {
483
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
484
- res.end(buildDashboardHtml(pending, mode));
485
- return;
486
- }
487
- if (url === '/api/status') {
488
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
489
- res.end(JSON.stringify({ statuses: [...statuses.values()] }));
490
- return;
491
- }
492
- res.writeHead(404).end('not found');
493
- });
494
- const boundPort = await new Promise((resolve) => {
495
- server.listen(opts.port ?? 0, '127.0.0.1', () => {
496
- const addr = server.address();
497
- resolve(typeof addr === 'object' && addr ? addr.port : (opts.port ?? 0));
498
- });
499
- });
500
- const dashUrl = `http://127.0.0.1:${boundPort}/`;
501
- if (!opts.json) {
502
- console.log(`Dashboard: ${dashUrl}`);
503
- }
504
- if (opts.open !== false) {
505
- const shown = await showUrl(dashUrl);
506
- if (shown.via === 'none') {
507
- console.error('Could not open a browser — open this yourself:');
508
- console.error(` ${dashUrl}`);
509
- }
510
- }
511
- const driver = opts.driver ?? defaultTermDriver();
512
- const remotable = pending.filter((p) => p.remotable);
513
- const driveOne = async (p) => {
514
- const k = key(p.device, p.agent);
515
- const st = statuses.get(k);
516
- st.state = 'driving';
517
- const baseMtime = await remoteFileMtime(p.target, p.flow.successFile, p.extraSshArgs).catch(() => 0);
518
- let sessionId;
519
- try {
520
- const r = await driveRemoteLogin(p.target, p.flow, driver, opts.drive, p.extraSshArgs);
521
- sessionId = r.sessionId;
522
- if (r.url && r.code) {
523
- st.url = r.url;
524
- st.code = r.code;
525
- st.expiresAt = Date.now() + codeTtl;
526
- st.state = 'ready';
527
- }
528
- else {
529
- st.state = 'error';
530
- st.detail = r.exited ? 'login process exited before printing a code' : 'timed out scraping the device code';
531
- if (sessionId)
532
- await driver.stop(sessionId);
533
- return;
534
- }
535
- }
536
- catch (e) {
537
- st.state = 'error';
538
- st.detail = e.message;
539
- return;
540
- }
541
- // Poll the credential file for the human completing the browser step.
542
- const codeDeadline = st.expiresAt ?? Date.now() + codeTtl;
543
- for (;;) {
544
- if (Date.now() > codeDeadline) {
545
- st.state = 'error';
546
- st.detail = 'device code expired before authorization';
547
- break;
548
- }
549
- const mtime = await remoteFileMtime(p.target, p.flow.successFile, p.extraSshArgs).catch(() => 0);
550
- if (mtime > baseMtime) {
551
- st.state = 'authorized';
552
- break;
553
- }
554
- await sleep(3000);
555
- }
556
- if (sessionId)
557
- await driver.stop(sessionId);
558
- };
559
- if (mode === 'interactive') {
560
- for (const p of remotable)
561
- await driveOne(p); // sequential, just-in-time codes
562
- }
563
- else {
564
- await Promise.all(remotable.map((p) => driveOne(p))); // bulk, concurrent
565
- }
566
- server.close();
567
- return [...statuses.values()];
568
- }