From f23fb8fc018b092924ec3ea33d0d835ecc1de61b Mon Sep 17 00:00:00 2001 From: rakxdev Date: Thu, 8 Oct 2026 05:26:21 +0530 Subject: [PATCH 1/8] feat(linux): add AutoClaw 2.x Linux vault auto-sync, TLS impersonation bridge, and 4-model catalog --- .gitignore | 1 + lib/bridge.js | 139 ++++++++++++++++++ lib/bridge.py | 128 ++++++++++++++++ lib/core.js | 190 +++++++++++++++++------- lib/fallback-models.json | 10 +- lib/shape.js | 5 + lib/transport.js | 22 +++ lib/vault.js | 306 +++++++++++++++++++++++++++++++++++++++ package.json | 2 +- tests/taxonomy.mjs | 6 +- tests/vault.mjs | 109 ++++++++++++++ 11 files changed, 859 insertions(+), 59 deletions(-) create mode 100644 lib/bridge.js create mode 100755 lib/bridge.py create mode 100644 lib/vault.js create mode 100644 tests/vault.mjs 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/lib/bridge.js b/lib/bridge.js new file mode 100644 index 0000000..7dc6b1c --- /dev/null +++ b/lib/bridge.js @@ -0,0 +1,139 @@ +/** + * 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; + +export function isBridgeAvailable() { + return process.platform === "linux" || process.env.GLMP_USE_BRIDGE === "1"; +} + +export async function ensureBridge(config, log = null) { + 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) { + _bridgeReadyPromise = null; + reject(new Error(`Failed to start TLS bridge: ${err.message}`)); + } + }); + + child.on("exit", (code) => { + _bridgeProcess = null; + _bridgePort = null; + _bridgeReadyPromise = null; + if (!portFound) { + 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(); + _bridgeReadyPromise = null; + reject(new Error("Timeout waiting for TLS bridge to start")); + } + }, 10000); + } catch (err) { + _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..67558a9 --- /dev/null +++ b/lib/bridge.py @@ -0,0 +1,128 @@ +#!/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") + + resp = requests.post( + url, + headers=headers, + json=data, + impersonate=impersonate, + stream=stream, + timeout=timeout + ) + + self.send_response(resp.status_code) + for k, v in resp.headers.items(): + k_lower = k.lower() + if k_lower in ("content-type", "content-encoding", "vary"): + self.send_header(k, v) + + if 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..d7c33d9 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, @@ -285,24 +292,26 @@ 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" + ? (isAutoClaw2 ? "" : "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" } : {}), + x_trace_id: "autoclaw-desktop", + ...(HARNESS_TYPE ? { "X-Harness-Type": HARNESS_TYPE } : {}), }; 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(); @@ -585,7 +594,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) => ({ @@ -707,28 +716,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; } @@ -741,14 +797,28 @@ export function createTokenLayer(config, log) { // Hot-reload token when AutoClaw rotates it - avoids restart function startWatch() { - fs.watchFile(config.TOKEN_FILE, { interval: 1000 }, () => { - try { - _token = loadToken(); - log.info("Token reloaded"); - } catch (e) { - log.warn(`Token reload failed: ${e.message}`); - } - }); + if (fs.existsSync(config.TOKEN_FILE)) { + fs.watchFile(config.TOKEN_FILE, { interval: 1000 }, () => { + try { + _token = loadToken(); + log?.info?.("Token reloaded from headers file"); + } catch (e) { + 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 }; @@ -1694,6 +1764,36 @@ export function normalizeClientMessages(body) { async function callUpstream(config, clientHeaders, getToken, sanitizedBody, log, transport = null) { // header keeps the full catalog id; body model goes upstream bare const payload = JSON.stringify({ ...sanitizedBody, model: stripProviderPrefix(sanitizedBody.model) }); + + 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"); + 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", + "x_trace_id": "autoclaw-desktop", + "X-Auth-Appid": APP_ID, + "X-Auth-TimeStamp": nowSec, + "X-Auth-Sign": authSign, + "X-Tm": "linux", + "X-Version": "2.0.3", + "X-Product": "autoclaw", + "X-Channel": "official", + "X-Lang": "zh-CN", + "X-Client-Type": "pc", + ...clientHeaders, + }; + delete upstreamHeaders["X-Harness-Type"]; + // 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 +1805,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 +1819,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 +2046,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..5dbd719 100644 --- a/lib/fallback-models.json +++ b/lib/fallback-models.json @@ -1,10 +1,8 @@ { "models": [ - { "id": "zai_auto", "name": "Auto", "contextWindow": 1048576, "maxTokens": 131072 }, - { "id": "zaicoding_glm-5.3", "name": "GLM-5.3", "contextWindow": 1048576, "maxTokens": 131072 }, - { "id": "zai_glm-5-turbo", "name": "GLM-5-Turbo", "contextWindow": 204800, "maxTokens": 131072 }, - { "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 } + { "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/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..a23b090 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,27 @@ 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 res = await forwardViaBridge({ + url: targetUrl, + headers: merged, + data: parsedData, + stream: isStream, + timeout: timeoutMs, + }, log); + absorbCookies(res.headers); + return { res }; + } catch (err) { + warn(`transport: bridge forward failed (${err.message}) — falling back to standard transport`); + } + } + 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..9072aaa --- /dev/null +++ b/lib/vault.js @@ -0,0 +1,306 @@ +/** + * 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 dirs = []; + const envDir = (process.env.AUTOCLAW_CONFIG_DIR || "").trim(); + if (envDir && !dirs.includes(envDir)) dirs.push(envDir); + + const home = os.homedir(); + const defaults = [ + path.join(home, ".config", "AutoClaw-oversea-official"), + path.join(home, ".config", "AutoClaw"), + ]; + + for (const d of defaults) { + if (!dirs.includes(d)) dirs.push(d); + } + return dirs; +} + +/** + * 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; + } + + // Check persistent cache in stateDir + const cacheFile = path.join(stateDir(config), "autoclaw-keyring.json"); + const cached = readJsonSafe(cacheFile, null); + if (cached?.password && typeof cached.password === "string") { + _cachedKeyringPassword = cached.password; + return cached.password; + } + + // 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; + try { + writeJsonAtomic(cacheFile, { password: pw, updatedAt: Date.now() }); + } catch (_) {} + 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/package.json b/package.json index 886953b..5f739f5 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" }, "keywords": [ "glm", diff --git a/tests/taxonomy.mjs b/tests/taxonomy.mjs index 6b4a3f2..434fcfd 100644 --- a/tests/taxonomy.mjs +++ b/tests/taxonomy.mjs @@ -255,9 +255,9 @@ 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 four models incl glm-5.3-flash", () => { const cfg = loadConfig({ format: "openai" }); - assert.ok(Array.isArray(cfg.FALLBACK_MODELS) && cfg.FALLBACK_MODELS.length === 6); + assert.ok(Array.isArray(cfg.FALLBACK_MODELS) && cfg.FALLBACK_MODELS.length === 4); assert.ok(cfg.FALLBACK_MODELS.some((m) => m.id === "zai_glm-5.3-flash")); }); @@ -333,7 +333,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..ae3efd1 --- /dev/null +++ b/tests/vault.mjs @@ -0,0 +1,109 @@ +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 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.ok(typeof token === "string"); + assert.ok(token.startsWith("Bearer ")); + assert.ok(fs.existsSync(tokenFile)); + + const synced = JSON.parse(fs.readFileSync(tokenFile, "utf8")); + assert.equal(synced.headers["X-Authorization"], token); +}); From 74c2a6e9d67525b62d4098e859f35d2273e2c853 Mon Sep 17 00:00:00 2001 From: rakxdev Date: Thu, 8 Oct 2026 05:26:21 +0530 Subject: [PATCH 2/8] fix(transport): strip Content-Length header so curl_cffi computes exact wire payload --- lib/bridge.py | 5 ++++- lib/transport.js | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/bridge.py b/lib/bridge.py index 67558a9..618c786 100755 --- a/lib/bridge.py +++ b/lib/bridge.py @@ -43,9 +43,12 @@ def do_POST(self): 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=headers, + headers=clean_headers, json=data, impersonate=impersonate, stream=stream, diff --git a/lib/transport.js b/lib/transport.js index a23b090..6fa509d 100644 --- a/lib/transport.js +++ b/lib/transport.js @@ -270,9 +270,12 @@ export function createUpstreamTransport(config, log = null, identityLayer = 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: merged, + headers: bridgeHeaders, data: parsedData, stream: isStream, timeout: timeoutMs, From c3314f44c3ae77c2f9dfd3eee30141391057598d Mon Sep 17 00:00:00 2001 From: rakxdev Date: Thu, 8 Oct 2026 05:26:21 +0530 Subject: [PATCH 3/8] feat(queue): serialize in-flight requests to eliminate subagent 406 rejections - Introduce InFlightQueue FIFO mutex in lib/queue.js - Acquire in-flight slot before calling upstream in openai.js and anthropic.js - Release lock upon stream completion, error, or client disconnect - Add comprehensive unit test suite in tests/queue.mjs and register in package.json - Fix ReferenceError on undeclared cloudEvidence in anthropic.js --- anthropic.js | 33 +++++++++++++++++++++++++++- lib/queue.js | 48 +++++++++++++++++++++++++++++++++++++++++ openai.js | 25 ++++++++++++++++++++++ package.json | 2 +- tests/queue.mjs | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 lib/queue.js create mode 100644 tests/queue.mjs diff --git a/anthropic.js b/anthropic.js index 6fad45a..7f8bf38 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" }); @@ -545,6 +546,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 +578,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,6 +603,7 @@ 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 @@ -607,6 +625,8 @@ async function handleMessages(req, res) { // Success paths record(statusCode, { lastMessage: lastMsgForLog(), messageCount: openAIBody.messages?.length || 0 }); + res.on("close", releaseOnce); + if (stream) { res.writeHead(200, SSE_HEADERS); @@ -643,9 +663,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 +684,15 @@ 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) => { + 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/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/openai.js b/openai.js index 1943e89..c93e01b 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,25 @@ 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", releaseOnce); + if (stream) { res.writeHead(200, SSE_HEADERS); // `pipe` does not forward source errors to the destination, so a mid-stream @@ -265,7 +279,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 +299,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 +326,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 +348,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 +379,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 5f739f5..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 && node tests/vault.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(); +})(); From 2032dfd30b3e3c05f9c38c4f9e45a5c2f5f3b704 Mon Sep 17 00:00:00 2001 From: rakxdev Date: Thu, 8 Oct 2026 05:26:21 +0530 Subject: [PATCH 4/8] feat(platform): unify Windows and Linux AutoClaw 2.0.3 dual-compatibility - Provide dynamic fallback model catalogs for Linux (4 models) and Windows (6 models with DeepSeek) - Add zai_auto-fast to OUTPUT_CAPS with verified 131072 max output cap - Ensure wire header precedence preserves Windows AutoClaw 1.x headers while applying AutoClaw 2.x HMAC signatures when vault is detected - Update tests/taxonomy.mjs assertions for platform-aware model counts - Document AutoClaw 2.0.3 Linux AppImage support, platform catalogs, and subagent concurrency queue in README.md --- README.md | 40 +++++++++++++++---- anthropic.js | 11 ++++-- lib/core.js | 84 ++++++++++++++++++++++++++++------------ lib/fallback-models.json | 8 ++++ tests/taxonomy.mjs | 5 ++- 5 files changed, 111 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index d5e67f6..21e7640 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ 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. --- @@ -160,24 +162,48 @@ 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) | + +### Linux (AutoClaw 2.0.3) -> GLM 5.3 flash new in the proxy!!!!! ox alpha the goat +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): + +| 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 | -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: +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 | |---|---| | `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) | +| `claude-haiku-*` | `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. --- diff --git a/anthropic.js b/anthropic.js index 7f8bf38..9f43bd3 100644 --- a/anthropic.js +++ b/anthropic.js @@ -133,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 @@ -148,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; } @@ -175,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 */ } } diff --git a/lib/core.js b/lib/core.js index d7c33d9..a8823ca 100644 --- a/lib/core.js +++ b/lib/core.js @@ -53,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 @@ -294,7 +303,7 @@ export function loadConfig({ format = "openai", defaultPort = DEFAULT_PORTS[form // 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 - ? (isAutoClaw2 ? "" : "zcode") + ? "zcode" : process.env.GLMP_HARNESS_TYPE.trim(); const CLIENT_HEADERS = { @@ -304,8 +313,7 @@ export function loadConfig({ format = "openai", defaultPort = DEFAULT_PORTS[form "X-Channel": isAutoClaw2 ? "official" : "AutoClaw4", "X-Lang": isAutoClaw2 ? "zh-CN" : "en", "X-Client-Type": "pc", - x_trace_id: "autoclaw-desktop", - ...(HARNESS_TYPE ? { "X-Harness-Type": HARNESS_TYPE } : {}), + ...(HARNESS_TYPE ? { "X-Harness-Type": HARNESS_TYPE, x_trace_id: "autoclaw-desktop" } : {}), }; const RUNTIME_FILE = path.join(AUTOCLAW_STATE_DIR, "openclaw.runtime.json"); @@ -658,6 +666,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, }); @@ -1757,18 +1766,28 @@ 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 = 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 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"); const physicalReqId = crypto.randomUUID(); const upstreamHeaders = { @@ -1780,19 +1799,36 @@ async function callUpstream(config, clientHeaders, getToken, sanitizedBody, log, "X-Trace-Id": physicalReqId, "X-Session-Id": crypto.randomUUID(), "X-Agent-Id": "main", - "x_trace_id": "autoclaw-desktop", - "X-Auth-Appid": APP_ID, - "X-Auth-TimeStamp": nowSec, - "X-Auth-Sign": authSign, - "X-Tm": "linux", - "X-Version": "2.0.3", - "X-Product": "autoclaw", - "X-Channel": "official", - "X-Lang": "zh-CN", - "X-Client-Type": "pc", ...clientHeaders, }; - delete upstreamHeaders["X-Harness-Type"]; + + 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. diff --git a/lib/fallback-models.json b/lib/fallback-models.json index 5dbd719..4f74bf6 100644 --- a/lib/fallback-models.json +++ b/lib/fallback-models.json @@ -1,5 +1,13 @@ { "models": [ + { "id": "zai_auto", "name": "Auto", "contextWindow": 1048576, "maxTokens": 131072 }, + { "id": "zaicoding_glm-5.3", "name": "GLM-5.3", "contextWindow": 1048576, "maxTokens": 131072 }, + { "id": "zai_glm-5-turbo", "name": "GLM-5-Turbo", "contextWindow": 204800, "maxTokens": 131072 }, + { "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 }, diff --git a/tests/taxonomy.mjs b/tests/taxonomy.mjs index 434fcfd..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 four 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 === 4); + 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")); }); From 5006ecd6bb7de62a171a301d954c74f85b92fa91 Mon Sep 17 00:00:00 2001 From: rakxdev Date: Thu, 8 Oct 2026 06:42:06 +0530 Subject: [PATCH 5/8] fix(bridge): deliver non-2xx responses unchunked and log upstream diagnostic payloads --- anthropic.js | 2 +- lib/bridge.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/anthropic.js b/anthropic.js index 9f43bd3..4837f50 100644 --- a/anthropic.js +++ b/anthropic.js @@ -613,7 +613,7 @@ async function handleMessages(req, res) { // 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, { diff --git a/lib/bridge.py b/lib/bridge.py index 618c786..10cc3a5 100755 --- a/lib/bridge.py +++ b/lib/bridge.py @@ -58,10 +58,17 @@ def do_POST(self): self.send_response(resp.status_code) for k, v in resp.headers.items(): k_lower = k.lower() - if k_lower in ("content-type", "content-encoding", "vary"): + if k_lower in ("content-type", "vary"): self.send_header(k, v) - if stream: + 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): From 057bc106d9e3f6bfd8fc9f31d8066adafe68c4cc Mon Sep 17 00:00:00 2001 From: rakxdev Date: Thu, 8 Oct 2026 06:47:50 +0530 Subject: [PATCH 6/8] docs: update compatibility guide for Linux AutoClaw 2.0.3, catalogs, and Claude Code --- README.md | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 21e7640..a54324f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ CI

-

v2.6.0. One CLI, two API formats, zero dependencies.

+

v3.0.0. One CLI, two API formats, zero dependencies.

--- @@ -41,7 +41,7 @@ Then point any OpenAI-compatible tool at `http://127.0.0.1:18791/v1`, or any Ant ## Integrations -### Claude Code CLI +### Claude Code CLI & Anthropic API Add to `~/.claude/settings.json`: @@ -54,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": { @@ -68,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 @@ -186,13 +196,15 @@ On Linux, AutoClaw 2.0.3 natively exposes **4 models** in its upstream runtime c | `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 | -|---|---| -| `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.3-flash` / `zai_glm-5-turbo` | +| 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` | --- @@ -567,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. +- 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. - 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 From 127ea5105ee86867a83e1d3f6aed0f59e1485e81 Mon Sep 17 00:00:00 2001 From: rakxdev Date: Thu, 8 Oct 2026 07:21:55 +0530 Subject: [PATCH 7/8] fix(review): address review feedback on stream aborts, headers, and vault security --- anthropic.js | 13 ++++++++- lib/bridge.js | 9 ++++++ lib/bridge.py | 7 +++-- lib/core.js | 30 ++++++++++++-------- lib/transport.js | 11 +++++++- lib/vault.js | 21 ++------------ openai.js | 7 ++++- tests/vault.mjs | 71 ++++++++++++++++++++++++++++++++++-------------- 8 files changed, 112 insertions(+), 57 deletions(-) diff --git a/anthropic.js b/anthropic.js index 4837f50..6ae0204 100644 --- a/anthropic.js +++ b/anthropic.js @@ -628,7 +628,12 @@ async function handleMessages(req, res) { // Success paths record(statusCode, { lastMessage: lastMsgForLog(), messageCount: openAIBody.messages?.length || 0 }); - res.on("close", releaseOnce); + res.on("close", () => { + if (!res.writableFinished && upstreamRes && !upstreamRes.destroyed) { + try { upstreamRes.destroy(); } catch (_) {} + } + releaseOnce(); + }); if (stream) { res.writeHead(200, SSE_HEADERS); @@ -692,6 +697,12 @@ async function handleMessages(req, res) { } }); 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) { diff --git a/lib/bridge.js b/lib/bridge.js index 7dc6b1c..ac42d3d 100644 --- a/lib/bridge.js +++ b/lib/bridge.js @@ -16,12 +16,17 @@ 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; @@ -53,6 +58,7 @@ export async function ensureBridge(config, log = null) { child.on("error", (err) => { if (!portFound) { + _bridgeStartupFailed = true; _bridgeReadyPromise = null; reject(new Error(`Failed to start TLS bridge: ${err.message}`)); } @@ -63,6 +69,7 @@ export async function ensureBridge(config, log = null) { _bridgePort = null; _bridgeReadyPromise = null; if (!portFound) { + _bridgeStartupFailed = true; reject(new Error(`TLS bridge exited prematurely with code ${code}`)); } }); @@ -85,11 +92,13 @@ export async function ensureBridge(config, log = null) { 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); } diff --git a/lib/bridge.py b/lib/bridge.py index 10cc3a5..3a90556 100755 --- a/lib/bridge.py +++ b/lib/bridge.py @@ -56,9 +56,10 @@ def do_POST(self): ) self.send_response(resp.status_code) - for k, v in resp.headers.items(): - k_lower = k.lower() - if k_lower in ("content-type", "vary"): + 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: diff --git a/lib/core.js b/lib/core.js index a8823ca..8ff0d5e 100644 --- a/lib/core.js +++ b/lib/core.js @@ -323,6 +323,15 @@ export function loadConfig({ format = "openai", defaultPort = DEFAULT_PORTS[form 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, @@ -333,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, }; } @@ -806,16 +816,14 @@ export function createTokenLayer(config, log) { // Hot-reload token when AutoClaw rotates it - avoids restart function startWatch() { - if (fs.existsSync(config.TOKEN_FILE)) { - fs.watchFile(config.TOKEN_FILE, { interval: 1000 }, () => { - try { - _token = loadToken(); - log?.info?.("Token reloaded from headers file"); - } catch (e) { - log?.warn?.(`Token reload failed: ${e.message}`); - } - }); - } + fs.watchFile(config.TOKEN_FILE, { interval: 1000 }, () => { + try { + _token = loadToken(); + log?.info?.("Token reloaded from headers file"); + } catch (e) { + log?.warn?.(`Token reload failed: ${e.message}`); + } + }); const vaults = findAutoClawVaults(); if (vaults.length > 0) { @@ -1775,7 +1783,7 @@ export function normalizeClientMessages(body) { } async function callUpstream(config, clientHeaders, getToken, sanitizedBody, log, transport = null) { - const isAutoClaw2 = findAutoClawVaults().length > 0 || process.env.AUTOCLAW_VERSION === "2"; + 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. diff --git a/lib/transport.js b/lib/transport.js index 6fa509d..c609251 100644 --- a/lib/transport.js +++ b/lib/transport.js @@ -283,7 +283,16 @@ export function createUpstreamTransport(config, log = null, identityLayer = null absorbCookies(res.headers); return { res }; } catch (err) { - warn(`transport: bridge forward failed (${err.message}) — falling back to standard transport`); + 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; + } } } diff --git a/lib/vault.js b/lib/vault.js index 9072aaa..814a336 100644 --- a/lib/vault.js +++ b/lib/vault.js @@ -59,20 +59,14 @@ export function isJwtExpired(token, bufferSeconds = 60) { * Candidates for AutoClaw 2.x configuration base directories. */ export function getAutoClawConfigDirs() { - const dirs = []; const envDir = (process.env.AUTOCLAW_CONFIG_DIR || "").trim(); - if (envDir && !dirs.includes(envDir)) dirs.push(envDir); + if (envDir) return [envDir]; const home = os.homedir(); - const defaults = [ + return [ path.join(home, ".config", "AutoClaw-oversea-official"), path.join(home, ".config", "AutoClaw"), ]; - - for (const d of defaults) { - if (!dirs.includes(d)) dirs.push(d); - } - return dirs; } /** @@ -163,14 +157,6 @@ export function getKeyringPassword(config = null, log = null) { return _cachedKeyringPassword; } - // Check persistent cache in stateDir - const cacheFile = path.join(stateDir(config), "autoclaw-keyring.json"); - const cached = readJsonSafe(cacheFile, null); - if (cached?.password && typeof cached.password === "string") { - _cachedKeyringPassword = cached.password; - return cached.password; - } - // On Linux, query Secret Service via python3 dbus if (process.platform === "linux") { const pyScript = ` @@ -199,9 +185,6 @@ except Exception: if (res.status === 0 && res.stdout.trim()) { const pw = res.stdout.trim(); _cachedKeyringPassword = pw; - try { - writeJsonAtomic(cacheFile, { password: pw, updatedAt: Date.now() }); - } catch (_) {} return pw; } } catch (err) { diff --git a/openai.js b/openai.js index c93e01b..7f6c8ff 100644 --- a/openai.js +++ b/openai.js @@ -268,7 +268,12 @@ async function handleChatCompletions(req, res) { record(successRes.statusCode, { model: modelId, lastMessage: lastMsgForLog(), messageCount: body.messages?.length || 0 }); log.debug(`← upstream status=${successRes.statusCode}`); - res.on("close", releaseOnce); + res.on("close", () => { + if (!res.writableFinished && successRes && !successRes.destroyed) { + try { successRes.destroy(); } catch (_) {} + } + releaseOnce(); + }); if (stream) { res.writeHead(200, SSE_HEADERS); diff --git a/tests/vault.mjs b/tests/vault.mjs index ae3efd1..95c711b 100644 --- a/tests/vault.mjs +++ b/tests/vault.mjs @@ -85,25 +85,54 @@ test("syncTokenToHeadersFile atomically writes valid headers format", () => { 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 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.ok(typeof token === "string"); - assert.ok(token.startsWith("Bearer ")); - assert.ok(fs.existsSync(tokenFile)); - - const synced = JSON.parse(fs.readFileSync(tokenFile, "utf8")); - assert.equal(synced.headers["X-Authorization"], token); + 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 (_) {} + } }); From 2c2c39c925f08a96fc6d914788a3910ac009e8a9 Mon Sep 17 00:00:00 2001 From: rakxdev Date: Thu, 8 Oct 2026 07:23:25 +0530 Subject: [PATCH 8/8] docs: note in-memory keyring security and stream abort on client disconnect --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a54324f..f058691 100644 --- a/README.md +++ b/README.md @@ -580,8 +580,8 @@ TRUSTED_PROXIES=127.0.0.1 glmproxy --host 0.0.0.0 - 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. -- 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. -- 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. +- 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