diff --git a/docs/depandabot/2026-05-26-phase-2-auth-adjustments.md b/docs/depandabot/2026-05-26-phase-2-auth-adjustments.md
new file mode 100644
index 0000000..8bd7eee
--- /dev/null
+++ b/docs/depandabot/2026-05-26-phase-2-auth-adjustments.md
@@ -0,0 +1,82 @@
+# depandabot audit — Phase 2 Auth Adjustments
+
+## §1 Current State
+
+1. **Phase 1 complete on `feat/auth-phase-1`** (commits `cbd083e`→`803d91a`, 589 tests green). Launcher injects `X-Ccxray-Auth`, WS gate classifies + warns, internal headers stripped before upstream. Pushed, not yet merged to main. (`server/providers.js:15-79`, `server/ws-proxy.js:489-520`)
+
+2. **`verifyUpstream` exists in warn-only mode** (`server/auth.js:364-371`). It calls legacy `authMiddleware` under the hood and adds deprecation headers. Flipping to enforce = changing the fallback from `authMiddleware(req, res)` to a 401 rejection when `X-Ccxray-Auth` is absent.
+
+3. **Hub IPC is 100% HTTP-over-TCP** (`server/hub.js`, 533 LOC). Routes: `/_api/hub/register`, `/_api/hub/unregister`, `/_api/hub/bootstrap-token`, `/_api/hub/status`, `/_api/health`. Client discovery reads `hub.json` for `{port, pid}` and makes HTTP calls. No Unix socket code exists yet.
+
+4. **The spec explicitly supports "optional remote deployment"** behind a TLS terminator (Tailscale Serve, Caddy, nginx). The `CCXRAY_PUBLIC_ORIGINS` env var is designed for this. (`reason/260525-0055-ccxray-auth-design/task.md:13`, `candidate-A.md:370`)
+
+5. **`_isLoopbackPeer(req)` already exists** in `server/hub.js:370-373` — checks `req.socket.remoteAddress` only. Used to gate `/_api/hub/bootstrap-token`.
+
+## §2 Intended Goal
+
+Should we proceed with the three Phase 2 adjustments I proposed:
+(a) adding dual-direction loopback check (`localAddress` + `remoteAddress`) to `isLoopbackChatGPTCodex`,
+(b) splitting 2.3 into sub-commits 2.3a/2.3b/2.3c,
+(c) stating that the verifyUpstream flip is "just a flag"?
+
+## §3 Current Plan
+
+1. **2.1**: Flip `verifyUpstream` from warn→reject for requests without `X-Ccxray-Auth`. Add `isLoopbackChatGPTCodex(req)` helper checking `(a) req.socket.remoteAddress is loopback, (b) req.socket.localAddress is loopback, (c) Authorization is JWT-shaped, (d) chatgpt-account-id present`. The dual-loopback check prevents the reverse-proxy bypass.
+2. **2.2**: Dashboard enforcement + ephemeral mode default. Straightforward.
+3. **2.3a**: Create `hub.sock` Unix socket listener + framed IPC protocol (register/unregister/health/bootstrap-token) running parallel to existing HTTP.
+4. **2.3b**: Client code prefers socket over HTTP when `hub.json` reports `sockPath`.
+5. **2.3c**: HTTP `/_hub/*` routes return 410 Gone.
+
+## §4 Missing Directional Confirmations
+
+1. **[assumption]** `req.socket.localAddress` reliably reflects the actual bind address even when Node's HTTP server listens on `0.0.0.0`. (If it reflects the wildcard, the check is useless.)
+2. **[risk]** The dual-loopback check (`localAddress` + `remoteAddress`) is the **exact same pattern** that OpenClaw (GHSA-xc7w-v5x6-cc87) found to be bypassable behind a reverse proxy. When a TLS terminator connects to ccxray over loopback, BOTH `localAddress` and `remoteAddress` are `127.0.0.1` — the attacker's traffic arrives looking fully loopback.
+3. **[unknown]** Whether splitting 2.3 into 3 sub-commits is necessary vs the existing hub.js complexity. The file is 533 LOC — is it really that coupled?
+4. **[assumption]** The verifyUpstream flip is "just a flag change" — but the ChatGPT-OAuth carve-out adds a new code path that needs its own tests and can introduce regressions.
+5. **[risk]** `isLoopbackChatGPTCodex` creates a permanent authentication exemption that no amount of header checking can secure against a same-host reverse proxy. The spec explicitly documents remote deployment as supported.
+
+## §5 Evidence & Arguments
+
+1. **[OpenClaw GHSA-xc7w-v5x6-cc87](https://github.com/openclaw/openclaw/security/advisories/GHSA-xc7w-v5x6-cc87)** — Real-world CVE where loopback `remoteAddress` trust was bypassed by a same-host reverse proxy (Tailscale Serve, nginx). The fix was to **remove loopback-based auth bypass entirely** and always require the shared secret. → §4.2, §4.5
+
+2. **[Node.js net documentation](https://nodejs.org/api/net.html)** — Confirms `socket.localAddress` reflects the specific interface the connection arrived on (not the wildcard). When server listens on `0.0.0.0` and client connects to `127.0.0.1`, localAddress is `127.0.0.1`. → §4.1
+
+3. **[Express behind proxies](https://expressjs.com/en/guide/behind-proxies/)** — Documents that behind a reverse proxy, `req.socket.remoteAddress` shows the proxy's address (loopback for same-host proxies). This is the architectural reason loopback checks fail as security gates when proxied. → §4.2
+
+4. **[Unix Domain Sockets in Node (Krun.Pro, Apr 2026)](https://medium.com/@krun_dev/unix-domain-sockets-in-node-749b3b7319e5)** — Confirms that UDS with filesystem permissions (`0600`) is the standard Node.js approach for same-machine IPC access control. No need for complex peer-UID detection. → §3.3-§3.5
+
+5. **[Dissent: errata §1.3 already acknowledges the residual risk]** — The errata explicitly states the ChatGPT-OAuth carve-out has residual "cost amplification / log pollution" risk from other-UID local attackers, and calls the mitigation primitive "bind ccxray to a Unix socket." This means the spec designers already accepted that loopback-check is imperfect — but they accepted it as a Phase 1 tradeoff, not a permanent architecture. The dual-loopback "improvement" I proposed doesn't actually improve the security property; it just adds code. → §4.5, §3.1
+
+## §6 Second Opinion
+
+### Round 1 — Reviewer verdict: OBJECT
+
+| ID | Severity | Category | Summary |
+|----|----------|----------|---------|
+| O1 | high | conceptual | Dual-loopback check is security theater; OpenClaw CVE is direct evidence. Behind a same-host reverse proxy both addresses are 127.0.0.1. Either require secret on every request or use Unix sockets. |
+| O2 | medium | conceptual | "Just a flag" understates the ChatGPT-OAuth carve-out work — a permanent exemption with 4 conjunctive conditions needs its own test matrix. |
+| O3 | low | implementation | Splitting 2.3 into 3 sub-commits for 533 LOC is unnecessary ceremony. |
+
+### Claude's response:
+
+- **O1 (conceptual): ACCEPT → REFRAME.** The dual-loopback check I proposed adds zero real security. The OpenClaw CVE proves the pattern is broken. The errata already identified the real fix (Unix socket). My proposal was adding code without adding defense.
+
+- **O2 (conceptual): ACCEPT → REFRAME.** The ChatGPT-OAuth carve-out is not a flag flip — it's a new authentication exemption that needs comprehensive testing. Calling it "just a flag" would lead to under-scoping.
+
+- **O3 (implementation): ACCEPT → AMEND.** 2.3 will be a single commit.
+
+### Terminal state
+
+Two conceptual objections accepted → loop terminates immediately.
+
+**REFRAME**
+
+### What needs to change before Phase 2 proceeds:
+
+1. **Drop the dual-loopback proposal entirely.** `isLoopbackChatGPTCodex` should NOT use `localAddress`/`remoteAddress` as security gates. The ChatGPT-OAuth carve-out in 2.1 must be redesigned:
+ - Option A: Accept the ChatGPT-OAuth path is unauthenticated-by-design until Unix socket binding (2.3) closes the multi-UID hole. Keep `classifyUpstreamAuth` returning `chatgpt-oauth` and exempt it from 401, but document it as an accepted residual risk, not a security check.
+ - Option B: Reorder Phase 2 so 2.3 (Unix socket) lands BEFORE 2.1 (enforcement). Once ccxray binds to a Unix socket, there is no multi-UID attack surface and the ChatGPT-OAuth carve-out doesn't need IP checks at all.
+
+2. **Scope the ChatGPT-OAuth carve-out as a real feature with its own test plan**, not as a footnote on the verifyUpstream flip. Write the test matrix before writing the code.
+
+3. **2.3 is a single commit**, not three.
diff --git a/public/index.html b/public/index.html
index f5ef295..767c6be 100644
--- a/public/index.html
+++ b/public/index.html
@@ -14,6 +14,49 @@
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
})();
+
diff --git a/reason/260525-0055-ccxray-auth-design/errata.md b/reason/260525-0055-ccxray-auth-design/errata.md
index e5e0101..78fa9b4 100644
--- a/reason/260525-0055-ccxray-auth-design/errata.md
+++ b/reason/260525-0055-ccxray-auth-design/errata.md
@@ -139,3 +139,24 @@ This is a credential-leak surface in Codex itself, not in ccxray. It is unrelate
| Docs | `ccxray_session` (in `overview.md`) | Standardize on `ccxray_s` |
No commit is added or removed. The total surface remains 8 commits across Phases 1–3.
+
+---
+
+## 5. Phase 2 reorder (depandabot audit, 2026-05-26)
+
+**Trigger:** depandabot audit (`docs/depandabot/2026-05-26-phase-2-auth-adjustments.md`) found that the proposed `isLoopbackChatGPTCodex` dual-loopback check (`localAddress` + `remoteAddress`) replicates a known-broken pattern (OpenClaw GHSA-xc7w-v5x6-cc87). Behind a same-host reverse proxy both addresses are `127.0.0.1` — the check adds code without adding security.
+
+**Decision:** Reorder Phase 2 so Unix socket hub IPC (originally 2.3) lands **before** upstream enforcement (originally 2.1). Once ccxray binds to a Unix socket, the multi-UID attack surface is closed by filesystem permissions and the ChatGPT-OAuth carve-out doesn't need IP-based checks at all.
+
+**Revised Phase 2 order:**
+
+| New # | Old # | Content |
+|-------|-------|---------|
+| 2.1 | 2.3 | Unix socket hub IPC + HTTP `/_hub/*` → 410 (single commit, not split) |
+| 2.2 | 2.1 | Upstream domain enforcement — `verifyUpstream` flips from warn to reject. ChatGPT-OAuth carve-out scoped as a standalone feature with its own test matrix, not a footnote. No loopback IP checks. |
+| 2.3 | 2.2 | Dashboard enforcement + ephemeral mode default |
+
+**What was dropped:**
+- Dual-direction loopback check (`localAddress` + `remoteAddress`) in `isLoopbackChatGPTCodex` — security theater per OpenClaw CVE precedent.
+- Splitting the Unix socket commit into 3 sub-commits — unnecessary for 533 LOC.
+- Framing the enforce flip as "just a flag" — the ChatGPT-OAuth carve-out is a real auth exemption needing proper test coverage.
diff --git a/server/auth.js b/server/auth.js
index a93c77e..749bb09 100644
--- a/server/auth.js
+++ b/server/auth.js
@@ -1,28 +1,395 @@
'use strict';
/**
- * Simple API key authentication middleware for cloud deployments.
+ * Auth primitives for the two-domain auth migration.
*
- * Enable by setting AUTH_TOKEN environment variable.
- * When set, all requests must include either:
- * - Header: Authorization: Bearer
- * - Query param: ?token=
+ * Phase 1.1: pure crypto + root secret resolution. Module is exported but
+ * not yet wired into the request path (that lands in Phase 1.2). The
+ * existing authMiddleware below is preserved unchanged so current behavior
+ * is byte-identical until Phase 1.2 swaps the call site over.
*
- * Dashboard and SSE endpoints are also protected.
- * The proxy endpoint (forwarding to Anthropic) uses the client's own API key
- * for Anthropic auth, but still requires AUTH_TOKEN for access control.
+ * Authoritative design: reason/260525-0055-ccxray-auth-design/candidate-AB.md
+ * Implementation deviations: reason/260525-0055-ccxray-auth-design/errata.md
*/
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+// ─── Root secret resolution ──────────────────────────────────────────
+
+function getHubDir() {
+ return process.env.CCXRAY_HOME || path.join(os.homedir(), '.ccxray');
+}
+
+function ensureHubDir() {
+ const dir = getHubDir();
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
+ // mkdirSync ignores mode on existing dirs; tighten explicitly.
+ try { fs.chmodSync(dir, 0o700); } catch {}
+ return dir;
+}
+
+function readOrCreateEphemeralSecret() {
+ const dir = ensureHubDir();
+ const secretPath = path.join(dir, 'local-secret');
+ try {
+ const existing = fs.readFileSync(secretPath);
+ if (existing.length === 32) return existing;
+ // Wrong length — treat as corrupt and regenerate.
+ } catch (err) {
+ if (err.code !== 'ENOENT') throw err;
+ }
+ const fresh = crypto.randomBytes(32);
+ fs.writeFileSync(secretPath, fresh, { mode: 0o600 });
+ // writeFileSync respects mode only on create; tighten explicitly in case
+ // the file already existed with looser perms.
+ try { fs.chmodSync(secretPath, 0o600); } catch {}
+ return fresh;
+}
+
+function getRootSecret() {
+ const token = process.env.AUTH_TOKEN;
+ if (token) {
+ return crypto.createHash('sha256').update(token, 'utf8').digest();
+ }
+ return readOrCreateEphemeralSecret();
+}
+
+// ─── HKDF label-separated derivation ─────────────────────────────────
+
+const LABELS = Object.freeze({
+ K_upstream: 'ccxray/v1/upstream',
+ K_session: 'ccxray/v1/session-hmac',
+ K_bootstrap: 'ccxray/v1/bootstrap',
+});
+
+function hkdf(rootKey, label, len = 32) {
+ return Buffer.from(crypto.hkdfSync('sha256', rootKey, Buffer.alloc(0), Buffer.from(label, 'utf8'), len));
+}
+
+function deriveSecrets(rootKey) {
+ return {
+ K_upstream: hkdf(rootKey, LABELS.K_upstream),
+ K_session: hkdf(rootKey, LABELS.K_session),
+ K_bootstrap: hkdf(rootKey, LABELS.K_bootstrap),
+ };
+}
+
+// ─── Stateless HMAC session cookie ───────────────────────────────────
+
+const COOKIE_VERSION = 1;
+
+function signCookie(payload, K_session) {
+ const json = JSON.stringify(payload);
+ const payloadBuf = Buffer.from(json, 'utf8');
+ const hmac = crypto.createHmac('sha256', K_session).update(payloadBuf).digest();
+ return `${payloadBuf.toString('base64url')}.${hmac.toString('base64url')}`;
+}
+
+function verifyCookie(raw, K_session) {
+ if (typeof raw !== 'string' || raw.length === 0) return null;
+ const dot = raw.indexOf('.');
+ if (dot <= 0 || dot === raw.length - 1) return null;
+
+ const payloadB64 = raw.slice(0, dot);
+ const hmacB64 = raw.slice(dot + 1);
+
+ let payloadBuf, providedHmac;
+ try {
+ payloadBuf = Buffer.from(payloadB64, 'base64url');
+ providedHmac = Buffer.from(hmacB64, 'base64url');
+ } catch {
+ return null;
+ }
+ // base64url decode is lenient — reject anything that round-trips to a
+ // different string (catches the '!!!.!!!' garbage-in case).
+ if (payloadBuf.toString('base64url') !== payloadB64) return null;
+ if (providedHmac.toString('base64url') !== hmacB64) return null;
+ if (providedHmac.length !== 32) return null;
+
+ const expected = crypto.createHmac('sha256', K_session).update(payloadBuf).digest();
+ if (!crypto.timingSafeEqual(providedHmac, expected)) return null;
+
+ let payload;
+ try {
+ payload = JSON.parse(payloadBuf.toString('utf8'));
+ } catch {
+ return null;
+ }
+ if (!payload || typeof payload !== 'object') return null;
+ if (payload.v !== COOKIE_VERSION) return null;
+ if (typeof payload.exp !== 'number') return null;
+ if (payload.exp < Math.floor(Date.now() / 1000)) return null;
+
+ return payload;
+}
+
+// ─── Constant-time string compare ────────────────────────────────────
+
+function compareSecret(provided, expected) {
+ if (typeof provided !== 'string' || typeof expected !== 'string') return false;
+ // Hash both sides to a fixed-width buffer so timingSafeEqual never throws
+ // on length mismatch and the comparison work is independent of input length.
+ const ph = crypto.createHash('sha256').update(provided, 'utf8').digest();
+ const eh = crypto.createHash('sha256').update(expected, 'utf8').digest();
+ return crypto.timingSafeEqual(ph, eh) && provided.length === expected.length;
+}
+
+// ─── Two-domain dispatcher (Phase 1.2: warn-only) ────────────────────
+//
+// dispatch(req) classifies a request by path into upstream or dashboard
+// and returns the matching verifier. The verifiers are byte-identical
+// to authMiddleware on success/failure decisions — they internally
+// delegate to it. The only new behavior is X-Ccxray-Deprecation
+// response headers on requests that used credential forms slated for
+// removal in Phase 2:
+// - dashboard: ?token= → deprecation (Bearer stays permanent)
+// - upstream: Bearer or ?token= → deprecation
+//
+// The headers are set via setHeader so they survive the downstream
+// handler's writeHead call; setHeader can never affect status code
+// or body, so this code is incapable of breaking a request that
+// authMiddleware would have allowed.
+
+const UPSTREAM_PREFIXES = ['/v1/'];
+
+function getPathname(url) {
+ const q = url.indexOf('?');
+ return q === -1 ? url : url.slice(0, q);
+}
+
+function classifyDomain(req) {
+ const pathname = getPathname(req.url || '');
+ for (const prefix of UPSTREAM_PREFIXES) {
+ if (pathname === prefix.slice(0, -1) || pathname.startsWith(prefix)) {
+ return 'upstream';
+ }
+ }
+ return 'dashboard';
+}
+
+function whichLegacyMechanism(req) {
+ // Re-derive the same checks authMiddleware did, so we know which
+ // legacy form succeeded. Returns 'bearer' | 'token-query' | null.
+ const token = process.env.AUTH_TOKEN;
+ if (!token) return null;
+ const authHeader = req.headers['authorization'] || '';
+ if (authHeader === `Bearer ${token}`) return 'bearer';
+ try {
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
+ if (url.searchParams.get('token') === token) return 'token-query';
+ } catch {}
+ return null;
+}
+
+function setDeprecation(res, value) {
+ if (typeof res.setHeader === 'function') {
+ res.setHeader('X-Ccxray-Deprecation', value);
+ }
+}
+
+// ─── Phase 1.3: cookie path + bootstrap flow ─────────────────────────
+
+const ALLOWED_HOSTS = new Set(); // populated lazily from req.headers.host
+const COOKIE_TTL_SECONDS = 24 * 60 * 60; // 24h per "最小開發" decision
+const BOOTSTRAP_TTL_MS = 60 * 1000;
+const BOOTSTRAP_MAX_PENDING = 8;
+
+// Module-level state. Cleared whenever the module is re-required (tests do
+// this via delete require.cache).
+const pendingBootstraps = new Map(); // hashHex → expireEpochMs
+let _cachedSecrets = null;
+
+function getSecrets() {
+ if (_cachedSecrets) return _cachedSecrets;
+ _cachedSecrets = deriveSecrets(getRootSecret());
+ return _cachedSecrets;
+}
+
+function _hashBootstrap(tok) {
+ const { K_bootstrap } = getSecrets();
+ return crypto.createHmac('sha256', K_bootstrap).update(tok, 'utf8').digest('hex');
+}
+
+function _gcBootstraps(now = Date.now()) {
+ for (const [k, exp] of pendingBootstraps) if (exp < now) pendingBootstraps.delete(k);
+}
+
+function mintBootstrapToken() {
+ _gcBootstraps();
+ // Cap the pending set so a runaway minter can't grow it unbounded.
+ while (pendingBootstraps.size >= BOOTSTRAP_MAX_PENDING) {
+ // Drop oldest by insertion order (Map preserves it).
+ const oldest = pendingBootstraps.keys().next().value;
+ pendingBootstraps.delete(oldest);
+ }
+ const tok = crypto.randomBytes(24).toString('base64url');
+ pendingBootstraps.set(_hashBootstrap(tok), Date.now() + BOOTSTRAP_TTL_MS);
+ return tok;
+}
+
+function _isAllowedHost(host) {
+ if (!host) return false;
+ // Phase 1.3 is permissive: any localhost/loopback host is allowed. Phase
+ // 2.2 will tighten this with an explicit allowlist + CCXRAY_PUBLIC_ORIGINS.
+ if (host.startsWith('localhost:') || host === 'localhost') return true;
+ if (host.startsWith('127.0.0.1:') || host === '127.0.0.1') return true;
+ if (host.startsWith('[::1]:') || host === '[::1]') return true;
+ return false;
+}
+
+function _passesCsrfGate(req) {
+ const sfs = req.headers['sec-fetch-site'];
+ if (sfs !== undefined) {
+ return sfs === 'same-origin' || sfs === 'none';
+ }
+ // Older browser / non-browser fallback: require Origin to match Host.
+ const origin = req.headers.origin;
+ if (!origin) return false;
+ let u;
+ try { u = new URL(origin); } catch { return false; }
+ return _isAllowedHost(u.host);
+}
+
+function parseCookie(raw, name) {
+ if (typeof raw !== 'string') return null;
+ for (const part of raw.split(';')) {
+ const trimmed = part.trim();
+ const eq = trimmed.indexOf('=');
+ if (eq <= 0) continue;
+ if (trimmed.slice(0, eq) === name) return trimmed.slice(eq + 1);
+ }
+ return null;
+}
+
+function _readSessionCookie(req) {
+ return parseCookie(req.headers.cookie, 'ccxray_s');
+}
+
+function _signSessionCookie() {
+ const { K_session } = getSecrets();
+ const payload = {
+ v: 1,
+ n: crypto.randomBytes(12).toString('base64url'),
+ exp: Math.floor(Date.now() / 1000) + COOKIE_TTL_SECONDS,
+ };
+ return signCookie(payload, K_session);
+}
+
+function _verifySessionCookieValue(value) {
+ if (!value) return null;
+ const { K_session } = getSecrets();
+ return verifyCookie(value, K_session);
+}
+
+function _send(res, code, body, contentType = 'application/json') {
+ res.writeHead(code, { 'Content-Type': contentType });
+ res.end(body == null ? '' : (typeof body === 'string' ? body : JSON.stringify(body)));
+}
+
+function redeemBootstrap(req, res) {
+ // Drain the body (we don't read it — it's required for POST and that's all).
+ let drained = false;
+ const finish = (code) => {
+ if (drained) return;
+ drained = true;
+ if (code === 204) {
+ const setCookie = `ccxray_s=${_signSessionCookie()}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_TTL_SECONDS}`;
+ res.setHeader('Set-Cookie', setCookie);
+ res.writeHead(204);
+ res.end();
+ } else if (code === 401) {
+ _send(res, 401, { error: 'invalid_bootstrap' });
+ } else if (code === 403) {
+ _send(res, 403, { error: 'csrf' });
+ }
+ };
+
+ req.on('data', () => {});
+ req.on('end', () => {
+ const tok = req.headers['x-ccxray-bootstrap'];
+ if (!tok) return finish(401);
+ if (!_passesCsrfGate(req)) return finish(403);
+
+ _gcBootstraps();
+ const hash = _hashBootstrap(tok);
+ if (!pendingBootstraps.has(hash)) return finish(401);
+ pendingBootstraps.delete(hash); // single-use
+ finish(204);
+ });
+}
+
+function _isDashboardAuthenticated(req) {
+ // 1. Cookie path — fastest if present and valid.
+ const cookieValue = _readSessionCookie(req);
+ if (cookieValue && _verifySessionCookieValue(cookieValue)) return true;
+ // 2. Bearer / ?token= path via legacy authMiddleware.
+ // We can't call authMiddleware directly because it writes 401 on miss;
+ // instead replicate its accept checks without the side effect.
+ if (!AUTH_TOKEN) return true;
+ const authHeader = req.headers['authorization'] || '';
+ if (authHeader === `Bearer ${AUTH_TOKEN}`) return true;
+ try {
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
+ if (url.searchParams.get('token') === AUTH_TOKEN) return true;
+ } catch {}
+ return false;
+}
+
+function authStatus(req, res) {
+ if (_isDashboardAuthenticated(req)) {
+ _send(res, 200, { ok: true });
+ } else {
+ _send(res, 401, { error: 'no_session' });
+ }
+}
+
+function verifyDashboard(req, res) {
+ // Cookie path first — succeeds silently with no deprecation header.
+ const cookieValue = _readSessionCookie(req);
+ if (cookieValue && _verifySessionCookieValue(cookieValue)) return true;
+
+ // Fall through to legacy authMiddleware (byte-identical Phase 1.2 behavior
+ // for callers that never used the cookie path).
+ const ok = authMiddleware(req, res);
+ if (!ok) return false;
+ if (whichLegacyMechanism(req) === 'token-query') {
+ setDeprecation(res, 'token-query');
+ }
+ return true;
+}
+
+function verifyUpstream(req, res) {
+ const ok = authMiddleware(req, res);
+ if (!ok) return false;
+ const mech = whichLegacyMechanism(req);
+ if (mech === 'bearer') setDeprecation(res, 'bearer-on-upstream');
+ else if (mech === 'token-query') setDeprecation(res, 'token-query');
+ return true;
+}
+
+function dispatch(req) {
+ const domain = classifyDomain(req);
+ return {
+ domain,
+ verify: domain === 'upstream' ? verifyUpstream : verifyDashboard,
+ };
+}
+
+// ─── Legacy middleware (call site swapped in Phase 1.2; kept exported
+// so test/auth.test.js stays green and downstream code can still
+// import it through the deprecation window) ───────────────────────
+
const AUTH_TOKEN = process.env.AUTH_TOKEN || null;
function authMiddleware(req, res) {
if (!AUTH_TOKEN) return true; // no auth configured — allow all
- // Check Authorization header
const authHeader = req.headers['authorization'] || '';
if (authHeader === `Bearer ${AUTH_TOKEN}`) return true;
- // Check query param
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
if (url.searchParams.get('token') === AUTH_TOKEN) return true;
@@ -31,4 +398,25 @@ function authMiddleware(req, res) {
return false;
}
-module.exports = { authMiddleware, AUTH_TOKEN };
+module.exports = {
+ // Phase 1.1 additions
+ deriveSecrets,
+ getRootSecret,
+ signCookie,
+ verifyCookie,
+ compareSecret,
+ // Phase 1.2 additions
+ dispatch,
+ verifyDashboard,
+ verifyUpstream,
+ // Phase 1.3 additions
+ mintBootstrapToken,
+ redeemBootstrap,
+ authStatus,
+ parseCookie,
+ // Legacy exports — call site swapped to dispatch() in Phase 1.2,
+ // but authMiddleware stays exported so test/auth.test.js and any
+ // downstream importer continue to work through the deprecation window.
+ authMiddleware,
+ AUTH_TOKEN,
+};
diff --git a/server/hub.js b/server/hub.js
index c38c222..bf987b5 100644
--- a/server/hub.js
+++ b/server/hub.js
@@ -367,6 +367,11 @@ function getHubStatus() {
// ── Hub route handler (mounted in server) ───────────────────────────
+function _isLoopbackPeer(req) {
+ const addr = req.socket?.remoteAddress || '';
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
+}
+
function handleHubRoutes(clientReq, clientRes) {
const pathname = clientReq.url.split('?')[0];
@@ -376,6 +381,21 @@ function handleHubRoutes(clientReq, clientRes) {
return true;
}
+ // Phase 1.3: bootstrap-token. Restricted to loopback peers; the real
+ // peer-UID gate lands with Phase 2.3 when we move to a Unix socket.
+ if (pathname === '/_api/hub/bootstrap-token' && clientReq.method === 'POST') {
+ if (!_isLoopbackPeer(clientReq)) {
+ clientRes.writeHead(403, { 'Content-Type': 'application/json' });
+ clientRes.end(JSON.stringify({ error: 'loopback_only' }));
+ return true;
+ }
+ const auth = require('./auth');
+ const token = auth.mintBootstrapToken();
+ clientRes.writeHead(200, { 'Content-Type': 'application/json' });
+ clientRes.end(JSON.stringify({ token }));
+ return true;
+ }
+
if (pathname === '/_api/hub/status' && clientReq.method === 'GET') {
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
clientRes.end(JSON.stringify(getHubStatus()));
diff --git a/server/index.js b/server/index.js
index 6313059..07a9a6d 100755
--- a/server/index.js
+++ b/server/index.js
@@ -14,7 +14,7 @@ const { warmUp: warmUpCosts } = require('./cost-budget');
const { forwardRequest, setStatusLineEnabled, getStatusLineEnabled } = require('./forward');
const { readSettings } = require('./settings');
const { broadcastSessionStatus, broadcastPendingRequest } = require('./sse-broadcast');
-const { authMiddleware } = require('./auth');
+const { dispatch } = require('./auth');
const { extractAgentType, extractPromptAgentType, splitB2IntoBlocks } = require('./system-prompt');
const { findSharedPrefix } = require('./delta-helpers');
const providers = require('./providers');
@@ -49,6 +49,7 @@ if (noBrowser) process.argv.splice(process.argv.indexOf('--no-browser'), 1);
const cliCommand = process.argv[2];
const unknownCommand = cliCommand
&& cliCommand !== 'status'
+ && cliCommand !== 'open'
&& !cliCommand.startsWith('-')
&& !providers.isAgentProvider(cliCommand);
if (unknownCommand) {
@@ -74,6 +75,7 @@ const { handleSSERoute } = require('./routes/sse');
const { handleApiRoutes } = require('./routes/api');
const { handleInterceptRoutes } = require('./routes/intercept');
const { handleCostRoutes } = require('./routes/costs');
+const { handleAuthRoutes } = require('./routes/auth');
const hub = require('./hub');
// ── Web UI: Static files from public/ ────────────────────────────────
@@ -127,6 +129,8 @@ const HOP_BY_HOP_HEADERS = new Set([
'upgrade',
]);
+const CCXRAY_INTERNAL_HEADERS = ['x-ccxray-auth', 'x-ccxray-bootstrap'];
+
function buildForwardHeaders(clientHeaders, upstream) {
const fwdHeaders = { ...clientHeaders };
const connectionTokens = String(clientHeaders.connection || '')
@@ -136,6 +140,7 @@ function buildForwardHeaders(clientHeaders, upstream) {
for (const header of HOP_BY_HOP_HEADERS) delete fwdHeaders[header];
for (const header of connectionTokens) delete fwdHeaders[header];
+ for (const header of CCXRAY_INTERNAL_HEADERS) delete fwdHeaders[header];
delete fwdHeaders.host;
delete fwdHeaders['accept-encoding'];
fwdHeaders.host = upstream.host;
@@ -188,8 +193,14 @@ const server = http.createServer((clientReq, clientRes) => {
// Placed before auth: these are local IPC endpoints, not user-facing
if (hub.handleHubRoutes(clientReq, clientRes)) return;
- // ── Auth check (enabled via AUTH_TOKEN env var) ──
- if (!authMiddleware(clientReq, clientRes)) return;
+ // ── Auth bootstrap routes (Phase 1.3) ──
+ // /_auth/redeem and /_auth/status run BEFORE the auth gate: redeem is
+ // the entry point that creates a cookie, status answers "am I
+ // authenticated?" without itself enforcing auth.
+ if (handleAuthRoutes(clientReq, clientRes)) return;
+
+ // ── Auth check (Phase 1.2 dispatcher; legacy-compatible) ──
+ if (!dispatch(clientReq).verify(clientReq, clientRes)) return;
// ── Static files (HTML, CSS, JS) ──
if (serveStatic(clientReq.url, clientRes)) return;
@@ -480,6 +491,60 @@ function spawnStandaloneAgent(port, command, args) {
});
}
+// ── "open" subcommand (Phase 1.3) ──
+// Mints a one-time bootstrap URL via the running hub (or standalone server
+// on the default port) and prints it. The user opens that URL in a browser;
+// the inline script in index.html redeems the token and mints the session
+// cookie. Token is 60s TTL, single-use, only ever appears here and in the
+// browser's URL bar (the fragment never reaches a server log).
+if (process.argv[2] === 'open') {
+ const lock = hub.readHubLock();
+ const port = lock?.port || config.PORT;
+ const http = require('http');
+ const body = JSON.stringify({});
+ const reqOpts = {
+ hostname: 'localhost',
+ port,
+ path: '/_api/hub/bootstrap-token',
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
+ timeout: 3000,
+ };
+ const req = http.request(reqOpts, res => {
+ let buf = '';
+ res.on('data', c => { buf += c; });
+ res.on('end', () => {
+ if (res.statusCode !== 200) {
+ console.error(`\x1b[31mFailed to mint bootstrap token: HTTP ${res.statusCode}\x1b[0m`);
+ process.exit(1);
+ }
+ let token;
+ try { token = JSON.parse(buf).token; } catch {}
+ if (!token) {
+ console.error('\x1b[31mHub did not return a token. Run "ccxray status" to check.\x1b[0m');
+ process.exit(1);
+ }
+ const url = `http://localhost:${port}/#k=${token}`;
+ console.log(url);
+ console.log('\x1b[90mOpen this URL in your browser (one-time, valid 60 seconds).\x1b[0m');
+ if (!process.env.BROWSER && process.env.BROWSER !== 'none' && !process.env.CI && !process.env.SSH_TTY) {
+ const { exec } = require('child_process');
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
+ exec(`${cmd} ${JSON.stringify(url)}`);
+ }
+ process.exit(0);
+ });
+ });
+ req.on('error', err => {
+ console.error(`\x1b[31mCannot reach ccxray on port ${port}: ${err.message}\x1b[0m`);
+ console.error('\x1b[90mStart ccxray first (e.g. "ccxray claude") and try again.\x1b[0m');
+ process.exit(1);
+ });
+ req.on('timeout', () => { req.destroy(); });
+ req.end(body);
+ return; // prevent falling through to startup
+}
+
// ── "status" subcommand ──
if (process.argv[2] === 'status') {
const lock = hub.readHubLock();
diff --git a/server/providers.js b/server/providers.js
index 334845f..1116b32 100644
--- a/server/providers.js
+++ b/server/providers.js
@@ -5,6 +5,17 @@
// ccxray proxy, so new launchers should be additive registry entries instead
// of new command-specific branches in server/index.js.
+function getUpstreamToken() {
+ try {
+ const auth = require('./auth');
+ const secrets = auth.deriveSecrets(auth.getRootSecret());
+ return secrets.K_upstream.toString('base64url');
+ } catch (e) {
+ console.warn(`[ccxray] Could not derive X-Ccxray-Auth token: ${e.message}`);
+ return null;
+ }
+}
+
const AGENT_PROVIDERS = Object.freeze({
claude: Object.freeze({
id: 'claude',
@@ -13,11 +24,16 @@ const AGENT_PROVIDERS = Object.freeze({
upstream: 'anthropic',
installHint: ' npm install -g @anthropic-ai/claude-code',
createLaunch({ port, args, env }) {
- return {
- bin: 'claude',
- args: [...args],
- env: { ...env, ANTHROPIC_BASE_URL: `http://localhost:${port}` },
- };
+ const launchEnv = { ...env, ANTHROPIC_BASE_URL: `http://localhost:${port}` };
+ const token = getUpstreamToken();
+ if (token) {
+ const authHeader = `X-Ccxray-Auth: ${token}`;
+ const existing = launchEnv.ANTHROPIC_CUSTOM_HEADERS;
+ launchEnv.ANTHROPIC_CUSTOM_HEADERS = existing
+ ? `${existing}, ${authHeader}`
+ : authHeader;
+ }
+ return { bin: 'claude', args: [...args], env: launchEnv };
},
}),
@@ -29,6 +45,23 @@ const AGENT_PROVIDERS = Object.freeze({
installHint: ' npm install -g @openai/codex',
createLaunch({ port, args, env }) {
const proxyBaseUrl = `http://localhost:${port}/v1`;
+ const hasApiKey = Boolean(env.OPENAI_API_KEY);
+
+ if (hasApiKey) {
+ const token = getUpstreamToken();
+ if (token) {
+ const mpConfig = `model_providers.ccxray={name="ccxray", base_url="${proxyBaseUrl}", wire_api="responses", http_headers={"X-Ccxray-Auth"="${token}"}}`;
+ return {
+ bin: 'codex',
+ args: ['-c', mpConfig, '-c', 'model_provider="ccxray"', ...args],
+ env: { ...env },
+ };
+ }
+ // Token derivation failed — fall through to legacy path with warning
+ // (warning already emitted by getUpstreamToken)
+ }
+
+ // ChatGPT-OAuth mode or token derivation failure: legacy path
return {
bin: 'codex',
args: [
diff --git a/server/routes/auth.js b/server/routes/auth.js
new file mode 100644
index 0000000..fc25b12
--- /dev/null
+++ b/server/routes/auth.js
@@ -0,0 +1,37 @@
+'use strict';
+
+// Phase 1.3 — auth domain routes.
+//
+// POST /_auth/redeem → consume one-time bootstrap token, mint cookie.
+// GET /_auth/status → server-side session probe (replaces the HttpOnly-
+// incompatible document.cookie check; errata §1.1).
+//
+// Both endpoints live on the dashboard domain so dispatch() routes them
+// through verifyDashboard. /_auth/redeem MUST run BEFORE verifyDashboard
+// because it's the entry point that creates the cookie; we therefore
+// invoke it directly from server/index.js before the auth gate.
+//
+// /_auth/status is exempt from authentication too: its whole job is to
+// answer "am I authenticated?" without forcing a 401 elsewhere. The
+// inline browser bootstrap polls it to decide whether to show the
+// "Run `ccxray open`" message.
+
+const auth = require('../auth');
+
+function handleAuthRoutes(req, res) {
+ const pathname = req.url.split('?')[0];
+
+ if (req.method === 'POST' && pathname === '/_auth/redeem') {
+ auth.redeemBootstrap(req, res);
+ return true;
+ }
+
+ if (req.method === 'GET' && pathname === '/_auth/status') {
+ auth.authStatus(req, res);
+ return true;
+ }
+
+ return false;
+}
+
+module.exports = { handleAuthRoutes };
diff --git a/server/ws-proxy.js b/server/ws-proxy.js
index 0bd7a6d..f322b90 100644
--- a/server/ws-proxy.js
+++ b/server/ws-proxy.js
@@ -92,6 +92,8 @@ function isAuthorized(req) {
}
}
+const CCXRAY_INTERNAL_HEADERS = new Set(['x-ccxray-auth', 'x-ccxray-bootstrap']);
+
function buildWebSocketHeaders(clientHeaders, upstream) {
const headers = {};
const connectionTokens = String(clientHeaders.connection || '')
@@ -103,6 +105,7 @@ function buildWebSocketHeaders(clientHeaders, upstream) {
const lower = name.toLowerCase();
if (HOP_BY_HOP_HEADERS.has(lower)) continue;
if (WS_HANDSHAKE_HEADERS.has(lower)) continue;
+ if (CCXRAY_INTERNAL_HEADERS.has(lower)) continue;
if (connectionTokens.includes(lower)) continue;
if (lower === 'host') continue;
headers[name] = value;
@@ -327,6 +330,11 @@ function handleWebSocketUpgrade(req, socket, head) {
return true;
}
+ const authClass = classifyUpstreamAuth(req.headers);
+ if (authClass === 'warn') {
+ console.warn('[ccxray] WebSocket upgrade without X-Ccxray-Auth header (warn-only, Phase 2.1 will enforce)');
+ }
+
const id = helpers.timestamp();
const ts = helpers.taipeiTime();
const startTime = Date.now();
@@ -498,10 +506,26 @@ async function drainWebSocketProxy() {
await Promise.allSettled([...pendingEntries]);
}
+// JWT-shaped: "Bearer .." where header is base64url JSON
+function isJwtShaped(authHeader) {
+ if (!authHeader || typeof authHeader !== 'string') return false;
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
+ if (!token) return false;
+ const parts = token.split('.');
+ return parts.length === 3 && parts[0].length > 10;
+}
+
+function classifyUpstreamAuth(headers) {
+ if (headers['x-ccxray-auth']) return 'authed';
+ if (headers['chatgpt-account-id'] && isJwtShaped(headers.authorization)) return 'chatgpt-oauth';
+ return 'warn';
+}
+
module.exports = {
handleWebSocketUpgrade,
buildWebSocketHeaders,
isOpenAIWebSocket,
normalizeCloseCode,
drainWebSocketProxy,
+ classifyUpstreamAuth,
};
diff --git a/test/auth-bootstrap.test.js b/test/auth-bootstrap.test.js
new file mode 100644
index 0000000..011907a
--- /dev/null
+++ b/test/auth-bootstrap.test.js
@@ -0,0 +1,302 @@
+'use strict';
+
+const { describe, it, before, after, beforeEach } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+// Per-run temp CCXRAY_HOME so getRootSecret writes its local-secret somewhere
+// safe. AUTH_TOKEN stays unset so we're in ephemeral mode end-to-end.
+const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-bootstrap-test-'));
+process.env.CCXRAY_HOME = TEST_HOME;
+delete process.env.AUTH_TOKEN;
+
+function loadAuthFresh() {
+ delete require.cache[require.resolve('../server/auth')];
+ return require('../server/auth');
+}
+
+after(() => {
+ fs.rmSync(TEST_HOME, { recursive: true, force: true });
+});
+
+function mockReqRes(opts = {}) {
+ const headers = { host: 'localhost:5577', ...(opts.headers || {}) };
+ const req = { method: opts.method || 'POST', url: opts.url || '/_auth/redeem', headers };
+ const setHeaderCalls = {};
+ let bodyBuf = '';
+ // Provide chunk delivery for endpoints that read req.on('data') / .on('end').
+ const dataListeners = [];
+ const endListeners = [];
+ req.on = (evt, fn) => {
+ if (evt === 'data') dataListeners.push(fn);
+ else if (evt === 'end') endListeners.push(fn);
+ return req;
+ };
+ // Caller can invoke req._deliverBody(string) to feed the body chunk + end.
+ req._deliverBody = (s) => {
+ if (s) dataListeners.forEach(fn => fn(Buffer.from(s, 'utf8')));
+ endListeners.forEach(fn => fn());
+ };
+ const res = {
+ statusCode: null,
+ body: null,
+ writeHeadCalled: false,
+ writeHeadHeaders: null,
+ setHeader(name, value) { setHeaderCalls[name.toLowerCase()] = value; },
+ getHeader(name) { return setHeaderCalls[name.toLowerCase()]; },
+ writeHead(code, h) { this.writeHeadCalled = true; this.statusCode = code; this.writeHeadHeaders = h || {}; },
+ end(b) { this.body = b == null ? null : String(b); },
+ };
+ return { req, res, setHeaderCalls };
+}
+
+// Helper: mint a bootstrap token directly from the auth module (simulates the
+// hub's bootstrap-token endpoint without spinning up the hub).
+function mintBootstrap(auth) {
+ return auth.mintBootstrapToken();
+}
+
+describe('mintBootstrapToken — one-time pad', () => {
+ it('returns a URL-safe token string', () => {
+ const auth = loadAuthFresh();
+ const tok = mintBootstrap(auth);
+ assert.equal(typeof tok, 'string');
+ assert.ok(/^[A-Za-z0-9_-]{20,}$/.test(tok), `unexpected token shape: ${tok}`);
+ });
+
+ it('produces unique tokens across calls', () => {
+ const auth = loadAuthFresh();
+ const a = mintBootstrap(auth);
+ const b = mintBootstrap(auth);
+ assert.notEqual(a, b);
+ });
+});
+
+describe('/_auth/redeem — happy path', () => {
+ it('200 + Set-Cookie when token is valid and request is same-origin', () => {
+ const auth = loadAuthFresh();
+ const tok = mintBootstrap(auth);
+ const { req, res, setHeaderCalls } = mockReqRes({
+ method: 'POST',
+ url: '/_auth/redeem',
+ headers: {
+ 'x-ccxray-bootstrap': tok,
+ 'sec-fetch-site': 'same-origin',
+ origin: 'http://localhost:5577',
+ host: 'localhost:5577',
+ 'content-type': 'application/json',
+ },
+ });
+ auth.redeemBootstrap(req, res);
+ req._deliverBody('{}');
+ assert.equal(res.statusCode, 204);
+ const setCookie = setHeaderCalls['set-cookie'];
+ assert.ok(setCookie, 'expected Set-Cookie header');
+ assert.match(setCookie, /^ccxray_s=[^;]+;/);
+ assert.match(setCookie, /HttpOnly/);
+ assert.match(setCookie, /SameSite=Strict/);
+ assert.match(setCookie, /Path=\//);
+ });
+});
+
+describe('/_auth/redeem — rejection cases', () => {
+ it('401 when no bootstrap token is sent', () => {
+ const auth = loadAuthFresh();
+ const { req, res } = mockReqRes({
+ headers: { 'sec-fetch-site': 'same-origin', origin: 'http://localhost:5577' },
+ });
+ auth.redeemBootstrap(req, res);
+ req._deliverBody('{}');
+ assert.equal(res.statusCode, 401);
+ });
+
+ it('401 when the token is unknown', () => {
+ const auth = loadAuthFresh();
+ mintBootstrap(auth); // mint one but use a different value
+ const { req, res } = mockReqRes({
+ headers: {
+ 'x-ccxray-bootstrap': 'definitely-not-the-token',
+ 'sec-fetch-site': 'same-origin',
+ origin: 'http://localhost:5577',
+ },
+ });
+ auth.redeemBootstrap(req, res);
+ req._deliverBody('{}');
+ assert.equal(res.statusCode, 401);
+ });
+
+ it('401 on replay — same token cannot redeem twice', () => {
+ const auth = loadAuthFresh();
+ const tok = mintBootstrap(auth);
+ {
+ const { req, res } = mockReqRes({
+ headers: { 'x-ccxray-bootstrap': tok, 'sec-fetch-site': 'same-origin', origin: 'http://localhost:5577' },
+ });
+ auth.redeemBootstrap(req, res);
+ req._deliverBody('{}');
+ assert.equal(res.statusCode, 204, 'first redeem should succeed');
+ }
+ {
+ const { req, res } = mockReqRes({
+ headers: { 'x-ccxray-bootstrap': tok, 'sec-fetch-site': 'same-origin', origin: 'http://localhost:5577' },
+ });
+ auth.redeemBootstrap(req, res);
+ req._deliverBody('{}');
+ assert.equal(res.statusCode, 401, 'second redeem should fail (replay)');
+ }
+ });
+
+ it('403 when Sec-Fetch-Site is cross-site and Origin is missing', () => {
+ const auth = loadAuthFresh();
+ const tok = mintBootstrap(auth);
+ const { req, res } = mockReqRes({
+ headers: { 'x-ccxray-bootstrap': tok, 'sec-fetch-site': 'cross-site' },
+ });
+ auth.redeemBootstrap(req, res);
+ req._deliverBody('{}');
+ assert.equal(res.statusCode, 403);
+ });
+
+ it('403 when Origin is on a foreign host', () => {
+ const auth = loadAuthFresh();
+ const tok = mintBootstrap(auth);
+ const { req, res } = mockReqRes({
+ headers: {
+ 'x-ccxray-bootstrap': tok,
+ origin: 'http://evil.example.com',
+ },
+ });
+ auth.redeemBootstrap(req, res);
+ req._deliverBody('{}');
+ assert.equal(res.statusCode, 403);
+ });
+
+ it('accepts when Sec-Fetch is absent but Origin matches Host (older browsers)', () => {
+ const auth = loadAuthFresh();
+ const tok = mintBootstrap(auth);
+ const { req, res } = mockReqRes({
+ headers: {
+ 'x-ccxray-bootstrap': tok,
+ origin: 'http://localhost:5577',
+ host: 'localhost:5577',
+ },
+ });
+ auth.redeemBootstrap(req, res);
+ req._deliverBody('{}');
+ assert.equal(res.statusCode, 204);
+ });
+});
+
+describe('/_auth/status — probe endpoint for inline browser script', () => {
+ beforeEach(() => { delete process.env.AUTH_TOKEN; });
+
+ it('returns 200 when no AUTH_TOKEN configured (ephemeral mode allows)', () => {
+ const auth = loadAuthFresh();
+ const { req, res } = mockReqRes({ method: 'GET', url: '/_auth/status' });
+ auth.authStatus(req, res);
+ assert.equal(res.statusCode, 200);
+ });
+
+ it('returns 401 with AUTH_TOKEN configured and no credentials', () => {
+ process.env.AUTH_TOKEN = 'sec1';
+ const auth = loadAuthFresh();
+ const { req, res } = mockReqRes({ method: 'GET', url: '/_auth/status' });
+ auth.authStatus(req, res);
+ assert.equal(res.statusCode, 401);
+ delete process.env.AUTH_TOKEN;
+ });
+
+ it('returns 200 with AUTH_TOKEN + correct Bearer', () => {
+ process.env.AUTH_TOKEN = 'sec1';
+ const auth = loadAuthFresh();
+ const { req, res } = mockReqRes({
+ method: 'GET',
+ url: '/_auth/status',
+ headers: { authorization: 'Bearer sec1' },
+ });
+ auth.authStatus(req, res);
+ assert.equal(res.statusCode, 200);
+ delete process.env.AUTH_TOKEN;
+ });
+
+ it('returns 200 after successful bootstrap (cookie path)', () => {
+ process.env.AUTH_TOKEN = 'sec1';
+ const auth = loadAuthFresh();
+ const tok = mintBootstrap(auth);
+ // Redeem to get a cookie
+ const { req: r1, res: res1, setHeaderCalls: sh } = mockReqRes({
+ headers: {
+ 'x-ccxray-bootstrap': tok,
+ 'sec-fetch-site': 'same-origin',
+ origin: 'http://localhost:5577',
+ },
+ });
+ auth.redeemBootstrap(r1, res1);
+ r1._deliverBody('{}');
+ assert.equal(res1.statusCode, 204);
+ const setCookie = sh['set-cookie'];
+ const cookieValue = setCookie.match(/^ccxray_s=([^;]+);/)[1];
+
+ // Use cookie on status endpoint
+ const { req: r2, res: res2 } = mockReqRes({
+ method: 'GET',
+ url: '/_auth/status',
+ headers: { cookie: `ccxray_s=${cookieValue}` },
+ });
+ auth.authStatus(r2, res2);
+ assert.equal(res2.statusCode, 200);
+ delete process.env.AUTH_TOKEN;
+ });
+});
+
+describe('verifyDashboard — cookie path added in Phase 1.3', () => {
+ it('accepts a valid cookie when AUTH_TOKEN is set and no Bearer/?token=', () => {
+ process.env.AUTH_TOKEN = 'sec1';
+ const auth = loadAuthFresh();
+ const tok = mintBootstrap(auth);
+ // Mint a cookie via redeem
+ const { req: r1, res: res1, setHeaderCalls: sh } = mockReqRes({
+ headers: {
+ 'x-ccxray-bootstrap': tok,
+ 'sec-fetch-site': 'same-origin',
+ origin: 'http://localhost:5577',
+ },
+ });
+ auth.redeemBootstrap(r1, res1);
+ r1._deliverBody('{}');
+ const cookieValue = sh['set-cookie'].match(/^ccxray_s=([^;]+);/)[1];
+
+ const { req, res } = mockReqRes({
+ method: 'GET',
+ url: '/_api/entries',
+ headers: { cookie: `ccxray_s=${cookieValue}` },
+ });
+ assert.equal(auth.verifyDashboard(req, res), true);
+ assert.equal(res.writeHeadCalled, false);
+ delete process.env.AUTH_TOKEN;
+ });
+
+ it('falls through to authMiddleware when cookie is absent', () => {
+ process.env.AUTH_TOKEN = 'sec1';
+ const auth = loadAuthFresh();
+ const { req, res } = mockReqRes({ method: 'GET', url: '/_api/entries', headers: {} });
+ assert.equal(auth.verifyDashboard(req, res), false);
+ assert.equal(res.statusCode, 401);
+ delete process.env.AUTH_TOKEN;
+ });
+
+ it('falls through to authMiddleware when cookie is present but invalid', () => {
+ process.env.AUTH_TOKEN = 'sec1';
+ const auth = loadAuthFresh();
+ const { req, res } = mockReqRes({
+ method: 'GET',
+ url: '/_api/entries',
+ headers: { cookie: 'ccxray_s=garbage.value' },
+ });
+ assert.equal(auth.verifyDashboard(req, res), false);
+ assert.equal(res.statusCode, 401);
+ delete process.env.AUTH_TOKEN;
+ });
+});
diff --git a/test/auth-cookie.test.js b/test/auth-cookie.test.js
new file mode 100644
index 0000000..0bd5090
--- /dev/null
+++ b/test/auth-cookie.test.js
@@ -0,0 +1,119 @@
+'use strict';
+
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const crypto = require('crypto');
+
+const auth = require('../server/auth');
+
+function freshKey() {
+ return crypto.randomBytes(32);
+}
+
+function nowSec() {
+ return Math.floor(Date.now() / 1000);
+}
+
+describe('signCookie + verifyCookie roundtrip', () => {
+ it('signs and verifies a well-formed payload', () => {
+ const key = freshKey();
+ const payload = { v: 1, n: 'abc123', exp: nowSec() + 3600 };
+ const raw = auth.signCookie(payload, key);
+ assert.equal(typeof raw, 'string');
+ assert.ok(raw.includes('.'), 'expected payload.hmac format');
+ const got = auth.verifyCookie(raw, key);
+ assert.deepEqual(got, payload);
+ });
+
+ it('survives non-ASCII fields in the payload', () => {
+ const key = freshKey();
+ const payload = { v: 1, n: '隨機', exp: nowSec() + 60 };
+ const raw = auth.signCookie(payload, key);
+ assert.deepEqual(auth.verifyCookie(raw, key), payload);
+ });
+});
+
+describe('verifyCookie — rejection cases', () => {
+ it('returns null when the HMAC is tampered', () => {
+ const key = freshKey();
+ const raw = auth.signCookie({ v: 1, n: 'a', exp: nowSec() + 60 }, key);
+ const tampered = raw.slice(0, -1) + (raw.slice(-1) === 'A' ? 'B' : 'A');
+ assert.equal(auth.verifyCookie(tampered, key), null);
+ });
+
+ it('returns null when the payload is tampered', () => {
+ const key = freshKey();
+ const raw = auth.signCookie({ v: 1, n: 'a', exp: nowSec() + 60 }, key);
+ const dot = raw.indexOf('.');
+ const payloadB64 = raw.slice(0, dot);
+ const hmacB64 = raw.slice(dot + 1);
+ // Flip the first character of the base64url payload section
+ const flipped = (payloadB64[0] === 'A' ? 'B' : 'A') + payloadB64.slice(1);
+ assert.equal(auth.verifyCookie(`${flipped}.${hmacB64}`, key), null);
+ });
+
+ it('returns null when expired', () => {
+ const key = freshKey();
+ const raw = auth.signCookie({ v: 1, n: 'a', exp: nowSec() - 10 }, key);
+ assert.equal(auth.verifyCookie(raw, key), null);
+ });
+
+ it('returns null for the wrong key', () => {
+ const k1 = freshKey();
+ const k2 = freshKey();
+ const raw = auth.signCookie({ v: 1, n: 'a', exp: nowSec() + 60 }, k1);
+ assert.equal(auth.verifyCookie(raw, k2), null);
+ });
+
+ it('returns null for a malformed cookie (no dot)', () => {
+ assert.equal(auth.verifyCookie('not-a-cookie', freshKey()), null);
+ });
+
+ it('returns null for an empty string', () => {
+ assert.equal(auth.verifyCookie('', freshKey()), null);
+ });
+
+ it('returns null for invalid base64url in payload', () => {
+ assert.equal(auth.verifyCookie('!!!.!!!', freshKey()), null);
+ });
+
+ it('returns null when payload JSON is malformed', () => {
+ const key = freshKey();
+ const badPayload = Buffer.from('not json', 'utf8').toString('base64url');
+ const hmac = crypto.createHmac('sha256', key)
+ .update(Buffer.from('not json', 'utf8')).digest().toString('base64url');
+ assert.equal(auth.verifyCookie(`${badPayload}.${hmac}`, key), null);
+ });
+
+ it('returns null when payload version is unsupported', () => {
+ const key = freshKey();
+ const raw = auth.signCookie({ v: 999, n: 'a', exp: nowSec() + 60 }, key);
+ assert.equal(auth.verifyCookie(raw, key), null);
+ });
+});
+
+describe('compareSecret — constant-time correctness', () => {
+ it('returns true for identical strings', () => {
+ assert.equal(auth.compareSecret('hunter2', 'hunter2'), true);
+ });
+
+ it('returns false for different strings of the same length', () => {
+ assert.equal(auth.compareSecret('hunter2', 'hunter3'), false);
+ });
+
+ it('returns false for different lengths', () => {
+ assert.equal(auth.compareSecret('hunter2', 'hunter2-longer'), false);
+ });
+
+ it('handles empty inputs without throwing', () => {
+ assert.equal(auth.compareSecret('', ''), true);
+ assert.equal(auth.compareSecret('', 'x'), false);
+ assert.equal(auth.compareSecret('x', ''), false);
+ });
+
+ it('handles null/undefined inputs without throwing', () => {
+ assert.equal(auth.compareSecret(null, 'x'), false);
+ assert.equal(auth.compareSecret('x', null), false);
+ assert.equal(auth.compareSecret(undefined, undefined), false);
+ });
+});
diff --git a/test/auth-dispatcher.test.js b/test/auth-dispatcher.test.js
new file mode 100644
index 0000000..6edee69
--- /dev/null
+++ b/test/auth-dispatcher.test.js
@@ -0,0 +1,183 @@
+'use strict';
+
+const { describe, it, before, after, beforeEach } = require('node:test');
+const assert = require('node:assert/strict');
+
+// Match the existing test/auth.test.js style: re-require server/auth after
+// each AUTH_TOKEN flip so the module's AUTH_TOKEN constant refreshes.
+function loadAuthWith(token) {
+ if (token === null) delete process.env.AUTH_TOKEN;
+ else process.env.AUTH_TOKEN = token;
+ delete require.cache[require.resolve('../server/auth')];
+ return require('../server/auth');
+}
+
+function mockReqRes(headers = {}, url = '/') {
+ const setHeaderCalls = {};
+ const req = { headers, url };
+ const res = {
+ statusCode: null,
+ body: null,
+ writeHeadCalled: false,
+ writeHeadHeaders: null,
+ setHeader(name, value) { setHeaderCalls[name] = value; },
+ getHeader(name) { return setHeaderCalls[name]; },
+ writeHead(code, h) { this.writeHeadCalled = true; this.statusCode = code; this.writeHeadHeaders = h || {}; },
+ end(body) { this.body = body; },
+ };
+ return { req, res, setHeaderCalls };
+}
+
+let originalToken;
+before(() => { originalToken = process.env.AUTH_TOKEN; });
+after(() => {
+ if (originalToken !== undefined) process.env.AUTH_TOKEN = originalToken;
+ else delete process.env.AUTH_TOKEN;
+ delete require.cache[require.resolve('../server/auth')];
+});
+
+describe('dispatch(req) — path classification', () => {
+ const auth = loadAuthWith(null);
+
+ it('classifies /v1/messages as upstream', () => {
+ assert.equal(auth.dispatch({ url: '/v1/messages', headers: {} }).domain, 'upstream');
+ });
+
+ it('classifies /v1/responses (codex WS upgrade) as upstream', () => {
+ assert.equal(auth.dispatch({ url: '/v1/responses', headers: {} }).domain, 'upstream');
+ });
+
+ it('classifies /v1/anything-else as upstream', () => {
+ assert.equal(auth.dispatch({ url: '/v1/foo/bar', headers: {} }).domain, 'upstream');
+ });
+
+ it('classifies query strings on /v1/* as upstream (pathname split)', () => {
+ assert.equal(auth.dispatch({ url: '/v1/messages?x=1', headers: {} }).domain, 'upstream');
+ });
+
+ it('classifies / as dashboard', () => {
+ assert.equal(auth.dispatch({ url: '/', headers: {} }).domain, 'dashboard');
+ });
+
+ it('classifies /_api/entries as dashboard', () => {
+ assert.equal(auth.dispatch({ url: '/_api/entries', headers: {} }).domain, 'dashboard');
+ });
+
+ it('classifies /_api/entries?token=x as dashboard (pathname split)', () => {
+ assert.equal(auth.dispatch({ url: '/_api/entries?token=x', headers: {} }).domain, 'dashboard');
+ });
+
+ it('classifies /_events as dashboard', () => {
+ assert.equal(auth.dispatch({ url: '/_events', headers: {} }).domain, 'dashboard');
+ });
+
+ it('classifies /style.css as dashboard', () => {
+ assert.equal(auth.dispatch({ url: '/style.css', headers: {} }).domain, 'dashboard');
+ });
+
+ it('exposes a verify function for each domain', () => {
+ assert.equal(typeof auth.dispatch({ url: '/v1/m', headers: {} }).verify, 'function');
+ assert.equal(typeof auth.dispatch({ url: '/_api/x', headers: {} }).verify, 'function');
+ });
+});
+
+describe('verifyDashboard — Phase 1.2 byte-identical to authMiddleware', () => {
+ beforeEach(() => {});
+
+ it('no AUTH_TOKEN: returns true without touching res', () => {
+ const auth = loadAuthWith(null);
+ const { req, res } = mockReqRes({}, '/_api/entries');
+ assert.equal(auth.verifyDashboard(req, res), true);
+ assert.equal(res.writeHeadCalled, false);
+ });
+
+ it('AUTH_TOKEN set, no credentials: returns false and writes 401', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res } = mockReqRes({}, '/_api/entries');
+ assert.equal(auth.verifyDashboard(req, res), false);
+ assert.equal(res.statusCode, 401);
+ });
+
+ it('AUTH_TOKEN set, correct Bearer: returns true', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res } = mockReqRes({ authorization: 'Bearer sec1', host: 'localhost' }, '/_api/entries');
+ assert.equal(auth.verifyDashboard(req, res), true);
+ assert.equal(res.writeHeadCalled, false);
+ });
+
+ it('AUTH_TOKEN set, wrong Bearer: returns false and writes 401', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res } = mockReqRes({ authorization: 'Bearer wrong', host: 'localhost' }, '/_api/entries');
+ assert.equal(auth.verifyDashboard(req, res), false);
+ assert.equal(res.statusCode, 401);
+ });
+
+ it('AUTH_TOKEN set, correct ?token= query: returns true', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res } = mockReqRes({ host: 'localhost' }, '/_api/entries?token=sec1');
+ assert.equal(auth.verifyDashboard(req, res), true);
+ assert.equal(res.writeHeadCalled, false);
+ });
+
+ it('AUTH_TOKEN set, correct ?token= query: adds X-Ccxray-Deprecation header', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res, setHeaderCalls } = mockReqRes({ host: 'localhost' }, '/_api/entries?token=sec1');
+ auth.verifyDashboard(req, res);
+ assert.match(setHeaderCalls['X-Ccxray-Deprecation'] || '', /token-query/);
+ });
+
+ it('AUTH_TOKEN set, correct Bearer: does NOT add deprecation header (Bearer is permanent on dashboard)', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res, setHeaderCalls } = mockReqRes({ authorization: 'Bearer sec1', host: 'localhost' }, '/_api/entries');
+ auth.verifyDashboard(req, res);
+ assert.equal(setHeaderCalls['X-Ccxray-Deprecation'], undefined);
+ });
+});
+
+describe('verifyUpstream — Phase 1.2 byte-identical to authMiddleware (with deprecation hint)', () => {
+ it('no AUTH_TOKEN: returns true without touching res', () => {
+ const auth = loadAuthWith(null);
+ const { req, res } = mockReqRes({}, '/v1/messages');
+ assert.equal(auth.verifyUpstream(req, res), true);
+ assert.equal(res.writeHeadCalled, false);
+ });
+
+ it('AUTH_TOKEN set, no credentials: returns false and writes 401', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res } = mockReqRes({}, '/v1/messages');
+ assert.equal(auth.verifyUpstream(req, res), false);
+ assert.equal(res.statusCode, 401);
+ });
+
+ it('AUTH_TOKEN set, Bearer accepted on /v1/* (warn-only Phase 1): returns true + deprecation header', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res, setHeaderCalls } = mockReqRes({ authorization: 'Bearer sec1', host: 'localhost' }, '/v1/messages');
+ assert.equal(auth.verifyUpstream(req, res), true);
+ assert.match(setHeaderCalls['X-Ccxray-Deprecation'] || '', /bearer-on-upstream/);
+ });
+
+ it('AUTH_TOKEN set, ?token= accepted on /v1/* (warn-only Phase 1): returns true + deprecation header', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res, setHeaderCalls } = mockReqRes({ host: 'localhost' }, '/v1/messages?token=sec1');
+ assert.equal(auth.verifyUpstream(req, res), true);
+ assert.match(setHeaderCalls['X-Ccxray-Deprecation'] || '', /token-query/);
+ });
+});
+
+describe('dispatch().verify — sanity: same instance routes to correct verifier', () => {
+ it('upstream path routes to verifyUpstream', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res, setHeaderCalls } = mockReqRes({ authorization: 'Bearer sec1' }, '/v1/messages');
+ const { verify } = auth.dispatch(req);
+ assert.equal(verify(req, res), true);
+ assert.match(setHeaderCalls['X-Ccxray-Deprecation'] || '', /bearer-on-upstream/);
+ });
+
+ it('dashboard path routes to verifyDashboard', () => {
+ const auth = loadAuthWith('sec1');
+ const { req, res, setHeaderCalls } = mockReqRes({ authorization: 'Bearer sec1' }, '/_api/entries');
+ const { verify } = auth.dispatch(req);
+ assert.equal(verify(req, res), true);
+ assert.equal(setHeaderCalls['X-Ccxray-Deprecation'], undefined);
+ });
+});
diff --git a/test/auth-header-injection.e2e.test.js b/test/auth-header-injection.e2e.test.js
new file mode 100644
index 0000000..a1184fd
--- /dev/null
+++ b/test/auth-header-injection.e2e.test.js
@@ -0,0 +1,425 @@
+'use strict';
+
+const { describe, it, after } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+const WebSocket = require('ws');
+
+const SERVER_SCRIPT = path.join(__dirname, '..', 'server', 'index.js');
+const tmpDirs = [];
+
+async function findFreePort() {
+ return new Promise(resolve => {
+ const server = http.createServer();
+ server.listen(0, () => {
+ const port = server.address().port;
+ server.close(() => resolve(port));
+ });
+ });
+}
+
+function waitForPort(port, timeoutMs = 8000) {
+ return new Promise((resolve, reject) => {
+ const start = Date.now();
+ const check = () => {
+ const req = http.get(`http://localhost:${port}/_api/health`, { timeout: 1000 }, res => {
+ res.resume();
+ res.on('end', () => resolve());
+ });
+ req.on('error', () => {
+ if (Date.now() - start > timeoutMs) return reject(new Error('proxy did not start'));
+ setTimeout(check, 100);
+ });
+ req.on('timeout', () => {
+ req.destroy();
+ if (Date.now() - start > timeoutMs) return reject(new Error('proxy did not start'));
+ setTimeout(check, 100);
+ });
+ };
+ check();
+ });
+}
+
+function killAndWait(child) {
+ return new Promise(resolve => {
+ if (!child || child.exitCode !== null) return resolve();
+ child.on('exit', resolve);
+ child.kill('SIGTERM');
+ setTimeout(() => {
+ try { child.kill('SIGKILL'); } catch {}
+ resolve();
+ }, 3000);
+ });
+}
+
+function makeTmpHome() {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-auth-e2e-'));
+ tmpDirs.push(home);
+ return home;
+}
+
+function postJson(port, urlPath, body, headers = {}) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = http.request({
+ hostname: 'localhost', port, path: urlPath, method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'content-length': Buffer.byteLength(data),
+ 'x-api-key': 'sk-fake',
+ 'anthropic-version': '2023-06-01',
+ ...headers,
+ },
+ }, res => {
+ const chunks = [];
+ res.on('data', c => chunks.push(c));
+ res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString() }));
+ });
+ req.on('error', reject);
+ req.write(data); req.end();
+ });
+}
+
+describe('Auth header injection E2E (1.4)', () => {
+ after(() => {
+ for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
+ });
+
+ it('strips X-Ccxray-Auth and X-Ccxray-Bootstrap from HTTP requests forwarded to upstream', async () => {
+ const upstreamPort = await findFreePort();
+ const proxyPort = await findFreePort();
+ const home = makeTmpHome();
+
+ const upstreamRequests = [];
+ const upstream = http.createServer((req, res) => {
+ upstreamRequests.push({ url: req.url, headers: { ...req.headers } });
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({
+ id: 'msg_fake', type: 'message', role: 'assistant',
+ model: 'claude-3-haiku-20240307', stop_reason: 'end_turn', stop_sequence: null,
+ content: [{ type: 'text', text: 'ok' }],
+ usage: { input_tokens: 1, output_tokens: 1 },
+ }));
+ });
+ await new Promise(resolve => upstream.listen(upstreamPort, '127.0.0.1', resolve));
+
+ let stderr = '';
+ const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(proxyPort), '--no-browser'], {
+ env: {
+ ...process.env,
+ ANTHROPIC_TEST_HOST: '127.0.0.1',
+ ANTHROPIC_TEST_PORT: String(upstreamPort),
+ ANTHROPIC_TEST_PROTOCOL: 'http',
+ CCXRAY_HOME: home,
+ BROWSER: 'none',
+ RESTORE_DAYS: '0',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ child.stdout.on('data', () => {});
+ child.stderr.on('data', d => { stderr += d.toString(); });
+
+ try {
+ await waitForPort(proxyPort);
+
+ const resp = await postJson(proxyPort, '/v1/messages', {
+ model: 'claude-3-haiku-20240307',
+ max_tokens: 8,
+ messages: [{ role: 'user', content: 'hello' }],
+ }, {
+ 'x-ccxray-auth': 'super-secret-token-123',
+ 'x-ccxray-bootstrap': 'bootstrap-secret-456',
+ });
+
+ assert.equal(resp.statusCode, 200, 'proxy should forward and respond 200');
+ assert.equal(upstreamRequests.length, 1, 'upstream should receive exactly one request');
+
+ const fwdHeaders = upstreamRequests[0].headers;
+ assert.equal(fwdHeaders['x-ccxray-auth'], undefined,
+ 'X-Ccxray-Auth must NOT be forwarded to upstream');
+ assert.equal(fwdHeaders['x-ccxray-bootstrap'], undefined,
+ 'X-Ccxray-Bootstrap must NOT be forwarded to upstream');
+ assert.ok(fwdHeaders['x-api-key'], 'x-api-key should still be forwarded');
+ assert.ok(fwdHeaders['anthropic-version'], 'anthropic-version should still be forwarded');
+ } finally {
+ upstream.close();
+ await killAndWait(child);
+ }
+ });
+
+ it('X-Ccxray-Auth does NOT appear in disk logs (_req.json)', async () => {
+ const upstreamPort = await findFreePort();
+ const proxyPort = await findFreePort();
+ const home = makeTmpHome();
+
+ const upstream = http.createServer((req, res) => {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({
+ id: 'msg_fake', type: 'message', role: 'assistant',
+ model: 'claude-3-haiku-20240307', stop_reason: 'end_turn', stop_sequence: null,
+ content: [{ type: 'text', text: 'ok' }],
+ usage: { input_tokens: 1, output_tokens: 1 },
+ }));
+ });
+ await new Promise(resolve => upstream.listen(upstreamPort, '127.0.0.1', resolve));
+
+ const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(proxyPort), '--no-browser'], {
+ env: {
+ ...process.env,
+ ANTHROPIC_TEST_HOST: '127.0.0.1',
+ ANTHROPIC_TEST_PORT: String(upstreamPort),
+ ANTHROPIC_TEST_PROTOCOL: 'http',
+ CCXRAY_HOME: home,
+ BROWSER: 'none',
+ RESTORE_DAYS: '0',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ child.stdout.on('data', () => {});
+ child.stderr.on('data', () => {});
+
+ try {
+ await waitForPort(proxyPort);
+
+ await postJson(proxyPort, '/v1/messages', {
+ model: 'claude-3-haiku-20240307',
+ max_tokens: 8,
+ messages: [{ role: 'user', content: 'hello' }],
+ }, {
+ 'x-ccxray-auth': 'LEAK-CHECK-TOKEN-789',
+ 'cookie': 'ccxray_s=sensitive-session-cookie',
+ });
+
+ // Wait for async disk write
+ await new Promise(r => setTimeout(r, 500));
+
+ const logsDir = path.join(home, 'logs');
+ if (fs.existsSync(logsDir)) {
+ const files = fs.readdirSync(logsDir).filter(f => f.endsWith('.json'));
+ for (const f of files) {
+ const content = fs.readFileSync(path.join(logsDir, f), 'utf8');
+ assert.ok(!content.includes('LEAK-CHECK-TOKEN-789'),
+ `X-Ccxray-Auth value leaked to ${f}`);
+ assert.ok(!content.includes('sensitive-session-cookie'),
+ `Cookie value leaked to ${f}`);
+ }
+ }
+ } finally {
+ upstream.close();
+ await killAndWait(child);
+ }
+ });
+
+ it('WS upgrade without X-Ccxray-Auth emits warning but still succeeds (warn-only)', async () => {
+ const upstreamPort = await findFreePort();
+ const proxyPort = await findFreePort();
+ const home = makeTmpHome();
+
+ // Create a simple WebSocket echo upstream
+ const upstreamWss = new WebSocket.Server({ noServer: true });
+ const upstreamHttp = http.createServer();
+ upstreamHttp.on('upgrade', (req, socket, head) => {
+ upstreamWss.handleUpgrade(req, socket, head, ws => {
+ ws.on('message', data => ws.send(data));
+ // Auto close after 1s to keep test short
+ setTimeout(() => ws.close(1000, 'done'), 500);
+ });
+ });
+ await new Promise(resolve => upstreamHttp.listen(upstreamPort, '127.0.0.1', resolve));
+
+ let stderr = '';
+ const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(proxyPort), '--no-browser'], {
+ env: {
+ ...process.env,
+ OPENAI_TEST_HOST: '127.0.0.1',
+ OPENAI_TEST_PORT: String(upstreamPort),
+ OPENAI_TEST_PROTOCOL: 'http',
+ AUTH_TOKEN: 'ws-test-secret',
+ CCXRAY_HOME: home,
+ BROWSER: 'none',
+ RESTORE_DAYS: '0',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ child.stdout.on('data', () => {});
+ child.stderr.on('data', d => { stderr += d.toString(); });
+
+ try {
+ await waitForPort(proxyPort);
+
+ // Connect without X-Ccxray-Auth — should warn but still upgrade
+ const ws = new WebSocket(`ws://localhost:${proxyPort}/v1/responses?token=ws-test-secret`, {
+ headers: {
+ 'openai-beta': 'responses_websockets=v1',
+ 'codex-session-id': 'test-session-123',
+ },
+ });
+
+ const opened = await new Promise((resolve, reject) => {
+ ws.on('open', () => resolve(true));
+ ws.on('error', reject);
+ setTimeout(() => reject(new Error('WS connect timeout')), 5000);
+ });
+ assert.ok(opened, 'WS should connect successfully (warn-only, not blocking)');
+
+ // Wait for close
+ await new Promise(resolve => {
+ ws.on('close', resolve);
+ setTimeout(resolve, 2000);
+ });
+
+ // Allow stderr to flush
+ await new Promise(r => setTimeout(r, 300));
+
+ assert.ok(
+ stderr.includes('without X-Ccxray-Auth'),
+ 'Should have emitted warning about missing X-Ccxray-Auth in stderr'
+ );
+ } finally {
+ upstreamHttp.close();
+ await killAndWait(child);
+ }
+ });
+
+ it('WS upgrade with ChatGPT-OAuth markers does NOT warn', async () => {
+ const upstreamPort = await findFreePort();
+ const proxyPort = await findFreePort();
+ const home = makeTmpHome();
+
+ const upstreamWss = new WebSocket.Server({ noServer: true });
+ const upstreamHttp = http.createServer();
+ upstreamHttp.on('upgrade', (req, socket, head) => {
+ upstreamWss.handleUpgrade(req, socket, head, ws => {
+ setTimeout(() => ws.close(1000, 'done'), 500);
+ });
+ });
+ await new Promise(resolve => upstreamHttp.listen(upstreamPort, '127.0.0.1', resolve));
+
+ let stderr = '';
+ const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(proxyPort), '--no-browser'], {
+ env: {
+ ...process.env,
+ OPENAI_TEST_HOST: '127.0.0.1',
+ OPENAI_TEST_PORT: String(upstreamPort),
+ OPENAI_TEST_PROTOCOL: 'http',
+ AUTH_TOKEN: 'ws-oauth-secret',
+ CCXRAY_HOME: home,
+ BROWSER: 'none',
+ RESTORE_DAYS: '0',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ child.stdout.on('data', () => {});
+ child.stderr.on('data', d => { stderr += d.toString(); });
+
+ try {
+ await waitForPort(proxyPort);
+
+ // Connect with ChatGPT-OAuth markers — should NOT warn
+ const ws = new WebSocket(`ws://localhost:${proxyPort}/v1/responses?token=ws-oauth-secret`, {
+ headers: {
+ 'openai-beta': 'responses_websockets=v1',
+ 'chatgpt-account-id': 'acct-test-456',
+ 'authorization': 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.fakesig',
+ 'codex-session-id': 'test-session-oauth',
+ },
+ });
+
+ await new Promise((resolve, reject) => {
+ ws.on('open', () => resolve(true));
+ ws.on('error', reject);
+ setTimeout(() => reject(new Error('WS connect timeout')), 5000);
+ });
+
+ await new Promise(resolve => {
+ ws.on('close', resolve);
+ setTimeout(resolve, 2000);
+ });
+
+ await new Promise(r => setTimeout(r, 300));
+
+ assert.ok(
+ !stderr.includes('without X-Ccxray-Auth'),
+ 'Should NOT warn for ChatGPT-OAuth path (chatgpt-account-id + JWT present)'
+ );
+ } finally {
+ upstreamHttp.close();
+ await killAndWait(child);
+ }
+ });
+
+ it('WS upgrade strips X-Ccxray-Auth before forwarding to upstream', async () => {
+ const upstreamPort = await findFreePort();
+ const proxyPort = await findFreePort();
+ const home = makeTmpHome();
+
+ const receivedUpgradeHeaders = [];
+ const upstreamWss = new WebSocket.Server({ noServer: true });
+ const upstreamHttp = http.createServer();
+ upstreamHttp.on('upgrade', (req, socket, head) => {
+ receivedUpgradeHeaders.push({ ...req.headers });
+ upstreamWss.handleUpgrade(req, socket, head, ws => {
+ setTimeout(() => ws.close(1000, 'done'), 500);
+ });
+ });
+ await new Promise(resolve => upstreamHttp.listen(upstreamPort, '127.0.0.1', resolve));
+
+ const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(proxyPort), '--no-browser'], {
+ env: {
+ ...process.env,
+ OPENAI_TEST_HOST: '127.0.0.1',
+ OPENAI_TEST_PORT: String(upstreamPort),
+ OPENAI_TEST_PROTOCOL: 'http',
+ AUTH_TOKEN: 'ws-strip-secret',
+ CCXRAY_HOME: home,
+ BROWSER: 'none',
+ RESTORE_DAYS: '0',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ child.stdout.on('data', () => {});
+ child.stderr.on('data', () => {});
+
+ try {
+ await waitForPort(proxyPort);
+
+ const ws = new WebSocket(`ws://localhost:${proxyPort}/v1/responses?token=ws-strip-secret`, {
+ headers: {
+ 'openai-beta': 'responses_websockets=v1',
+ 'x-ccxray-auth': 'must-not-reach-upstream',
+ 'x-ccxray-bootstrap': 'also-must-not-reach',
+ 'authorization': 'Bearer sk-real-key',
+ 'codex-session-id': 'test-session-strip',
+ },
+ });
+
+ await new Promise((resolve, reject) => {
+ ws.on('open', () => resolve(true));
+ ws.on('error', reject);
+ setTimeout(() => reject(new Error('WS connect timeout')), 5000);
+ });
+
+ await new Promise(resolve => {
+ ws.on('close', resolve);
+ setTimeout(resolve, 2000);
+ });
+
+ assert.equal(receivedUpgradeHeaders.length, 1, 'upstream should receive one upgrade');
+ const h = receivedUpgradeHeaders[0];
+ assert.equal(h['x-ccxray-auth'], undefined,
+ 'X-Ccxray-Auth must NOT reach upstream via WS');
+ assert.equal(h['x-ccxray-bootstrap'], undefined,
+ 'X-Ccxray-Bootstrap must NOT reach upstream via WS');
+ assert.equal(h['authorization'], 'Bearer sk-real-key',
+ 'Authorization header should be preserved');
+ assert.ok(h['openai-beta'], 'openai-beta should be preserved');
+ } finally {
+ upstreamHttp.close();
+ await killAndWait(child);
+ }
+ });
+});
diff --git a/test/auth-hkdf.test.js b/test/auth-hkdf.test.js
new file mode 100644
index 0000000..8381a40
--- /dev/null
+++ b/test/auth-hkdf.test.js
@@ -0,0 +1,124 @@
+'use strict';
+
+const { describe, it, before, after, beforeEach } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const crypto = require('crypto');
+
+// Use a per-run temp CCXRAY_HOME so we never touch the user's real ~/.ccxray
+// and tests can manipulate the local-secret file freely.
+const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-hkdf-test-'));
+process.env.CCXRAY_HOME = TEST_HOME;
+
+// Lazy-require so we pick up the env override above.
+const auth = require('../server/auth');
+
+function clearTestHome() {
+ for (const name of fs.readdirSync(TEST_HOME)) {
+ fs.rmSync(path.join(TEST_HOME, name), { recursive: true, force: true });
+ }
+}
+
+after(() => {
+ fs.rmSync(TEST_HOME, { recursive: true, force: true });
+});
+
+describe('deriveSecrets(rootKey) — HKDF label separation', () => {
+ it('returns three Buffer keys with the expected labels', () => {
+ const root = crypto.randomBytes(32);
+ const out = auth.deriveSecrets(root);
+ assert.ok(Buffer.isBuffer(out.K_upstream), 'K_upstream is a Buffer');
+ assert.ok(Buffer.isBuffer(out.K_session), 'K_session is a Buffer');
+ assert.ok(Buffer.isBuffer(out.K_bootstrap), 'K_bootstrap is a Buffer');
+ assert.equal(out.K_upstream.length, 32);
+ assert.equal(out.K_session.length, 32);
+ assert.equal(out.K_bootstrap.length, 32);
+ });
+
+ it('is deterministic — same root produces identical secrets', () => {
+ const root = Buffer.from('a'.repeat(32));
+ const a = auth.deriveSecrets(root);
+ const b = auth.deriveSecrets(root);
+ assert.deepEqual(a.K_upstream, b.K_upstream);
+ assert.deepEqual(a.K_session, b.K_session);
+ assert.deepEqual(a.K_bootstrap, b.K_bootstrap);
+ });
+
+ it('produces three pairwise-distinct keys (label separation)', () => {
+ const root = crypto.randomBytes(32);
+ const { K_upstream, K_session, K_bootstrap } = auth.deriveSecrets(root);
+ assert.notDeepEqual(K_upstream, K_session);
+ assert.notDeepEqual(K_session, K_bootstrap);
+ assert.notDeepEqual(K_upstream, K_bootstrap);
+ });
+
+ it('different roots produce different secrets', () => {
+ const a = auth.deriveSecrets(Buffer.alloc(32, 1));
+ const b = auth.deriveSecrets(Buffer.alloc(32, 2));
+ assert.notDeepEqual(a.K_upstream, b.K_upstream);
+ assert.notDeepEqual(a.K_session, b.K_session);
+ assert.notDeepEqual(a.K_bootstrap, b.K_bootstrap);
+ });
+});
+
+describe('getRootSecret() — AUTH_TOKEN vs ephemeral mode', () => {
+ beforeEach(() => {
+ delete process.env.AUTH_TOKEN;
+ clearTestHome();
+ });
+
+ it('with AUTH_TOKEN set: returns sha256(AUTH_TOKEN)', () => {
+ process.env.AUTH_TOKEN = 'hunter2';
+ const got = auth.getRootSecret();
+ const expected = crypto.createHash('sha256').update('hunter2', 'utf8').digest();
+ assert.deepEqual(got, expected);
+ });
+
+ it('with AUTH_TOKEN set: ignores local-secret on disk', () => {
+ fs.mkdirSync(TEST_HOME, { recursive: true });
+ fs.writeFileSync(path.join(TEST_HOME, 'local-secret'), Buffer.alloc(32, 9));
+ process.env.AUTH_TOKEN = 'env-wins';
+ const got = auth.getRootSecret();
+ const expected = crypto.createHash('sha256').update('env-wins', 'utf8').digest();
+ assert.deepEqual(got, expected);
+ });
+
+ it('with AUTH_TOKEN unset: creates ~/.ccxray/local-secret on first call', () => {
+ const secretPath = path.join(TEST_HOME, 'local-secret');
+ assert.equal(fs.existsSync(secretPath), false, 'precondition: no file');
+ const got = auth.getRootSecret();
+ assert.equal(got.length, 32);
+ assert.equal(fs.existsSync(secretPath), true, 'file created');
+ const onDisk = fs.readFileSync(secretPath);
+ assert.deepEqual(got, onDisk, 'returned key matches on-disk bytes');
+ });
+
+ it('with AUTH_TOKEN unset: subsequent calls reuse the same secret', () => {
+ const first = auth.getRootSecret();
+ const second = auth.getRootSecret();
+ assert.deepEqual(first, second);
+ });
+
+ it('with AUTH_TOKEN unset: local-secret file is mode 0600', () => {
+ auth.getRootSecret();
+ const stat = fs.statSync(path.join(TEST_HOME, 'local-secret'));
+ const mode = stat.mode & 0o777;
+ assert.equal(mode, 0o600, `expected mode 0600, got 0${mode.toString(8)}`);
+ });
+
+ it('with AUTH_TOKEN unset: parent dir is mode 0700', () => {
+ auth.getRootSecret();
+ const stat = fs.statSync(TEST_HOME);
+ const mode = stat.mode & 0o777;
+ assert.equal(mode, 0o700, `expected mode 0700, got 0${mode.toString(8)}`);
+ });
+
+ it('switching from ephemeral to AUTH_TOKEN changes the derived secrets', () => {
+ const ephemeral = auth.getRootSecret();
+ process.env.AUTH_TOKEN = 'now-with-token';
+ const withToken = auth.getRootSecret();
+ assert.notDeepEqual(ephemeral, withToken);
+ });
+});
diff --git a/test/auth-launcher.test.js b/test/auth-launcher.test.js
new file mode 100644
index 0000000..faa1e7d
--- /dev/null
+++ b/test/auth-launcher.test.js
@@ -0,0 +1,164 @@
+'use strict';
+
+const { describe, it, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const crypto = require('crypto');
+
+describe('auth launcher header injection (1.4a)', () => {
+ let tmpHome;
+ let originalEnv;
+
+ beforeEach(() => {
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-auth-launcher-'));
+ originalEnv = { ...process.env };
+ process.env.CCXRAY_HOME = tmpHome;
+ // Ensure auth module re-derives from fresh CCXRAY_HOME
+ delete require.cache[require.resolve('../server/auth')];
+ delete require.cache[require.resolve('../server/providers')];
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ delete require.cache[require.resolve('../server/auth')];
+ delete require.cache[require.resolve('../server/providers')];
+ });
+
+ function getKUpstreamBase64url() {
+ const auth = require('../server/auth');
+ const secrets = auth.deriveSecrets(auth.getRootSecret());
+ return secrets.K_upstream.toString('base64url');
+ }
+
+ describe('Claude launcher', () => {
+ it('injects X-Ccxray-Auth via ANTHROPIC_CUSTOM_HEADERS', () => {
+ const providers = require('../server/providers');
+ const kUp = getKUpstreamBase64url();
+
+ const launch = providers.getAgentLaunch('claude', 5577, ['--continue'], {
+ PATH: '/usr/bin',
+ });
+
+ assert.equal(
+ launch.env.ANTHROPIC_CUSTOM_HEADERS,
+ `X-Ccxray-Auth: ${kUp}`
+ );
+ // Still sets ANTHROPIC_BASE_URL
+ assert.equal(launch.env.ANTHROPIC_BASE_URL, 'http://localhost:5577');
+ });
+
+ it('appends to existing ANTHROPIC_CUSTOM_HEADERS', () => {
+ const providers = require('../server/providers');
+ const kUp = getKUpstreamBase64url();
+
+ const launch = providers.getAgentLaunch('claude', 5577, [], {
+ PATH: '/usr/bin',
+ ANTHROPIC_CUSTOM_HEADERS: 'X-Existing: foo',
+ });
+
+ assert.equal(
+ launch.env.ANTHROPIC_CUSTOM_HEADERS,
+ `X-Existing: foo, X-Ccxray-Auth: ${kUp}`
+ );
+ });
+ });
+
+ describe('Codex launcher — API-key mode (OPENAI_API_KEY set)', () => {
+ it('injects model_providers.ccxray with http_headers + model_provider override', () => {
+ const providers = require('../server/providers');
+ const kUp = getKUpstreamBase64url();
+
+ const launch = providers.getAgentLaunch('codex', 5577, ['exec', 'hello'], {
+ PATH: '/usr/bin',
+ OPENAI_API_KEY: 'sk-test-key',
+ });
+
+ // Should have model_providers.ccxray config
+ const mpArg = launch.args.find(a => a.includes('model_providers.ccxray'));
+ assert.ok(mpArg, 'should have model_providers.ccxray arg');
+ assert.match(mpArg, /base_url="http:\/\/localhost:5577\/v1"/);
+ assert.match(mpArg, /wire_api="responses"/);
+ assert.match(mpArg, new RegExp(`X-Ccxray-Auth.*${kUp.slice(0, 10)}`));
+
+ // Should have model_provider="ccxray"
+ const providerIdx = launch.args.indexOf('-c');
+ const mpOverride = launch.args.find(a => a.includes('model_provider="ccxray"'));
+ assert.ok(mpOverride, 'should have model_provider="ccxray" arg');
+
+ // Should NOT have old-style openai_base_url / chatgpt_base_url
+ const hasOldStyle = launch.args.some(a => a.includes('openai_base_url'));
+ assert.equal(hasOldStyle, false, 'should not have openai_base_url in API-key mode');
+
+ // User args still pass through
+ assert.ok(launch.args.includes('exec'));
+ assert.ok(launch.args.includes('hello'));
+ });
+ });
+
+ describe('Codex launcher — ChatGPT-OAuth mode (no OPENAI_API_KEY)', () => {
+ it('uses legacy openai_base_url + chatgpt_base_url, no model_provider override', () => {
+ const providers = require('../server/providers');
+
+ const launch = providers.getAgentLaunch('codex', 5577, ['exec', 'hello'], {
+ PATH: '/usr/bin',
+ // No OPENAI_API_KEY
+ });
+
+ // Should have old-style base_url configs
+ assert.ok(
+ launch.args.some(a => a.includes('openai_base_url="http://localhost:5577/v1"')),
+ 'should have openai_base_url'
+ );
+ assert.ok(
+ launch.args.some(a => a.includes('chatgpt_base_url="http://localhost:5577/v1"')),
+ 'should have chatgpt_base_url'
+ );
+
+ // Should NOT have model_provider override
+ const mpOverride = launch.args.find(a => a.includes('model_provider='));
+ assert.equal(mpOverride, undefined, 'should not have model_provider in OAuth mode');
+
+ // Should NOT have model_providers.ccxray
+ const mpConfig = launch.args.find(a => a.includes('model_providers.ccxray'));
+ assert.equal(mpConfig, undefined, 'should not have model_providers.ccxray in OAuth mode');
+ });
+ });
+
+ describe('graceful fallback when K_upstream derivation fails', () => {
+ it('warns but does not abort when getRootSecret throws', () => {
+ // Point CCXRAY_HOME at a read-only path so ensureHubDir() fails on mkdir
+ const readonlyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccxray-ro-'));
+ const impossibleChild = path.join(readonlyDir, 'nope', 'deeper');
+ process.env.CCXRAY_HOME = impossibleChild;
+ fs.chmodSync(readonlyDir, 0o444);
+
+ delete require.cache[require.resolve('../server/auth')];
+ delete require.cache[require.resolve('../server/providers')];
+
+ const warnings = [];
+ const origWarn = console.warn;
+ console.warn = (...args) => warnings.push(args.join(' '));
+
+ try {
+ const providers = require('../server/providers');
+ const launch = providers.getAgentLaunch('claude', 5577, [], { PATH: '/usr/bin' });
+
+ assert.ok(launch, 'launch should not be null');
+ assert.equal(launch.bin, 'claude');
+ assert.equal(launch.env.ANTHROPIC_BASE_URL, 'http://localhost:5577');
+ assert.equal(launch.env.ANTHROPIC_CUSTOM_HEADERS, undefined);
+ assert.ok(warnings.length > 0, 'should have emitted a warning');
+ assert.ok(
+ warnings.some(w => w.includes('X-Ccxray-Auth')),
+ 'warning should mention X-Ccxray-Auth'
+ );
+ } finally {
+ console.warn = origWarn;
+ fs.chmodSync(readonlyDir, 0o755);
+ fs.rmSync(readonlyDir, { recursive: true });
+ }
+ });
+ });
+});
diff --git a/test/auth-ws.test.js b/test/auth-ws.test.js
new file mode 100644
index 0000000..98a9069
--- /dev/null
+++ b/test/auth-ws.test.js
@@ -0,0 +1,78 @@
+'use strict';
+
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+
+// Unit-test the warn-only auth gate helper that ws-proxy.js uses to decide
+// whether an upgrade request is missing X-Ccxray-Auth. The actual WebSocket
+// upgrade is tested in websocket-proxy.test.js; here we test the classification
+// logic in isolation.
+
+const wsProxy = require('../server/ws-proxy');
+
+describe('WS header stripping (1.4c)', () => {
+ it('buildWebSocketHeaders strips X-Ccxray-Auth and X-Ccxray-Bootstrap from upstream', () => {
+ const { buildWebSocketHeaders } = wsProxy;
+ const clientHeaders = {
+ 'x-ccxray-auth': 'secret-token',
+ 'x-ccxray-bootstrap': 'bootstrap-token',
+ 'authorization': 'Bearer sk-test',
+ 'openai-beta': 'responses_websockets=v1',
+ 'host': 'localhost:5577',
+ };
+ const upstream = { host: 'api.openai.com', port: 443 };
+ const result = buildWebSocketHeaders(clientHeaders, upstream);
+
+ assert.equal(result['x-ccxray-auth'], undefined);
+ assert.equal(result['x-ccxray-bootstrap'], undefined);
+ assert.equal(result['authorization'], 'Bearer sk-test');
+ assert.equal(result['openai-beta'], 'responses_websockets=v1');
+ assert.equal(result.host, 'api.openai.com');
+ });
+});
+
+describe('WS auth gate classification (1.4b)', () => {
+ describe('classifyUpstreamAuth', () => {
+ it('returns "authed" when X-Ccxray-Auth is present', () => {
+ const headers = { 'x-ccxray-auth': 'some-token-value' };
+ assert.equal(wsProxy.classifyUpstreamAuth(headers), 'authed');
+ });
+
+ it('returns "warn" when no X-Ccxray-Auth and no ChatGPT-OAuth markers', () => {
+ const headers = { 'openai-beta': 'responses_websockets=v1' };
+ assert.equal(wsProxy.classifyUpstreamAuth(headers), 'warn');
+ });
+
+ it('returns "chatgpt-oauth" for ChatGPT-OAuth carve-out (no X-Ccxray-Auth + chatgpt-account-id + JWT authorization)', () => {
+ const headers = {
+ 'chatgpt-account-id': 'acct-123',
+ authorization: 'Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.fake',
+ };
+ assert.equal(wsProxy.classifyUpstreamAuth(headers), 'chatgpt-oauth');
+ });
+
+ it('returns "warn" when chatgpt-account-id present but Authorization is not JWT-shaped', () => {
+ const headers = {
+ 'chatgpt-account-id': 'acct-123',
+ authorization: 'Bearer sk-proj-abc123',
+ };
+ assert.equal(wsProxy.classifyUpstreamAuth(headers), 'warn');
+ });
+
+ it('returns "warn" when JWT-shaped Authorization present but no chatgpt-account-id', () => {
+ const headers = {
+ authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.sig',
+ };
+ assert.equal(wsProxy.classifyUpstreamAuth(headers), 'warn');
+ });
+
+ it('returns "authed" when X-Ccxray-Auth is present even with ChatGPT-OAuth markers', () => {
+ const headers = {
+ 'x-ccxray-auth': 'token',
+ 'chatgpt-account-id': 'acct-123',
+ authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.sig',
+ };
+ assert.equal(wsProxy.classifyUpstreamAuth(headers), 'authed');
+ });
+ });
+});