|
| 1 | +/** |
| 2 | + * run402 operator — the operator (human / email) session. |
| 3 | + * |
| 4 | + * The operator is YOU, the human, identified by email — distinct from the |
| 5 | + * AGENT (your wallet / SIWX identity). One browser login spans every wallet |
| 6 | + * that verified your email, so `operator overview` returns the cross-wallet |
| 7 | + * union. For a single wallet's account state, use `run402 status`. |
| 8 | + * |
| 9 | + * Auth: browser-delegated device-authorization grant (RFC 8628, the |
| 10 | + * `aws sso login` model). The CLI never performs WebAuthn — the browser does, |
| 11 | + * via the existing magic-link / passkey flows — and the CLI brokers the |
| 12 | + * resulting operator-session token, cached at the BASE config dir (shared |
| 13 | + * across named wallets, since the session is email-scoped). |
| 14 | + * |
| 15 | + * Agent-first: JSON to stdout. `login` additionally prints the verification URL |
| 16 | + * + user code to stderr (human-in-the-loop) and degrades gracefully when not a |
| 17 | + * TTY. Gated on the gateway device-auth bridge (kychee-com/run402-private#443). |
| 18 | + */ |
| 19 | + |
| 20 | +import { setTimeout as sleep } from "node:timers/promises"; |
| 21 | +import { spawn } from "node:child_process"; |
| 22 | +import { fail, reportSdkError } from "./sdk-errors.mjs"; |
| 23 | +import { getSdk } from "./sdk.mjs"; |
| 24 | +import { normalizeArgv, hasHelp, assertKnownFlags } from "./argparse.mjs"; |
| 25 | +import { |
| 26 | + saveOperatorSession, |
| 27 | + clearOperatorSession, |
| 28 | + loadLiveOperatorSession, |
| 29 | + readOperatorSession, |
| 30 | + isOperatorSessionExpired, |
| 31 | + operatorSessionFromTokenResponse, |
| 32 | +} from "../core-dist/operator-session.js"; |
| 33 | + |
| 34 | +const CLIENT_NAME = "run402 CLI"; |
| 35 | + |
| 36 | +const HELP = `run402 operator — operator (human / email) session |
| 37 | +
|
| 38 | +The operator is YOU, the human, identified by email — distinct from the agent |
| 39 | +(your wallet). One browser login spans every wallet that verified your email. |
| 40 | +For a single wallet's account state, use 'run402 status'. |
| 41 | +
|
| 42 | +Usage: |
| 43 | + run402 operator login [--no-open] |
| 44 | + run402 operator overview |
| 45 | + run402 operator whoami |
| 46 | + run402 operator logout |
| 47 | +
|
| 48 | +Subcommands: |
| 49 | + login Sign in via the browser (device-authorization, like 'aws sso login') |
| 50 | + overview Account view across ALL wallets controlling your email (requires login) |
| 51 | + whoami Show the cached session (email, wallets, expiry) — local, no network |
| 52 | + logout Revoke the session server-side and clear the local cache |
| 53 | +
|
| 54 | +Options: |
| 55 | + --no-open (login) Do not auto-open the browser; just print the URL + code. |
| 56 | +
|
| 57 | +Notes: |
| 58 | + - The session is cached at the base config dir, shared across named wallets. |
| 59 | + - 'overview' requires 'login' and never falls back to a single wallet. |
| 60 | + - JSON to stdout; 'login' prints the URL + code to stderr (human-in-the-loop). |
| 61 | +`; |
| 62 | + |
| 63 | +/** Shared output shape for `whoami` and the `login` success result. */ |
| 64 | +function sessionView(session, nowMs = Date.now()) { |
| 65 | + return { |
| 66 | + logged_in: true, |
| 67 | + email: session.email, |
| 68 | + wallets: session.wallets, |
| 69 | + wallet_count: session.wallets.length, |
| 70 | + expires_at: new Date(session.expires_at).toISOString(), |
| 71 | + absolute_expires_at: session.absolute_expires_at || null, |
| 72 | + expires_in_seconds: Math.max(0, Math.round((session.expires_at - nowMs) / 1000)), |
| 73 | + }; |
| 74 | +} |
| 75 | + |
| 76 | +/** Best-effort, cross-platform browser open. Never throws. */ |
| 77 | +function openBrowser(url) { |
| 78 | + try { |
| 79 | + let cmd; |
| 80 | + let cmdArgs; |
| 81 | + if (process.platform === "darwin") { |
| 82 | + cmd = "open"; |
| 83 | + cmdArgs = [url]; |
| 84 | + } else if (process.platform === "win32") { |
| 85 | + cmd = "cmd"; |
| 86 | + cmdArgs = ["/c", "start", "", url]; |
| 87 | + } else { |
| 88 | + cmd = "xdg-open"; |
| 89 | + cmdArgs = [url]; |
| 90 | + } |
| 91 | + const child = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true }); |
| 92 | + child.on("error", () => {}); // ignore: the URL is also printed to stderr |
| 93 | + child.unref(); |
| 94 | + } catch { |
| 95 | + // Best-effort only — the human can always copy the printed URL. |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +async function login(args) { |
| 100 | + assertKnownFlags(args, ["--help", "-h", "--no-open"]); |
| 101 | + const noOpen = args.includes("--no-open"); |
| 102 | + const sdk = getSdk(); |
| 103 | + |
| 104 | + let start; |
| 105 | + try { |
| 106 | + start = await sdk.operator.deviceStart({ clientName: CLIENT_NAME }); |
| 107 | + } catch (err) { |
| 108 | + return reportSdkError(err); |
| 109 | + } |
| 110 | + |
| 111 | + // Human-in-the-loop prompt → stderr, so stdout stays clean for the final JSON. |
| 112 | + const target = start.verification_uri_complete || start.verification_uri; |
| 113 | + process.stderr.write( |
| 114 | + `\nTo authorize the ${CLIENT_NAME}, open:\n ${start.verification_uri}\n` + |
| 115 | + `and enter the code: ${start.user_code}\n\n`, |
| 116 | + ); |
| 117 | + if (!noOpen && process.stderr.isTTY) { |
| 118 | + openBrowser(target); |
| 119 | + process.stderr.write("(opening your browser…)\n\n"); |
| 120 | + } |
| 121 | + process.stderr.write("Waiting for approval…\n"); |
| 122 | + |
| 123 | + // Poll loop — honor the server interval, back off on slow_down, and stop at |
| 124 | + // the device-code deadline. if/else (not switch) so the sync scanner doesn't |
| 125 | + // mistake the poll states for CLI subcommands. |
| 126 | + let intervalMs = Math.max(1, Number(start.interval) || 5) * 1000; |
| 127 | + const deadline = Date.now() + Math.max(1, Number(start.expires_in) || 600) * 1000; |
| 128 | + |
| 129 | + while (Date.now() < deadline) { |
| 130 | + await sleep(intervalMs); |
| 131 | + let result; |
| 132 | + try { |
| 133 | + result = await sdk.operator.devicePoll(start.device_code); |
| 134 | + } catch (err) { |
| 135 | + return reportSdkError(err); |
| 136 | + } |
| 137 | + if (result.kind === "approved") { |
| 138 | + const session = operatorSessionFromTokenResponse(result.session); |
| 139 | + saveOperatorSession(session); |
| 140 | + process.stderr.write(`\nSigned in as ${session.email}.\n`); |
| 141 | + console.log(JSON.stringify(sessionView(session))); |
| 142 | + return; |
| 143 | + } |
| 144 | + if (result.kind === "authorization_pending") continue; |
| 145 | + if (result.kind === "slow_down") { |
| 146 | + intervalMs += 5000; |
| 147 | + continue; |
| 148 | + } |
| 149 | + if (result.kind === "access_denied") { |
| 150 | + fail({ |
| 151 | + code: "OPERATOR_LOGIN_DENIED", |
| 152 | + message: "Authorization was denied in the browser.", |
| 153 | + hint: "Run 'run402 operator login' to try again.", |
| 154 | + }); |
| 155 | + } |
| 156 | + if (result.kind === "expired_token") { |
| 157 | + fail({ |
| 158 | + code: "OPERATOR_LOGIN_EXPIRED", |
| 159 | + message: "The device code expired before approval.", |
| 160 | + hint: "Run 'run402 operator login' to get a fresh code.", |
| 161 | + }); |
| 162 | + } |
| 163 | + fail({ code: "OPERATOR_LOGIN_FAILED", message: `Unexpected device poll result: ${result.kind}` }); |
| 164 | + } |
| 165 | + fail({ |
| 166 | + code: "OPERATOR_LOGIN_TIMEOUT", |
| 167 | + message: "Timed out waiting for browser approval.", |
| 168 | + hint: "Run 'run402 operator login' to try again.", |
| 169 | + }); |
| 170 | +} |
| 171 | + |
| 172 | +async function logout(args) { |
| 173 | + assertKnownFlags(args, ["--help", "-h"]); |
| 174 | + const session = loadLiveOperatorSession(); |
| 175 | + let revoked = false; |
| 176 | + if (session) { |
| 177 | + try { |
| 178 | + await getSdk().operator.revoke({ token: session.operator_session_token }); |
| 179 | + revoked = true; |
| 180 | + } catch { |
| 181 | + // Best-effort: a failed server revoke (expired token, offline) must not |
| 182 | + // block clearing the local cache. The local token is removed regardless. |
| 183 | + revoked = false; |
| 184 | + } |
| 185 | + } |
| 186 | + clearOperatorSession(); |
| 187 | + console.log(JSON.stringify({ revoked, cleared: true })); |
| 188 | +} |
| 189 | + |
| 190 | +async function overview(args) { |
| 191 | + assertKnownFlags(args, ["--help", "-h"]); |
| 192 | + const session = loadLiveOperatorSession(); |
| 193 | + if (!session) { |
| 194 | + fail({ |
| 195 | + code: "OPERATOR_LOGIN_REQUIRED", |
| 196 | + message: "No operator session. Run 'run402 operator login' to sign in.", |
| 197 | + hint: "operator overview shows the union across all wallets controlling your email; for a single wallet use 'run402 status'.", |
| 198 | + }); |
| 199 | + } |
| 200 | + try { |
| 201 | + const result = await getSdk().operator.overview({ token: session.operator_session_token }); |
| 202 | + console.log(JSON.stringify(result, null, 2)); |
| 203 | + } catch (err) { |
| 204 | + // 401/403 means the session was revoked or expired server-side. Clear the |
| 205 | + // stale cache and point at re-login instead of leaving a dead token behind. |
| 206 | + if (err && (err.status === 401 || err.status === 403)) { |
| 207 | + clearOperatorSession(); |
| 208 | + fail({ |
| 209 | + code: "OPERATOR_SESSION_INVALID", |
| 210 | + message: "Operator session is no longer valid (revoked or expired).", |
| 211 | + hint: "Run 'run402 operator login' to sign in again.", |
| 212 | + }); |
| 213 | + } |
| 214 | + reportSdkError(err); |
| 215 | + } |
| 216 | +} |
| 217 | + |
| 218 | +async function whoami(args) { |
| 219 | + assertKnownFlags(args, ["--help", "-h"]); |
| 220 | + const now = Date.now(); |
| 221 | + const session = readOperatorSession(); |
| 222 | + if (!session) { |
| 223 | + console.log(JSON.stringify({ logged_in: false, reason: "no_session", hint: "Run 'run402 operator login' to sign in." })); |
| 224 | + process.exitCode = 1; |
| 225 | + return; |
| 226 | + } |
| 227 | + if (isOperatorSessionExpired(session, now)) { |
| 228 | + console.log(JSON.stringify({ logged_in: false, reason: "expired", email: session.email, hint: "Run 'run402 operator login' to sign in again." })); |
| 229 | + process.exitCode = 1; |
| 230 | + return; |
| 231 | + } |
| 232 | + console.log(JSON.stringify(sessionView(session, now))); |
| 233 | +} |
| 234 | + |
| 235 | +export async function run(sub, args = []) { |
| 236 | + args = normalizeArgv(args); |
| 237 | + if (!sub || sub === "--help" || sub === "-h" || hasHelp(args)) { |
| 238 | + console.log(HELP); |
| 239 | + process.exit(0); |
| 240 | + } |
| 241 | + switch (sub) { |
| 242 | + case "login": |
| 243 | + await login(args); |
| 244 | + break; |
| 245 | + case "logout": |
| 246 | + await logout(args); |
| 247 | + break; |
| 248 | + case "overview": |
| 249 | + await overview(args); |
| 250 | + break; |
| 251 | + case "whoami": |
| 252 | + await whoami(args); |
| 253 | + break; |
| 254 | + default: |
| 255 | + fail({ |
| 256 | + code: "BAD_USAGE", |
| 257 | + message: `Unknown subcommand: operator ${sub}`, |
| 258 | + hint: "Run 'run402 operator --help' for usage.", |
| 259 | + }); |
| 260 | + } |
| 261 | +} |
0 commit comments