Skip to content

Commit 1e4dddd

Browse files
authored
Merge pull request #42 from B1anYu/feat/headless-paste-login
Feat: headless paste login for bigmodel OAuth (--paste, TUI L key)
2 parents 32b2cd1 + 103e34f commit 1e4dddd

9 files changed

Lines changed: 489 additions & 22 deletions

File tree

‎README.md‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ If you already use the ZCode desktop app, import the API key directly:
5454
bun run src/index.ts auth login bigmodel --import
5555
```
5656

57+
### Headless / Docker Login (paste mode, bigmodel)
58+
59+
On a headless machine (Docker container, VPS) the browser cannot reach the
60+
localhost callback server inside the container.
61+
62+
```bash
63+
bun run src/index.ts auth login bigmodel --paste
64+
```
65+
or via env (handy in docker):
66+
```bash
67+
ZCODE_OAUTH_PASTE=1 bun run src/index.ts auth login bigmodel
68+
```
69+
70+
1. The CLI prints the bigmodel authorize URL — open it in a browser **on any
71+
machine** (your laptop is fine).
72+
2. After authorizing, the browser redirects to
73+
`http://127.0.0.1:<port>/oauth/callback/bigmodel?...` and the page will
74+
not load (connection refused).
75+
3. Copy the full redirected URL from the address bar, paste it back into
76+
the CLI, and press Enter. The CLI exchanges it for credentials.
77+
78+
Tips:
79+
80+
- The TUI supports the same flow: press `l` (or click `OAuth Login`); if the callback can't reach the machine
81+
(headless), press `L` and the panel temporarily suspends while you paste the
82+
redirected URL, then resumes.
83+
5784
## Endpoints
5885

5986
| Method | Path | Description |
@@ -223,6 +250,7 @@ captured in-process) with scrollback and tail-following.
223250
|-----|--------|
224251
| `s` | Start / stop the proxy server |
225252
| `l` | OAuth login for the current provider (opens the browser) |
253+
| `L` | Headless paste login (`bigmodel`): while a login is waiting, switch it to paste-the-redirected-URL mode |
226254
| `o` | Logout (cancels an in-flight login and releases its callback port) |
227255
| `p` / `t` | Switch provider (`zai` ↔ `bigmodel`) / plan (`coding-plan` ↔ `start-plan`) — requires the proxy stopped, persisted to `config.yaml` |
228256
| `↑↓` / `PgUp`/`PgDn` / `Home`/`End` | Scroll the log pane |
@@ -333,7 +361,9 @@ docker pull ghcr.io/tridefender/zcode-proxy:latest
333361

