Skip to content
220,638 changes: 110,976 additions & 109,662 deletions Android-APP/app/src/main/assets/server_bundle/server.cjs

Large diffs are not rendered by default.

32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ bun install

# Copy and edit config
cp config.example.yaml config.yaml
# Default auth is OAuth — log in first (see Authentication below)

# Log in (OAuth, browser-based)
bun run src/index.ts auth login zai # or: bigmodel

# Start the proxy
bun run src/index.ts
Expand Down Expand Up @@ -196,6 +198,34 @@ upload (auto-enabled for models whose id contains `v`), MCP HTTP servers,
markdown + code-highlight rendering, light/dark theme, and per-browser
multi-session autosave (localStorage). Open Settings (⚙) to configure.

### Terminal UI (TUI)

```bash
bun run src/index.ts tui # same as serve, plus an interactive panel
bun run src/index.ts tui debug # with per-request debug diagnostics
```

A PC terminal control panel mirroring the Android app's layout — three cards
(Settings & Login / Proxy Server / Logs) rendered in the alternate screen with
zero extra dependencies. The proxy auto-starts on launch when credentials are
available; the log card shows live per-request rows (console output is
captured in-process) with scrollback and tail-following.

| Key | Action |
|-----|--------|
| `s` | Start / stop the proxy server |
| `l` | OAuth login for the current provider (opens the browser) |
| `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 |
| `g` | Jump back to the tail (re-enable following) |
| `c` | Clear the log pane |
| `q` / `Ctrl+C` | Quit |

Set `ZCODE_TUI_LOGFILE=/path/to/file.log` to also tee every captured line to a
file (best-effort; never affects handling). Requires a TTY with at least
40x14 cells; Windows Terminal, mintty and common Unix terminals are supported.

## Configuration

| Field | Env Var | Default | Description |
Expand Down
38 changes: 38 additions & 0 deletions src/config/edit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { updateConfigYaml } from "./edit.js";

describe("updateConfigYaml", () => {
test("rewrites top-level provider and plan, preserving other keys", () => {
const dir = mkdtempSync(join(tmpdir(), "zcode-tui-test-"));
const path = join(dir, "config.yaml");
writeFileSync(
path,
[
"server:",
" host: 127.0.0.1",
" port: 8080",
"provider: zai",
"plan: coding-plan",
"models:",
" - glm-4.6",
" - glm-5.3",
"",
].join("\n"),
"utf-8",
);

try {
updateConfigYaml(path, { provider: "bigmodel", plan: "start-plan" });
const updated = readFileSync(path, "utf-8");
expect(updated).toContain("provider: bigmodel");
expect(updated).toContain("plan: start-plan");
expect(updated).toContain("port: 8080");
expect(updated).toContain("- glm-5.3");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
19 changes: 19 additions & 0 deletions src/config/edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Targeted YAML config editing helpers shared by the CLI entries (serve /
* android / tui) and the TUI runtime.
*/
import { parse, stringify } from "yaml";
import { readFileSync, writeFileSync } from "node:fs";
import type { ProviderId } from "../provider/types.js";

/** Targeted YAML update of top-level `provider` and `plan` keys. */
export function updateConfigYaml(
path: string,
fields: { provider: ProviderId; plan: "coding-plan" | "start-plan" },
): void {
const raw = readFileSync(path, "utf-8");
const parsed = parse(raw) ?? {};
parsed.provider = fields.provider;
parsed.plan = fields.plan;
writeFileSync(path, stringify(parsed), "utf-8");
}
52 changes: 16 additions & 36 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,22 @@ import { EXAMPLE_CONFIG_YAML } from "./config/template.js";
import { AuthManager } from "./auth/manager.js";
import { startServer, type ProxyServer } from "./server/server.js";
import { startControlListener, LogBuffer, type ControlState } from "./android/control.js";
import { ResponseStore } from "./responses/store.js";
import { loadCredential, saveCredential, clearCredential, getStorePath } from "./auth/store.js";
import { ZaiOAuthClient, BigmodelOAuthClient } 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 { parse, stringify } from "yaml";
import { spawn } from "node:child_process";
import { updateConfigYaml } from "./config/edit.js";
import { openBrowser } from "./runtime/open-browser.js";
import { buildServerOptions } from "./server/server-options.js";
import { readFileSync, existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { randomUUID } from "node:crypto";
import { ensureNodeFetchNoTimeouts } from "./runtime/node-fetch-compat.js";

const VERSION = "2.6.0";
export const VERSION = "2.6.0";

if (require.main === module) main();

Expand Down Expand Up @@ -74,6 +74,16 @@ function runCli(): void {
process.stderr.write(`zcode-proxy: android entry failed: ${(err as Error).stack ?? String(err)}\n`);
process.exit(1);
});
} else if (cmd === "tui") {
// Dynamic import: the TUI module imports helpers back from this file, so a
// static edge would create a load-time cycle (same pattern as claimCommand).
const tuiArgs = parseServeArgs(args.slice(1));
import("./tui/app.js")
.then((m) => m.runTui(tuiArgs))
.catch((err: unknown) => {
process.stderr.write(`zcode-proxy: tui failed: ${(err as Error).stack ?? String(err)}\n`);
process.exit(1);
});
} else if (cmd === "serve" || cmd.endsWith(".yaml") || cmd.endsWith(".yml")) {
const serveArgs = cmd === "serve"
? parseServeArgs(args.slice(1))
Expand All @@ -97,6 +107,7 @@ Usage:
zcode-proxy serve [config.yaml] Start the proxy server (default)
zcode-proxy serve debug [config.yaml]
Start with verbose per-request diagnostics
zcode-proxy tui [config.yaml] Interactive terminal UI (login / start-stop / live logs)
zcode-proxy android Android entry: proxy + localhost control listener
zcode-proxy auth login <provider> Login via OAuth (provider: zai | bigmodel)
zcode-proxy auth login <provider> --import
Expand All @@ -110,6 +121,7 @@ Usage:
Examples:
zcode-proxy Start server with default config.yaml
zcode-proxy serve debug Start with extra debug logging
zcode-proxy tui Terminal UI: login, start/stop, live logs
zcode-proxy auth login bigmodel OAuth login for Bigmodel
zcode-proxy auth login bigmodel --import
Import existing key from ZCode config
Expand Down Expand Up @@ -170,15 +182,6 @@ async function serve(configPath: string | undefined, debug: boolean): Promise<vo
});
}

/** Build `startServer` options, wiring the Responses store and MCP pool when their config gates are on. */
function buildServerOptions(config: ProxyConfig, auth: AuthManager, debug: boolean): { config: ProxyConfig; auth: AuthManager; debug: boolean; responseStore?: ResponseStore } {
const opts: { config: ProxyConfig; auth: AuthManager; debug: boolean; responseStore?: ResponseStore } = { config, auth, debug };
if (config.responses.enabled) {
opts.responseStore = new ResponseStore({ maxEntries: config.responses.storeMaxEntries, ttlMs: config.responses.storeTtlMs });
}
return opts;
}

/**
* Desktop-Linux identity defaults for the Android entry (anti-pattern #34).
* Without these, the Node process on Android reports its true host values:
Expand Down Expand Up @@ -303,15 +306,6 @@ async function runAndroid(): Promise<void> {
});
}

/** Targeted YAML update of top-level `provider` and `plan` keys. */
function updateConfigYaml(path: string, fields: { provider: ProviderId; plan: "coding-plan" | "start-plan" }): void {
const raw = readFileSync(path, "utf-8");
const parsed = parse(raw) ?? {};
parsed.provider = fields.provider;
parsed.plan = fields.plan;
writeFileSync(path, stringify(parsed), "utf-8");
}

function printDebugBanner(config: ProxyConfig, path: string, cred: Credential | null): void {
const credShape = cred
? `${cred.apiKey.slice(0, 6)}...${cred.apiKey.slice(-4)} (${cred.apiKey.length} chars)`
Expand Down Expand Up @@ -529,17 +523,3 @@ function importFromZCodeConfig(provider: ProviderId): Credential {
if (jwt) console.log(` Start-plan JWT: ${jwt.slice(0, 12)}...`);
return { apiKey, provider, jwt };
}

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();
} else if (process.platform === "darwin") {
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
} else {
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
}
} catch { /* user copies URL manually */ }
}
36 changes: 36 additions & 0 deletions src/proxy/captcha-happy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { makeDualConsole } from "./captcha-happy.js";

describe("dual console (guest spam guard)", () => {
test("drops every console method's call while the guest predicate holds", () => {
const seen: string[] = [];
const recorder: Record<string, unknown> = {};
for (const m of ["log", "debug", "info", "dir", "table", "group", "warn", "error"]) {
recorder[m] = (...a: unknown[]) => { seen.push(m + ": " + a.join(" ")); };
}
const orig = console as unknown as Record<string, unknown>;
const saved = Object.fromEntries(
Object.keys(recorder).map((k) => [k, orig[k]]),
);
Object.assign(orig, recorder);

try {
// Build the dual console against the recorder and route calls through
// it with the guest predicate forced on — the FeiLin spam condition.
const dual = makeDualConsole(() => true) as Record<string, (...a: unknown[]) => void>;
dual.log!("%c%d", "font-size:0;color:transparent", "Error");
dual.debug!("anything");
dual.dir!({ an: "object" });
dual.table!([1, 2]);
expect(seen).toEqual([]);

// Host condition — identical calls must forward untouched.
const forward = makeDualConsole(() => false) as Record<string, (...a: unknown[]) => void>;
forward.log!("hello", 42);
forward.error!("[real] failure");
expect(seen).toEqual(["log: hello 42", "error: [real] failure"]);
} finally {
Object.assign(orig, saved);
}
});
});
62 changes: 62 additions & 0 deletions src/proxy/captcha-happy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1789,8 +1789,60 @@ const EXTRA_WINDOW_PROPS = [
// destroyDom() pulls `window` out from under a sibling mid-solve.
let _aliasRefCount = 0;

// Under Bun, guest scripts run in the HOST realm — a bare `console` inside
// SDK code resolves to the HOST console (that is the FeiLin spam channel:
// invisible devtools-format lines probed across every console method, ~1/sec
// while solving). While the aliases are installed, `console` on globalThis is
// replaced with a DUAL console: calls whose stack originates from guest SDK
// script URLs are dropped, everything else forwards to the console that was
// live at install time (the TUI's interception or the serve terminal — so
// host logging keeps working untouched). Restored on the last destroyDom.
// Skipped under CAPTCHA_DEBUG so guest logs stay inspectable.
let _savedConsoleDescriptor: PropertyDescriptor | undefined;
let _dualConsole: Record<string, unknown> | null = null;

function isGuestConsoleCall(): boolean {
const stack = new Error().stack ?? "";
return /alicdn\.com|aliyuncs\.com/i.test(stack);
}

/** Build a console facade that drops calls while `isGuest()` holds (test seam). */
export function makeDualConsole(isGuest: () => boolean = isGuestConsoleCall): Record<string, unknown> {
// Capture the console OBJECT at build time — methods must keep their
// original receiver (looking up `console` again inside the closure would
// resolve to the dual getter itself and recurse).
const host = console as unknown as Record<string, unknown>;
const dual: Record<string, unknown> = {};
for (const key of Object.keys(host)) {
const fn = host[key];
if (typeof fn !== "function") {
dual[key] = fn;
continue;
}
dual[key] = (...args: unknown[]) => {
if (isGuest()) return;
return (fn as (...a: unknown[]) => void).apply(host, args);
};
}
return dual;
}

function installGlobalWindowAlias(g, w) {
_aliasRefCount += 1;
if (_aliasRefCount === 1 && !process.env.CAPTCHA_DEBUG) {
_dualConsole = makeDualConsole(); _savedConsoleDescriptor = Object.getOwnPropertyDescriptor(g, "console");
try {
Object.defineProperty(g, "console", {
get() {
return _dualConsole;
},
set(v) {
try { w.console = v; } catch (_) {}
},
configurable: true,
});
} catch (_) {}
}
const props = new Set(Object.getOwnPropertyNames(w));
for (const name of EXTRA_WINDOW_PROPS) props.add(name);
// also walk the prototype chain one level (BrowserWindow getters like
Expand Down Expand Up @@ -1852,6 +1904,16 @@ function removeGlobalWindowAlias(g, w) {
for (const prop of ["window", "self", "top", "parent"]) {
try { delete g[prop]; } catch (_) {}
}
// Restore the pre-alias console descriptor — `delete g.console` above (the
// getter installed for the dual console) would otherwise leave globalThis
// without a console at all.
if (_savedConsoleDescriptor) {
try {
Object.defineProperty(g, "console", _savedConsoleDescriptor);
} catch (_) {}
_savedConsoleDescriptor = undefined;
_dualConsole = null;
}
}

function destroyDom(win) {
Expand Down
20 changes: 20 additions & 0 deletions src/runtime/open-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Cross-platform default-browser launcher, shared by the CLI auth flows and
* 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";

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();
} else if (process.platform === "darwin") {
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
} else {
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
}
} catch { /* user copies URL manually */ }
}
20 changes: 20 additions & 0 deletions src/server/server-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* `startServer` option assembly shared by every entry that boots the proxy
* in-process (serve / android / tui).
*/
import type { ProxyConfig } from "../config/types.js";
import type { AuthManager } from "../auth/manager.js";
import { ResponseStore } from "../responses/store.js";

/** Build `startServer` options, wiring the Responses store when its config gate is on. */
export function buildServerOptions(
config: ProxyConfig,
auth: AuthManager,
debug: boolean,
): { config: ProxyConfig; auth: AuthManager; debug: boolean; responseStore?: ResponseStore } {
const opts: { config: ProxyConfig; auth: AuthManager; debug: boolean; responseStore?: ResponseStore } = { config, auth, debug };
if (config.responses.enabled) {
opts.responseStore = new ResponseStore({ maxEntries: config.responses.storeMaxEntries, ttlMs: config.responses.storeTtlMs });
}
return opts;
}
Loading