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
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ teamclaude version # Print the installed version
teamclaude help # Show all commands
```

`teamclaude status` prints the same picture as the TUI, once, as text. Handy over SSH or in a script; `--json` for machine-readable output. The JSON's `server.version` is the version of the process answering — read once at startup, so right after `teamclaude update` it still names the old code until the restart, where the installed CLI's `teamclaude version` already names the new one. Each account's shared windows carry the time upstream last stated them, beside the value: `quota.unified5hSeenAt` and `quota.unified7dSeenAt`, in epoch milliseconds, for Claude and Codex accounts alike. Only a response or probe that states that window's utilization moves its stamp; a reset time alone, a failed probe or a model-scoped weekly bucket does not. (A Codex subscription states its only 5-hour window inside a model-named family; that reading is the account's 5-hour value, so it moves the 5-hour stamp.) The stamps survive a restart, and a value restored from a state file written before they existed reads `null` until upstream states it again, so `null` means "age unknown", never "just now".
`teamclaude status` prints the same picture as the TUI, once, as text. Handy over SSH or in a script; `--json` for machine-readable output. The JSON's `server.version` is the version of the process answering — read once at startup, so right after `teamclaude update` it still names the old code until the restart, where the installed CLI's `teamclaude version` already names the new one; `server.pid` is that process's id, so a caller can tell which server answered on a port. Each account's shared windows carry the time upstream last stated them, beside the value: `quota.unified5hSeenAt` and `quota.unified7dSeenAt`, in epoch milliseconds, for Claude and Codex accounts alike. Only a response or probe that states that window's utilization moves its stamp; a reset time alone, a failed probe or a model-scoped weekly bucket does not. (A Codex subscription states its only 5-hour window inside a model-named family; that reading is the account's 5-hour value, so it moves the 5-hour stamp.) The stamps survive a restart, and a value restored from a state file written before they existed reads `null` until upstream states it again, so `null` means "age unknown", never "just now".

`teamclaude attach` opens the terminal dashboard itself against a server that is already running, which is how you get interactive control back when the proxy runs as a background service. It polls the same status endpoint every second and can do the two things the remote control exposes: `s` switches account, `R` reloads config. The browser dashboard adds the matching **Reload config** action plus a zero-spend **Probe quotas** action; settings editing and the request activity stream still stay in the server's own TUI because they need state that only that process has. When contact with the server drops, the header marker turns from `▲` to `▼` and what is on screen is the last snapshot, not the current state.

Expand Down
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,9 @@ async function serverCommand() {
startedAt: new Date(serverStartedAt).toISOString(),
uptimeSeconds: Math.round((Date.now() - serverStartedAt) / 1000),
port,
// Identity, not just liveness: a client (or a test) that finds a server on
// the configured port can tell whether it is the one it expects.
pid: process.pid,
upstream: config.upstream || 'https://api.anthropic.com',
eventLoop: eventLoopMonitor.status(),
},
Expand Down
177 changes: 177 additions & 0 deletions test-helpers/spawn-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import net from 'node:net';
import { spawn } from 'node:child_process';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

// The real server as a subprocess, on a port that is verifiably its own.
//
// Tests that drive `src/index.js server --headless` used to each pick a port
// with closedPort(), write it into a throwaway config and poll
// /teamclaude/status until something answered. `node --test` runs files in
// parallel, so between the probe closing and the child's listen() another test
// (or anything else on the machine) could take the port. Two outcomes, both
// seen in CI and locally under load: the child exits with "Port N is already
// in use" and the test times out with a confusing error; or, worse, ANOTHER
// server answers on that port — a neighbouring test's in-process
// createProxyServer, say, which has no hooks.reload — the 200 satisfies the
// poll, and the test then talks to the wrong server (reload → 501). Readiness
// here is a status reply whose `server.pid` is the child's own, and a child
// that lost the port to a neighbour is respawned on a fresh one.
//
// This file lives outside test/ on purpose: node's default test glob includes
// `**/test/**/*.js`, so anything under test/ is run as a test file.

const cliPath = fileURLToPath(new URL('../src/index.js', import.meta.url));

// How many ports to try before giving up. Losing one race is expected under
// load; losing ten in a row means something else is wrong.
const MAX_ATTEMPTS = 10;

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

/**
* A port nothing is listening on: bind one, learn its number, give it back.
*
* Fine for a DEAD upstream (a stand-in for a backend that refuses). Not fine
* on its own for the proxy's own port — see spawnServer for why.
* @returns {Promise<number>}
*/
export function closedPort() {
return new Promise(resolve => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = /** @type {import('node:net').AddressInfo} */ (probe.address());
probe.close(() => resolve(port));
});
});
}

/**
* One GET /teamclaude/status, parsed; null when nothing answered, when the
* reply was not a 200, or when the request timed out. Bounded per attempt so
* an accepted-but-unanswered connection cannot wedge a polling loop.
* @param {number} port
* @returns {Promise<any|null>}
*/
async function fetchStatus(port) {
try {
const res = await fetch(`http://127.0.0.1:${port}/teamclaude/status`, { signal: AbortSignal.timeout(2_000) });
if (!res.ok) { await res.arrayBuffer(); return null; }
return await res.json();
} catch {
return null;
}
}

