@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.
- package/CHANGELOG.md +149 -14
- package/README.md +1 -1
- package/dist/commands/browser.js +11 -0
- package/dist/commands/exec.js +212 -141
- package/dist/commands/feed.js +65 -14
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +88 -7
- package/dist/commands/sessions.d.ts +21 -2
- package/dist/commands/sessions.js +157 -3
- package/dist/commands/setup-secrets.d.ts +2 -2
- package/dist/commands/setup-term.d.ts +24 -0
- package/dist/commands/setup-term.js +70 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +12 -4
- package/dist/commands/ssh.js +1 -69
- package/dist/lib/accounting/rotate.d.ts +63 -1
- package/dist/lib/accounting/rotate.js +56 -0
- package/dist/lib/accounts/add.js +2 -2
- package/dist/lib/accounts/slots.js +32 -2
- package/dist/lib/answer-router.d.ts +11 -2
- package/dist/lib/answer-router.js +26 -2
- package/dist/lib/auth-mint.d.ts +5 -4
- package/dist/lib/auth-mint.js +4 -3
- package/dist/lib/browser/drivers/arc.d.ts +1 -1
- package/dist/lib/browser/service.d.ts +10 -0
- package/dist/lib/browser/service.js +208 -36
- package/dist/lib/browser/types.d.ts +18 -0
- package/dist/lib/config-keys.d.ts +1 -1
- package/dist/lib/config-keys.js +5 -0
- package/dist/lib/device-config.js +61 -0
- package/dist/lib/devices/doctor-findings.js +2 -6
- package/dist/lib/feed/answer.d.ts +153 -4
- package/dist/lib/feed/answer.js +716 -105
- package/dist/lib/feed/feed.d.ts +61 -1
- package/dist/lib/feed/feed.js +226 -14
- package/dist/lib/feed/hub-server.d.ts +58 -3
- package/dist/lib/feed/hub-server.js +306 -54
- package/dist/lib/feed/pr-status.d.ts +8 -0
- package/dist/lib/feed/pr-status.js +9 -1
- package/dist/lib/feed-outcome.d.ts +1 -1
- package/dist/lib/feed-outcome.js +9 -2
- package/dist/lib/feed-policy.js +9 -3
- package/dist/lib/fleet/auth-sync.d.ts +2 -55
- package/dist/lib/fleet/auth-sync.js +2 -89
- package/dist/lib/harness-auth-capabilities.js +7 -2
- package/dist/lib/hosts/dispatch.d.ts +20 -1
- package/dist/lib/hosts/dispatch.js +52 -30
- package/dist/lib/hosts/remote-cmd.d.ts +21 -0
- package/dist/lib/hosts/remote-cmd.js +26 -2
- package/dist/lib/mailbox.d.ts +12 -0
- package/dist/lib/mailbox.js +16 -2
- package/dist/lib/menubar/snapshot.d.ts +51 -0
- package/dist/lib/menubar/snapshot.js +42 -3
- package/dist/lib/open-url.js +2 -2
- package/dist/lib/projects.d.ts +23 -0
- package/dist/lib/projects.js +78 -0
- package/dist/lib/secrets-cli.d.ts +3 -3
- package/dist/lib/secrets-cli.js +1 -1
- package/dist/lib/session/active.d.ts +1 -0
- package/dist/lib/session/active.js +8 -0
- package/dist/lib/session/db.d.ts +67 -3
- package/dist/lib/session/db.js +381 -126
- package/dist/lib/session/prompt.d.ts +23 -7
- package/dist/lib/session/prompt.js +46 -8
- package/dist/lib/session/remote/remote-list.d.ts +20 -0
- package/dist/lib/session/remote/remote-list.js +22 -6
- package/dist/lib/session/remote/watch.d.ts +12 -0
- package/dist/lib/session/remote/watch.js +9 -0
- package/dist/lib/session/remote-preview-cache.d.ts +29 -0
- package/dist/lib/session/remote-preview-cache.js +373 -0
- package/dist/lib/session/tail.d.ts +50 -0
- package/dist/lib/session/tail.js +219 -0
- package/dist/lib/setup-tool-install.js +2 -1
- package/dist/lib/setup-tool-status.d.ts +1 -1
- package/dist/lib/setup-tool-status.js +6 -1
- package/dist/lib/signin-badge.d.ts +19 -4
- package/dist/lib/signin-badge.js +29 -11
- package/dist/lib/term-driver.d.ts +24 -0
- package/dist/lib/term-driver.js +36 -0
- package/dist/lib/terminal/index.d.ts +1 -1
- package/dist/lib/terminal/index.js +1 -1
- package/dist/lib/terminal/inject.d.ts +38 -0
- package/dist/lib/terminal/inject.js +55 -9
- package/dist/lib/terminal/transport.d.ts +15 -5
- package/dist/lib/terminal/transport.js +61 -11
- package/package.json +1 -1
- package/dist/lib/fleet/remote-login.d.ts +0 -170
- 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, '&')
|
|
177
|
-
.replace(/</g, '<')
|
|
178
|
-
.replace(/>/g, '>')
|
|
179
|
-
.replace(/"/g, '"');
|
|
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} · ${remotableCount} remotable · <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 →</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
|
-
}
|