Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>/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 |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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:

Expand Down
77 changes: 76 additions & 1 deletion src/auth/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, status = 200): Response {
Expand Down Expand Up @@ -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 &amp; entities from a copy out of a rendered page", () => {
const pasted = `http://127.0.0.1/cb?authCode=amp_code&amp;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(`(<http://127.0.0.1/?code=p&state=${state}>)`, 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();
}
});
});
45 changes: 44 additions & 1 deletion src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `&amp;` entities (a paste from
* a rendered page splits the query at the `&` inside `&amp;`, 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(/&amp;/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
// ---------------------------------------------------------------------------
Expand Down
54 changes: 47 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -405,22 +406,31 @@ async function claimCommand(args: string[]): Promise<void> {
async function authLogin(args: string[]): Promise<void> {
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 <zai|bigmodel> [--import]");
console.error("Usage: zcode-proxy auth login <zai|bigmodel> [--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);
Expand Down Expand Up @@ -506,16 +516,21 @@ async function authStatus(): Promise<void> {
console.log(` Store: ${getStorePath()}`);
}

async function runOAuth(provider: ProviderId): Promise<{ accessToken: string; userId?: string; jwt?: string }> {
async function runOAuth(provider: ProviderId, pasteMode: boolean): Promise<OAuthResult> {
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();
Expand All @@ -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<OAuthResult> {
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 {
Expand Down
25 changes: 19 additions & 6 deletions src/runtime/open-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Loading