334362
Upstream credentials come only from `auth login`, so the container needs the
335363
encrypted credential store. Log in on the host with a fixed encryption seed
336-
(the container cannot derive the default machine seed), then mount the store:
364+
(the container cannot derive the default machine seed), then mount the store
365+
— or log in **inside** the container with bigmodel paste mode (see
366+
[Headless / Docker Login](#headless--docker-login-paste-mode-bigmodel)):
337367

338368
```bash
339369
# 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
359389
| `ZCODE_PROXY_API_KEY` | Client auth shared secret |
360390
| `ZCODE_PROXY_CREDENTIAL_SECRET` | Encryption seed for the credential store (must match the seed used at `auth login`) |
361391
| `ZCODE_PROXY_PORT` | Listen port (default `8080`) |
392+
| `ZCODE_OAUTH_PASTE` | Set `1` for bigmodel paste-mode login inside the container (see [Headless / Docker Login](#headless--docker-login-paste-mode-bigmodel)) |
362393

363394
docker-compose:
364395

‎src/auth/oauth.test.ts‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* @see _reverse/NOTEPAD.md "Method 1: OAuth Flow"
1010
*/
1111
import { describe, it, expect } from "bun:test";
12-
import { ZaiOAuthClient, BigmodelOAuthClient } from "./oauth.js";
12+
import { ZaiOAuthClient, BigmodelOAuthClient, parsePastedCallbackUrl } from "./oauth.js";
1313

1414
/** Wrap data in the zcode.z.ai `{code, data, msg}` envelope as a JSON Response. */
1515
function envelopeResponse(data: Record<string, unknown>, status = 200): Response {
@@ -240,3 +240,78 @@ describe("BigmodelOAuthClient (auth-code flow)", () => {
240240
expect(result.jwt).toBe("jwt_full");
241241
});
242242
});
243+
244+
describe("parsePastedCallbackUrl (headless paste login)", () => {
245+
const state = "a".repeat(64);
246+
247+
it("extracts authCode from a full callback URL", () => {
248+
const pasted = `http://127.0.0.1:49152/oauth/callback/bigmodel?authCode=code_1&state=${state}`;
249+
expect(parsePastedCallbackUrl(pasted, state)).toBe("code_1");
250+
});
251+
252+
it("accepts the `code` param name as well", () => {
253+
expect(parsePastedCallbackUrl(`http://127.0.0.1/cb?code=code_2&state=${state}`, state)).toBe("code_2");
254+
});
255+
256+
it("only inspects the query — pathname/host are not validated", () => {
257+
const pasted = `https://anything.example/whatever/path?code=c&state=${state}`;
258+
expect(parsePastedCallbackUrl(pasted, state)).toBe("c");
259+
});
260+
261+
it("throws on a CSRF state mismatch", () => {
262+
const pasted = `http://127.0.0.1/?code=x&state=${"b".repeat(64)}`;
263+
expect(() => parsePastedCallbackUrl(pasted, state)).toThrow(/state mismatch/i);
264+
});
265+
266+
it("throws when the state param is absent", () => {
267+
expect(() => parsePastedCallbackUrl("http://127.0.0.1/?code=x", state)).toThrow(/state mismatch/i);
268+
});
269+
270+
it("throws when the code is missing", () => {
271+
const pasted = `http://127.0.0.1/?state=${state}`;
272+
expect(() => parsePastedCallbackUrl(pasted, state)).toThrow(/authorization code/i);
273+
});
274+
275+
it("unescapes &amp; entities from a copy out of a rendered page", () => {
276+
const pasted = `http://127.0.0.1/cb?authCode=amp_code&amp;state=${state}`;
277+
expect(parsePastedCallbackUrl(pasted, state)).toBe("amp_code");
278+
});
279+
280+
it("trims whitespace and wrapping quotes/brackets", () => {
281+
expect(parsePastedCallbackUrl(` "http://127.0.0.1/?code=q&state=${state}"\n`, state)).toBe("q");
282+
expect(parsePastedCallbackUrl(`(<http://127.0.0.1/?code=p&state=${state}>)`, state)).toBe("p");
283+
});
284+
285+
it("surfaces the provider error param when authorization was denied", () => {
286+
const pasted = `http://127.0.0.1/?error=access_denied&state=${state}`;
287+
expect(() => parsePastedCallbackUrl(pasted, state)).toThrow(/access_denied/);
288+
});
289+
290+
it("paste round-trip: the exchange redirect_uri equals the authorize redirect param", async () => {
291+
const { impl, calls } = scriptedFetch([
292+
() => envelopeResponse({
293+
token: "jwt_paste",
294+
bigmodel: { access_token: "bm_paste" },
295+
user: { user_id: "u9" },
296+
}),
297+
]);
298+
const client = new BigmodelOAuthClient(impl);
299+
const started = await client.start();
300+
try {
301+
// The browser appends the code to the authorize URL's exact `redirect`;
302+
// the user pastes that unreachable URL back.
303+
const redirect = new URL(started.authorizeUrl).searchParams.get("redirect") ?? "";
304+
expect(started.callbackUrl).toBe(redirect);
305+
const pasted = `${redirect}?authCode=paste_code&state=${started.state}`;
306+
const code = parsePastedCallbackUrl(pasted, started.state);
307+
await client.exchangeCode(code, started.callbackUrl, started.state);
308+
309+
const exchange = JSON.parse(String(calls[0].init.body));
310+
expect(exchange.redirect_uri).toBe(redirect);
311+
expect(exchange.code).toBe("paste_code");
312+
expect(exchange.state).toBe(started.state);
313+
} finally {
314+
await client.close();
315+
}
316+
});
317+
});

‎src/auth/oauth.ts‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const BIGMODEL_HOST = "https://bigmodel.cn";
3838
const BIGMODEL_APP_ID = "zcode";
3939

4040
/** Overall login timeout shared by both flows (bundle `tln`). */
41-
const LOGIN_TIMEOUT_MS = 300_000;
41+
export const LOGIN_TIMEOUT_MS = 300_000;
4242

4343
// ---------------------------------------------------------------------------
4444
// Shared types
@@ -515,6 +515,49 @@ export class BigmodelOAuthClient extends AuthCodeOAuthClient {
515515
}
516516
}
517517

