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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/account-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -3919,6 +3919,23 @@ export class AccountManager {
this._onTokenRefresh = callback;
}

/**
* Take an account out of rotation because upstream rejected its credential
* and nothing in this process can repair it. `error` is the state selection
* already skips and status already explains ("needs re-login"); a reload that
* brings a new credential, or re-enabling the account, clears it.
*
* @param {number} accountIndex
* @param {string} why one clause for the log line
*/
markCredentialRejected(accountIndex, why) {
const account = this.accounts[accountIndex];
if (!account || account.status === 'error') return;
account.status = 'error';
const remedy = account.type === 'oauth' ? 'run: teamclaude login' : 'check the key in the config';
console.error(`[TeamClaude] Account "${safeLine(account.name, 64)}" taken out of rotation: ${why} — ${remedy}`);
}

/**
* Update a specific account's OAuth tokens (e.g. after intercepting a token refresh).
* @param {number} accountIndex
Expand Down
17 changes: 16 additions & 1 deletion src/config-ops.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DEFAULT_SWITCH_THRESHOLD, distributionMode } from './account-manager.js';
import { THRESHOLD_BUCKET_KEYS } from './model.js';
import { THRESHOLD_BUCKET_KEYS, WEEKLY_BUCKET_KEYS } from './model.js';
import { createRollingWarmupSchedule, resolveWarmupSchedule } from './warmup-schedule.js';

/**
Expand Down Expand Up @@ -138,6 +138,14 @@ export function thresholdRatio(percent) {
* @returns {Record<string, number>}
*/
export function thresholdTable(value) {
// Only a hand edit produces an array; the CLI and the TUI write a number or a
// keyed object. Spread, it became a table with a bucket named "0" that
// nothing ever asks about, while the threshold in force stayed the default
// and nothing said why (#425).
if (Array.isArray(value)) {
console.error('[TeamClaude] switchThreshold is an array in the config — expected a number or an object keyed by bucket; using the default');
return { default: DEFAULT_SWITCH_THRESHOLD };
}
if (value && typeof value === 'object') {
return { default: DEFAULT_SWITCH_THRESHOLD, ...value };
}
Expand Down Expand Up @@ -263,6 +271,13 @@ export function upsertRoute(config, spec) {
if (accounts.length) route.accounts = accounts;
if (typeof spec.bucket === 'string' && spec.bucket) {
refuseControlCharacters(spec.bucket, 'A route bucket');
// Stored verbatim, this becomes the route's weekly gating key. A typo names
// a bucket no account's quota map carries, so the route is never gated on
// its weekly window and nothing says so (#424). `threshold` refuses an
// unknown bucket for the same reason.
if (!WEEKLY_BUCKET_KEYS.includes(spec.bucket)) {
throw new ConfigOpError(`Unknown route bucket "${spec.bucket}" — expected one of: ${WEEKLY_BUCKET_KEYS.join(', ')}`);
}
route.bucket = spec.bucket;
}
if (color) route.color = color;
Expand Down
60 changes: 60 additions & 0 deletions src/early-log.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Console output produced before the TUI exists.
//
// `tui.start()` swaps console.log/error for the activity pane and moves the
// terminal to the alternate screen, and it runs from the `server.listen()`
// callback. Everything the server says before that — which account it starts
// on, a token refresh, "Quota probe enabled", the upstream proxy line — went to
// the normal screen instead. It never reached the activity pane or the
// `--activity-log` file, stayed hidden for as long as the TUI ran, and came back
// after `q` looking like something that had just been logged (#413).
//
// So when a TUI is going to run, startup output is held here and replayed
// through the console once the TUI owns it. If the process exits first (port in
// use, a bad config) the held lines go to the real stderr, or those errors
// would be lost with the screen they were meant for.

/**
* @param {Object} [opts]
* @param {{ log: Function, error: Function, warn: Function }} [opts.target] the console to hold
* @param {{ write: (s: string) => unknown }} [opts.stderr] where held lines go if the process exits first
* @param {(fn: () => void) => void} [opts.onExit]
* @param {(fn: () => void) => void} [opts.offExit]
* @returns {{ release: () => string[] }} `release()` restores the console and
* returns the held lines, oldest first, for the caller to replay
*/
export function captureEarlyConsole({
target = console,
stderr = process.stderr,
onExit = (fn) => { process.once('exit', fn); },
offExit = (fn) => { process.removeListener('exit', fn); },
} = {}) {
/** @type {string[]} */
const lines = [];
const original = { log: target.log, error: target.error, warn: target.warn };
let done = false;
const hold = (/** @type {unknown[]} */ ...args) => { lines.push(args.join(' ')); };
const restore = () => {
target.log = original.log;
target.error = original.error;
target.warn = original.warn;
};
const flushOnExit = () => {
if (done) return;
done = true;
restore();
for (const line of lines) stderr.write(`${line}\n`);
};
target.log = hold;
target.error = hold;
target.warn = hold;
onExit(flushOnExit);
return {
release() {
if (done) return [];
done = true;
restore();
offExit(flushOnExit);
return lines.splice(0);
},
};
}
20 changes: 19 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { Warmer } from './warmer.js';
import { formatWarmupScheduleConfirmation, resolveWarmupConfig } from './warmup-schedule.js';
import { TUI } from './tui.js';
import { SessionTitles } from './session-titles.js';
import { captureEarlyConsole } from './early-log.js';
import { RemoteControl, createAttachSession } from './tui-remote.js';
import { SxManager } from './sx.js';
import { autoUpdate, checkForUpdate, currentVersion, resolveVersionLabel, runUpdate, installKind, updateAvailableFromCache, PKG_NAME } from './updater.js';
Expand Down Expand Up @@ -218,6 +219,14 @@ async function serverCommand() {
// per stall in the service log, lag figures under `server.eventLoop`).
const eventLoopMonitor = startEventLoopMonitor();

// With a TUI coming, hold what startup prints until the TUI owns the console,
// then replay it into the activity pane (and its log file) — see early-log.js.
// The same test `useTUI` applies below, taken here because the first lines are
// printed long before that one is reached.
const tuiExpected = !(args.includes('--headless') || args.includes('--no-tui'))
&& process.stdout.isTTY && process.stdin.isTTY;
const earlyConsole = tuiExpected ? captureEarlyConsole() : null;

const config = await loadOrCreateConfig();
// Token writes below pair rows by entry id against a re-read of the file, so
// the ids have to be on disk before the first refresh, not just in memory.
Expand Down Expand Up @@ -316,7 +325,10 @@ async function serverCommand() {
atomicConfigUpdate(diskConfig => {
// Pick up any new accounts from disk so the running fleet serves them
// (only add, don't refresh credentials — we're about to write the authoritative tokens)
const removed = removedAccountIds(config);
for (const diskAcct of diskConfig.accounts) {
// A row the TUI is in the middle of removing is not a new account (#422).
if (diskAcct?.id && removed.has(diskAcct.id)) continue;
const known = config.accounts.some(a => sameIdentity(a, diskAcct));
if (!known) {
// Same object into both lists, so the account carries its entry's id;
Expand Down Expand Up @@ -621,7 +633,7 @@ async function serverCommand() {
});
hooks.getQuotaExtra = () => ({ warmup: resolveWarmupConfig(config) });

const server = createProxyServer(accountManager, config, hooks, sx, clientUsage, dimensionUsage);
const server = createProxyServer(accountManager, config, hooks, sx, clientUsage, dimensionUsage, { bindHost });
// Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
// remove this handler so a later runtime 'error' isn't misreported as a
// listen failure and exit the whole proxy.
Expand All @@ -646,9 +658,15 @@ async function serverCommand() {
console.log(`[TeamClaude] Upstream proxy: direct — ${describeSelfProxy(egressProxy)}`);
}
if (tui) {
const held = earlyConsole?.release() ?? [];
tui.start();
// In the order they were said, ahead of the line that follows them.
for (const line of held) console.log(line);
console.log(`Listening on port ${port} with ${accounts.length} account(s)`);
} else {
// Same test as `tuiExpected`, so this holds nothing; released all the same
// so a line can never stay held if the two ever come to disagree.
for (const line of earlyConsole?.release() ?? []) console.log(line);
const sep = '='.repeat(60);
console.log('');
console.log(sep);
Expand Down
75 changes: 67 additions & 8 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,17 @@ export function loopbackExempt(headers, remoteAddress, proxyConfig) {
* @param {import('node:http').IncomingHttpHeaders} headers
* @param {string|undefined} remoteAddress
* @param {Record<string, any>|undefined} proxyConfig
* @param {string|null} [boundHost] the address the server bound, when it is not `proxyConfig.host`
* @returns {string|null}
*/
export function keylessMcpRefusal(headers, remoteAddress, proxyConfig) {
export function keylessMcpRefusal(headers, remoteAddress, proxyConfig, boundHost = null) {
if (!resolveClientAuth(proxyConfig, undefined).ok) return null;
if (!loopbackExempt(headers, remoteAddress, proxyConfig)) {
return 'request refused: with no proxy key configured the MCP endpoint serves only this machine; set proxy.apiKey to reach it from elsewhere';
}
if (!isLocalHostHeader(headers.host ?? headers[':authority'], proxyConfig?.host)) {
// The address actually bound, when the caller knows it (TEAMCLAUDE_HOST can
// differ from proxy.host — see createProxyServer's `bindHost`).
if (!isLocalHostHeader(headers.host ?? headers[':authority'], boundHost || proxyConfig?.host)) {
return 'request refused: the Host header does not name this proxy';
}
return null;
Expand Down Expand Up @@ -296,7 +299,21 @@ export function resolveClientAuth(proxyConfig, presented) {
return { ok: false, client: null };
}

export function createProxyServer(accountManager, config, hooks = {}, sx = null, clientUsage = null, dimensionUsage = null) {
/**
* @param {any} accountManager
* @param {any} config
* @param {any} [hooks]
* @param {any} [sx]
* @param {any} [clientUsage]
* @param {any} [dimensionUsage]
* @param {{ bindHost?: string|null }} [opts] `bindHost`: the address the caller
* binds, when it is not `config.proxy.host` (TEAMCLAUDE_HOST overrides it). The
* DNS-rebinding Host check accepts that address as naming this machine; given
* only the config value, a server bound off-box through the env var refused a
* key-less caller naming the very address it listens on (#423).
*/
export function createProxyServer(accountManager, config, hooks = {}, sx = null, clientUsage = null, dimensionUsage = null, { bindHost = null } = {}) {
const boundHost = () => bindHost || config.proxy?.host;
const upstream = config.upstream || 'https://api.anthropic.com';
const holdMs = (config.holdSeconds || 0) * 1000;

Expand Down Expand Up @@ -415,7 +432,7 @@ export function createProxyServer(accountManager, config, hooks = {}, sx = null,
// far as the browser can tell, and it can read the answers. What it
// cannot forge is the Host header, which the browser derives from its
// own URL bar — so a key-less loopback request must name this machine.
if (!isLocalHostHeader(req.headers.host ?? req.headers[':authority'], config.proxy?.host)) {
if (!isLocalHostHeader(req.headers.host ?? req.headers[':authority'], boundHost())) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
type: 'error',
Expand Down Expand Up @@ -554,7 +571,7 @@ export function createProxyServer(accountManager, config, hooks = {}, sx = null,
// into a client by hand, and a near miss would fall through to the
// forwarder below with a fleet credential attached.
if (/^\/teamclaude\/mcp\/?(\?|$)/.test(req.url || '')) {
const refusal = keylessMcpRefusal(req.headers, req.socket.remoteAddress, config.proxy);
const refusal = keylessMcpRefusal(req.headers, req.socket.remoteAddress, config.proxy, boundHost());
if (refusal) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: refusal }));
Expand All @@ -564,6 +581,20 @@ export function createProxyServer(accountManager, config, hooks = {}, sx = null,
return;
}

// Every control route above matches an exact method and path, so a typo —
// or just the wrong verb, `GET /teamclaude/reload` — fell through to the
// forwarder: the request went upstream under a fleet account's credential
// and the client got THAT server's 404 (#420). The prefix is ours, so
// whatever under it no route claimed is answered here. Read on the
// classification path like every other prefix test; `/tc-acct/` and an
// absolute-form proxy URL do not start with it and are untouched.
const controlPath = classificationPath(req.url);
if (controlPath === '/teamclaude' || controlPath.startsWith('/teamclaude/')) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'unknown teamclaude control route (check the path and the method)' }));
return;
}

return forward(req, res);
} catch (err) {
reportFailure('[TeamClaude] Unhandled error:', err);
Expand Down Expand Up @@ -643,7 +674,7 @@ export function createProxyServer(accountManager, config, hooks = {}, sx = null,
// client's own headers), so it is not a way to spend the fleet's quota,
// but it is a way to reach the upstream on this host's address and
// bandwidth. A deployment on a public hostname hands that to anyone.
const auth = resolveUpgradeAuth(req, socket, config.proxy);
const auth = resolveUpgradeAuth(req, socket, config.proxy, boundHost());
if (!auth.ok) {
// Logged as well as answered: a WebSocket client discards the status
// line, so the 401 alone leaves an operator with a channel that is
Expand Down Expand Up @@ -1532,7 +1563,7 @@ function relayStream(req, res, upstream, sx) {
* proxy cannot honour the negotiation anyway because it relays the handshake
* rather than answering it.
*/
export function resolveUpgradeAuth(req, socket, proxyConfig) {
export function resolveUpgradeAuth(req, socket, proxyConfig, boundHost = null) {
const auth = resolveClientAuth(proxyConfig, req?.headers?.['x-api-key']);
if (auth.ok) return auth;
// Loopback is exempt from the key requirement, exactly as the HTTP and
Expand All @@ -1543,7 +1574,7 @@ export function resolveUpgradeAuth(req, socket, proxyConfig) {
// sets on every handshake and a CLI never sends, nor `Host`, which a
// rebound name (attacker.example → 127.0.0.1) leaves naming the attacker.
if (!loopbackExempt(req?.headers, socket?.remoteAddress, proxyConfig)) return auth;
const bindHost = proxyConfig?.host;
const bindHost = boundHost || proxyConfig?.host;
const origin = req?.headers?.origin;
if (origin) {
let originHost;
Expand Down Expand Up @@ -2914,6 +2945,34 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir, sx, route);
}

// A 401 the branch above did not take, or one that came BACK after it. The
// re-auth is gated on a stored refresh token, so an OAuth account without
// one, and every API-key account, had no 401 handler at all: the answer was
// relayed, the account stayed `active`, and the next request picked it
// again — measured as 4,124 consecutive 401s from one account over twenty
// hours while three healthy siblings served none of them (#412). Like the
// 403 above, the client must not see it: Claude Code reads a 401 as its own
// login having died. So the account is skipped for this request and the
// request fails over. It also leaves rotation when nothing here can ever
// repair it: an API key, or an OAuth account with no refresh token to try.
// An account that DOES hold one only fails over. Its second 401 can be stale
// news — the forced refresh is suppressed for a short floor after a
// successful one (the refresh-storm guard), so the retry may have gone out
// on the same token — and a later request gets to try the refresh again.
if (upstreamRes.status === 401 && !res.headersSent) {
await upstreamRes.body?.cancel();
if (account.type !== 'oauth' || !account.refreshToken) {
accountManager.markCredentialRejected(account.index, account.type !== 'oauth'
? 'upstream rejected its API key (401)'
: 'upstream rejected its token (401) and it has no refresh token');
}
(ctx.credentialRejected ??= new Set()).add(account.name);
ctx.tried.add(account.index);
console.error(`[TeamClaude] 401 on "${safeLine(account.name, 64)}"; failing over to another account`);
if (clientGone(res)) { ctx.abandoned = true; return; }
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir, sx, route);
}

// Log the request head (once) followed by the response headers, streaming
// to disk from here on.
logRequestHead();
Expand Down
2 changes: 1 addition & 1 deletion src/status-renderer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { formatMoney } from './oauth.js';
import { findFamilyBlock, modelGlobOverlaps, gatingUtilization, resolveMaxUsage, resolveSwitchThreshold, resolveFleetThreshold, switchThresholdDiffs } from './model.js';
import { safeLine } from './safe-text.js';
import { ROUTE_COLORS } from './config-ops.js';

const ESC = '\x1b[';
const RESET = `${ESC}0m`;
Expand Down Expand Up @@ -297,7 +298,6 @@ function colors(enabled) {
}

// Paint a route's name/globs in its configured color, defaulting to cyan.
const ROUTE_COLORS = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan'];
function paintRoute(paint, color, value) {
const fn = ROUTE_COLORS.includes(String(color || '').toLowerCase()) ? paint[color.toLowerCase()] : paint.cyan;
return fn(value);
Expand Down
24 changes: 21 additions & 3 deletions src/sx.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,29 @@ export function sxBase(env = process.env, warn = (m) => console.error(m)) {

// ── sx.org REST (apiKey is a query param; these hit api.sx.org directly, never
// the proxy, and are unrelated to Anthropic traffic) ──
// fetch gives a connect timeout and no response timeout, so an api.sx.org that
// accepts the connection and then says nothing hung the promise for good — and
// `reloadAccounts` awaits this path when the sx key or mode changed on disk, so
// `POST /teamclaude/reload` and the TUI's R key never came back (#421).
const SX_TIMEOUT_MS = 15_000;
// A provisioning reply is a few hundred bytes; nothing here needs more.
const SX_MAX_REPLY_BYTES = 1 << 20;

/** @param {Response} res */
async function sxJson(res) {
const declared = Number(res.headers.get('content-length'));
if (Number.isFinite(declared) && declared > SX_MAX_REPLY_BYTES) throw new Error('sx.org reply too large');
const text = await res.text();
if (text.length > SX_MAX_REPLY_BYTES) throw new Error('sx.org reply too large');
return JSON.parse(text);
}

async function sxGet(path, apiKey, params = {}) {
const url = new URL(sxBase() + path);
url.searchParams.set('apiKey', apiKey);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
const res = await fetch(url, { headers: { accept: 'application/json' } });
return res.json();
const res = await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(SX_TIMEOUT_MS) });
return sxJson(res);
}

async function sxPost(path, apiKey, body) {
Expand All @@ -57,8 +74,9 @@ async function sxPost(path, apiKey, body) {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(SX_TIMEOUT_MS),
});
return res.json();
return sxJson(res);
}

export const SX_MODES = ['off', '429', 'always'];
Expand Down
Loading
Loading