diff --git a/.gitignore b/.gitignore
index fb4c103..af9359a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ proxy_requests_anthropic.json
proxy_requests_test.json
proxy_requests*.jsonl
proxy_requests*.lock
+.venv/
diff --git a/README.md b/README.md
index d5e67f6..f058691 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-v2.6.0. One CLI, two API formats, zero dependencies.
+v3.0.0. One CLI, two API formats, zero dependencies.
---
@@ -33,13 +33,15 @@ glmproxy --anthropic --port 3001 --key mykey
Then point any OpenAI-compatible tool at `http://127.0.0.1:18791/v1`, or any Anthropic-compatible tool at `http://127.0.0.1:18792`. Default key is `mewmew` (see [Integrations](#integrations) below for exact per-tool setup).
-**You need:** [AutoClaw](https://autoclaw.z.ai) installed, running, and logged in (Windows/macOS), plus Node.js 18+. The proxy reads auth straight from AutoClaw's local token file, so there's no manual token setup and no API keys to copy.
+**You need:** [AutoClaw](https://autoclaw.z.ai) installed, running, and logged in:
+- **Windows / macOS**: AutoClaw 1.x or 2.x desktop app running. Auth is automatically read from AutoClaw's local runtime state.
+- **Linux**: AutoClaw 2.0.3 AppImage running. The proxy automatically discovers and syncs account tokens from the system Secret Service / GNOME Keyring vault with zero manual setup. For Linux TLS compatibility matching AutoClaw's Electron fingerprint, ensure `curl_cffi` is installed (`pip install curl_cffi`). Node.js 18+ required on all platforms.
---
## Integrations
-### Claude Code CLI
+### Claude Code CLI & Anthropic API
Add to `~/.claude/settings.json`:
@@ -52,8 +54,18 @@ Add to `~/.claude/settings.json`:
}
```
+#### Supported Modes & Known Caveats:
+- **Claude Agent SDK & Direct API**: Fully supported. Direct `/v1/messages` calls, streaming SSE deltas, thinking blocks, and tool use execute cleanly without restrictions.
+- **Claude Code CLI (Print Mode)**: Works reliably for CLI tasks using the `-p` / non-interactive flag across Opus, Sonnet, and Haiku models:
+ ```bash
+ claude -p "Explain the codebase architecture"
+ ```
+- **Interactive Mode Notice**: Running interactive `claude` sessions injects Claude Code's internal auto-mode security classifier prompt (a ~285 KB / 71,000+ token evaluation prompt with multi-tier session hooks) which triggers an upstream `HTTP 406 Not Acceptable` filter from AutoClaw. For interactive agentic sessions, use the OpenAI-compatible endpoint (`http://localhost:18791/v1`) with Cursor, OpenCode, or Continue, or run Claude Code in print mode (`-p`).
+
### OpenCode
+Add to your OpenCode configuration (or select auto-discovered models from `/v1/models`):
+
```json
{
"provider": {
@@ -66,15 +78,15 @@ Add to `~/.claude/settings.json`:
},
"models": {
"zai_auto": { "name": "AutoClaw Auto" },
+ "zai_auto-fast": { "name": "AutoClaw Auto-Fast" },
"zaicoding_glm-5.3": { "name": "AutoClaw GLM-5.3" },
- "zai_glm-5-turbo": { "name": "AutoClaw GLM-5 Turbo" },
- "tdpsk_deepseek-v4-flash-202605": { "name": "AutoClaw Deepseek-V4-Flash" },
- "tdpsk_deepseek-v4-pro-202606": { "name": "AutoClaw DeepSeek-V4-Pro" }
+ "zai_glm-5.3-flash": { "name": "AutoClaw GLM-5.3 Flash" }
}
}
}
}
```
+*(On Windows, `zai_glm-5-turbo`, `tdpsk_deepseek-v4-flash-202605`, and `tdpsk_deepseek-v4-pro-202606` are also available in the catalog).*
### Cursor / Continue / anything OpenAI-compatible
@@ -160,24 +172,50 @@ AutoClaw handles authentication automatically. The cloud is the only route: if i
## Current available models in autoclaw
+The proxy dynamically serves the correct model catalog for your platform and AutoClaw version.
+
+### Windows (AutoClaw 1.x / 2.x)
+
| ID | Name | Context | Max Output | Notes |
|----|------|---------|------------|-------|
| `zai_auto` | Auto | 1M | 131K | Routes to AutoClaw's optimal model (GLM-5.3-Flash today) |
| `zaicoding_glm-5.3` | GLM-5.3 | 1M | 131K | Latest GLM coding model |
| `zai_glm-5-turbo` | GLM-5-Turbo | 200K | 131K | Zhipu AI GLM-5 Turbo |
-| `zai_glm-5.3-flash` | GLM-5.3-Flash ("OX-alpha") | 1M | 131K | Now a regular catalog model, served straight through the cloud path |
-| `tdpsk_deepseek-v4-flash-202605` | Deepseek-V4-Flash | 1M | 393K | Fast DeepSeek model |
-| `tdpsk_deepseek-v4-pro-202606` | DeepSeek-V4-Pro | 1M | 393K | Deep reasoning model |
+| `zai_glm-5.3-flash` | GLM-5.3-Flash ("OX-alpha") | 1M | 131K | Fast high-throughput GLM model |
+| `tdpsk_deepseek-v4-flash-202605` | Deepseek-V4-Flash | 1M | 393K | Fast DeepSeek model (Windows upstream catalog) |
+| `tdpsk_deepseek-v4-pro-202606` | DeepSeek-V4-Pro | 1M | 393K | Deep reasoning model (Windows upstream catalog) |
-> GLM 5.3 flash new in the proxy!!!!! ox alpha the goat
+### Linux (AutoClaw 2.0.3)
-The catalog is re-read from AutoClaw's `openclaw.runtime.json` on every `/v1/models` call, with a built-in fallback if that file isn't readable. Run `glmproxy --doctor` to inspect the current catalog after an AutoClaw update. Claude model names sent to the Anthropic proxy are mapped automatically:
+On Linux, AutoClaw 2.0.3 natively exposes **4 models** in its upstream runtime catalog (DeepSeek models and GLM-5-Turbo are not present in Linux 2.x upstream):
-| Claude model | Routes to |
-|---|---|
-| `claude-opus-*` | First available GLM-5.3 / GLM-5 model |
-| `claude-sonnet-*` | `zai_auto` (or next available GLM-5 model) |
-| `claude-haiku-*` | `zai_glm-5-turbo` (or DeepSeek / Auto fallback) |
+| ID | Name | Context | Max Output | Notes |
+|----|------|---------|------------|-------|
+| `zai_auto` | Auto | 1M | 131K | Routes to AutoClaw's default model |
+| `zai_auto-fast` | Auto-Fast | 1M | 131K | Lightweight fast auto-routing |
+| `zaicoding_glm-5.3` | GLM-5.3 | 1M | 131K | Latest GLM coding model |
+| `zai_glm-5.3-flash` | GLM-5.3-Flash | 1M | 131K | Fast high-throughput GLM model |
+
+> **Platform Model Differences**: AutoClaw 2.0.3 AppImage on Linux natively provisions the 4 core ZAI models above in its runtime catalog. DeepSeek models (`tdpsk_*`) and `zai_glm-5-turbo` are served exclusively on Windows AutoClaw installations. The proxy dynamically inspects your environment and serves the correct model set at runtime.
+
+The catalog is re-read dynamically from AutoClaw's runtime snapshot on every `/v1/models` call, degrading to the platform-appropriate fallback catalog if the snapshot is unreadable. Run `glmproxy --doctor` to inspect the current catalog after an AutoClaw update. Claude model names sent to the Anthropic proxy are mapped automatically:
+
+| Claude model | Routes to (Linux) | Routes to (Windows) |
+|---|---|---|
+| `claude-opus-*` | `zaicoding_glm-5.3` | `zaicoding_glm-5.3` |
+| `claude-sonnet-*` | `zaicoding_glm-5.3` / `zai_auto` | `zaicoding_glm-5.3` / `zai_auto` |
+| `claude-haiku-*` | `zai_glm-5.3-flash` | `zai_glm-5.3-flash` / `zai_glm-5-turbo` |
+
+---
+
+## Subagent & Concurrency Support (In-Flight Queue)
+
+Upstream AutoClaw strictly limits accounts to **1 active generation at a time**. In environments that spawn autonomous subagents, background tools, or parallel turns (e.g. Claude Code subagents, Cursor Agent parallel tool calls, OpenCode multi-tasking), simultaneous requests previously caused upstream `HTTP 406 (upstream_failure)` errors.
+
+GLM Proxy includes a built-in serialized FIFO in-flight queue (`InFlightQueue`):
+- Concurrent requests automatically wait in-process until the active stream finishes.
+- Once the stream completes, queued requests immediately dispatch to upstream.
+- Prevents 406 concurrency collisions while ensuring zero dropped requests or failed turns.
---
@@ -541,8 +579,9 @@ TRUSTED_PROXIES=127.0.0.1 glmproxy --host 0.0.0.0
- Upstream 400 `"invalid request"` gets one retry after a 2s delay (a known upstream hiccup). Quota/plan errors are never retried
- The cloud upstream requires AutoClaw's app system-prompt banner in every request. The proxy injects it automatically and never duplicates it. In practice it doesn't change much since your harness's own system prompt overrides it anyway
- Max output is clamped to each model's real upstream cap (131K for every GLM model, 393K for DeepSeek). AutoClaw's runtime catalog overstates GLM-5.3's cap (307K), and asking the cloud for more than a model's real cap makes it **silently run a DeepSeek model instead and bill DeepSeek credits** — the proxy clamps so your `zai_glm-5.3` stays GLM-5.3
-- Images pass through natively: pasted/attached images (`image_url` data URLs, Anthropic `image` blocks) reach vision-capable models (e.g. `zai_glm-5.3-flash`) as real image parts — the model sees them directly, no OCR bridge. Text-only models reject them upstream and the request falls back to the desktop agent, mirroring AutoClaw's own gating
-- The token file is watched for changes, so AutoClaw can rotate auth mid-session without a restart
+- Images pass through natively: pasted/attached images (`image_url` data URLs, Anthropic `image` blocks) reach vision-capable models (e.g. `zai_glm-5.3-flash`) as real image parts — the model sees them directly, no OCR bridge.
+- The token file is watched for changes, so AutoClaw can rotate auth mid-session without a restart. On Linux, AutoClaw 2.0.3 vault tokens from the Secret Service / GNOME Keyring are automatically decrypted and synced with MD5 auth signatures. Master keys remain strictly in-memory and are never stored in plaintext on disk. Headless or custom environments can configure `AUTOCLAW_CONFIG_DIR` and `GLMP_KEYRING_PASSWORD`.
+- Upstream limits accounts to 1 concurrent generation: parallel requests from subagents or concurrent sessions are queued in-process in FIFO order, eliminating upstream 406 collisions. Client disconnects automatically abort in-flight upstream streams before releasing the queue slot.
- AutoClaw's client identity (app version, platform, channel) loads dynamically from its runtime file, same as the model catalog, so an AutoClaw app update is picked up without editing or restarting the proxy
- The fallback model catalog lives in `lib/fallback-models.json` (override with `FALLBACK_MODELS_PATH`). The built-in list is only a last resort when AutoClaw's runtime file is unreadable
- A capacity throttle is retried in-process (two attempts, about 4 s then 6 s) so a transient "high demand" wall does not stop your harness. A ban or a quota wall is never retried
diff --git a/anthropic.js b/anthropic.js
index 6fad45a..6ae0204 100644
--- a/anthropic.js
+++ b/anthropic.js
@@ -33,6 +33,7 @@ import {
getPacingGovernor, classifyGovernorError,
startConfigHeartbeat, startShapeWatch,
} from "./lib/core.js";
+import { globalInFlightQueue } from "./lib/queue.js";
// Config (per-format log filenames come from `format`)
const config = loadConfig({ format: "anthropic" });
@@ -132,14 +133,17 @@ function resolveModel(anthropicModel) {
// Convert an Anthropic Messages request body to OpenAI chat/completions format.
function anthropicToOpenAI(body, modelId) {
+ const sanitize = (s) => typeof s === "string" ? s.replace(/Claude Agent SDK/g, "Agent SDK") : s;
const messages = [];
// System prompt
if (body.system) {
const text = typeof body.system === "string"
? body.system
- : body.system.filter((b) => b.type === "text").map((b) => b.text).join("\n");
- if (text) messages.push({ role: "system", content: text });
+ : (Array.isArray(body.system)
+ ? body.system.map((b) => typeof b === "string" ? b : (b.type === "text" ? b.text : (b.text || ""))).filter(Boolean).join("\n")
+ : "");
+ if (text) messages.push({ role: "system", content: sanitize(text) });
}
// Convert messages
@@ -147,7 +151,7 @@ function anthropicToOpenAI(body, modelId) {
const content = msg.content;
if (typeof content === "string") {
- messages.push({ role: msg.role, content });
+ messages.push({ role: msg.role, content: sanitize(content) });
continue;
}
@@ -174,7 +178,7 @@ function anthropicToOpenAI(body, modelId) {
for (const block of content) {
if (block.type === "tool_result") toolResults.push(block);
else if (block.type === "tool_use") toolUses.push(block);
- else if (block.type === "text") textParts.push(block.text);
+ else if (block.type === "text") textParts.push(sanitize(block.text));
else if (block.type === "image") { const p = toOpenAIImage(block); if (p) imageParts.push(p); }
else if (block.type === "thinking") { /* skip */ }
}
@@ -545,6 +549,19 @@ async function handleMessages(req, res) {
const lastMsgForLog = () => lastMessagePreview(openAIBody.messages);
+ let cloudEvidence = null;
+ let releaseLock = null;
+ let released = false;
+ const releaseOnce = () => {
+ if (!released) {
+ released = true;
+ if (typeof releaseLock === "function") {
+ releaseLock();
+ releaseLock = null;
+ }
+ }
+ };
+
try {
const cachedFailure = permanentFailures.get(modelId);
if (cachedFailure) {
@@ -564,6 +581,9 @@ async function handleMessages(req, res) {
return sendClassifiedErrorAnthropic(res, cls);
}
+ // Upstream limits accounts to 1 concurrent active generation; serialize in-flight requests
+ releaseLock = await globalInFlightQueue.acquire();
+
// Cloud call with one retry on the flaky 400 "invalid request" hiccup;
// every >=400 body is buffered + logged (R1). Shared with openai.js.
const { res: upstreamRes, errBody: upstreamErrBody } = await callUpstreamWithInvalidRequestRetry(
@@ -586,13 +606,14 @@ async function handleMessages(req, res) {
// Failure path only — a 2xx falls through to the success renderer below.
if (statusCode >= 400) {
+ releaseOnce();
// Classify the verdict the client will see. The classified status is
// what goes on the wire: a raw 403 meaning "free-tier capacity throttle"
// (810002) becomes 429 upstream_busy, so a harness backs off instead of
// retrying into a wall.
const cls = classifyUpstreamError(statusCode, upstreamErrBody, modelId);
if (cls.permanent) permanentFailures.mark(modelId, cls);
- log.error(`Upstream error ${statusCode}:`, cls.message);
+ log.error(`Upstream error ${statusCode}:`, cls.message, upstreamErrBody ? `Body: ${upstreamErrBody.slice(0, 500)}` : "(empty body)");
cloudEvidence = { status: statusCode, code: cls.code };
record(cls.status, {
@@ -607,6 +628,13 @@ async function handleMessages(req, res) {
// Success paths
record(statusCode, { lastMessage: lastMsgForLog(), messageCount: openAIBody.messages?.length || 0 });
+ res.on("close", () => {
+ if (!res.writableFinished && upstreamRes && !upstreamRes.destroyed) {
+ try { upstreamRes.destroy(); } catch (_) {}
+ }
+ releaseOnce();
+ });
+
if (stream) {
res.writeHead(200, SSE_HEADERS);
@@ -643,9 +671,14 @@ async function handleMessages(req, res) {
}
for (const e of openAIChunkToAnthropicEvents("data: [DONE]", state)) res.write(e);
res.end();
+ releaseOnce();
});
- upstreamRes.on("error", (err) => { log.error("Stream error:", err); res.end(); });
+ upstreamRes.on("error", (err) => {
+ log.error("Stream error:", err);
+ try { res.end(); } catch (_) {}
+ releaseOnce();
+ });
return;
}
@@ -659,9 +692,21 @@ async function handleMessages(req, res) {
} catch (err) {
if (!res.headersSent) sendErrorAnthropic(res, `Failed to parse upstream response: ${err.message}`, "api_error", 502, "upstream_parse_failed");
else { try { res.end(); } catch (_) {} }
+ } finally {
+ releaseOnce();
+ }
+ });
+ upstreamRes.on("error", (err) => {
+ log.error("Non-stream upstream error:", err);
+ if (!res.headersSent) {
+ sendErrorAnthropic(res, `Upstream stream failed: ${err.message}`, "api_error", 502, "upstream_failed");
+ } else {
+ try { res.end(); } catch (_) {}
}
+ releaseOnce();
});
} catch (err) {
+ releaseOnce();
const cls = classifyTransportError(err);
log.error(`messages model=${body.model} transport failure:`, cls.message);
if (res.headersSent) { try { res.end(); } catch (_) {} return; }
diff --git a/lib/bridge.js b/lib/bridge.js
new file mode 100644
index 0000000..ac42d3d
--- /dev/null
+++ b/lib/bridge.js
@@ -0,0 +1,148 @@
+/**
+ * TLS Impersonation Bridge client (Requirement: Linux Alibaba Cloud WAF bypass)
+ *
+ * Spawns and manages a local bridge process (`lib/bridge.py`) running curl_cffi
+ * with Chrome TLS impersonation so egress requests match AutoClaw's Electron fingerprint.
+ */
+
+import http from "node:http";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const BRIDGE_SCRIPT = path.join(__dirname, "bridge.py");
+
+let _bridgeProcess = null;
+let _bridgePort = null;
+let _bridgeReadyPromise = null;
+let _bridgeStartupFailed = false;
+
+export function isBridgeAvailable() {
+ if (_bridgeStartupFailed) return false;
+ return process.platform === "linux" || process.env.GLMP_USE_BRIDGE === "1";
+}
+
+export async function ensureBridge(config, log = null) {
+ if (_bridgeStartupFailed) {
+ throw new Error("TLS bridge unavailable (startup previously failed)");
+ }
+ if (_bridgePort) return _bridgePort;
+ if (_bridgeReadyPromise) return _bridgeReadyPromise;
+
+ _bridgeReadyPromise = new Promise((resolve, reject) => {
+ try {
+ const child = spawn("python3", [BRIDGE_SCRIPT], {
+ stdio: ["pipe", "pipe", "inherit"],
+ env: { ...process.env },
+ });
+
+ _bridgeProcess = child;
+
+ let portFound = false;
+ let buf = "";
+
+ const onData = (data) => {
+ buf += data.toString("utf8");
+ const match = buf.match(/BRIDGE_READY_PORT=(\d+)/);
+ if (match) {
+ portFound = true;
+ _bridgePort = parseInt(match[1], 10);
+ child.stdout.removeListener("data", onData);
+ log?.info?.(`TLS bridge running on 127.0.0.1:${_bridgePort}`);
+ resolve(_bridgePort);
+ }
+ };
+
+ child.stdout.on("data", onData);
+
+ child.on("error", (err) => {
+ if (!portFound) {
+ _bridgeStartupFailed = true;
+ _bridgeReadyPromise = null;
+ reject(new Error(`Failed to start TLS bridge: ${err.message}`));
+ }
+ });
+
+ child.on("exit", (code) => {
+ _bridgeProcess = null;
+ _bridgePort = null;
+ _bridgeReadyPromise = null;
+ if (!portFound) {
+ _bridgeStartupFailed = true;
+ reject(new Error(`TLS bridge exited prematurely with code ${code}`));
+ }
+ });
+
+ const cleanup = () => {
+ if (_bridgeProcess) {
+ try {
+ _bridgeProcess.stdin.end();
+ _bridgeProcess.kill("SIGTERM");
+ } catch (_) {}
+ _bridgeProcess = null;
+ }
+ };
+
+ process.once("exit", cleanup);
+ process.once("SIGINT", cleanup);
+ process.once("SIGTERM", cleanup);
+
+ // 10 second timeout for startup
+ setTimeout(() => {
+ if (!portFound) {
+ cleanup();
+ _bridgeStartupFailed = true;
+ _bridgeReadyPromise = null;
+ reject(new Error("Timeout waiting for TLS bridge to start"));
+ }
+ }, 10000);
+ } catch (err) {
+ _bridgeStartupFailed = true;
+ _bridgeReadyPromise = null;
+ reject(err);
+ }
+ });
+
+ return _bridgeReadyPromise;
+}
+
+export async function forwardViaBridge({ url, headers, data, stream = false, timeout = 120000 }, log = null) {
+ const port = await ensureBridge(null, log);
+
+ const payload = JSON.stringify({
+ url,
+ headers,
+ data,
+ stream,
+ timeout: Math.round(timeout / 1000),
+ });
+
+ return new Promise((resolve, reject) => {
+ const req = http.request({
+ hostname: "127.0.0.1",
+ port,
+ path: "/",
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(payload),
+ },
+ timeout,
+ }, (res) => {
+ resolve(res);
+ });
+
+ req.on("timeout", () => {
+ req.destroy();
+ reject(Object.assign(
+ new Error("Upstream timeout via bridge — AutoClaw backend did not respond within budget"),
+ { code: "UPSTREAM_TIMEOUT" }
+ ));
+ });
+
+ req.on("error", reject);
+ req.write(payload);
+ req.end();
+ });
+}
diff --git a/lib/bridge.py b/lib/bridge.py
new file mode 100755
index 0000000..3a90556
--- /dev/null
+++ b/lib/bridge.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+"""
+Lightweight TLS Impersonation Bridge for GLM_proxy.
+Uses curl_cffi to bypass Alibaba Cloud WAF TLS bot detection by impersonating
+Chromium's TLS Client Hello (JA3/JA4 fingerprint), matching AutoClaw's Electron client.
+"""
+
+import argparse
+import http.server
+import json
+import os
+import sys
+import threading
+from curl_cffi import requests
+
+class ThreadedHTTPServer(http.server.ThreadingHTTPServer):
+ daemon_threads = True
+
+class BridgeHandler(http.server.BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def do_GET(self):
+ if self.path == "/health":
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", "15")
+ self.end_headers()
+ self.wfile.write(b'{"status":"ok"}')
+ else:
+ self.send_response(404)
+ self.end_headers()
+
+ def do_POST(self):
+ try:
+ content_len = int(self.headers.get("Content-Length", 0))
+ raw_body = self.rfile.read(content_len)
+ payload = json.loads(raw_body.decode("utf-8"))
+
+ url = payload["url"]
+ headers = payload.get("headers", {})
+ data = payload.get("data")
+ stream = payload.get("stream", False)
+ timeout = payload.get("timeout", 120)
+ impersonate = payload.get("impersonate", "chrome124")
+
+ # Remove pre-existing Content-Length, Host, Transfer-Encoding so curl_cffi computes exact wire values
+ clean_headers = {k: v for k, v in headers.items() if k.lower() not in ("content-length", "host", "transfer-encoding")}
+
+ resp = requests.post(
+ url,
+ headers=clean_headers,
+ json=data,
+ impersonate=impersonate,
+ stream=stream,
+ timeout=timeout
+ )
+
+ self.send_response(resp.status_code)
+ ALLOWED_HEADERS = {"content-type", "vary", "set-cookie", "retry-after"}
+ header_items = resp.headers.multi_items() if hasattr(resp.headers, "multi_items") else resp.headers.items()
+ for k, v in header_items:
+ if k.lower() in ALLOWED_HEADERS:
+ self.send_header(k, v)
+
+ if resp.status_code >= 400:
+ body = resp.content
+ sys.stderr.write(f"[BRIDGE] Upstream error {resp.status_code}: {resp.text[:500]}\n")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ self.wfile.flush()
+ elif stream:
+ self.send_header("Transfer-Encoding", "chunked")
+ self.end_headers()
+ for chunk in resp.iter_content(chunk_size=None):
+ if chunk:
+ chunk_len = f"{len(chunk):X}\r\n".encode("ascii")
+ self.wfile.write(chunk_len + chunk + b"\r\n")
+ self.wfile.flush()
+ self.wfile.write(b"0\r\n\r\n")
+ self.wfile.flush()
+ else:
+ body = resp.content
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ self.wfile.flush()
+
+ except Exception as e:
+ err_json = json.dumps({"error": str(e)}).encode("utf-8")
+ try:
+ self.send_response(502)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(err_json)))
+ self.end_headers()
+ self.wfile.write(err_json)
+ except Exception:
+ pass
+
+ def log_message(self, format, *args):
+ if os.environ.get("GLMP_BRIDGE_DEBUG") == "1":
+ sys.stderr.write("%s - - [%s] %s\n" % (self.address_string(), self.log_date_time_string(), format % args))
+
+
+def watch_parent():
+ """Auto-terminate when parent process dies or stdin closes."""
+ try:
+ sys.stdin.read()
+ except Exception:
+ pass
+ os._exit(0)
+
+
+def main():
+ parser = argparse.ArgumentParser(description="GLM Proxy TLS Bridge")
+ parser.add_argument("--port", type=int, default=int(os.environ.get("GLMP_BRIDGE_PORT", "0")))
+ parser.add_argument("--host", type=str, default="127.0.0.1")
+ args = parser.parse_args()
+
+ server = ThreadedHTTPServer((args.host, args.port), BridgeHandler)
+ assigned_port = server.server_address[1]
+
+ # Spawn thread to watch stdin for parent exit
+ stdin_watcher = threading.Thread(target=watch_parent, daemon=True)
+ stdin_watcher.start()
+
+ sys.stdout.write(f"BRIDGE_READY_PORT={assigned_port}\n")
+ sys.stdout.flush()
+
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/lib/core.js b/lib/core.js
index 93e3470..8ff0d5e 100644
--- a/lib/core.js
+++ b/lib/core.js
@@ -20,6 +20,13 @@ import { createPacingGovernor } from "./governor.js";
import { decodeBuffer, decodeStream } from "./decode.js";
import { createConfigHeartbeat } from "./heartbeat.js";
import { createShapeLayer, PINNED_BANNER } from "./shape.js";
+import {
+ findAutoClawVaults,
+ findAutoClawRuntimeCatalogs,
+ extractAutoClawVaultToken,
+ syncTokenToHeadersFile,
+ isJwtExpired,
+} from "./vault.js";
// Single source of truth for the package version (used by the UA string and
// the startup banners). Read from package.json so a release bump is one edit,
@@ -46,27 +53,36 @@ export const DEFAULT_THROTTLE_RETRY_MS = 4000;
// Built-in last-resort catalog if even fallback-models.json is unreadable.
// The editable copy lives in lib/fallback-models.json - keep both in sync.
const BUILTIN_FALLBACK_MODELS = [
- { id: "zai_auto", name: "Auto", contextWindow: 1_048_576, maxTokens: 393_216 },
- { id: "zaicoding_glm-5.3", name: "GLM-5.3", contextWindow: 1_048_576, maxTokens: 307_200 },
+ { id: "zai_auto", name: "Auto", contextWindow: 1_048_576, maxTokens: 131_072 },
+ { id: "zaicoding_glm-5.3", name: "GLM-5.3", contextWindow: 1_048_576, maxTokens: 131_072 },
{ id: "zai_glm-5-turbo", name: "GLM-5-Turbo", contextWindow: 204_800, maxTokens: 131_072 },
{ id: "zai_glm-5.3-flash", name: "GLM-5.3-Flash", contextWindow: 1_048_576, maxTokens: 131_072 },
{ id: "tdpsk_deepseek-v4-flash-202605", name: "Deepseek-V4-Flash", contextWindow: 1_048_576, maxTokens: 393_216 },
{ id: "tdpsk_deepseek-v4-pro-202606", name: "DeepSeek-V4-Pro", contextWindow: 1_048_576, maxTokens: 393_216 },
];
+const BUILTIN_LINUX_MODELS = [
+ { id: "zai_auto", name: "Auto", contextWindow: 1_048_576, maxTokens: 131_072 },
+ { id: "zai_auto-fast", name: "Auto-Fast", contextWindow: 1_048_576, maxTokens: 131_072 },
+ { id: "zaicoding_glm-5.3", name: "GLM-5.3", contextWindow: 1_048_576, maxTokens: 131_072 },
+ { id: "zai_glm-5.3-flash", name: "GLM-5.3-Flash", contextWindow: 1_048_576, maxTokens: 131_072 },
+];
+
// External fallback catalog (editable without a release), overridable via
// FALLBACK_MODELS_PATH. Never throws - a missing or malformed file degrades
// to the built-ins above, same as today.
-function loadFallbackModels() {
+function loadFallbackModels(isLinux = (process.platform === "linux")) {
try {
const override = process.env.FALLBACK_MODELS_PATH;
const source = override ? path.resolve(override) : new URL("./fallback-models.json", import.meta.url);
const parsed = JSON.parse(fs.readFileSync(source, "utf8"));
- const models = Array.isArray(parsed?.models) ? parsed.models : parsed;
+ const models = (isLinux && Array.isArray(parsed?.linuxModels))
+ ? parsed.linuxModels
+ : (Array.isArray(parsed?.models) ? parsed.models : (Array.isArray(parsed) ? parsed : null));
if (Array.isArray(models) && models.length > 0
&& models.every((m) => m && typeof m.id === "string")) return models;
} catch (_) { /* fall through to built-ins */ }
- return BUILTIN_FALLBACK_MODELS;
+ return isLinux ? BUILTIN_LINUX_MODELS : BUILTIN_FALLBACK_MODELS;
}
// The AutoClaw state dir, resolved the way the app resolves it. Measured in the
@@ -285,16 +301,17 @@ export function loadConfig({ format = "openai", defaultPort = DEFAULT_PORTS[form
// to a harmless belt: if the app ever rewords its own prompt, this header still
// opens the door, where the pinned banner string would rot.
// Override with GLMP_HARNESS_TYPE, or set it to "" to send neither header.
+ const isAutoClaw2 = findAutoClawVaults().length > 0;
const HARNESS_TYPE = process.env.GLMP_HARNESS_TYPE === undefined
? "zcode"
: process.env.GLMP_HARNESS_TYPE.trim();
const CLIENT_HEADERS = {
- "X-Tm": "win",
- "X-Version": "1.18.5",
+ "X-Tm": isAutoClaw2 ? (process.platform === "win32" ? "win" : "linux") : "win",
+ "X-Version": isAutoClaw2 ? "2.0.3" : "1.18.5",
"X-Product": "autoclaw",
- "X-Channel": "AutoClaw4",
- "X-Lang": "en",
+ "X-Channel": isAutoClaw2 ? "official" : "AutoClaw4",
+ "X-Lang": isAutoClaw2 ? "zh-CN" : "en",
"X-Client-Type": "pc",
...(HARNESS_TYPE ? { "X-Harness-Type": HARNESS_TYPE, x_trace_id: "autoclaw-desktop" } : {}),
};
@@ -302,10 +319,19 @@ export function loadConfig({ format = "openai", defaultPort = DEFAULT_PORTS[form
const RUNTIME_FILE = path.join(AUTOCLAW_STATE_DIR, "openclaw.runtime.json");
const RUNTIME_LAST_GOOD = path.join(AUTOCLAW_STATE_DIR, "openclaw.runtime.json.last-good");
// Ordered fallbacks - try newest first, degrade gracefully
- const RUNTIME_CANDIDATES = [RUNTIME_FILE, RUNTIME_LAST_GOOD];
+ const RUNTIME_CANDIDATES = [...findAutoClawRuntimeCatalogs(), RUNTIME_FILE, RUNTIME_LAST_GOOD];
const FALLBACK_MODELS = loadFallbackModels();
+ const IS_AUTOCLAW2 = (() => {
+ if (process.env.AUTOCLAW_VERSION === "2") return true;
+ try {
+ return findAutoClawVaults().length > 0;
+ } catch (_) {
+ return false;
+ }
+ })();
+
return {
PORT, HOST, PROXY_KEY, LOG_LEVEL, MAX_BODY_BYTES, RATE_LIMIT, MAX_MESSAGES,
JSONL_LOG, JSONL_SYNC, JSONL_FILE, JSONL_MAX_BYTES, REQUEST_LOG_FILE,
@@ -316,6 +342,7 @@ export function loadConfig({ format = "openai", defaultPort = DEFAULT_PORTS[form
HEARTBEAT_INTERVAL_MS, PROMPT_ENVELOPE_KB, TOOL_SHAPE_BLOCKLIST, GATEWAY_DIST_CANDIDATES,
UPSTREAM_TIMEOUT_MS, THROTTLE_RETRIES, THROTTLE_RETRY_MS,
CLIENT_HEADERS, RUNTIME_FILE, RUNTIME_LAST_GOOD, RUNTIME_CANDIDATES, FALLBACK_MODELS,
+ IS_AUTOCLAW2,
};
}
@@ -585,7 +612,7 @@ export function readRuntimeModels(config) {
try {
const raw = fs.readFileSync(candidate, "utf-8");
const data = JSON.parse(raw);
- const rawModels = data?.models?.providers?.zai?.models;
+ const rawModels = data?.models?.providers?.zai?.models || (Array.isArray(data?.models) ? data.models : null);
if (!Array.isArray(rawModels) || rawModels.length === 0) continue;
const models = rawModels.map((m) => ({
@@ -649,6 +676,7 @@ export const OUTPUT_CAPS = Object.freeze({
"zai_glm-5.3-flash": 131_072,
"zai_glm-5-turbo": 131_072,
"zai_auto": 131_072,
+ "zai_auto-fast": 131_072,
"tdpsk_deepseek-v4-flash-202605": 393_216,
"tdpsk_deepseek-v4-pro-202606": 393_216,
});
@@ -707,28 +735,75 @@ export function createTokenLayer(config, log) {
let _token = null;
let _tokenReadAt = 0;
- // Read the X-Authorization JWT from AutoClaw's local token file. Throws if AutoClaw isn't running / logged in.
+ // Read the X-Authorization JWT from AutoClaw's local vault or token file.
function loadToken() {
- try {
- const raw = fs.readFileSync(config.TOKEN_FILE, "utf-8");
- const data = JSON.parse(raw);
- const auth = data?.headers?.["X-Authorization"];
- if (!auth) throw new Error("X-Authorization field missing");
- return auth; // "Bearer "
- } catch (err) {
- throw new Error(
- `Cannot read AutoClaw token from ${config.TOKEN_FILE}. ` +
- `Make sure AutoClaw is running and you are logged in. (${err.message})`
- );
+ let tokenFromFile = null;
+ let fileMtimeMs = 0;
+
+ if (fs.existsSync(config.TOKEN_FILE)) {
+ try {
+ const raw = fs.readFileSync(config.TOKEN_FILE, "utf-8");
+ const data = JSON.parse(raw);
+ tokenFromFile = data?.headers?.["X-Authorization"] || null;
+ fileMtimeMs = fs.statSync(config.TOKEN_FILE).mtimeMs;
+ } catch (_) {}
+ }
+
+ // Check if AutoClaw 2.x vault is available
+ const vaults = findAutoClawVaults();
+ const newestVault = vaults.length > 0 ? vaults[0] : null;
+
+ // Prefer vault if:
+ // 1. Token file is missing or has no token
+ // 2. Token in file is expired (via JWT exp check)
+ // 3. Vault is newer than token file
+ const fileExpired = tokenFromFile ? isJwtExpired(tokenFromFile) : true;
+ const vaultIsNewer = newestVault && (newestVault.mtimeMs > fileMtimeMs);
+
+ if (newestVault && (!tokenFromFile || fileExpired || vaultIsNewer)) {
+ try {
+ const vaultRes = extractAutoClawVaultToken(config, log);
+ if (vaultRes?.token) {
+ syncTokenToHeadersFile(vaultRes.token, config.TOKEN_FILE);
+ if (vaultRes.profile?.displayName || vaultRes.profile?.email) {
+ const user = vaultRes.profile.displayName || vaultRes.profile.email;
+ log?.info?.(`AutoClaw vault token loaded for account ${user}`);
+ }
+ return vaultRes.token;
+ }
+ } catch (err) {
+ log?.warn?.(`AutoClaw vault extraction failed: ${err.message}`);
+ }
+ }
+
+ // Fall back to token from file if valid
+ if (tokenFromFile) {
+ return tokenFromFile;
+ }
+
+ // If file had no token, try vault as last resort
+ if (newestVault) {
+ try {
+ const vaultRes = extractAutoClawVaultToken(config, log);
+ if (vaultRes?.token) {
+ syncTokenToHeadersFile(vaultRes.token, config.TOKEN_FILE);
+ return vaultRes.token;
+ }
+ } catch (_) {}
}
+
+ throw new Error(
+ `Cannot read AutoClaw token from ${config.TOKEN_FILE} or system vault. ` +
+ `Make sure AutoClaw is running and you are logged in.`
+ );
}
- // Return a cached token, refreshing from disk if the TTL has elapsed.
+ // Return a cached token, refreshing from disk if the TTL has elapsed or token expired.
function getToken() {
- if (!_token || Date.now() - _tokenReadAt > config.TOKEN_TTL_MS) {
+ if (!_token || isJwtExpired(_token) || Date.now() - _tokenReadAt > config.TOKEN_TTL_MS) {
_token = loadToken();
_tokenReadAt = Date.now();
- log.info(`Token loaded (expires cache in ${config.TOKEN_TTL_MS / 60_000} min)`);
+ log?.info?.(`Token loaded (expires cache in ${config.TOKEN_TTL_MS / 60_000} min)`);
}
return _token;
}
@@ -744,11 +819,23 @@ export function createTokenLayer(config, log) {
fs.watchFile(config.TOKEN_FILE, { interval: 1000 }, () => {
try {
_token = loadToken();
- log.info("Token reloaded");
+ log?.info?.("Token reloaded from headers file");
} catch (e) {
- log.warn(`Token reload failed: ${e.message}`);
+ log?.warn?.(`Token reload failed: ${e.message}`);
}
});
+
+ const vaults = findAutoClawVaults();
+ if (vaults.length > 0) {
+ fs.watchFile(vaults[0].encFile, { interval: 1000 }, () => {
+ try {
+ _token = loadToken();
+ log?.info?.("Token reloaded from AutoClaw vault");
+ } catch (e) {
+ log?.warn?.(`Vault token reload failed: ${e.message}`);
+ }
+ });
+ }
}
return { loadToken, getToken, invalidateToken, startWatch };
@@ -1687,13 +1774,70 @@ export function normalizeClientMessages(body) {
newMsg.content = "";
}
+ if (typeof newMsg.content === "string") {
+ newMsg.content = newMsg.content.replace(/Claude Agent SDK/g, "Agent SDK");
+ }
+
return newMsg;
});
}
async function callUpstream(config, clientHeaders, getToken, sanitizedBody, log, transport = null) {
+ const isAutoClaw2 = config?.IS_AUTOCLAW2 ?? (findAutoClawVaults().length > 0 || process.env.AUTOCLAW_VERSION === "2");
+
+ // AutoClaw 2.x upstream strictly rejects arbitrary client-specified max_tokens with HTTP 406.
+ // The native desktop client omits max_tokens, letting models generate up to their natural cap.
+ const bodyForUpstream = { ...sanitizedBody };
+ if (isAutoClaw2) {
+ delete bodyForUpstream.max_tokens;
+ delete bodyForUpstream.max_completion_tokens;
+ }
+
// header keeps the full catalog id; body model goes upstream bare
- const payload = JSON.stringify({ ...sanitizedBody, model: stripProviderPrefix(sanitizedBody.model) });
+ const payload = JSON.stringify({ ...bodyForUpstream, model: stripProviderPrefix(bodyForUpstream.model) });
+
+ const physicalReqId = crypto.randomUUID();
+
+ const upstreamHeaders = {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(payload),
+ "X-Authorization": getToken(),
+ "X-Request-Model": sanitizedBody.model,
+ "X-Request-Id": physicalReqId,
+ "X-Trace-Id": physicalReqId,
+ "X-Session-Id": crypto.randomUUID(),
+ "X-Agent-Id": "main",
+ ...clientHeaders,
+ };
+
+ if (isAutoClaw2) {
+ const nowSec = String(Math.floor(Date.now() / 1000));
+ const APP_ID = "100003";
+ const APP_KEY = "38d2391985e2369a5fb8227d8e6cd5e5";
+ const authSign = crypto.createHash("md5").update(`${APP_ID}&${nowSec}&${APP_KEY}`).digest("hex");
+
+ upstreamHeaders["X-Auth-Appid"] = APP_ID;
+ upstreamHeaders["X-Auth-TimeStamp"] = nowSec;
+ upstreamHeaders["X-Auth-Sign"] = authSign;
+ upstreamHeaders["X-Tm"] = process.platform === "win32" ? "win" : "linux";
+ upstreamHeaders["X-Version"] = (clientHeaders?.["X-Version"] && clientHeaders["X-Version"] !== "1.18.5")
+ ? clientHeaders["X-Version"]
+ : "2.0.3";
+ upstreamHeaders["X-Product"] = "autoclaw";
+ upstreamHeaders["X-Channel"] = "official";
+ upstreamHeaders["X-Lang"] = "zh-CN";
+ upstreamHeaders["X-Client-Type"] = "pc";
+ upstreamHeaders["x_trace_id"] = "autoclaw-desktop";
+ delete upstreamHeaders["X-Harness-Type"];
+ } else {
+ if (!upstreamHeaders["X-Tm"]) upstreamHeaders["X-Tm"] = "win";
+ if (!upstreamHeaders["X-Version"]) upstreamHeaders["X-Version"] = "1.18.5";
+ if (!upstreamHeaders["X-Product"]) upstreamHeaders["X-Product"] = "autoclaw";
+ if (!upstreamHeaders["X-Channel"]) upstreamHeaders["X-Channel"] = "AutoClaw4";
+ if (!upstreamHeaders["X-Lang"]) upstreamHeaders["X-Lang"] = "en";
+ if (!upstreamHeaders["X-Client-Type"]) upstreamHeaders["X-Client-Type"] = "pc";
+ }
+
// Prefer the shared transport (identity headers, cookie jar, h2-when-enabled);
// fall back to the inline h1 path if it is somehow unavailable.
let active = transport;
@@ -1705,15 +1849,7 @@ async function callUpstream(config, clientHeaders, getToken, sanitizedBody, log,
const { res } = await active.request({
path: config.UPSTREAM_PATH || "/autoclaw-proxy/proxy/autoclaw/chat/completions",
payload,
- headers: {
- "Content-Type": "application/json",
- "Content-Length": Buffer.byteLength(payload),
- "X-Authorization": getToken(),
- "X-Request-Model": sanitizedBody.model,
- "X-Request-Id": crypto.randomUUID(),
- "X-Agent-Id": "main",
- ...clientHeaders,
- },
+ headers: upstreamHeaders,
timeoutMs: config.UPSTREAM_TIMEOUT_MS,
});
// Decode at this single seam so EVERY consumer of an upstream body gets
@@ -1727,15 +1863,7 @@ async function callUpstream(config, clientHeaders, getToken, sanitizedBody, log,
port: config.UPSTREAM_PORT || 443,
path: config.UPSTREAM_PATH || "/autoclaw-proxy/proxy/autoclaw/chat/completions",
method: "POST",
- headers: {
- "Content-Type": "application/json",
- "Content-Length": Buffer.byteLength(payload),
- "X-Authorization": getToken(),
- "X-Request-Model": sanitizedBody.model,
- "X-Request-Id": crypto.randomUUID(),
- "X-Agent-Id": "main",
- ...clientHeaders,
- },
+ headers: upstreamHeaders,
timeout: config.UPSTREAM_TIMEOUT_MS, // per-attempt budget (idle-based; env-tunable)
}, payload, log), { log });
}
@@ -1962,3 +2090,11 @@ export function installProcessGuards(log) {
process.on("uncaughtException", (e) => log.error("Uncaught exception:", e));
process.on("unhandledRejection", (e) => log.error("Unhandled rejection:", e));
}
+
+export {
+ findAutoClawVaults,
+ findAutoClawRuntimeCatalogs,
+ extractAutoClawVaultToken,
+ syncTokenToHeadersFile,
+ isJwtExpired,
+} from "./vault.js";
diff --git a/lib/fallback-models.json b/lib/fallback-models.json
index 06b8331..4f74bf6 100644
--- a/lib/fallback-models.json
+++ b/lib/fallback-models.json
@@ -6,5 +6,11 @@
{ "id": "zai_glm-5.3-flash", "name": "GLM-5.3-Flash", "contextWindow": 1048576, "maxTokens": 131072 },
{ "id": "tdpsk_deepseek-v4-flash-202605", "name": "Deepseek-V4-Flash", "contextWindow": 1048576, "maxTokens": 393216 },
{ "id": "tdpsk_deepseek-v4-pro-202606", "name": "DeepSeek-V4-Pro", "contextWindow": 1048576, "maxTokens": 393216 }
+ ],
+ "linuxModels": [
+ { "id": "zai_auto", "name": "Auto", "contextWindow": 1048576, "maxTokens": 131072 },
+ { "id": "zai_auto-fast", "name": "Auto-Fast", "contextWindow": 1048576, "maxTokens": 131072 },
+ { "id": "zaicoding_glm-5.3", "name": "GLM-5.3", "contextWindow": 1048576, "maxTokens": 131072 },
+ { "id": "zai_glm-5.3-flash", "name": "GLM-5.3-Flash", "contextWindow": 1048576, "maxTokens": 131072 }
]
}
diff --git a/lib/queue.js b/lib/queue.js
new file mode 100644
index 0000000..4bac011
--- /dev/null
+++ b/lib/queue.js
@@ -0,0 +1,48 @@
+/**
+ * In-flight request serializer (FIFO queue / Mutex).
+ *
+ * Upstream AutoClaw (autoglm-api.autoglm.ai) strictly limits accounts to
+ * 1 concurrent active generation. Parallel requests (e.g. from subagents,
+ * background tools, or simultaneous turns) are rejected by upstream with HTTP 406.
+ *
+ * This queue ensures that overlapping requests wait in-process until the previous
+ * generation stream completes, then immediately dispatch to upstream.
+ */
+export class InFlightQueue {
+ constructor() {
+ this._tail = Promise.resolve();
+ this._activeCount = 0;
+ }
+
+ get activeCount() {
+ return this._activeCount;
+ }
+
+ /**
+ * Enqueues an execution slot.
+ * Resolves with a `release` function when the slot is ready.
+ * Call `release()` when the upstream response stream completes (ends or errors).
+ *
+ * @returns {Promise<() => void>} release callback
+ */
+ async acquire() {
+ let releaseLock;
+ const nextLock = new Promise((resolve) => { releaseLock = resolve; });
+ const currentTail = this._tail;
+ this._tail = currentTail.then(() => nextLock);
+
+ await currentTail;
+ this._activeCount++;
+
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ this._activeCount--;
+ releaseLock();
+ };
+ }
+}
+
+// Global shared queue instance across entrypoints
+export const globalInFlightQueue = new InFlightQueue();
diff --git a/lib/shape.js b/lib/shape.js
index 7be7cb9..035a8e2 100644
--- a/lib/shape.js
+++ b/lib/shape.js
@@ -35,10 +35,12 @@ import path from "node:path";
import { defaultClock } from "./clock.js";
import { readJsonSafe, stateDir, writeJsonAtomic } from "./state.js";
+import { findAutoClawVaults } from "./vault.js";
// The literal the app itself ships. Kept as the last rung and as the anchor the
// extractor looks for, so a reworded app is *discovered*, not mis-parsed.
export const BANNER_ANCHOR = "You are a personal assistant running inside OpenClaw.";
+export const AUTOCLAW_2X_BANNER = "You are AutoClaw. Answer the user directly and concisely.";
// The pin is the anchor plus the section heading, joined the way the app joins
// its prompt lines. Note the blank line: this is the byte shape the app emits,
@@ -289,6 +291,9 @@ export function createShapeLayer(config, log = null, clock = defaultClock) {
}
const discovered = fromGatewayDist() || fromSnapshot() || null;
if (discovered) return discovered;
+ if (findAutoClawVaults().length > 0) {
+ return { text: AUTOCLAW_2X_BANNER, source: "autoclaw-2x", file: null, observedAt: clock.now() };
+ }
return { text: PINNED_BANNER, source: "pinned", file: null, observedAt: null };
}
diff --git a/lib/transport.js b/lib/transport.js
index f8a3aa4..c609251 100644
--- a/lib/transport.js
+++ b/lib/transport.js
@@ -31,6 +31,7 @@ import http2 from "node:http2";
import path from "node:path";
import { readJsonSafe, stateDir, writeJsonAtomic } from "./state.js";
+import { isBridgeAvailable, forwardViaBridge } from "./bridge.js";
export const COOKIES_FILE = "cookies.json";
@@ -262,6 +263,39 @@ export function createUpstreamTransport(config, log = null, identityLayer = null
async function request({ path: reqPath, payload, headers = {}, timeoutMs = 120000 } = {}) {
const merged = { ...standardHeaders(), ...headers };
+ const useBridge = isBridgeAvailable() && (host === "autoglm-api.autoglm.ai" || process.env.GLMP_FORCE_BRIDGE === "1");
+ if (useBridge) {
+ try {
+ let parsedData = null;
+ try { parsedData = JSON.parse(payload); } catch (_) {}
+ const isStream = parsedData?.stream === true;
+ const targetUrl = `https://${host}${port === 443 ? "" : ":" + port}${reqPath}`;
+ const bridgeHeaders = { ...merged };
+ delete bridgeHeaders["Content-Length"];
+ delete bridgeHeaders["content-length"];
+ const res = await forwardViaBridge({
+ url: targetUrl,
+ headers: bridgeHeaders,
+ data: parsedData,
+ stream: isStream,
+ timeout: timeoutMs,
+ }, log);
+ absorbCookies(res.headers);
+ return { res };
+ } catch (err) {
+ const isBridgeStartupFailure = err.code === "ECONNREFUSED" ||
+ err.code === "ENOENT" ||
+ err.message?.includes("TLS bridge") ||
+ err.message?.includes("Failed to start TLS bridge") ||
+ err.message?.includes("bridge unavailable");
+ if (isBridgeStartupFailure) {
+ warn(`transport: bridge unavailable (${err.message}) — falling back to standard transport`);
+ } else {
+ throw err;
+ }
+ }
+ }
+
if (h2Requested) {
try {
return { res: await attemptH2({ path: reqPath, payload, timeoutMs }, merged) };
diff --git a/lib/vault.js b/lib/vault.js
new file mode 100644
index 0000000..814a336
--- /dev/null
+++ b/lib/vault.js
@@ -0,0 +1,289 @@
+/**
+ * Vault layer — AutoClaw 2.x credentials discovery and extraction (Linux/Electron).
+ *
+ * In AutoClaw 1.x (legacy/Windows), the app wrote plaintext JSON headers to
+ * ~/.openclaw-autoclaw/request-headers.json.
+ *
+ * In AutoClaw 2.x (including the official Linux AppImage and Electron builds),
+ * credentials are encrypted on disk under ~/.config/AutoClaw-oversea-official/
+ * (or ~/.config/AutoClaw/) in accounts//account-credentials.enc
+ * using Chromium OSCrypt v11 (AES-128-CBC) with the master secret stored in
+ * the OS Secret Service (GNOME Keyring / KWallet via D-Bus).
+ *
+ * This module:
+ * 1. Discovers AutoClaw 2.x account vaults.
+ * 2. Retrieves the decryption key via Secret Service / D-Bus (or cached / env).
+ * 3. Decrypts account-credentials.enc using Node.js built-in crypto (zero dependencies).
+ * 4. Syncs the active token to ~/.openclaw-autoclaw/request-headers.json so legacy
+ * consumers and existing file watches remain seamlessly supported.
+ * 5. Discovers AutoClaw 2.x model snapshots (remote-snapshot.json).
+ */
+
+import child_process from "node:child_process";
+import crypto from "node:crypto";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { readJsonSafe, stateDir, writeJsonAtomic } from "./state.js";
+
+let _cachedKeyringPassword = null;
+
+/**
+ * Parses the payload of a JWT token without external libraries.
+ */
+export function parseJwtPayload(token) {
+ try {
+ if (!token || typeof token !== "string") return null;
+ const raw = token.replace(/^Bearer\s+/i, "").trim();
+ const parts = raw.split(".");
+ if (parts.length < 2) return null;
+ const json = Buffer.from(parts[1], "base64").toString("utf-8");
+ return JSON.parse(json);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Checks whether a JWT is expired or within bufferSeconds of expiring.
+ */
+export function isJwtExpired(token, bufferSeconds = 60) {
+ const payload = parseJwtPayload(token);
+ if (!payload || typeof payload.exp !== "number") return false;
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ return nowSeconds >= (payload.exp - bufferSeconds);
+}
+
+/**
+ * Candidates for AutoClaw 2.x configuration base directories.
+ */
+export function getAutoClawConfigDirs() {
+ const envDir = (process.env.AUTOCLAW_CONFIG_DIR || "").trim();
+ if (envDir) return [envDir];
+
+ const home = os.homedir();
+ return [
+ path.join(home, ".config", "AutoClaw-oversea-official"),
+ path.join(home, ".config", "AutoClaw"),
+ ];
+}
+
+/**
+ * Discovers AutoClaw 2.x encrypted account credential vaults.
+ * Returns an array of vault objects sorted by mtime descending (most recent first).
+ */
+export function findAutoClawVaults() {
+ const configDirs = getAutoClawConfigDirs();
+ const vaults = [];
+
+ for (const base of configDirs) {
+ const accountsDir = path.join(base, "accounts");
+ if (!fs.existsSync(accountsDir)) continue;
+
+ let subdirs;
+ try {
+ subdirs = fs.readdirSync(accountsDir);
+ } catch {
+ continue;
+ }
+
+ for (const sub of subdirs) {
+ const accountDir = path.join(accountsDir, sub);
+ const encFile = path.join(accountDir, "account-credentials.enc");
+ if (fs.existsSync(encFile)) {
+ try {
+ const stat = fs.statSync(encFile);
+ const profileFile = path.join(accountDir, "account-profile.json");
+ vaults.push({
+ accountDir,
+ encFile,
+ profileFile: fs.existsSync(profileFile) ? profileFile : null,
+ mtimeMs: stat.mtimeMs,
+ });
+ } catch {
+ // ignore unreadable
+ }
+ }
+ }
+ }
+
+ return vaults.sort((a, b) => b.mtimeMs - a.mtimeMs);
+}
+
+/**
+ * Discovers AutoClaw 2.x model catalog snapshots (remote-snapshot.json).
+ */
+export function findAutoClawRuntimeCatalogs() {
+ const configDirs = getAutoClawConfigDirs();
+ const catalogs = [];
+
+ for (const base of configDirs) {
+ const accountsDir = path.join(base, "accounts");
+ if (!fs.existsSync(accountsDir)) continue;
+
+ let subdirs;
+ try {
+ subdirs = fs.readdirSync(accountsDir);
+ } catch {
+ continue;
+ }
+
+ for (const sub of subdirs) {
+ const snapshot = path.join(accountsDir, sub, "runtime", "model-catalog", "remote-snapshot.json");
+ if (fs.existsSync(snapshot)) {
+ try {
+ const stat = fs.statSync(snapshot);
+ catalogs.push({ file: snapshot, mtimeMs: stat.mtimeMs });
+ } catch {}
+ }
+ }
+ }
+
+ return catalogs.sort((a, b) => b.mtimeMs - a.mtimeMs).map((c) => c.file);
+}
+
+/**
+ * Retrieves the AutoClaw master password from the OS Keyring.
+ * On Linux, queries GNOME Keyring / Secret Service via D-Bus (using python3).
+ * Falls back to GLMP_KEYRING_PASSWORD env or cached state.
+ */
+export function getKeyringPassword(config = null, log = null) {
+ if (process.env.GLMP_KEYRING_PASSWORD) {
+ return process.env.GLMP_KEYRING_PASSWORD.trim();
+ }
+
+ if (_cachedKeyringPassword) {
+ return _cachedKeyringPassword;
+ }
+
+ // On Linux, query Secret Service via python3 dbus
+ if (process.platform === "linux") {
+ const pyScript = `
+import dbus, sys
+try:
+ bus = dbus.SessionBus()
+ service = bus.get_object('org.freedesktop.secrets', '/org/freedesktop/secrets')
+ ss = dbus.Interface(service, 'org.freedesktop.Secret.Service')
+ session = ss.OpenSession('plain', '')[1]
+ for app in ['AutoClaw', 'AutoClaw-oversea-official', 'chromium']:
+ unlocked, _ = ss.SearchItems({'application': app})
+ if unlocked:
+ item = bus.get_object('org.freedesktop.secrets', unlocked[0])
+ pw = bytes(dbus.Interface(item, 'org.freedesktop.Secret.Item').GetSecret(session)[2]).decode('utf-8')
+ print(pw)
+ sys.exit(0)
+ sys.exit(1)
+except Exception:
+ sys.exit(1)
+`;
+ try {
+ const res = child_process.spawnSync("python3", ["-c", pyScript], {
+ encoding: "utf-8",
+ timeout: 3000,
+ });
+ if (res.status === 0 && res.stdout.trim()) {
+ const pw = res.stdout.trim();
+ _cachedKeyringPassword = pw;
+ return pw;
+ }
+ } catch (err) {
+ log?.warn?.(`Failed to query Secret Service for AutoClaw key: ${err.message}`);
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Decrypts Chromium OSCrypt v11 ciphertext.
+ * Algorithm: AES-128-CBC, IV: 16 spaces (0x20), PBKDF2: 1 iteration, salt: "saltysalt".
+ */
+export function decryptCredentials(encBuffer, keyringPassword) {
+ if (!Buffer.isBuffer(encBuffer) || encBuffer.length < 4) {
+ throw new Error("Invalid credentials buffer");
+ }
+
+ const prefix = encBuffer.subarray(0, 3).toString("utf-8");
+ if (prefix !== "v11") {
+ throw new Error(`Unsupported credentials encryption format: ${prefix}`);
+ }
+
+ if (!keyringPassword) {
+ throw new Error("AutoClaw keyring password is required for decryption");
+ }
+
+ const ciphertext = encBuffer.subarray(3);
+ const key = crypto.pbkdf2Sync(keyringPassword, "saltysalt", 1, 16, "sha1");
+ const iv = Buffer.alloc(16, 0x20);
+
+ const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
+ return JSON.parse(decrypted.toString("utf-8"));
+}
+
+/**
+ * Extracts and returns the active AutoClaw token from the newest vault.
+ * Returns { token, account, vaultFile, mtimeMs } or null if no vault exists.
+ */
+export function extractAutoClawVaultToken(config = null, log = null) {
+ const vaults = findAutoClawVaults();
+ if (vaults.length === 0) return null;
+
+ const newest = vaults[0];
+ const pw = getKeyringPassword(config, log);
+ if (!pw) {
+ log?.warn?.("AutoClaw vault found but keyring password is unavailable");
+ return null;
+ }
+
+ try {
+ const encBuffer = fs.readFileSync(newest.encFile);
+ const creds = decryptCredentials(encBuffer, pw);
+ if (!creds?.accessToken) {
+ log?.warn?.("Decrypted AutoClaw credentials do not contain accessToken");
+ return null;
+ }
+
+ const token = creds.accessToken.startsWith("Bearer ")
+ ? creds.accessToken
+ : `Bearer ${creds.accessToken}`;
+
+ let profile = null;
+ if (newest.profileFile) {
+ const profData = readJsonSafe(newest.profileFile, null);
+ if (profData?.profile) {
+ profile = profData.profile;
+ }
+ }
+
+ return {
+ token,
+ profile,
+ vaultFile: newest.encFile,
+ accountDir: newest.accountDir,
+ mtimeMs: newest.mtimeMs,
+ };
+ } catch (err) {
+ log?.warn?.(`Failed to decrypt AutoClaw vault (${err.message})`);
+ return null;
+ }
+}
+
+/**
+ * Synchronizes the token to ~/.openclaw-autoclaw/request-headers.json.
+ */
+export function syncTokenToHeadersFile(token, targetFile) {
+ if (!token || !targetFile) return;
+ try {
+ writeJsonAtomic(targetFile, {
+ headers: {
+ "X-Authorization": token,
+ },
+ updatedAt: new Date().toISOString(),
+ source: "autoclaw-vault-extractor",
+ });
+ } catch (_) {
+ // writing sync file is best-effort
+ }
+}
diff --git a/openai.js b/openai.js
index 1943e89..7f6c8ff 100644
--- a/openai.js
+++ b/openai.js
@@ -36,6 +36,7 @@ import {
getPacingGovernor, classifyGovernorError,
startConfigHeartbeat, startShapeWatch,
} from "./lib/core.js";
+import { globalInFlightQueue } from "./lib/queue.js";
// Config
const config = loadConfig({ format: "openai" });
@@ -250,12 +251,30 @@ async function handleChatCompletions(req, res) {
// rejected this request before fallback ran; consumed by record() so the
// terminal ring entry carries the full story.
let cloudEvidence = null;
+ let releaseLock = null;
+ let released = false;
+ const releaseOnce = () => {
+ if (!released) {
+ released = true;
+ if (typeof releaseLock === "function") {
+ releaseLock();
+ releaseLock = null;
+ }
+ }
+ };
// Terminal success handling shared by first-attempt and retried responses.
async function respondSuccess(successRes) {
record(successRes.statusCode, { model: modelId, lastMessage: lastMsgForLog(), messageCount: body.messages?.length || 0 });
log.debug(`← upstream status=${successRes.statusCode}`);
+ res.on("close", () => {
+ if (!res.writableFinished && successRes && !successRes.destroyed) {
+ try { successRes.destroy(); } catch (_) {}
+ }
+ releaseOnce();
+ });
+
if (stream) {
res.writeHead(200, SSE_HEADERS);
// `pipe` does not forward source errors to the destination, so a mid-stream
@@ -265,7 +284,10 @@ async function handleChatCompletions(req, res) {
successRes.on("error", (err) => {
log.warn(`Upstream stream failed mid-response: ${err.message}`);
try { res.end(); } catch (_) {}
+ releaseOnce();
});
+ successRes.on("end", releaseOnce);
+ successRes.on("close", releaseOnce);
successRes.pipe(res);
return;
}
@@ -282,6 +304,8 @@ async function handleChatCompletions(req, res) {
} catch (err) {
if (!res.headersSent) sendErrorOpenAI(res, err.message, "api_error", 502, "upstream_parse_failed");
else { try { res.end(); } catch (_) {} }
+ } finally {
+ releaseOnce();
}
}
@@ -307,6 +331,9 @@ async function handleChatCompletions(req, res) {
return sendClassifiedErrorOpenAI(res, cls);
}
+ // Upstream limits accounts to 1 concurrent active generation; serialize in-flight requests
+ releaseLock = await globalInFlightQueue.acquire();
+
// Cloud call with one retry on the flaky 400 "invalid request" hiccup;
// every >=400 body is buffered + logged (R1). Shared with anthropic.js.
const { res: upstreamRes, errBody: upstreamErrBody } = await callUpstreamWithInvalidRequestRetry(
@@ -326,6 +353,8 @@ async function handleChatCompletions(req, res) {
if (effectiveStatus < 400) return respondSuccess(upstreamRes);
+ releaseOnce();
+
// Rotate-out token caches BEFORE deciding fallback so the very next
// request picks up the fresh JWT regardless of who serves this one.
if (effectiveStatus === 401) invalidateAuth();
@@ -355,6 +384,7 @@ async function handleChatCompletions(req, res) {
});
return sendClassifiedErrorOpenAI(res, cls);
} catch (err) {
+ releaseOnce();
// Transport-level failure (no HTTP response at all): dead token, connect
// reset, upstream timeout…
const cls = classifyTransportError(err);
diff --git a/package.json b/package.json
index 886953b..2cfbd5b 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
"scripts": {
"start": "node bin/cli.js",
"anthropic": "node anthropic.js",
- "test": "node tests/pen-test-p1.mjs && node tests/pen-test-p2.mjs && node tests/taxonomy.mjs && node tests/pen-test-p3.mjs && node tests/pen-test-p4.mjs && node tests/pen-test-p5.mjs && node tests/pen-test-p6.mjs && node tests/pen-test-p7.mjs && node tests/pen-test-p8.mjs && node tests/pen-test-p9.mjs && node tests/pen-test-p10.mjs && node tests/catalog-refresh.mjs"
+ "test": "node tests/pen-test-p1.mjs && node tests/pen-test-p2.mjs && node tests/taxonomy.mjs && node tests/pen-test-p3.mjs && node tests/pen-test-p4.mjs && node tests/pen-test-p5.mjs && node tests/pen-test-p6.mjs && node tests/pen-test-p7.mjs && node tests/pen-test-p8.mjs && node tests/pen-test-p9.mjs && node tests/pen-test-p10.mjs && node tests/catalog-refresh.mjs && node tests/vault.mjs && node tests/queue.mjs"
},
"keywords": [
"glm",
diff --git a/tests/queue.mjs b/tests/queue.mjs
new file mode 100644
index 0000000..cdcd8fa
--- /dev/null
+++ b/tests/queue.mjs
@@ -0,0 +1,57 @@
+import assert from "node:assert/strict";
+import { InFlightQueue } from "../lib/queue.js";
+
+async function testQueueSequential() {
+ const q = new InFlightQueue();
+ const order = [];
+
+ async function job(id, durationMs) {
+ const release = await q.acquire();
+ order.push(`start-${id}`);
+ await new Promise((r) => setTimeout(r, durationMs));
+ order.push(`end-${id}`);
+ release();
+ }
+
+ await Promise.all([job(1, 40), job(2, 20), job(3, 10)]);
+
+ assert.deepEqual(order, [
+ "start-1", "end-1",
+ "start-2", "end-2",
+ "start-3", "end-3",
+ ]);
+ assert.equal(q.activeCount, 0);
+ console.log(" ✓ in-flight queue serializes concurrent jobs strictly in FIFO order");
+}
+
+async function testQueueErrorSafety() {
+ const q = new InFlightQueue();
+ let job2Ran = false;
+
+ async function job1() {
+ const release = await q.acquire();
+ try {
+ throw new Error("fail");
+ } finally {
+ release();
+ }
+ }
+
+ async function job2() {
+ const release = await q.acquire();
+ job2Ran = true;
+ release();
+ }
+
+ await assert.rejects(job1(), /fail/);
+ await job2();
+
+ assert.equal(job2Ran, true);
+ assert.equal(q.activeCount, 0);
+ console.log(" ✓ in-flight queue releases lock even if an in-flight job throws");
+}
+
+(async () => {
+ await testQueueSequential();
+ await testQueueErrorSafety();
+})();
diff --git a/tests/taxonomy.mjs b/tests/taxonomy.mjs
index 6b4a3f2..26c7065 100644
--- a/tests/taxonomy.mjs
+++ b/tests/taxonomy.mjs
@@ -255,9 +255,10 @@ check("resolveUpstreamModelId keeps known ids, maps auto, prefixes others", () =
// ── Fallback model catalog ─────────────────────────────────────────────────
-check("fallback catalog ships six models incl glm-5.3-flash", () => {
+check("fallback catalog ships expected models incl glm-5.3-flash", () => {
const cfg = loadConfig({ format: "openai" });
- assert.ok(Array.isArray(cfg.FALLBACK_MODELS) && cfg.FALLBACK_MODELS.length === 6);
+ const expectedCount = process.platform === "linux" ? 4 : 6;
+ assert.ok(Array.isArray(cfg.FALLBACK_MODELS) && cfg.FALLBACK_MODELS.length === expectedCount);
assert.ok(cfg.FALLBACK_MODELS.some((m) => m.id === "zai_glm-5.3-flash"));
});
@@ -333,7 +334,7 @@ check("malformed FALLBACK_MODELS_PATH degrades to built-ins", () => {
process.env.FALLBACK_MODELS_PATH = "C:/definitely/not/here.json";
try {
const cfg = loadConfig({ format: "openai" });
- assert.ok(cfg.FALLBACK_MODELS.length >= 5);
+ assert.ok(cfg.FALLBACK_MODELS.length >= 4);
assert.ok(cfg.FALLBACK_MODELS.every((m) => typeof m.id === "string"));
} finally {
delete process.env.FALLBACK_MODELS_PATH;
diff --git a/tests/vault.mjs b/tests/vault.mjs
new file mode 100644
index 0000000..95c711b
--- /dev/null
+++ b/tests/vault.mjs
@@ -0,0 +1,138 @@
+import assert from "node:assert/strict";
+import crypto from "node:crypto";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import {
+ decryptCredentials,
+ findAutoClawRuntimeCatalogs,
+ findAutoClawVaults,
+ isJwtExpired,
+ parseJwtPayload,
+ syncTokenToHeadersFile,
+} from "../lib/vault.js";
+import { createTokenLayer, loadConfig } from "../lib/core.js";
+
+test("parseJwtPayload extracts claims from valid JWTs", () => {
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64");
+ const payload = Buffer.from(JSON.stringify({ user_id: 12345, exp: 1999999999, email: "test@example.com" })).toString("base64");
+ const fakeJwt = `${header}.${payload}.signature`;
+
+ const parsed = parseJwtPayload(`Bearer ${fakeJwt}`);
+ assert.equal(parsed?.user_id, 12345);
+ assert.equal(parsed?.email, "test@example.com");
+
+ const parsedRaw = parseJwtPayload(fakeJwt);
+ assert.equal(parsedRaw?.user_id, 12345);
+
+ assert.equal(parseJwtPayload("invalid-token"), null);
+ assert.equal(parseJwtPayload(null), null);
+});
+
+test("isJwtExpired correctly identifies expired vs valid tokens", () => {
+ const header = Buffer.from(JSON.stringify({ alg: "HS256" })).toString("base64");
+
+ // Token expired in the past
+ const pastExp = Math.floor(Date.now() / 1000) - 300;
+ const expiredPayload = Buffer.from(JSON.stringify({ exp: pastExp })).toString("base64");
+ const expiredToken = `Bearer ${header}.${expiredPayload}.sig`;
+ assert.equal(isJwtExpired(expiredToken), true);
+
+ // Token valid far into the future
+ const futureExp = Math.floor(Date.now() / 1000) + 3600;
+ const validPayload = Buffer.from(JSON.stringify({ exp: futureExp })).toString("base64");
+ const validToken = `Bearer ${header}.${validPayload}.sig`;
+ assert.equal(isJwtExpired(validToken), false);
+
+ // Non-JWT token is not treated as expired
+ assert.equal(isJwtExpired("Bearer non-jwt-token"), false);
+});
+
+test("decryptCredentials correctly decrypts v11 OSCrypt payload", () => {
+ const password = "test-master-password";
+ const plaintext = JSON.stringify({
+ schemaVersion: 1,
+ accessToken: "Bearer decrypted-secret-token",
+ refreshToken: "refresh-123",
+ });
+
+ const key = crypto.pbkdf2Sync(password, "saltysalt", 1, 16, "sha1");
+ const iv = Buffer.alloc(16, 0x20);
+ const cipher = crypto.createCipheriv("aes-128-cbc", key, iv);
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(plaintext, "utf8")), cipher.final()]);
+
+ const encBuffer = Buffer.concat([Buffer.from("v11", "utf8"), ciphertext]);
+ const decrypted = decryptCredentials(encBuffer, password);
+
+ assert.equal(decrypted.accessToken, "Bearer decrypted-secret-token");
+ assert.equal(decrypted.refreshToken, "refresh-123");
+});
+
+test("syncTokenToHeadersFile atomically writes valid headers format", () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vault-sync-test-"));
+ const targetFile = path.join(tmpDir, "request-headers.json");
+
+ syncTokenToHeadersFile("Bearer test-token-123", targetFile);
+
+ assert.ok(fs.existsSync(targetFile));
+ const content = JSON.parse(fs.readFileSync(targetFile, "utf8"));
+ assert.equal(content?.headers?.["X-Authorization"], "Bearer test-token-123");
+ assert.equal(content?.source, "autoclaw-vault-extractor");
+});
+
+test("createTokenLayer loads from vault and syncs to token file", () => {
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vault-layer-test-"));
+ const tokenFile = path.join(tmpDir, "request-headers.json");
+ const accountsDir = path.join(tmpDir, "accounts", "test-user");
+ fs.mkdirSync(accountsDir, { recursive: true });
+
+ const password = "test-master-password";
+ const plaintext = JSON.stringify({
+ schemaVersion: 1,
+ accessToken: "Bearer mock-vault-token-xyz",
+ refreshToken: "mock-refresh-xyz",
+ });
+ const key = crypto.pbkdf2Sync(password, "saltysalt", 1, 16, "sha1");
+ const iv = Buffer.alloc(16, 0x20);
+ const cipher = crypto.createCipheriv("aes-128-cbc", key, iv);
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(plaintext, "utf8")), cipher.final()]);
+ const encBuffer = Buffer.concat([Buffer.from("v11", "utf8"), ciphertext]);
+ fs.writeFileSync(path.join(accountsDir, "account-credentials.enc"), encBuffer);
+
+ const prevConfigDir = process.env.AUTOCLAW_CONFIG_DIR;
+ const prevKeyringPw = process.env.GLMP_KEYRING_PASSWORD;
+
+ process.env.AUTOCLAW_CONFIG_DIR = tmpDir;
+ process.env.GLMP_KEYRING_PASSWORD = password;
+
+ try {
+ const config = {
+ TOKEN_FILE: tokenFile,
+ TOKEN_TTL_MS: 10000,
+ };
+
+ const logs = [];
+ const log = {
+ info: (msg) => logs.push(msg),
+ warn: (msg) => logs.push(msg),
+ error: (msg) => logs.push(msg),
+ };
+
+ const layer = createTokenLayer(config, log);
+ const token = layer.getToken();
+
+ assert.equal(token, "Bearer mock-vault-token-xyz");
+ assert.ok(fs.existsSync(tokenFile));
+
+ const synced = JSON.parse(fs.readFileSync(tokenFile, "utf8"));
+ assert.equal(synced.headers["X-Authorization"], "Bearer mock-vault-token-xyz");
+ } finally {
+ if (prevConfigDir !== undefined) process.env.AUTOCLAW_CONFIG_DIR = prevConfigDir;
+ else delete process.env.AUTOCLAW_CONFIG_DIR;
+ if (prevKeyringPw !== undefined) process.env.GLMP_KEYRING_PASSWORD = prevKeyringPw;
+ else delete process.env.GLMP_KEYRING_PASSWORD;
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
+ }
+});