/**
* Poll /teamclaude/status on `port` until a reply satisfies `predicate`, or
* until `stopWhen()` says there is no point waiting any longer (the child
* exited). No overall deadline of its own: a child that hangs at startup
* without listening is what the runner's --test-timeout is for.
* @param {number} port
* @param {(status: any) => boolean} [predicate]
* @param {{ stopWhen?: () => boolean }} [opts]
* @returns {Promise<any|null>} the matching status, or null when stopWhen fired
*/
export async function waitForStatus(port, predicate = () => true, { stopWhen } = {}) {
for (;;) {
const status = await fetchStatus(port);
// A matching reply wins even if the child has meanwhile exited: it was the
// child that answered, and whatever killed it afterwards is the test's to
// notice.
if (status && predicate(status)) return status;
if (stopWhen?.()) return null;
await sleep(75);
}
}

/**
* Spawn the real server on a port that is verifiably its own.
*
* Picks a candidate port, writes `config(port)` (with `proxy.port` set) to
* `<dir>/config.json` as compact `JSON.stringify` — so a test can tell the
* server's own 2-space save from this write by the bytes — and spawns
* `src/index.js server --headless` on it. Resolves once the child answers
* status with its own pid. If the child instead exits because the port was
* taken, it is respawned on a fresh port; any other exit throws with the
* child's output.
*
* @param {object} opts
* @param {(port: number) => object} opts.config builds the config for a candidate port (proxy.port is set by the helper; the callback may read the port for other fields)
* @param {string} [opts.dir] directory for the config file (mkdtemp'd when absent)
* @param {Record<string, string>} [opts.env] extra environment for the child
* @param {string[]} [opts.args] extra CLI args after `server --headless`
* @returns {Promise<{ port: number, pid: number, configPath: string, dir: string, config: object, child: import('node:child_process').ChildProcess, output: () => string, stop: () => Promise<void> }>}
*/
export async function spawnServer({ config: buildConfig, dir, env = {}, args = [] }) {
dir ??= await mkdtemp(join(tmpdir(), 'teamclaude-spawn-'));
const configPath = join(dir, 'config.json');
const failures = [];

for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const port = await closedPort();
const config = buildConfig(port);
config.proxy = { ...(config.proxy || {}), port };
await writeFile(configPath, JSON.stringify(config));

const { child, exited, output } = spawnChild(configPath, env, args);
let done = false;
exited.then(() => { done = true; });

// Readiness is a status reply carrying the child's OWN pid. A 200 from a
// server with a different pid is the wrong-server case: keep polling — the
// child cannot come up on a taken port, so it will exit and end the wait.
const status = await waitForStatus(port, s => s?.server?.pid === child.pid, { stopWhen: () => done });
if (status) {
return {
port, pid: child.pid, configPath, dir, config, child, output,
stop: () => stop(child, exited),
};
}

const { code, signal } = await exited;
const report = `attempt ${attempt} on port ${port}: exited (code ${code}, signal ${signal})\n${output()}`;
if (!/already in use/.test(output())) {
throw new Error(`server exited before answering status\n${report}`);
}
failures.push(report);
}
throw new Error(`server lost the port race ${MAX_ATTEMPTS} times\n${failures.join('\n')}`);
}

