From 103e34f4e4eff607602b26e3033e4b5d08a34311 Mon Sep 17 00:00:00 2001 From: B1anYu <97398768+B1anYu@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:21:44 -0400 Subject: [PATCH] Feat: headless paste login for bigmodel OAuth (--paste, TUI L key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI: `auth login bigmodel --paste` or ZCODE_OAUTH_PASTE=1 — paste the redirected 127.0.0.1 callback URL back instead of waiting on a callback the browser can never reach inside a container (gcloud/gh-style). The normal flow prints a headless hint pointing at --paste. TUI: single OAuth Login entry as before; while a login waits, a yellow highlight row offers `L` to switch it to paste mode (Promise.race with the callback waiter, losing branch's timeout swallowed, client closed exactly once). Idle `L` starts a paste login directly. parsePastedCallbackUrl (auth/oauth.ts): query-only parsing, copy-artifact tolerant (quotes/brackets, & entities), accepts both authCode and code params, strict CSRF state check. runtime/paste-login.ts (new): banner instructions with a bracketed URL example using the real callback port (ANSI-free when piped) + readline with timeout. open-browser.ts: fix ENOENT crash on headless boxes (async spawn error had no listener). Tests: 14 new cases (parser incl. authorize↔exchange redirect_uri round-trip, IO helpers). bun test 732 pass; bun x tsc --noEmit clean. README: headless/Docker login section, TUI key table, env var table. Co-Authored-By: Claude Code --- README.md | 33 +++++++- src/auth/oauth.test.ts | 77 ++++++++++++++++- src/auth/oauth.ts | 45 +++++++++- src/index.ts | 54 ++++++++++-- src/runtime/open-browser.ts | 25 ++++-- src/runtime/paste-login.test.ts | 47 +++++++++++ src/runtime/paste-login.ts | 81 ++++++++++++++++++ src/tui/app.ts | 142 ++++++++++++++++++++++++++++++-- src/tui/frame.ts | 7 +- 9 files changed, 489 insertions(+), 22 deletions(-) create mode 100644 src/runtime/paste-login.test.ts create mode 100644 src/runtime/paste-login.ts diff --git a/README.md b/README.md index 5f708e8..8ddecae 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,33 @@ If you already use the ZCode desktop app, import the API key directly: bun run src/index.ts auth login bigmodel --import ``` +### Headless / Docker Login (paste mode, bigmodel) + +On a headless machine (Docker container, VPS) the browser cannot reach the +localhost callback server inside the container. + +```bash +bun run src/index.ts auth login bigmodel --paste +``` +or via env (handy in docker): +```bash +ZCODE_OAUTH_PASTE=1 bun run src/index.ts auth login bigmodel +``` + +1. The CLI prints the bigmodel authorize URL — open it in a browser **on any + machine** (your laptop is fine). +2. After authorizing, the browser redirects to + `http://127.0.0.1:/oauth/callback/bigmodel?...` and the page will + not load (connection refused). +3. Copy the full redirected URL from the address bar, paste it back into + the CLI, and press Enter. The CLI exchanges it for credentials. + +Tips: + +- The TUI supports the same flow: press `l` (or click `OAuth Login`); if the callback can't reach the machine + (headless), press `L` and the panel temporarily suspends while you paste the + redirected URL, then resumes. + ## Endpoints | Method | Path | Description | @@ -223,6 +250,7 @@ captured in-process) with scrollback and tail-following. |-----|--------| | `s` | Start / stop the proxy server | | `l` | OAuth login for the current provider (opens the browser) | +| `L` | Headless paste login (`bigmodel`): while a login is waiting, switch it to paste-the-redirected-URL mode | | `o` | Logout (cancels an in-flight login and releases its callback port) | | `p` / `t` | Switch provider (`zai` ↔ `bigmodel`) / plan (`coding-plan` ↔ `start-plan`) — requires the proxy stopped, persisted to `config.yaml` | | `↑↓` / `PgUp`/`PgDn` / `Home`/`End` | Scroll the log pane | @@ -333,7 +361,9 @@ docker pull ghcr.io/tridefender/zcode-proxy:latest Upstream credentials come only from `auth login`, so the container needs the encrypted credential store. Log in on the host with a fixed encryption seed -(the container cannot derive the default machine seed), then mount the store: +(the container cannot derive the default machine seed), then mount the store +— or log in **inside** the container with bigmodel paste mode (see +[Headless / Docker Login](#headless--docker-login-paste-mode-bigmodel)): ```bash # 1. Log in on the host with a portable seed @@ -359,6 +389,7 @@ Common environment variables (see the Configuration table above for the full lis | `ZCODE_PROXY_API_KEY` | Client auth shared secret | | `ZCODE_PROXY_CREDENTIAL_SECRET` | Encryption seed for the credential store (must match the seed used at `auth login`) | | `ZCODE_PROXY_PORT` | Listen port (default `8080`) | +| `ZCODE_OAUTH_PASTE` | Set `1` for bigmodel paste-mode login inside the container (see [Headless / Docker Login](#headless--docker-login-paste-mode-bigmodel)) | docker-compose: diff --git a/src/auth/oauth.test.ts b/src/auth/oauth.test.ts index 87e398f..555680a 100644 --- a/src/auth/oauth.test.ts +++ b/src/auth/oauth.test.ts @@ -9,7 +9,7 @@ * @see _reverse/NOTEPAD.md "Method 1: OAuth Flow" */ import { describe, it, expect } from "bun:test"; -import { ZaiOAuthClient, BigmodelOAuthClient } from "./oauth.js"; +import { ZaiOAuthClient, BigmodelOAuthClient, parsePastedCallbackUrl } from "./oauth.js"; /** Wrap data in the zcode.z.ai `{code, data, msg}` envelope as a JSON Response. */ function envelopeResponse(data: Record, status = 200): Response { @@ -240,3 +240,78 @@ describe("BigmodelOAuthClient (auth-code flow)", () => { expect(result.jwt).toBe("jwt_full"); }); }); + +describe("parsePastedCallbackUrl (headless paste login)", () => { + const state = "a".repeat(64); + + it("extracts authCode from a full callback URL", () => { + const pasted = `http://127.0.0.1:49152/oauth/callback/bigmodel?authCode=code_1&state=${state}`; + expect(parsePastedCallbackUrl(pasted, state)).toBe("code_1"); + }); + + it("accepts the `code` param name as well", () => { + expect(parsePastedCallbackUrl(`http://127.0.0.1/cb?code=code_2&state=${state}`, state)).toBe("code_2"); + }); + + it("only inspects the query — pathname/host are not validated", () => { + const pasted = `https://anything.example/whatever/path?code=c&state=${state}`; + expect(parsePastedCallbackUrl(pasted, state)).toBe("c"); + }); + + it("throws on a CSRF state mismatch", () => { + const pasted = `http://127.0.0.1/?code=x&state=${"b".repeat(64)}`; + expect(() => parsePastedCallbackUrl(pasted, state)).toThrow(/state mismatch/i); + }); + + it("throws when the state param is absent", () => { + expect(() => parsePastedCallbackUrl("http://127.0.0.1/?code=x", state)).toThrow(/state mismatch/i); + }); + + it("throws when the code is missing", () => { + const pasted = `http://127.0.0.1/?state=${state}`; + expect(() => parsePastedCallbackUrl(pasted, state)).toThrow(/authorization code/i); + }); + + it("unescapes & entities from a copy out of a rendered page", () => { + const pasted = `http://127.0.0.1/cb?authCode=amp_code&state=${state}`; + expect(parsePastedCallbackUrl(pasted, state)).toBe("amp_code"); + }); + + it("trims whitespace and wrapping quotes/brackets", () => { + expect(parsePastedCallbackUrl(` "http://127.0.0.1/?code=q&state=${state}"\n`, state)).toBe("q"); + expect(parsePastedCallbackUrl(`()`, state)).toBe("p"); + }); + + it("surfaces the provider error param when authorization was denied", () => { + const pasted = `http://127.0.0.1/?error=access_denied&state=${state}`; + expect(() => parsePastedCallbackUrl(pasted, state)).toThrow(/access_denied/); + }); + + it("paste round-trip: the exchange redirect_uri equals the authorize redirect param", async () => { + const { impl, calls } = scriptedFetch([ + () => envelopeResponse({ + token: "jwt_paste", + bigmodel: { access_token: "bm_paste" }, + user: { user_id: "u9" }, + }), + ]); + const client = new BigmodelOAuthClient(impl); + const started = await client.start(); + try { + // The browser appends the code to the authorize URL's exact `redirect`; + // the user pastes that unreachable URL back. + const redirect = new URL(started.authorizeUrl).searchParams.get("redirect") ?? ""; + expect(started.callbackUrl).toBe(redirect); + const pasted = `${redirect}?authCode=paste_code&state=${started.state}`; + const code = parsePastedCallbackUrl(pasted, started.state); + await client.exchangeCode(code, started.callbackUrl, started.state); + + const exchange = JSON.parse(String(calls[0].init.body)); + expect(exchange.redirect_uri).toBe(redirect); + expect(exchange.code).toBe("paste_code"); + expect(exchange.state).toBe(started.state); + } finally { + await client.close(); + } + }); +}); diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 57abf05..88b4eda 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -38,7 +38,7 @@ const BIGMODEL_HOST = "https://bigmodel.cn"; const BIGMODEL_APP_ID = "zcode"; /** Overall login timeout shared by both flows (bundle `tln`). */ -const LOGIN_TIMEOUT_MS = 300_000; +export const LOGIN_TIMEOUT_MS = 300_000; // --------------------------------------------------------------------------- // Shared types @@ -515,6 +515,49 @@ export class BigmodelOAuthClient extends AuthCodeOAuthClient { } } +// --------------------------------------------------------------------------- +// Headless paste login (auth-code flow) +// --------------------------------------------------------------------------- + +/** + * Parse a callback URL the user pasted back into the terminal (headless + * `--paste` login, mirroring gcloud/gh CLI). Only the query string is + * inspected — the pathname/host are whatever the browser's address bar shows + * and are never validated. Tolerates copy artifacts from web pages: wrapping + * quotes/brackets, surrounding whitespace, and `&` entities (a paste from + * a rendered page splits the query at the `&` inside `&`, which breaks + * `state`). Returns the auth code (`authCode` or `code` param); throws on a + * CSRF `state` mismatch or a missing code. + */ +export function parsePastedCallbackUrl(raw: string, expectedState: string): string { + let text = raw.trim().replace(/^["'`<([{]+/, "").replace(/["'`>\])}]+$/, ""); + text = text.replace(/&/gi, "&"); + + const q = text.indexOf("?"); + const query = (q >= 0 ? text.slice(q + 1) : text).split("#")[0]; + const params = new URLSearchParams(query); + + const state = params.get("state") ?? ""; + if (!state || state !== expectedState) { + throw new Error( + "OAuth state mismatch — the pasted URL does not belong to this login " + + "session (possible CSRF). Retry the login.", + ); + } + + const providerError = params.get("error"); + const code = params.get("authCode") ?? params.get("code") ?? ""; + if (!code) { + throw new Error( + providerError + ? `Authorization failed: provider returned error=${providerError}` + : "No authorization code in the pasted URL — paste the redirected " + + "127.0.0.1 callback URL (the one that failed to load), not the authorize URL.", + ); + } + return code; +} + // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- diff --git a/src/index.ts b/src/index.ts index e071d94..8d08a7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,13 +8,14 @@ import { AuthManager } from "./auth/manager.js"; import { startServer, type ProxyServer } from "./server/server.js"; import { startControlListener, LogBuffer, type ControlState } from "./android/control.js"; import { loadCredential, saveCredential, clearCredential, getStorePath } from "./auth/store.js"; -import { ZaiOAuthClient, BigmodelOAuthClient } from "./auth/oauth.js"; +import { ZaiOAuthClient, BigmodelOAuthClient, LOGIN_TIMEOUT_MS, parsePastedCallbackUrl, type OAuthResult } from "./auth/oauth.js"; import { KeyResolver } from "./auth/resolver.js"; import type { Credential } from "./auth/types.js"; import type { ProviderId } from "./provider/types.js"; import type { ProxyConfig } from "./config/types.js"; import { updateConfigYaml } from "./config/edit.js"; import { openBrowser } from "./runtime/open-browser.js"; +import { pasteLoginInstructions, readPastedLine, boldIfTTY } from "./runtime/paste-login.js"; import { buildServerOptions } from "./server/server-options.js"; import { readFileSync, existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -405,22 +406,31 @@ async function claimCommand(args: string[]): Promise { async function authLogin(args: string[]): Promise { const provider = args[0] as ProviderId | undefined; const importMode = args.includes("--import"); + // Headless paste login: --paste flag or ZCODE_OAUTH_PASTE=1 (docker-friendly). + const pasteMode = + args.includes("--paste") || /^(1|true|yes)$/i.test(process.env.ZCODE_OAUTH_PASTE ?? ""); if (!provider || (provider !== "zai" && provider !== "bigmodel")) { - console.error("Usage: zcode-proxy auth login [--import]"); + console.error("Usage: zcode-proxy auth login [--import] [--paste]"); + process.exit(1); + } + if (pasteMode && provider !== "bigmodel") { + console.error("--paste applies to the bigmodel auth-code flow only."); + console.error("zai login is server-mediated (no localhost callback) and already works headless."); process.exit(1); } ensureConfigWithDeviceMid(); - console.log(`Logging in: ${provider}${importMode ? " (import)" : " (OAuth)"}\n`); + const mode = importMode ? "(import)" : pasteMode ? "(OAuth, paste)" : "(OAuth)"; + console.log(`Logging in: ${provider} ${mode}\n`); let cred: Credential; if (importMode) { cred = importFromZCodeConfig(provider); } else { - const { accessToken, userId, jwt } = await runOAuth(provider); + const { accessToken, userId, jwt } = await runOAuth(provider, pasteMode); console.log("\nResolving API key..."); const resolver = new KeyResolver(); cred = await resolver.resolveCodingPlanCredential(accessToken, provider, userId); @@ -506,16 +516,21 @@ async function authStatus(): Promise { console.log(` Store: ${getStorePath()}`); } -async function runOAuth(provider: ProviderId): Promise<{ accessToken: string; userId?: string; jwt?: string }> { +async function runOAuth(provider: ProviderId, pasteMode: boolean): Promise { if (provider === "bigmodel") { const oauth = new BigmodelOAuthClient(); + if (pasteMode) return runPasteLogin(oauth); const result = await oauth.authorize((url) => { console.log("Open this URL to authorize:\n"); console.log(` ${url}\n`); console.log("Waiting for authorization... (expires in 300s)\n"); + console.log( + "Headless/Docker? The callback page will NOT load here — Ctrl-C and " + + "re-run with `auth login bigmodel --paste` to paste the redirected URL instead.\n", + ); openBrowser(url); }); - return { accessToken: result.accessToken, userId: result.userId, jwt: result.jwt }; + return result; } const oauth = new ZaiOAuthClient(); @@ -525,7 +540,32 @@ async function runOAuth(provider: ProviderId): Promise<{ accessToken: string; us console.log("Waiting for authorization... (expires in 300s)\n"); openBrowser(url); }); - return { accessToken: result.accessToken, userId: result.userId, jwt: result.jwt }; + return result; +} + +/** + * Headless bigmodel login (`auth login bigmodel --paste`): the localhost + * callback server is still bound — it defines the redirect port and the + * browser can never reach it from inside a container anyway — but instead of + * waiting on it, the user pastes the redirected URL back. The exact + * `started.callbackUrl` string is used BOTH as the authorize `redirect` param + * and as the exchange `redirect_uri` (the token endpoint requires them to + * match), so the pair stays consistent by construction. + */ +async function runPasteLogin(oauth: BigmodelOAuthClient): Promise { + const started = await oauth.start(); + try { + console.log(pasteLoginInstructions(started.authorizeUrl, started.callbackUrl, LOGIN_TIMEOUT_MS)); + openBrowser(started.authorizeUrl); + process.stdout.write("\n" + boldIfTTY("Paste the FULL redirected URL here, then press Enter:") + "\n> "); + const pasted = await readPastedLine(LOGIN_TIMEOUT_MS); + const code = parsePastedCallbackUrl(pasted, started.state); + console.log("\nExchanging authorization code..."); + const tokens = await oauth.exchangeCode(code, started.callbackUrl, started.state); + return { accessToken: tokens.accessToken, provider: "bigmodel", userId: tokens.userId, jwt: tokens.jwt }; + } finally { + await oauth.close(); + } } function importFromZCodeConfig(provider: ProviderId): Credential { diff --git a/src/runtime/open-browser.ts b/src/runtime/open-browser.ts index 21b0e17..4188bdd 100644 --- a/src/runtime/open-browser.ts +++ b/src/runtime/open-browser.ts @@ -3,18 +3,31 @@ * the TUI login action. Best-effort: a failure leaves the URL on screen for * the user to copy manually. */ -import { spawn } from "node:child_process"; +import { spawn, type SpawnOptions } from "node:child_process"; export function openBrowser(url: string): void { try { if (process.platform === "win32") { - spawn("cmd.exe", ["/c", `start "" "${url}"`], { - detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true, - }).unref(); + spawnDetached("cmd.exe", ["/c", `start "" "${url}"`], { + windowsHide: true, windowsVerbatimArguments: true, + }); } else if (process.platform === "darwin") { - spawn("open", [url], { detached: true, stdio: "ignore" }).unref(); + spawnDetached("open", [url]); } else { - spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref(); + spawnDetached("xdg-open", [url]); } } catch { /* user copies URL manually */ } } + +/** + * Spawn a fire-and-forget browser opener. Spawn failures surface + * asynchronously on the child (ENOENT for xdg-open on headless boxes — there + * is no browser to open), not as a synchronous throw, so an "error" listener + * MUST be attached or the crash takes down the whole login process. Either + * way the authorize URL is already on screen for manual copying. + */ +function spawnDetached(cmd: string, args: string[], extra: SpawnOptions = {}): void { + const child = spawn(cmd, args, { detached: true, stdio: "ignore", ...extra }); + child.on("error", () => { /* headless — user copies URL manually */ }); + child.unref(); +} diff --git a/src/runtime/paste-login.test.ts b/src/runtime/paste-login.test.ts new file mode 100644 index 0000000..cf9b89a --- /dev/null +++ b/src/runtime/paste-login.test.ts @@ -0,0 +1,47 @@ +/** + * Tests for the shared paste-login IO helpers (headless bigmodel login). + * The URL parsing / CSRF state check lives in auth/oauth.test.ts; this file + * covers the terminal instructions and the readline-with-timeout. + * + * @see src/runtime/paste-login.ts + */ +import { describe, it, expect } from "bun:test"; +import { PassThrough } from "node:stream"; +import { pasteLoginInstructions, readPastedLine } from "./paste-login.js"; + +describe("readPastedLine", () => { + it("resolves with the first completed line", async () => { + const input = new PassThrough(); + const pending = readPastedLine(1_000, input); + input.write("http://127.0.0.1:9/cb?code=x&state=y\r\n"); + expect(await pending).toBe("http://127.0.0.1:9/cb?code=x&state=y"); + }); + + it("rejects with the login-timeout message when no line arrives", async () => { + const input = new PassThrough(); + await expect(readPastedLine(20, input)).rejects.toThrow(/timed out/); + input.end(); + }); + + it("rejects when the input closes before a line arrives", async () => { + const input = new PassThrough(); + const pending = readPastedLine(1_000, input); + input.end(); + await expect(pending).rejects.toThrow(/stdin closed/); + }); +}); + +describe("pasteLoginInstructions", () => { + it("shows the authorize URL and the bracketed redirected-URL example with the real port", () => { + const text = pasteLoginInstructions( + "https://auth.example/login?appId=zcode", + "http://127.0.0.1:41235/oauth/callback/bigmodel", + 300_000, + ); + expect(text).toContain("https://auth.example/login?appId=zcode"); + expect(text).toContain( + "(http://127.0.0.1:41235/oauth/callback/bigmodel?authCode=xxxxxxxx&state=xxxxxxxx)", + ); + expect(text).toContain("timeout: 300s"); + }); +}); diff --git a/src/runtime/paste-login.ts b/src/runtime/paste-login.ts new file mode 100644 index 0000000..e0c210b --- /dev/null +++ b/src/runtime/paste-login.ts @@ -0,0 +1,81 @@ +/** + * Shared plumbing for the headless "paste" login (auth-code flow): the + * terminal instructions and the readline-with-timeout that collects the + * pasted callback URL. Pure presentation/IO — the URL parsing and CSRF state + * check live in `parsePastedCallbackUrl` (auth/oauth.ts). + * + * Used by the CLI (`auth login bigmodel --paste`) and the TUI (`L` key), + * which suspend their own rendering before calling into these. + */ +import { createInterface } from "node:readline"; + +/** Bold only when attached to a terminal — keeps piped output ANSI-free. */ +export function boldIfTTY(text: string): string { + return process.stdout.isTTY ? `\x1b[1m${text}\x1b[0m` : text; +} + +/** + * Multi-line instructions shown after the flow started, before reading the + * pasted URL. Printed to the REAL terminal by both entries (the CLI's + * console is never intercepted; the TUI passes its captured stdout writer). + * Deliberately loud: the user must understand that the browser "error" page + * is the expected hand-off, and what the redirected URL looks like. + */ +export function pasteLoginInstructions( + authorizeUrl: string, + callbackUrl: string, + timeoutMs: number, +): string { + const bar = "=".repeat(72); + return [ + "", + bar, + boldIfTTY(" PASTE LOGIN — the callback page is expected NOT to load"), + bar, + "", + "Open this URL to authorize (any machine with a browser):", + "", + ` ${authorizeUrl}`, + "", + "After you authorize, the browser redirects to a localhost URL shaped like:", + "", + ` (${callbackUrl}?authCode=xxxxxxxx&state=xxxxxxxx)`, + "", + "That page will NOT load (connection refused) — that is NORMAL on a", + "headless machine. Copy the FULL redirected URL from the browser's", + "address bar, paste it below, and press Enter.", + `(timeout: ${Math.round(timeoutMs / 1000)}s)`, + bar, + ].join("\n"); +} + +/** + * Read one line of stdin (the pasted callback URL) with an overall timeout. + * Callers put the terminal into cooked mode first (the TUI leaves raw mode + * before this), so the tty line discipline echoes the paste. Rejects with + * the standard login-timeout message, or when stdin closes without a line + * (e.g. piped/empty input) so the flow can never hang forever. + */ +export function readPastedLine( + timeoutMs: number, + input: NodeJS.ReadableStream = process.stdin, +): Promise { + return new Promise((resolve, reject) => { + const rl = createInterface({ input, crlfDelay: Infinity }); + let settled = false; + const settle = (fn: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + rl.close(); + fn(); + }; + const timer = setTimeout(() => { + settle(() => reject(new Error("Authorization timed out. Please retry login."))); + }, timeoutMs); + rl.on("line", (line: string) => settle(() => resolve(line))); + rl.on("close", () => { + settle(() => reject(new Error("stdin closed before a callback URL was pasted."))); + }); + }); +} diff --git a/src/tui/app.ts b/src/tui/app.ts index b458893..e479b1b 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -19,9 +19,10 @@ import { AuthManager } from "../auth/manager.js"; import { startServer, type ProxyServer } from "../server/server.js"; import { buildServerOptions } from "../server/server-options.js"; import { loadCredential, saveCredential, clearCredential } from "../auth/store.js"; -import { ZaiOAuthClient, BigmodelOAuthClient, type OAuthFlowClient } from "../auth/oauth.js"; +import { ZaiOAuthClient, BigmodelOAuthClient, LOGIN_TIMEOUT_MS, parsePastedCallbackUrl, type OAuthFlowClient, type OAuthFlowStart, type OAuthFlowTokens } from "../auth/oauth.js"; import { KeyResolver } from "../auth/resolver.js"; import { openBrowser } from "../runtime/open-browser.js"; +import { pasteLoginInstructions, readPastedLine, boldIfTTY } from "../runtime/paste-login.js"; import { ensureDeviceMidInConfig, VERSION, type ServeArgs } from "../index.js"; import { writeFileSync, existsSync, appendFileSync } from "node:fs"; import type { ProxyConfig } from "../config/types.js"; @@ -93,7 +94,10 @@ export async function runTui(args: ServeArgs): Promise { process.exit(1); } // Alt screen + hide cursor + SGR mouse tracking (buttons clickable, wheel scrolls). - realStdoutWrite("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h\x1b[2J"); + const enterAltScreen = (): void => { + realStdoutWrite("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h\x1b[2J"); + }; + enterAltScreen(); const restore = (): void => { try { realStdoutWrite("\x1b[0m\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"); } catch { /* terminal gone */ } @@ -162,7 +166,12 @@ export async function runTui(args: ServeArgs): Promise { let renderTimer: ReturnType | null = null; let lastRenderAt = 0; let activeRegions: ClickRegion[] = []; + // Paste login (L key) hands the terminal back for a moment: renders and + // key handling are frozen while the real screen shows the paste prompt. + let rendersSuspended = false; + let inputSuspended = false; function renderNow(): void { + if (rendersSuspended) return; // paste prompt owns the terminal if (renderTimer) clearTimeout(renderTimer); // watchdog may fire mid-cycle renderTimer = null; lastRenderAt = Date.now(); @@ -332,14 +341,52 @@ export async function runTui(args: ServeArgs): Promise { // --- login / logout (mirrors the control-listener startOAuth/logout) ----- let activeOauth: { client: OAuthFlowClient; provider: ProviderId } | null = null; + /** Set while a bigmodel login waits on the browser callback; `L` invokes it to switch to paste mode. */ + let pasteSwitchBigmodel: (() => void) | null = null; - async function startLogin(): Promise { + async function startLogin(opts: { paste?: boolean } = {}): Promise { if (state.loginInFlight) { setToast("login already in progress", "info"); return; } const provider = state.provider; - const client: OAuthFlowClient = provider === "bigmodel" ? new BigmodelOAuthClient() : new ZaiOAuthClient(); + if (opts.paste && provider !== "bigmodel") { + setToast("paste login is bigmodel-only — zai login already works headless (press l)", "info"); + return; + } + + if (provider === "bigmodel") { + const client = new BigmodelOAuthClient(); + let started: Awaited>; + try { + started = await client.start(); + } catch (err) { + void client.close().catch(() => {}); + setToast(`login failed: ${(err as Error).message}`, "err"); + return; + } + activeOauth = { client, provider }; + state.loginInFlight = true; + console.log(`OAuth: opening ${started.authorizeUrl}`); + console.log("If the browser did not open, copy the URL above into a browser."); + openBrowser(started.authorizeUrl); + if (opts.paste) { + state.loginHint = "paste the callback URL in the terminal…"; + scheduleRender(); + settleLogin(runPasteLoginInTui(client, started), client, provider); + } else { + console.log( + "▸ HEADLESS (Docker/VPS)? The callback page will NOT load here — " + + "PRESS L to paste the redirected URL instead.", + ); + state.loginHint = "waiting for callback · HEADLESS? PRESS L to paste URL"; + scheduleRender(); + settleLogin(waitForCallbackOrPaste(client, started), client, provider); + } + return; + } + + const client: OAuthFlowClient = new ZaiOAuthClient(); let started: Awaited>; try { started = await client.start(); @@ -356,7 +403,57 @@ export async function runTui(args: ServeArgs): Promise { openBrowser(started.authorizeUrl); scheduleRender(); - client.complete(started).then(async (tokens) => { + settleLogin(client.complete(started), client, provider); + } + + /** Sentinel the `L` key resolves to switch a pending login over to paste mode. */ + const PASTE_MODE = Symbol("paste-mode"); + + /** + * Wait for the browser callback — but let `L` switch a pending login to + * paste mode (a headless machine never receives the callback). Exactly one + * branch wins; the loser's eventual timeout rejection is swallowed by the + * race, and the client is closed exactly once in settleLogin. + */ + async function waitForCallbackOrPaste( + client: BigmodelOAuthClient, + started: OAuthFlowStart, + ): Promise { + let requestPaste: () => void = () => {}; + const pasteRequested = new Promise((resolve) => { + requestPaste = () => resolve(PASTE_MODE); + }); + pasteSwitchBigmodel = () => { + state.loginHint = "paste the callback URL in the terminal…"; + scheduleRender(); + requestPaste(); + }; + try { + const winner = await Promise.race([client.waitForCallback(), pasteRequested]); + if (winner === PASTE_MODE) return await runPasteLoginInTui(client, started); + return await client.exchangeCode(winner, started.callbackUrl, started.state); + } finally { + pasteSwitchBigmodel = null; + } + } + + /** `L` key: switch a pending bigmodel login to paste mode (or start one). */ + function requestPasteLogin(): void { + if (state.provider !== "bigmodel") { + setToast("paste login is bigmodel-only — zai login already works headless", "info"); + return; + } + if (state.loginInFlight) { + if (pasteSwitchBigmodel) pasteSwitchBigmodel(); + else setToast("already pasting — finish in the terminal", "info"); + return; + } + void startLogin({ paste: true }); + } + + /** Shared completion tail: resolve key, save, and ALWAYS close the client. */ + function settleLogin(pending: Promise, client: OAuthFlowClient, provider: ProviderId): void { + pending.then(async (tokens) => { const resolver = new KeyResolver(); const cred = await resolver.resolveCodingPlanCredential(tokens.accessToken, provider, tokens.userId); if (tokens.jwt) cred.jwt = tokens.jwt; @@ -379,6 +476,38 @@ export async function runTui(args: ServeArgs): Promise { }); } + /** + * Paste login for the TUI: briefly hands the terminal back (leaves the alt + * screen, drops raw mode) so the user can paste the redirected callback + * URL, then re-enters the TUI and exchanges the code. The callback server + * stays bound — it only defines the redirect port; the browser cannot + * reach it from another machine anyway. + */ + async function runPasteLoginInTui( + client: BigmodelOAuthClient, + started: OAuthFlowStart, + ): Promise { + let pasted: string; + try { + rendersSuspended = true; + inputSuspended = true; + try { (stdin as { setRawMode(m: boolean): void }).setRawMode(false); } catch { /* not raw-able */ } + restore(); // leave the alt screen: the real terminal takes over + realStdoutWrite("\n" + pasteLoginInstructions(started.authorizeUrl, started.callbackUrl, LOGIN_TIMEOUT_MS) + "\n\n"); + realStdoutWrite(boldIfTTY("Paste the FULL redirected URL here, then press Enter:") + "\n> "); + pasted = await readPastedLine(LOGIN_TIMEOUT_MS); + } finally { + enterAltScreen(); + try { (stdin as { setRawMode(m: boolean): void }).setRawMode(true); } catch { /* ignore */ } + inputSuspended = false; + rendersSuspended = false; + renderNow(); + } + const code = parsePastedCallbackUrl(pasted, started.state); + realStdoutWrite("Exchanging authorization code…\n"); + return client.exchangeCode(code, started.callbackUrl, started.state); + } + async function logout(): Promise { // Stop-first guard: the running proxy keeps serving with its in-memory // credential until restarted, so logging out mid-run would only look applied. @@ -406,6 +535,7 @@ export async function runTui(args: ServeArgs): Promise { (stdin as { setRawMode(m: boolean): void }).setRawMode(true); stdin.resume(); stdin.on("data", (chunk: string) => { + if (inputSuspended) return; // paste-login readline owns the terminal for (const action of parser.feed(chunk)) handleKey(action); }); @@ -435,6 +565,8 @@ export async function runTui(args: ServeArgs): Promise { scrollDown(3); return; case "char": { + // Uppercase L must not collapse into "l" below: paste fallback (headless). + if (action.key === "L") { requestPasteLogin(); return; } switch (action.key.toLowerCase()) { case "q": quit(); return; case "s": toggleProxy(); return; diff --git a/src/tui/frame.ts b/src/tui/frame.ts index 0dd19b5..38ba94e 100644 --- a/src/tui/frame.ts +++ b/src/tui/frame.ts @@ -85,6 +85,8 @@ const BTN_RED = "38;2;255;255;255;48;2;218;54;51"; const BTN_BLUE = "38;2;255;255;255;48;2;31;111;235"; const BTN_GRAY = "38;2;201;209;217;48;2;48;54;61"; const BTN_SELECTED = BTN_BLUE; +/** Black on bright yellow — login-hint attention chip, 16-color safe. */ +const HINT_CHIP = "30;103"; function paint(code: string, t: string): string { return `\x1b[${code}m${t}\x1b[0m`; @@ -292,7 +294,10 @@ export function buildFrame(s: FrameState): Frame { regions.push(...loginRow.regions); if (s.loginInFlight && s.loginHint) { - emit((composeRow(w, lines.length, "Login", [{ t: `» ${s.loginHint}`, c: AMBER }])).line); + // High-contrast attention chip (black on bright yellow): the headless + // paste fallback hint must not be missed — plain amber text reads as + // ordinary on many terminal palettes. + emit((composeRow(w, lines.length, "Login", [{ t: ` ▸ ${s.loginHint} `, c: HINT_CHIP }])).line); } emit(renderBottomBorder(w));