518+
// ---------------------------------------------------------------------------
519+
// Headless paste login (auth-code flow)
520+
// ---------------------------------------------------------------------------
521+
522+
/**
523+
* Parse a callback URL the user pasted back into the terminal (headless
524+
* `--paste` login, mirroring gcloud/gh CLI). Only the query string is
525+
* inspected — the pathname/host are whatever the browser's address bar shows
526+
* and are never validated. Tolerates copy artifacts from web pages: wrapping
527+
* quotes/brackets, surrounding whitespace, and `&amp;` entities (a paste from
528+
* a rendered page splits the query at the `&` inside `&amp;`, which breaks
529+
* `state`). Returns the auth code (`authCode` or `code` param); throws on a
530+
* CSRF `state` mismatch or a missing code.
531+
*/
532+
export function parsePastedCallbackUrl(raw: string, expectedState: string): string {
533+
let text = raw.trim().replace(/^["'`<([{]+/, "").replace(/["'`>\])}]+$/, "");
534+
text = text.replace(/&amp;/gi, "&");
535+
536+
const q = text.indexOf("?");
537+
const query = (q >= 0 ? text.slice(q + 1) : text).split("#")[0];
538+
const params = new URLSearchParams(query);
539+
540+
const state = params.get("state") ?? "";
541+
if (!state || state !== expectedState) {
542+
throw new Error(
543+
"OAuth state mismatch — the pasted URL does not belong to this login " +
544+
"session (possible CSRF). Retry the login.",
545+
);
546+
}
547+
548+
const providerError = params.get("error");
549+
const code = params.get("authCode") ?? params.get("code") ?? "";
550+
if (!code) {
551+
throw new Error(
552+
providerError
553+
? `Authorization failed: provider returned error=${providerError}`
554+
: "No authorization code in the pasted URL — paste the redirected " +
555+
"127.0.0.1 callback URL (the one that failed to load), not the authorize URL.",
556+
);
557+
}
558+
return code;
559+
}
560+
518561
// ---------------------------------------------------------------------------
519562
// Utility
520563
// ---------------------------------------------------------------------------

‎src/index.ts‎

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import { AuthManager } from "./auth/manager.js";
88
import { startServer, type ProxyServer } from "./server/server.js";
99
import { startControlListener, LogBuffer, type ControlState } from "./android/control.js";
1010
import { loadCredential, saveCredential, clearCredential, getStorePath } from "./auth/store.js";
11-
import { ZaiOAuthClient, BigmodelOAuthClient } from "./auth/oauth.js";
11+
import { ZaiOAuthClient, BigmodelOAuthClient, LOGIN_TIMEOUT_MS, parsePastedCallbackUrl, type OAuthResult } from "./auth/oauth.js";
1212
import { KeyResolver } from "./auth/resolver.js";
1313
import type { Credential } from "./auth/types.js";
1414
import type { ProviderId } from "./provider/types.js";
1515
import type { ProxyConfig } from "./config/types.js";
1616
import { updateConfigYaml } from "./config/edit.js";
1717
import { openBrowser } from "./runtime/open-browser.js";
18+
import { pasteLoginInstructions, readPastedLine, boldIfTTY } from "./runtime/paste-login.js";
1819
import { buildServerOptions } from "./server/server-options.js";
1920
import { readFileSync, existsSync, writeFileSync } from "node:fs";
2021
import { join } from "node:path";
@@ -405,22 +406,31 @@ async function claimCommand(args: string[]): Promise<void> {
405406
async function authLogin(args: string[]): Promise<void> {
406407
const provider = args[0] as ProviderId | undefined;
407408
const importMode = args.includes("--import");
409+
// Headless paste login: --paste flag or ZCODE_OAUTH_PASTE=1 (docker-friendly).
410+
const pasteMode =
411+
args.includes("--paste") || /^(1|true|yes)$/i.test(process.env.ZCODE_OAUTH_PASTE ?? "");
408412

409413
if (!provider || (provider !== "zai" && provider !== "bigmodel")) {
410-
console.error("Usage: zcode-proxy auth login <zai|bigmodel> [--import]");
414+
console.error("Usage: zcode-proxy auth login <zai|bigmodel> [--import] [--paste]");
415+
process.exit(1);
416+
}
417+
if (pasteMode && provider !== "bigmodel") {
418+
console.error("--paste applies to the bigmodel auth-code flow only.");
419+
console.error("zai login is server-mediated (no localhost callback) and already works headless.");
411420
process.exit(1);
412421
}
413422

414423
ensureConfigWithDeviceMid();
415424

416-
console.log(`Logging in: ${provider}${importMode ? " (import)" : " (OAuth)"}\n`);
425+
const mode = importMode ? "(import)" : pasteMode ? "(OAuth, paste)" : "(OAuth)";
426+
console.log(`Logging in: ${provider} ${mode}\n`);
417427

418428
let cred: Credential;
419429

420430
if (importMode) {
421431
cred = importFromZCodeConfig(provider);
422432
} else {
423-
const { accessToken, userId, jwt } = await runOAuth(provider);
433+
const { accessToken, userId, jwt } = await runOAuth(provider, pasteMode);
424434
console.log("\nResolving API key...");
425435
const resolver = new KeyResolver();
426436
cred = await resolver.resolveCodingPlanCredential(accessToken, provider, userId);
@@ -506,16 +516,21 @@ async function authStatus(): Promise<void> {
506516
console.log(` Store: ${getStorePath()}`);
507517
}
508518

509-
async function runOAuth(provider: ProviderId): Promise<{ accessToken: string; userId?: string; jwt?: string }> {
519+
async function runOAuth(provider: ProviderId, pasteMode: boolean): Promise<OAuthResult> {
510520
if (provider === "bigmodel") {
511521
const oauth = new BigmodelOAuthClient();
522+
if (pasteMode) return runPasteLogin(oauth);
512523
const result = await oauth.authorize((url) => {
513524
console.log("Open this URL to authorize:\n");
514525
console.log(` ${url}\n`);
515526
console.log("Waiting for authorization... (expires in 300s)\n");
527+
console.log(
528+
"Headless/Docker? The callback page will NOT load here — Ctrl-C and " +
529+
"re-run with `auth login bigmodel --paste` to paste the redirected URL instead.\n",
530+
);
516531
openBrowser(url);
517532
});
518-
return { accessToken: result.accessToken, userId: result.userId, jwt: result.jwt };
533+
return result;
519534
}
520535

521536
const oauth = new ZaiOAuthClient();
@@ -525,7 +540,32 @@ async function runOAuth(provider: ProviderId): Promise<{ accessToken: string; us
525540
console.log("Waiting for authorization... (expires in 300s)\n");
526541
openBrowser(url);
527542
});
528-
return { accessToken: result.accessToken, userId: result.userId, jwt: result.jwt };
543+
return result;
544+
}
545+
546+
/**
547+
* Headless bigmodel login (`auth login bigmodel --paste`): the localhost
548+
* callback server is still bound — it defines the redirect port and the
549+
* browser can never reach it from inside a container anyway — but instead of
550+
* waiting on it, the user pastes the redirected URL back. The exact
551+
* `started.callbackUrl` string is used BOTH as the authorize `redirect` param
552+
* and as the exchange `redirect_uri` (the token endpoint requires them to
553+
* match), so the pair stays consistent by construction.
554+
*/
555+
async function runPasteLogin(oauth: BigmodelOAuthClient): Promise<OAuthResult> {
556+
const started = await oauth.start();
557+
try {
558+
console.log(pasteLoginInstructions(started.authorizeUrl, started.callbackUrl, LOGIN_TIMEOUT_MS));
559+
openBrowser(started.authorizeUrl);
560+
process.stdout.write("\n" + boldIfTTY("Paste the FULL redirected URL here, then press Enter:") + "\n> ");
561+
const pasted = await readPastedLine(LOGIN_TIMEOUT_MS);
562+
const code = parsePastedCallbackUrl(pasted, started.state);
563+
console.log("\nExchanging authorization code...");
564+
const tokens = await oauth.exchangeCode(code, started.callbackUrl, started.state);
565+
return { accessToken: tokens.accessToken, provider: "bigmodel", userId: tokens.userId, jwt: tokens.jwt };
566+
} finally {
567+
await oauth.close();
568+
}
529569
}
530570

531571
function importFromZCodeConfig(provider: ProviderId): Credential {

‎src/runtime/open-browser.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,31 @@
33
* the TUI login action. Best-effort: a failure leaves the URL on screen for
44
* the user to copy manually.
55
*/
6-
import { spawn } from "node:child_process";
6+
import { spawn, type SpawnOptions } from "node:child_process";
77

88
export function openBrowser(url: string): void {
99
try {
1010
if (process.platform === "win32") {
11-
spawn("cmd.exe", ["/c", `start "" "${url}"`], {
12-
detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true,
13-
}).unref();
11+
spawnDetached("cmd.exe", ["/c", `start "" "${url}"`], {
12+
windowsHide: true, windowsVerbatimArguments: true,
13+
});
1414
} else if (process.platform === "darwin") {
15-
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
15+
spawnDetached("open", [url]);
1616
} else {
17-
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
17+
spawnDetached("xdg-open", [url]);
1818
}
1919
} catch { /* user copies URL manually */ }
2020
}
21+
22+
/**
23+
* Spawn a fire-and-forget browser opener. Spawn failures surface
24+
* asynchronously on the child (ENOENT for xdg-open on headless boxes — there
25+
* is no browser to open), not as a synchronous throw, so an "error" listener
26+
* MUST be attached or the crash takes down the whole login process. Either
27+
* way the authorize URL is already on screen for manual copying.
28+
*/
29+
function spawnDetached(cmd: string, args: string[], extra: SpawnOptions = {}): void {
30+
const child = spawn(cmd, args, { detached: true, stdio: "ignore", ...extra });
31+
child.on("error", () => { /* headless — user copies URL manually */ });
32+
child.unref();
33+
}

0 commit comments

Comments
 (0)