function spawnChild(configPath, extraEnv, args) {
// The child must not inherit a proxy from the shell: see test/README.md.
const env = { ...process.env, TEAMCLAUDE_CONFIG: configPath, TEAMCLAUDE_DISABLE_AUTOUPDATE: '1', ...extraEnv };
for (const key of Object.keys(env)) if (/^(https?|all|no)_proxy$/i.test(key)) delete env[key];
const child = spawn(process.execPath, [cliPath, 'server', '--headless', ...args], {
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', c => { output += c; });
child.stderr.on('data', c => { output += c; });
// Attached at spawn, not at stop(): Node does not replay 'exit' to late
// listeners, so a child that died before stop() ran (startup port race,
// mid-test crash) would otherwise hang the await. 'close' rather than 'exit'
// so the pipes have drained and output() is complete — the "already in use"
// line is what the retry decision reads. 'error' covers a spawn that never
// started, which fires neither.
const exited = new Promise(resolve => {
child.once('close', (code, signal) => resolve({ code, signal }));
child.once('error', err => resolve({ code: null, signal: null, error: err }));
});
return { child, exited, output: () => output };
}

// SIGTERM, escalate to SIGKILL after a few seconds, await exit. Safe on a
// child that has already exited: kill() is a no-op and `exited` is settled.
async function stop(child, exited) {
child.kill('SIGTERM');
const killer = setTimeout(() => child.kill('SIGKILL'), 5000);
await exited;
clearTimeout(killer);
}
16 changes: 16 additions & 0 deletions test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ that *wants* to go through a mock proxy quietly go direct. That is what
inherits the shell, so the in-process guard does not reach it. See
`connect-error-message.test.js`.

## Spawn the real server through `test-helpers/spawn-server.js`

A test that runs `src/index.js server --headless` as a child must not pick a
port itself and poll until "something answers". Files run in parallel, so the
port a probe just released can be taken before the child's `listen()` — and
if what took it was another test's in-process `createProxyServer`, its
`/teamclaude/status` answers 200, the poll is satisfied, and the test drives
the wrong server (a `POST /teamclaude/reload` there is a 501). `spawnServer()`
treats a status reply as readiness only when its `server.pid` is the child's
own, and respawns on a fresh port when the child lost the race. `closedPort()`
from the same module is still right for a *dead* upstream, where a port nothing
listens on is the point.

The helper lives outside `test/` because node's default test glob is
`**/test/**/*.js`: anything under this directory is run as a test file.

## Clean up in `finally`

A failed assertion must not leave a server listening. A leaked handle keeps the
Expand Down
82 changes: 14 additions & 68 deletions test/advisor-eligibility.test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import net from 'node:net';
import { spawn } from 'node:child_process';
import { mkdtemp, writeFile, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { writeFile, readFile } from 'node:fs/promises';
import { AccountManager, advisorEligibilityMode } from '../src/account-manager.js';
import { spawnServer, closedPort } from '../test-helpers/spawn-server.js';

// Issue #479. Claude Code declares the advisor tool on EVERY request, so the
// advisor's eligibility is paid by all of a client's traffic. With exactly one
Expand Down Expand Up @@ -260,60 +256,12 @@ test('setAdvisorEligibility switches a running manager both ways', () => {

// --- Live reload, against the real server ------------------------------------

const cliPath = fileURLToPath(new URL('../src/index.js', import.meta.url));

// A port nothing is listening on: bind one, learn its number, give it back.
function closedPort() {
return new Promise(resolve => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = /** @type {import('node:net').AddressInfo} */ (probe.address());
probe.close(() => resolve(port));
});
});
}

function startServer(configPath) {
const child = spawn(process.execPath, [cliPath, 'server', '--headless'], {
env: { ...process.env, TEAMCLAUDE_CONFIG: configPath, TEAMCLAUDE_DISABLE_AUTOUPDATE: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', c => { output += c; });
child.stderr.on('data', c => { output += c; });
const stop = async () => {
child.kill('SIGTERM');
const killer = setTimeout(() => child.kill('SIGKILL'), 5000);
// Node does not replay 'exit' to late listeners, so a child that already
// died must not hang the await.
if (child.exitCode === null && child.signalCode === null) {
await new Promise(resolve => child.on('exit', resolve));
}
clearTimeout(killer);
};
return { stop, output: () => output };
}

async function statusOf(port) {
const res = await fetch(`http://127.0.0.1:${port}/teamclaude/status`);
assert.equal(res.status, 200);
return res.json();
}

async function waitForServer(port, childOutput) {
const deadline = Date.now() + 10_000;
for (;;) {
try {
const res = await fetch(`http://127.0.0.1:${port}/teamclaude/status`);
if (res.ok) return;
} catch { /* not up yet */ }
if (Date.now() > deadline) throw new Error(`server did not start:\n${childOutput()}`);
await new Promise(r => setTimeout(r, 100));
}
}

async function reloadWith(port, configPath, mutate) {
const edited = JSON.parse(await readFile(configPath, 'utf8'));
mutate(edited);
Expand All @@ -324,21 +272,19 @@ async function reloadWith(port, configPath, mutate) {
}

test('reload hot-applies an advisorEligibility edit', async () => {
const port = await closedPort();
const dir = await mkdtemp(join(tmpdir(), 'teamclaude-advisor-eligibility-'));
const configPath = join(dir, 'config.json');
await writeFile(configPath, JSON.stringify({
proxy: { port, apiKey: 'tc-test' },
// Nothing here sends a request upstream; a closed port makes sure of it.
upstream: `http://127.0.0.1:${await closedPort()}`,
upstreamProxy: false,
advisorEligibility: 'prefer',
accounts: [{ name: 'a@example.com', type: 'apikey', apiKey: 'k1' }],
}));

const server = startServer(configPath);
// Nothing here sends a request upstream; a closed port makes sure of it.
const deadPort = await closedPort();
const server = await spawnServer({
config: () => ({
proxy: { apiKey: 'tc-test' },
upstream: `http://127.0.0.1:${deadPort}`,
upstreamProxy: false,
advisorEligibility: 'prefer',
accounts: [{ name: 'a@example.com', type: 'apikey', apiKey: 'k1' }],
}),
});
const { port, configPath } = server;
try {
await waitForServer(port, server.output);
assert.equal((await statusOf(port)).advisorEligibility, 'prefer', 'read at startup');

await reloadWith(port, configPath, c => { c.advisorEligibility = 'strict'; });
Expand Down
13 changes: 1 addition & 12 deletions test/cli-login-api-atomic.test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import net from 'node:net';
import http from 'node:http';
import { spawn } from 'node:child_process';
import { mkdtemp, writeFile, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { closedPort } from '../test-helpers/spawn-server.js';

// `teamclaude login --api` loads the config, waits on the key prompt for as long
// as the user takes, and then saves. A running server may rotate an OAuth
Expand Down Expand Up @@ -39,17 +39,6 @@ async function writeConfig(config) {
return path;
}

// A port nothing is listening on: bind one, learn its number, give it back.
function closedPort() {
return new Promise(resolve => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
});
}

// A stand-in running server that records what the CLI posts to it.
async function fakeServer(t) {
const seen = [];
Expand Down
8 changes: 1 addition & 7 deletions test/cli-routing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { mkdtemp, writeFile, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { closedPort } from '../test-helpers/spawn-server.js';

// `teamclaude routing` and `login --routing` drive the real CLI as a subprocess
// against a throwaway TEAMCLAUDE_CONFIG, so the user's real config is never
Expand Down Expand Up @@ -203,13 +204,6 @@ function startSocks5(connects, { refuse = false } = {}) {
});
}

function closedPort() {
return new Promise((resolve) => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => { const { port } = probe.address(); probe.close(() => resolve(port)); });
});
}

function startOrigin(seen = []) {
return http.createServer((req, res) => {
seen.push(req.headers['x-api-key']);
Expand Down
Loading
Loading