chore: merge origin/dev into issue #1688

# Conflicts:
#	docs/reports/hardening-inventory.json
#	docs/reports/hardening-inventory.md
This commit is contained in:
Tam Nhu Tran committed 2026-08-08 22:29:46 -04:00
commit 5f9db033e7
48 files changed
+3932 -390

No files matched your search

@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
findResumeSessionLanes,
parseResumeFlagIntent,
resolveConfiguredPlainCcsResumeLane,
} from '../../../src/auth/resume-lane-diagnostics';
@@ -140,4 +141,158 @@ describe('resume lane diagnostics', () => {
expect(lane.kind).toBe('account-inherited');
expect(lane.accountName).toBe('work');
});
it('finds an explicit session in native and account resume lanes', () => {
const sessionId = '33333333-3333-4333-8333-333333333333';
writeConfig([
'version: 12',
'accounts:',
' work:',
' created: "2026-04-04T00:00:00.000Z"',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const nativeProject = path.join(tempHome, '.claude', 'projects', 'workspace');
const accountProject = path.join(
tempHome,
'.ccs',
'instances',
'work',
'projects',
'workspace'
);
fs.mkdirSync(nativeProject, { recursive: true });
fs.mkdirSync(accountProject, { recursive: true });
fs.writeFileSync(path.join(nativeProject, `${sessionId}.jsonl`), '{}\n');
fs.writeFileSync(path.join(accountProject, `${sessionId}.jsonl`), '{}\n');
expect(findResumeSessionLanes(sessionId)).toEqual([
{ kind: 'native', configDir: path.join(tempHome, '.claude') },
{
kind: 'account',
configDir: path.join(tempHome, '.ccs', 'instances', 'work'),
accountName: 'work',
},
]);
});
it('does not interpret resume search text as a session filename', () => {
expect(findResumeSessionLanes('../../settings')).toEqual([]);
});
it('does not follow a symlinked project directory outside the native lane', () => {
const sessionId = '77777777-7777-4777-8777-777777777777';
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const externalProject = path.join(tempHome, 'external-project');
const nativeProjects = path.join(tempHome, '.claude', 'projects');
fs.mkdirSync(externalProject, { recursive: true });
fs.mkdirSync(nativeProjects, { recursive: true });
fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), '{}\n');
fs.symlinkSync(externalProject, path.join(nativeProjects, 'linked-project'));
expect(findResumeSessionLanes(sessionId)).toEqual([]);
});
it('does not follow a symlinked projects root outside the native lane', () => {
const sessionId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const externalProject = path.join(tempHome, 'external-projects', 'workspace');
const nativeDir = path.join(tempHome, '.claude');
fs.mkdirSync(externalProject, { recursive: true });
fs.mkdirSync(nativeDir, { recursive: true });
fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), '{}\n');
fs.symlinkSync(path.dirname(externalProject), path.join(nativeDir, 'projects'));
expect(findResumeSessionLanes(sessionId)).toEqual([]);
});
it('does not follow a symlinked native config root', () => {
const sessionId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const externalProject = path.join(tempHome, 'external-claude', 'projects', 'workspace');
fs.mkdirSync(externalProject, { recursive: true });
fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), '{}\n');
fs.symlinkSync(path.join(tempHome, 'external-claude'), path.join(tempHome, '.claude'));
expect(findResumeSessionLanes(sessionId)).toEqual([]);
});
it('rejects a project parent swapped to an external symlink at the open boundary', () => {
const sessionId = 'ffffffff-ffff-4fff-8fff-ffffffffffff';
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const nativeProject = path.join(tempHome, '.claude', 'projects', 'workspace');
const displacedProject = path.join(tempHome, 'displaced-project');
const externalProject = path.join(tempHome, 'external-project');
fs.mkdirSync(nativeProject, { recursive: true });
fs.mkdirSync(externalProject, { recursive: true });
fs.writeFileSync(path.join(nativeProject, `${sessionId}.jsonl`), 'native\n');
fs.writeFileSync(path.join(externalProject, `${sessionId}.jsonl`), 'external\n');
let swapped = false;
expect(
findResumeSessionLanes(sessionId, {
beforeSessionOpen: () => {
if (swapped) return;
swapped = true;
fs.renameSync(nativeProject, displacedProject);
fs.symlinkSync(externalProject, nativeProject);
},
})
).toEqual([]);
});
it('does not follow a symlinked session file outside the native lane', () => {
const sessionId = '88888888-8888-4888-8888-888888888888';
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const nativeProject = path.join(tempHome, '.claude', 'projects', 'workspace');
const externalSession = path.join(tempHome, 'external-session.jsonl');
fs.mkdirSync(nativeProject, { recursive: true });
fs.writeFileSync(externalSession, '{}\n');
fs.symlinkSync(externalSession, path.join(nativeProject, `${sessionId}.jsonl`));
expect(findResumeSessionLanes(sessionId)).toEqual([]);
});
});
+298 -1
View File
@@ -1,4 +1,9 @@
import { describe, expect, it } from 'bun:test';
import { afterEach, describe, expect, it } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Shell } from '../../../src/codex-auth/shell-detect';
import { maybeWarnAboutResumeLaneMismatch } from '../../../src/auth/resume-lane-warning';
function stripAnsi(input: string): string {
@@ -6,6 +11,14 @@ function stripAnsi(input: string): string {
}
describe('resume lane warning', () => {
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
it('prints guidance when the plain ccs lane differs from the account lane', async () => {
const logs: string[] = [];
@@ -56,4 +69,288 @@ describe('resume lane warning', () => {
expect(logs[0]).toContain('Resume lane guidance skipped because diagnostics failed');
expect(logs[1]).toContain('Diagnostic error: broken config');
});
it('does not warn when an explicit session already exists in the target account lane', async () => {
const logs: string[] = [];
await maybeWarnAboutResumeLaneMismatch(
'work',
'/tmp/account-lane',
['--resume', '11111111-1111-4111-8111-111111111111'],
{
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'native',
label: 'native Claude lane',
configDir: '/tmp/native-lane',
projectCount: 12,
}),
findSessionLanes: () => [
{
kind: 'account',
configDir: '/tmp/account-lane',
accountName: 'work',
},
],
}
);
expect(logs).toHaveLength(0);
});
it('points an explicit session at its unique account lane', async () => {
const logs: string[] = [];
const sessionId = '22222222-2222-4222-8222-222222222222';
await maybeWarnAboutResumeLaneMismatch(
'personal',
'/tmp/personal-lane',
['--resume', sessionId],
{
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'native',
label: 'native Claude lane',
configDir: '/tmp/native-lane',
projectCount: 12,
}),
findSessionLanes: () => [
{
kind: 'account',
configDir: '/tmp/work-lane',
accountName: 'work',
},
],
}
);
const plainLogs = logs.map((message) => stripAnsi(message));
expect(plainLogs).toContain(
`[i] Resume from the lane that owns this session: ccs work --resume ${sessionId}`
);
expect(plainLogs).toContain(
'[i] Back up that lane before changing setup: ccs auth backup work'
);
expect(plainLogs).not.toContain('[i] Recover the original lane first: ccs -r');
expect(plainLogs).not.toContain(
'[i] For future work, align plain ccs with this account: ccs auth default personal'
);
});
it('lists ambiguous source lanes without choosing or merging one', async () => {
const logs: string[] = [];
const sessionId = '44444444-4444-4444-8444-444444444444';
await maybeWarnAboutResumeLaneMismatch(
'personal',
'/tmp/personal-lane',
['--resume', sessionId],
{
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'native',
label: 'native Claude lane',
configDir: '/tmp/native-lane',
projectCount: 12,
}),
findSessionLanes: () => [
{ kind: 'native', configDir: '/tmp/native-lane' },
{ kind: 'account', configDir: '/tmp/work-lane', accountName: 'work' },
],
}
);
const plainLogs = logs.map((message) => stripAnsi(message));
expect(plainLogs).toContain(
'[i] This session exists in multiple lanes; choose one without merging history:'
);
expect(plainLogs).toContain(`[i] ccs --resume ${sessionId}`);
expect(plainLogs).toContain(`[i] ccs work --resume ${sessionId}`);
expect(plainLogs).not.toContain('[i] Recover the original lane first: ccs -r');
});
it('uses explicit native Claude routing when plain ccs defaults to another account', async () => {
const logs: string[] = [];
const sessionId = '55555555-5555-4555-8555-555555555555';
await maybeWarnAboutResumeLaneMismatch(
'personal',
'/tmp/personal-lane',
['--resume', sessionId],
{
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'account-default',
label: 'plain ccs defaults to account "work"',
configDir: '/tmp/work-lane',
accountName: 'work',
projectCount: 12,
}),
shell: 'bash',
findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native-lane' }],
}
);
const plainLogs = logs.map((message) => stripAnsi(message));
expect(plainLogs).toContain(
`[i] Resume from the lane that owns this session: unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native-lane'; claude --resume ${sessionId}`
);
expect(plainLogs.some((message) => message.includes(`ccs --resume ${sessionId}`))).toBe(false);
});
it('routes to native Claude when the target account is also the plain ccs default', async () => {
const logs: string[] = [];
const sessionId = '66666666-6666-4666-8666-666666666666';
await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work-lane', ['--resume', sessionId], {
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'account-default',
label: 'plain ccs defaults to account "work"',
configDir: '/tmp/work-lane',
accountName: 'work',
projectCount: 12,
}),
shell: 'bash',
findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native-lane' }],
});
const plainLogs = logs.map((message) => stripAnsi(message));
expect(plainLogs).toContain(
`[i] Resume from the lane that owns this session: unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native-lane'; claude --resume ${sessionId}`
);
expect(plainLogs.some((message) => message.includes(`ccs --resume ${sessionId}`))).toBe(false);
});
it('uses explicit native routing for an ambient lane even when its path matches', async () => {
const logs: string[] = [];
const sessionId = '99999999-9999-4999-8999-999999999999';
await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work-lane', ['--resume', sessionId], {
log: (message) => logs.push(stripAnsi(message)),
shell: 'zsh',
resolvePlainLane: async () => ({
kind: 'ambient',
label: 'current shell CLAUDE_CONFIG_DIR',
configDir: '/tmp/native-lane',
projectCount: 1,
}),
findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native-lane' }],
});
expect(logs.some((message) => message.includes('export CLAUDE_CONFIG_DIR'))).toBe(true);
expect(logs.some((message) => message.includes(`ccs --resume ${sessionId}`))).toBe(false);
});
it.each([
[
'bash',
"unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native lane'; claude --resume",
],
[
'zsh',
"unset ANTHROPIC_BASE_URL; unset ANTHROPIC_AUTH_TOKEN; unset ANTHROPIC_API_KEY; export CLAUDE_CONFIG_DIR='/tmp/native lane'; claude --resume",
],
[
'fish',
"set -e ANTHROPIC_BASE_URL; set -e ANTHROPIC_AUTH_TOKEN; set -e ANTHROPIC_API_KEY; set -gx CLAUDE_CONFIG_DIR '/tmp/native lane'; claude --resume",
],
[
'pwsh',
'Remove-Item Env:ANTHROPIC_BASE_URL -ErrorAction SilentlyContinue; Remove-Item Env:ANTHROPIC_AUTH_TOKEN -ErrorAction SilentlyContinue; Remove-Item Env:ANTHROPIC_API_KEY -ErrorAction SilentlyContinue; $env:CLAUDE_CONFIG_DIR = "/tmp/native lane"; claude --resume',
],
[
'cmd',
'set "ANTHROPIC_BASE_URL=" && set "ANTHROPIC_AUTH_TOKEN=" && set "ANTHROPIC_API_KEY=" && set "CLAUDE_CONFIG_DIR=/tmp/native lane" && claude --resume',
],
] as Array<[Shell, string]>)(
'formats executable native recovery for %s',
async (shell, prefix) => {
const logs: string[] = [];
const sessionId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work-lane', ['--resume', sessionId], {
log: (message) => logs.push(stripAnsi(message)),
shell,
resolvePlainLane: async () => ({
kind: 'account-default',
label: 'plain ccs defaults to account "work"',
configDir: '/tmp/work-lane',
accountName: 'work',
projectCount: 1,
}),
findSessionLanes: () => [{ kind: 'native', configDir: '/tmp/native lane' }],
});
expect(logs.some((message) => message.includes(`${prefix} ${sessionId}`))).toBe(true);
}
);
it('does not emit executable guidance for an unsafe account name', async () => {
const logs: string[] = [];
const sessionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
await maybeWarnAboutResumeLaneMismatch('personal', '/tmp/personal', ['--resume', sessionId], {
log: (message) => logs.push(stripAnsi(message)),
shell: 'bash',
resolvePlainLane: async () => ({
kind: 'native',
label: 'native Claude lane',
configDir: '/tmp/native',
projectCount: 1,
}),
findSessionLanes: () => [
{ kind: 'account', configDir: '/tmp/unsafe', accountName: 'work; touch /tmp/pwned' },
],
});
expect(logs.some((message) => message.includes('unsafe account name'))).toBe(true);
expect(logs.some((message) => message.includes('ccs work;'))).toBe(false);
expect(logs.some((message) => message.includes('backup work;'))).toBe(false);
});
it('executes the printed bash route with poisoned provider routing removed', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-resume-command-'));
temporaryDirectories.push(tempDir);
const binDir = path.join(tempDir, 'bin');
const capturePath = path.join(tempDir, 'capture');
const nativeConfigDir = path.join(tempDir, 'native lane');
const sessionId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
fs.mkdirSync(binDir);
fs.mkdirSync(path.join(nativeConfigDir, 'projects', 'workspace'), { recursive: true });
fs.writeFileSync(
path.join(nativeConfigDir, 'projects', 'workspace', `${sessionId}.jsonl`),
'{}\n'
);
fs.writeFileSync(
path.join(binDir, 'claude'),
'#!/bin/sh\nsession_path="$CLAUDE_CONFIG_DIR/projects/workspace/$2.jsonl"\n[ -f "$session_path" ] || exit 9\nprintf "%s|%s|%s|%s|%s|found\\n" "$CLAUDE_CONFIG_DIR" "${ANTHROPIC_BASE_URL-unset}" "${ANTHROPIC_AUTH_TOKEN-unset}" "${ANTHROPIC_API_KEY-unset}" "$*" > "$CAPTURE_PATH"\n',
{ mode: 0o700 }
);
const logs: string[] = [];
await maybeWarnAboutResumeLaneMismatch('work', '/tmp/work', ['--resume', sessionId], {
log: (message) => logs.push(stripAnsi(message)),
shell: 'bash',
resolvePlainLane: async () => ({
kind: 'account-default',
label: 'plain ccs defaults to account "work"',
configDir: '/tmp/work',
accountName: 'work',
projectCount: 1,
}),
findSessionLanes: () => [{ kind: 'native', configDir: nativeConfigDir }],
});
const guidance = logs.find((message) => message.includes('Resume from the lane'))!;
const command = guidance.slice(guidance.indexOf(': ') + 2);
const result = spawnSync('/bin/bash', ['-c', command], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
CAPTURE_PATH: capturePath,
ANTHROPIC_BASE_URL: 'https://poison.invalid',
ANTHROPIC_AUTH_TOKEN: 'poison-token',
ANTHROPIC_API_KEY: 'poison-key',
},
});
expect(result.status).toBe(0);
expect(fs.readFileSync(capturePath, 'utf8').trim()).toBe(
`${nativeConfigDir}|unset|unset|unset|--resume ${sessionId}|found`
);
});
});
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test';
import { detectShell, formatExport } from '../../../src/codex-auth/shell-detect';
import { detectShell, formatExport, formatUnset } from '../../../src/codex-auth/shell-detect';
import type { Shell } from '../../../src/codex-auth/shell-detect';
// ── detectShell ───────────────────────────────────────────────────────────────
@@ -146,6 +146,18 @@ describe('formatExport — cmd', () => {
});
});
describe('formatUnset', () => {
it('formats environment removal for every supported shell', () => {
expect(formatUnset('bash', 'ANTHROPIC_BASE_URL')).toBe('unset ANTHROPIC_BASE_URL');
expect(formatUnset('zsh', 'ANTHROPIC_BASE_URL')).toBe('unset ANTHROPIC_BASE_URL');
expect(formatUnset('fish', 'ANTHROPIC_BASE_URL')).toBe('set -e ANTHROPIC_BASE_URL;');
expect(formatUnset('pwsh', 'ANTHROPIC_BASE_URL')).toBe(
'Remove-Item Env:ANTHROPIC_BASE_URL -ErrorAction SilentlyContinue'
);
expect(formatUnset('cmd', 'ANTHROPIC_BASE_URL')).toBe('set "ANTHROPIC_BASE_URL="');
});
});
describe('formatExport — each shell produces distinct syntax', () => {
const shells: Shell[] = ['bash', 'zsh', 'fish', 'pwsh', 'cmd'];
it('all shells produce different output for same input', () => {
+378 -10
View File
@@ -169,6 +169,13 @@ describe('bar command dispatcher (index.ts)', () => {
expect(calls).toEqual(['launch:']);
});
it('rejects an unknown bare launch flag without dispatching launch', async () => {
const handleBarCommand = await loadHandleBarCommand();
await handleBarCommand(['--porrt', '3999']);
expect(calls).toEqual([]);
expect(process.exitCode).toBe(1);
});
it('dispatches `ccs bar install` to install subcommand', async () => {
const handleBarCommand = await loadHandleBarCommand();
await handleBarCommand(['install']);
@@ -3040,7 +3047,7 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired
// parser correctly sets authRequired without relying on the higher-level dep
// injection that existing tests use (which bypasses real status-line parsing).
function makeNetMock(statusLine: string) {
function makeNetMock(statusLine: string, token: string) {
return {
connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
const listeners: Record<string, Array<(arg?: unknown) => void>> = {};
@@ -3054,19 +3061,25 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired
void cb;
return socket;
},
write() {
write(request: string) {
const nonce =
request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ??
'';
const proof = createBarAuthProof(token, nonce);
setImmediate(() => {
for (const cb of listeners.data ?? []) {
cb(
Buffer.from(`${statusLine}\r\n${BAR_AUTH_TOKEN_HEADER}: ${proof}\r\n\r\n`, 'utf8')
);
}
});
return true;
},
destroy() {
return socket;
},
};
setImmediate(() => {
onConnect();
for (const cb of listeners.data ?? []) {
cb(Buffer.from(`${statusLine}\r\n\r\n`, 'utf8'));
}
});
setImmediate(onConnect);
return socket;
},
};
@@ -3081,7 +3094,8 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired
JSON.stringify({ port: 41401, baseUrl: 'http://127.0.0.1:41401', authMode: 'loopback' })
);
mock.module('net', () => makeNetMock('HTTP/1.1 401 Unauthorized'));
const token = getOrCreateBarAuthToken(ccsDir);
mock.module('net', () => makeNetMock('HTTP/1.1 401 Unauthorized', token));
moduleSeq++;
const { defaultFindRunningServer } = (await import(
@@ -3111,7 +3125,8 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired
JSON.stringify({ port: 41403, baseUrl: 'http://127.0.0.1:41403', authMode: 'loopback' })
);
mock.module('net', () => makeNetMock('HTTP/1.1 403 Forbidden'));
const token = getOrCreateBarAuthToken(ccsDir);
mock.module('net', () => makeNetMock('HTTP/1.1 403 Forbidden', token));
moduleSeq++;
const { defaultFindRunningServer } = (await import(
@@ -3302,6 +3317,30 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected
expect(result === 'timeout' || result === 'rejected').toBe(true);
});
it('unrelated 401/403 without a CCS proof does not trigger auth-required identity', async () => {
for (const status of [401, 403]) {
mock.module('net', () =>
buildNetMock(`HTTP/1.1 ${status} Unrelated Service\r\nConnection: close\r\n\r\n`)
);
moduleSeq++;
const { defaultWaitForServerLive } = (await import(
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
)) as {
defaultWaitForServerLive: (baseUrl: string) => Promise<void>;
};
const result = await Promise.race([
defaultWaitForServerLive(`http://127.0.0.1:${9900 + status}`).then(
() => 'resolved' as const,
() => 'rejected' as const
),
new Promise<'still-probing'>((resolve) => setTimeout(() => resolve('still-probing'), 300)),
]);
expect(result).toBe('still-probing');
mock.restore();
}
});
it('correct-token 200 resolves defaultWaitForServerLive immediately', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
@@ -3643,3 +3682,332 @@ describe('bar install: --await-quit waits for the running app to quit (GH-1588)'
expect(probes).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------------------
// --port support: `ccs bar [launch] --port N` (user-selectable dashboard port)
// ---------------------------------------------------------------------------
describe('bar dispatcher: bare flags route to launch', () => {
beforeEach(() => {
mock.module('../../../src/commands/bar/launch-subcommand', () => ({
handleBarLaunch: async (args: string[]) => {
calls.push(`launch:${args.join(' ')}`);
},
}));
});
it('dispatches `ccs bar --port 3999` to launch with the flag preserved', async () => {
const handleBarCommand = await loadHandleBarCommand();
await handleBarCommand(['--port', '3999']);
expect(calls).toEqual(['launch:--port 3999']);
});
it('dispatches `ccs bar launch --port 3999` to launch with the flag preserved', async () => {
const handleBarCommand = await loadHandleBarCommand();
await handleBarCommand(['launch', '--port', '3999']);
expect(calls).toEqual(['launch:--port 3999']);
});
});
describe('launch: --port selects the server port', () => {
function makePortDeps(ccsDir: string) {
const seen: {
spawnPort: number | null;
getPortCandidates: number[] | null;
stopped: boolean;
descriptorPort: number | null;
} = { spawnPort: null, getPortCandidates: null, stopped: false, descriptorPort: null };
const deps = {
findRunningServer: async () => null,
getPort: async (opts: { port: number[]; host: string }) => {
seen.getPortCandidates = opts.port;
return opts.port[0];
},
spawnDetachedServer: (p: number) => {
seen.spawnPort = p;
},
waitForServerLive: async () => {},
createLaunchDescriptor: (opts?: { port?: number }) => {
seen.descriptorPort = opts?.port ?? null;
return {
schema: 1 as const,
runtime: '/usr/bin/node',
args: ['/x/ccs.js', 'bar', 'serve'],
home: '/h',
};
},
writeLaunchDescriptor: () => {},
stopDetachedServer: () => {
seen.stopped = true;
},
openApp: async () => {},
getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
};
return { deps, seen };
}
it('spawns the detached server on the requested port and records it in bar.json', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps, seen } = makePortDeps(ccsDir);
await handleBarLaunch(['--port', '3999'], deps);
expect(seen.spawnPort).toBe(3999);
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
port: number;
baseUrl: string;
};
expect(barJson.port).toBe(3999);
expect(barJson.baseUrl).toBe('http://127.0.0.1:3999');
});
it('persists the chosen port into the launch descriptor for app self-start', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps, seen } = makePortDeps(ccsDir);
await handleBarLaunch(['--port', '3999'], deps);
expect(seen.descriptorPort).toBe(3999);
});
it('errors without spawning when the requested port is busy', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps, seen } = makePortDeps(ccsDir);
deps.getPort = async () => 4001; // get-port fell back: 3999 not free
await handleBarLaunch(['--port', '3999'], deps);
expect(seen.spawnPort).toBeNull();
const allOutput = consoleOutput.join('\n');
expect(allOutput).toMatch(/3999/);
expect(allOutput.toLowerCase()).toMatch(/in use|busy|not free|unavailable/);
});
it('errors on an invalid --port value', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps, seen } = makePortDeps(ccsDir);
await handleBarLaunch(['--port', 'banana'], deps);
expect(seen.spawnPort).toBeNull();
expect(consoleOutput.join('\n').toLowerCase()).toMatch(/invalid.*port|port.*invalid/);
});
it('reuses a running server already on the requested port', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps, seen } = makePortDeps(ccsDir);
deps.findRunningServer = async () => ({ port: 3999, baseUrl: 'http://127.0.0.1:3999' });
await handleBarLaunch(['--port', '3999'], deps);
expect(seen.spawnPort).toBeNull(); // reuse, no new spawn
expect(seen.stopped).toBe(false);
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
port: number;
};
expect(barJson.port).toBe(3999);
});
it('stops a running server on a different port, then starts on the requested one', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps, seen } = makePortDeps(ccsDir);
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
await handleBarLaunch(['--port', '3999'], deps);
expect(seen.stopped).toBe(true);
expect(seen.spawnPort).toBe(3999);
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
port: number;
};
expect(barJson.port).toBe(3999);
});
it('preflights the destination before stopping a healthy running server', async () => {
const ccsDir = path.join(tempHome, '.ccs');
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps } = makePortDeps(ccsDir);
const events: string[] = [];
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
deps.getPort = async () => {
events.push('preflight');
return 3999;
};
deps.stopDetachedServer = () => {
events.push('stop');
};
await handleBarLaunch(['--port', '3999'], deps);
expect(events).toEqual(['preflight', 'stop']);
});
it('leaves the healthy server running when destination preflight is busy', async () => {
const ccsDir = path.join(tempHome, '.ccs');
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps, seen } = makePortDeps(ccsDir);
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
deps.getPort = async () => 4000;
await handleBarLaunch(['--port', '3999'], deps);
expect(seen.stopped).toBe(false);
expect(seen.spawnPort).toBeNull();
});
it('aborts the move when the verified stop fails', async () => {
const ccsDir = path.join(tempHome, '.ccs');
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps, seen } = makePortDeps(ccsDir);
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
deps.stopDetachedServer = () => {
throw new Error('identity mismatch');
};
await handleBarLaunch(['--port', '3999'], deps);
expect(seen.spawnPort).toBeNull();
});
it('restores the prior server when a check-to-bind race breaks the replacement', async () => {
const ccsDir = path.join(tempHome, '.ccs');
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps } = makePortDeps(ccsDir);
const spawnedPorts: number[] = [];
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
deps.spawnDetachedServer = (port: number) => {
spawnedPorts.push(port);
};
deps.waitForServerLive = async (baseUrl: string) => {
if (baseUrl.endsWith(':3999')) throw new Error('EADDRINUSE after preflight');
};
await handleBarLaunch(['--port', '3999'], deps);
expect(spawnedPorts).toEqual([3999, 3000]);
});
it('restores the prior server when replacement spawn fails', async () => {
const ccsDir = path.join(tempHome, '.ccs');
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps } = makePortDeps(ccsDir);
const spawnedPorts: number[] = [];
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
deps.spawnDetachedServer = (port: number) => {
spawnedPorts.push(port);
if (port === 3999) throw new Error('spawn failed');
};
await handleBarLaunch(['--port', '3999'], deps);
expect(spawnedPorts).toEqual([3999, 3000]);
});
it('preserves prior discovery state when replacement and rollback both fail', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
const oldBarJson = JSON.stringify({
baseUrl: 'http://127.0.0.1:3000',
port: 3000,
authMode: 'loopback',
});
const oldLaunchJson = JSON.stringify({ args: ['ccs.js', 'bar', 'serve', '--port', '3000'] });
fs.writeFileSync(path.join(ccsDir, 'bar.json'), oldBarJson);
fs.writeFileSync(path.join(ccsDir, 'bar', 'launch.json'), oldLaunchJson);
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps } = makePortDeps(ccsDir);
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
deps.spawnDetachedServer = () => {
throw new Error('spawn failed');
};
await handleBarLaunch(['--port', '3999'], deps);
expect(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')).toBe(oldBarJson);
expect(fs.readFileSync(path.join(ccsDir, 'bar', 'launch.json'), 'utf8')).toBe(oldLaunchJson);
});
it('without --port, prefers the port recorded in bar.json (sticky port)', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'bar.json'),
JSON.stringify({ baseUrl: 'http://127.0.0.1:3777', port: 3777, authMode: 'loopback' })
);
const { handleBarLaunch } = await loadLaunchSubcommand();
const { deps, seen } = makePortDeps(ccsDir);
await handleBarLaunch([], deps);
expect(seen.getPortCandidates?.[0]).toBe(3777);
expect(seen.spawnPort).toBe(3777);
});
});
describe('resolveBarPort: launch.json fallback survives `ccs bar stop`', () => {
// `ccs bar stop` deletes bar.json, so bar.json alone cannot carry the sticky
// port across a stop/start cycle. launch.json (refreshed by launch, NOT
// deleted by stop) records the port in its args and acts as the fallback.
it('falls back to the --port recorded in launch.json when bar.json is absent', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'bar', 'launch.json'),
JSON.stringify({
schema: 1,
runtime: '/usr/bin/node',
args: ['/x/ccs.js', 'bar', 'serve', '--port', '3456'],
home: tempHome,
})
);
const { resolveBarPort } = await loadLaunchSubcommand();
expect(resolveBarPort(ccsDir)).toBe(3456);
});
it('bar.json port wins over launch.json when both exist', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'bar.json'),
JSON.stringify({ baseUrl: 'http://127.0.0.1:4000', port: 4000, authMode: 'loopback' })
);
fs.writeFileSync(
path.join(ccsDir, 'bar', 'launch.json'),
JSON.stringify({
schema: 1,
runtime: '/usr/bin/node',
args: ['/x/ccs.js', 'bar', 'serve', '--port', '3456'],
home: tempHome,
})
);
const { resolveBarPort } = await loadLaunchSubcommand();
expect(resolveBarPort(ccsDir)).toBe(4000);
});
it('returns null when launch.json has no --port and bar.json is absent', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'bar', 'launch.json'),
JSON.stringify({
schema: 1,
runtime: '/usr/bin/node',
args: ['/x/ccs.js', 'bar', 'serve'],
home: tempHome,
})
);
const { resolveBarPort } = await loadLaunchSubcommand();
expect(resolveBarPort(ccsDir)).toBeNull();
});
});
@@ -0,0 +1,307 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
import * as path from 'path';
import { spawn } from 'child_process';
import {
defaultFindRunningServer,
resolveBarPort,
} from '../../../src/commands/bar/bar-server-probe';
import {
serializeBarServerProcessRecord,
getProcessBirthIdentity,
removeBarDiscoveryIfNoProcess,
removeBarServerProcessRecordIfOwned,
stopDetachedBarServer,
stopBarServerProcessFile,
stopRecordedBarServer,
} from '../../../src/commands/bar/bar-process-control';
import { parsePortFlag, validatePortArgs } from '../../../src/commands/bar/port-arg';
import { handleBarServe } from '../../../src/commands/bar/serve-subcommand';
import { handleBarStop } from '../../../src/commands/bar/stop-subcommand';
let tempHome: string;
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
const liveChildren = new Set<ReturnType<typeof spawn>>();
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-lifecycle-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
process.env.HOME = tempHome;
process.env.CCS_HOME = path.join(tempHome, '.ccs');
process.exitCode = 0;
});
afterEach(() => {
for (const child of liveChildren) {
try {
child.kill('SIGKILL');
} catch {
// Already exited.
}
}
liveChildren.clear();
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalCcsHome === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = originalCcsHome;
process.exitCode = 0;
fs.rmSync(tempHome, { recursive: true, force: true });
});
describe('strict Bar port parsing', () => {
it('rejects numeric prefixes, fractions, signs, whitespace, and out-of-range values', () => {
for (const raw of ['3999junk', '3.5', '+3999', ' 3999', '0', '65536']) {
expect(parsePortFlag(['--port', raw])).toEqual({ present: true, port: null });
}
expect(parsePortFlag(['--port', '3999'])).toEqual({ present: true, port: 3999 });
});
it('rejects unknown and duplicate launch options', () => {
expect(validatePortArgs(['--porrt', '3999'])).toBe('Unknown option: --porrt');
expect(validatePortArgs(['--port', '3999', '--port', '4000'])).toBe('Duplicate option: --port');
});
it('rejects malformed persisted ports in both discovery files', () => {
const ccsDir = process.env.CCS_HOME!;
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify({ port: 3999.5 }));
fs.writeFileSync(
path.join(ccsDir, 'bar', 'launch.json'),
JSON.stringify({ args: ['ccs.js', 'bar', 'serve', '--port', '4555junk'] })
);
expect(resolveBarPort(ccsDir)).toBeNull();
});
});
describe('verified Bar process stopping', () => {
const rawRecord = serializeBarServerProcessRecord({ pid: 4321, birthIdentity: 'birth-a' });
it('does not signal when the PID birth identity changed', async () => {
let signaled = false;
const outcome = await stopRecordedBarServer(rawRecord, {
getProcessBirthIdentity: () => 'birth-b',
killProcess: () => {
signaled = true;
},
});
expect(outcome.result).toBe('identity-mismatch');
expect(signaled).toBe(false);
});
it('preserves server.pid and bar.json on mismatch, EPERM, and timeout', async () => {
for (const failure of [
'identity-mismatch',
'permission-denied',
'signal-failed',
'timeout',
] as const) {
const ccsDir = path.join(tempHome, failure);
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
const barJsonPath = path.join(ccsDir, 'bar.json');
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
fs.writeFileSync(pidPath, rawRecord);
fs.writeFileSync(barJsonPath, '{}');
await handleBarStop([], {
getCcsDir: () => ccsDir,
getProcessBirthIdentity: () => (failure === 'identity-mismatch' ? 'birth-b' : 'birth-a'),
killProcess: () => {
if (failure === 'permission-denied') {
const err = new Error('not permitted') as NodeJS.ErrnoException;
err.code = 'EPERM';
throw err;
}
if (failure === 'signal-failed') throw new Error('signal transport failed');
},
waitForProcessExit: async () => (failure === 'timeout' ? 'timeout' : 'exited'),
});
expect(fs.existsSync(pidPath)).toBe(true);
expect(fs.existsSync(barJsonPath)).toBe(true);
expect(process.exitCode).toBe(1);
process.exitCode = 0;
}
});
it('never signals a legacy integer PID and gives manual recovery guidance', async () => {
const ccsDir = path.join(tempHome, 'legacy');
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
fs.writeFileSync(pidPath, '4321');
let signaled = false;
await handleBarStop([], {
getCcsDir: () => ccsDir,
killProcess: () => {
signaled = true;
},
});
expect(signaled).toBe(false);
expect(fs.existsSync(pidPath)).toBe(true);
expect(process.exitCode).toBe(1);
});
it('atomically preserves a replacement record written while the old process stops', async () => {
const pidPath = path.join(tempHome, 'race', 'server.pid');
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
fs.writeFileSync(pidPath, rawRecord);
const replacement = serializeBarServerProcessRecord({
pid: 9876,
birthIdentity: 'replacement-birth',
});
const outcome = await stopBarServerProcessFile(pidPath, {
getProcessBirthIdentity: () => 'birth-a',
killProcess: () => fs.writeFileSync(pidPath, replacement),
waitForProcessExit: async () => 'exited',
});
expect(outcome.result).toBe('stopped');
expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement);
});
it('does not let an old serve cleanup unlink a replacement record', () => {
const pidPath = path.join(tempHome, 'serve-race', 'server.pid');
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
const replacement = serializeBarServerProcessRecord({
pid: 9876,
birthIdentity: 'replacement-birth',
});
fs.writeFileSync(pidPath, replacement);
removeBarServerProcessRecordIfOwned(pidPath, { pid: 4321, birthIdentity: 'birth-a' });
expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement);
});
it('stops a real recorded process through the claimed process file', async () => {
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
stdio: 'ignore',
});
liveChildren.add(child);
const birthIdentity = await waitForBirthIdentity(child.pid!);
const pidPath = path.join(tempHome, 'real-stop', 'server.pid');
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
fs.writeFileSync(pidPath, serializeBarServerProcessRecord({ pid: child.pid!, birthIdentity }));
const outcome = await stopBarServerProcessFile(pidPath);
expect(outcome.result).toBe('stopped');
expect(fs.existsSync(pidPath)).toBe(false);
liveChildren.delete(child);
});
it('stops a real recorded process through the launch-move stop path', async () => {
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
stdio: 'ignore',
});
liveChildren.add(child);
const birthIdentity = await waitForBirthIdentity(child.pid!);
const ccsDir = path.join(tempHome, 'real-move');
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
fs.writeFileSync(pidPath, serializeBarServerProcessRecord({ pid: child.pid!, birthIdentity }));
await stopDetachedBarServer(ccsDir);
expect(fs.existsSync(pidPath)).toBe(false);
liveChildren.delete(child);
});
});
describe('Bar serve publication ownership', () => {
it('publishes server.pid before bar.json so old-stop cleanup preserves replacement discovery', async () => {
const ccsDir = path.join(tempHome, 'serve-publication');
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
const barJsonPath = path.join(ccsDir, 'bar.json');
const publicationOrder: string[] = [];
let oldStopRemovedDiscovery: boolean | null = null;
await handleBarServe(['--port', '4555'], {
getCcsDir: () => ccsDir,
findRunningServer: async () => null,
getPort: async () => 4555,
startServer: async () => ({ port: 4555, baseUrl: 'http://127.0.0.1:4555' }),
getProcessBirthIdentity: () => 'replacement-birth',
writeFile: (filePath, content) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
publicationOrder.push(filePath);
if (filePath === barJsonPath) {
oldStopRemovedDiscovery = removeBarDiscoveryIfNoProcess(barJsonPath, pidPath);
}
},
onSignal: () => {},
exit: (code) => {
throw new Error(`unexpected exit ${code}`);
},
});
expect(publicationOrder).toEqual([pidPath, barJsonPath]);
expect(oldStopRemovedDiscovery).toBe(false);
expect(fs.existsSync(pidPath)).toBe(true);
expect(JSON.parse(fs.readFileSync(barJsonPath, 'utf8')).port).toBe(4555);
});
it('conditionally rolls back its owned process record when discovery publication fails', async () => {
const ccsDir = path.join(tempHome, 'serve-rollback');
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
const barJsonPath = path.join(ccsDir, 'bar.json');
await expect(
handleBarServe(['--port', '4555'], {
getCcsDir: () => ccsDir,
findRunningServer: async () => null,
getPort: async () => 4555,
startServer: async () => ({ port: 4555, baseUrl: 'http://127.0.0.1:4555' }),
getProcessBirthIdentity: () => 'replacement-birth',
writeFile: (filePath, content) => {
if (filePath === barJsonPath) throw new Error('discovery write failed');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
},
onSignal: () => {},
exit: (code) => {
throw new Error(`exit ${code}`);
},
})
).rejects.toThrow('exit 1');
expect(fs.existsSync(pidPath)).toBe(false);
});
});
async function waitForBirthIdentity(pid: number): Promise<string> {
for (let attempt = 0; attempt < 50; attempt += 1) {
const identity = getProcessBirthIdentity(pid);
if (identity !== null) return identity;
await new Promise<void>((resolve) => setTimeout(resolve, 20));
}
throw new Error(`Process ${pid} never became observable`);
}
describe('Bar server identity probe', () => {
it('ignores unrelated services returning 401 or 403 without a CCS proof', async () => {
for (const status of [401, 403]) {
const server = http.createServer((_req, res) => {
res.writeHead(status);
res.end();
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as { port: number }).port;
const ccsDir = path.join(tempHome, `status-${status}`);
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify({ port }));
try {
const result = await defaultFindRunningServer(ccsDir);
expect(result?.port).not.toBe(port);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
});
});
@@ -15,6 +15,10 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
function processRecord(pid: number, birthIdentity = 'test-birth'): string {
return JSON.stringify({ pid, birthIdentity }, null, 2);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -24,6 +28,7 @@ let tempHome: string;
let originalCcsHome: string | undefined;
let originalConsoleLog: typeof console.log;
let originalConsoleError: typeof console.error;
let originalExitCode: number | undefined;
function captureConsole(): void {
originalConsoleLog = console.log;
@@ -113,6 +118,8 @@ beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-lifecycle-test-'));
originalCcsHome = process.env.CCS_HOME;
originalExitCode = process.exitCode;
process.exitCode = 0;
process.env.CCS_HOME = tempHome;
});
@@ -124,6 +131,7 @@ afterEach(() => {
} else {
process.env.CCS_HOME = originalCcsHome;
}
process.exitCode = originalExitCode ?? 0;
try {
fs.rmSync(tempHome, { recursive: true, force: true });
@@ -214,6 +222,7 @@ describe('serve: start new server', () => {
exit: (code: number) => {
throw new Error(`__EXIT_${code}__`);
},
getProcessBirthIdentity: () => 'test-birth',
});
// bar.json must be written
@@ -226,7 +235,10 @@ describe('serve: start new server', () => {
// server.pid must be written
const pidPath = path.join(ccsDir, 'bar', 'server.pid');
expect(writtenFiles[pidPath]).toBeDefined();
expect(writtenFiles[pidPath]).toBe(String(process.pid));
expect(JSON.parse(writtenFiles[pidPath])).toEqual({
pid: process.pid,
birthIdentity: 'test-birth',
});
// Both SIGINT and SIGTERM handlers registered
expect(signals).toContain('SIGINT');
@@ -309,7 +321,7 @@ describe('stop: SIGTERM and cleanup', () => {
const ccsDir = path.join(tempHome, '.ccs');
const barDir = path.join(ccsDir, 'bar');
fs.mkdirSync(barDir, { recursive: true });
fs.writeFileSync(path.join(barDir, 'server.pid'), '12345');
fs.writeFileSync(path.join(barDir, 'server.pid'), processRecord(12345));
fs.writeFileSync(path.join(ccsDir, 'bar.json'), '{"baseUrl":"http://127.0.0.1:3000"}');
const killed: Array<{ pid: number; signal: string }> = [];
@@ -329,6 +341,8 @@ describe('stop: SIGTERM and cleanup', () => {
killProcess: (pid: number, signal: string) => {
killed.push({ pid, signal });
},
getProcessBirthIdentity: () => 'test-birth',
waitForProcessExit: async () => 'exited',
removeFile: (filePath: string) => {
removed.push(filePath);
},
@@ -338,7 +352,7 @@ describe('stop: SIGTERM and cleanup', () => {
// Both pid and bar.json must be removed
expect(removed.some((p) => p.includes('server.pid'))).toBe(true);
expect(removed.some((p) => p.includes('bar.json'))).toBe(true);
expect(allOutput()).toMatch(/\[OK\].*SIGTERM/i);
expect(allOutput()).toMatch(/\[OK\].*stopped/i);
});
it('prints guidance and returns cleanly when no server.pid exists', async () => {
@@ -370,7 +384,8 @@ describe('stop: SIGTERM and cleanup', () => {
await handleBarStop([], {
getCcsDir: () => ccsDir,
readPidFile: () => '99999',
readPidFile: () => processRecord(99999),
getProcessBirthIdentity: () => null,
killProcess: () => {
const err = new Error('no such process') as NodeJS.ErrnoException;
err.code = 'ESRCH';
@@ -402,9 +417,9 @@ describe('stop: SIGTERM and cleanup', () => {
},
});
expect(allOutput()).toMatch(/\[X\].*invalid/i);
// Corrupted pid file must be cleaned up
expect(removed.some((p) => p.includes('server.pid'))).toBe(true);
expect(allOutput()).toMatch(/\[X\].*verified process record/i);
// Unverified state is preserved rather than risking removal of recovery evidence.
expect(removed.some((p) => p.includes('server.pid'))).toBe(false);
});
});
@@ -419,7 +434,7 @@ describe('status: running state reporting', () => {
await handleBarStatus([], {
getCcsDir: () => ccsDir,
readPidFile: () => '12345',
readPidFile: () => processRecord(12345),
isProcessAlive: () => true,
probeServer: async () => true,
readBarJsonBaseUrl: () => 'http://127.0.0.1:3000',
@@ -451,7 +466,7 @@ describe('status: running state reporting', () => {
await handleBarStatus([], {
getCcsDir: () => ccsDir,
readPidFile: () => '99999',
readPidFile: () => processRecord(99999),
isProcessAlive: () => false,
probeServer: async () => false,
readBarJsonBaseUrl: () => null,
@@ -461,13 +476,34 @@ describe('status: running state reporting', () => {
expect(allOutput()).toMatch(/99999/);
});
it('reports legacy integer PIDs as unverified and does not inspect their liveness', async () => {
const ccsDir = path.join(tempHome, '.ccs');
const { handleBarStatus } = await loadStatusSubcommand();
let inspected = false;
await handleBarStatus([], {
getCcsDir: () => ccsDir,
readPidFile: () => '12345',
isProcessAlive: () => {
inspected = true;
return true;
},
probeServer: async () => true,
readBarJsonBaseUrl: () => null,
});
expect(inspected).toBe(false);
expect(allOutput()).toMatch(/legacy.*unverified PID 12345/i);
expect(allOutput()).toMatch(/ps -p 12345 -o command=/);
});
it('reports alive-but-unreachable when PID is alive but HTTP probe fails', async () => {
const ccsDir = path.join(tempHome, '.ccs');
const { handleBarStatus } = await loadStatusSubcommand();
await handleBarStatus([], {
getCcsDir: () => ccsDir,
readPidFile: () => '12345',
readPidFile: () => processRecord(12345),
isProcessAlive: () => true,
probeServer: async () => false,
readBarJsonBaseUrl: () => 'http://127.0.0.1:3000',
+74 -4
View File
@@ -9,8 +9,13 @@ import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-rou
import {
attachDisconnectAbortHandlers,
buildUpstreamAgentTimeouts,
handleProxyMessagesRequest,
handleProxyMessagesRequest as handleProxyMessagesRequestImpl,
} from '../../../src/proxy/server/messages-route';
import {
closeUpstreamDispatcher,
isBunRuntime,
toLogErrorInfo,
} from '../../../src/proxy/server/upstream-transport';
import { loadSettings } from '../../../src/utils/config-manager';
class FakeSocket extends EventEmitter {
@@ -70,6 +75,24 @@ function buildProfile(profileName: string) {
return profile!;
}
function handleProxyMessagesRequest(
req: Parameters<typeof handleProxyMessagesRequestImpl>[0],
res: Parameters<typeof handleProxyMessagesRequestImpl>[1],
profile: Parameters<typeof handleProxyMessagesRequestImpl>[2],
expectedAuthToken: string,
insecureDispatcher?: Parameters<typeof handleProxyMessagesRequestImpl>[4]
) {
return handleProxyMessagesRequestImpl(
req,
res,
profile,
expectedAuthToken,
insecureDispatcher,
undefined,
globalThis.fetch
);
}
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
originalFetch = globalThis.fetch;
@@ -191,6 +214,45 @@ describe('buildUpstreamAgentTimeouts', () => {
});
});
describe('closeUpstreamDispatcher', () => {
it('closes an owned dispatcher once across repeated cleanup', async () => {
let closeCalls = 0;
const dispatcher = {
close: async () => {
closeCalls += 1;
},
} as never;
await Promise.all([
closeUpstreamDispatcher(dispatcher),
closeUpstreamDispatcher(dispatcher),
closeUpstreamDispatcher(dispatcher),
]);
expect(closeCalls).toBe(1);
});
});
describe('toLogErrorInfo', () => {
it('preserves nested transport cause metadata for diagnostics', () => {
const cause = Object.assign(new Error('invalid onError method'), {
name: 'InvalidArgumentError',
code: 'UND_ERR_INVALID_ARG',
});
const error = new TypeError('fetch failed', { cause });
expect(toLogErrorInfo(error)).toEqual({
name: 'TypeError',
message: 'fetch failed',
cause: {
name: 'InvalidArgumentError',
message: 'invalid onError method',
code: 'UND_ERR_INVALID_ARG',
},
});
});
});
describe('handleProxyMessagesRequest', () => {
it('dispatches secure upstream fetches with an explicit dispatcher (not undici defaults)', async () => {
const activeProfile = buildProfile('hf');
@@ -217,7 +279,11 @@ describe('handleProxyMessagesRequest', () => {
// A bare global-dispatcher fallback would reapply undici's 300s
// headersTimeout/bodyTimeout and undercut the proxy's request timeout.
expect(capturedDispatcher).toBeDefined();
if (isBunRuntime()) {
expect(capturedDispatcher).toBeUndefined();
} else {
expect(capturedDispatcher).toBeDefined();
}
expect(res.statusCode).toBe(502);
});
@@ -403,9 +469,13 @@ describe('handleProxyMessagesRequest', () => {
);
await pending;
expect(capturedDispatcher).toBeInstanceOf(Agent);
if (isBunRuntime()) {
expect(capturedDispatcher).toBeUndefined();
} else {
expect(capturedDispatcher).toBeInstanceOf(Agent);
}
expect(capturedDispatcher).not.toBe(sharedDispatcher);
expect(closeCalls).toBe(1);
expect(closeCalls).toBe(isBunRuntime() ? 0 : 1);
expect(res.statusCode).toBe(502);
});
@@ -321,4 +321,100 @@ describe('ProxyRequestTransformer regressions', () => {
expect(result.tool_choice).toBe('auto');
});
it('merges the top-level system field with a mid-array system message into one leading system message', () => {
// Claude Code sends the main system prompt via the top-level `system`
// field AND a skill/plugin listing as a `role: "system"` message inside
// `messages` (see #1459). Prepending the top-level field unconditionally
// used to leave two non-adjacent `system` messages in the payload, which
// strict OpenAI-compatible backends (LiteLLM among them) reject with:
// `400 A 'system' message can only appear at index 0 of the messages array.`
const result = new ProxyRequestTransformer().transform({
system: [{ type: 'text', text: 'You are Claude Code, a CLI tool.' }],
messages: [
{ role: 'user', content: 'ping' },
{
role: 'system',
content: 'The following skills are available for use with the Skill tool:\n- foo',
},
{ role: 'user', content: 'pong' },
],
});
const systemMessages = result.messages.filter((message) => message.role === 'system');
expect(systemMessages).toHaveLength(1);
expect(result.messages[0]).toEqual({
role: 'system',
content:
'You are Claude Code, a CLI tool.\n\nThe following skills are available for use with the Skill tool:\n- foo',
});
expect(result.messages[1]).toEqual({ role: 'user', content: 'ping\npong' });
});
it('hoists a late system message after complete parallel tool results without disturbing tool order', () => {
const result = new ProxyRequestTransformer().transform({
system: 'base instructions',
messages: [
{ role: 'user', content: 'inspect both files' },
{
role: 'assistant',
content: [
{ type: 'tool_use', id: 'toolu_1', name: 'read', input: { path: 'a.ts' } },
{ type: 'tool_use', id: 'toolu_2', name: 'read', input: { path: 'b.ts' } },
],
},
{
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a contents' },
{ type: 'tool_result', tool_use_id: 'toolu_2', content: 'b contents' },
],
},
{ role: 'system', content: 'late instructions' },
{ role: 'user', content: 'compare them' },
],
});
expect(result.messages).toEqual([
{ role: 'system', content: 'base instructions\n\nlate instructions' },
{ role: 'user', content: 'inspect both files' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'toolu_1',
type: 'function',
function: { name: 'read', arguments: '{"path":"a.ts"}' },
},
{
id: 'toolu_2',
type: 'function',
function: { name: 'read', arguments: '{"path":"b.ts"}' },
},
],
},
{ role: 'tool', tool_call_id: 'toolu_1', content: 'a contents' },
{ role: 'tool', tool_call_id: 'toolu_2', content: 'b contents' },
{ role: 'user', content: 'compare them' },
]);
});
it('rejects a system message inserted before pending tool results', () => {
expect(() =>
new ProxyRequestTransformer().transform({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'read', input: { path: 'a.ts' } }],
},
{ role: 'system', content: 'interrupting instructions' },
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a contents' }],
},
],
})
).toThrow('role must be "user" with tool_result blocks after assistant tool_use');
});
});
@@ -43,7 +43,7 @@ describe('ProxyRequestTransformer', () => {
});
});
it('accepts Claude Code system messages in the messages array', () => {
it('accepts Claude Code system messages in the messages array and hoists them to a single leading system message', () => {
const transformer = new ProxyRequestTransformer();
const result = transformer.transform({
messages: [
@@ -53,10 +53,14 @@ describe('ProxyRequestTransformer', () => {
],
});
// Strict OpenAI-compatible backends (e.g. LiteLLM) reject any payload
// where `system` is not alone at index 0, so a mid-array `system`
// message must be hoisted rather than left in place. See #1459 for why
// the message must be accepted at all, and the coalesce-duplicate-system
// fix for why it can't simply stay where it landed.
expect(result.messages).toEqual([
{ role: 'user', content: 'hello' },
{ role: 'system', content: 'answer tersely' },
{ role: 'user', content: 'which model is this?' },
{ role: 'user', content: 'hello\nwhich model is this?' },
]);
});
@@ -63,7 +63,9 @@ beforeEach(() => {
baselineUncaughtExceptionListeners = process.listeners('uncaughtException');
baselineUnhandledRejectionListeners = process.listeners('unhandledRejection');
mock.module('../../../../src/utils/fetch-proxy-setup', () => ({}));
mock.module('../../../../src/utils/fetch-proxy-setup', () => ({
applyGlobalFetchProxy: () => ({ enabled: false }),
}));
mock.module('../../../../src/utils/error-manager', () => ({
ErrorManager: class ErrorManager {
static async showProfileNotFound(): Promise<void> {}
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'bun:test';
import { redactContext } from '../../../../src/services/logging/log-redaction';
import {
maskSecretTokens,
redactContext,
redactErrorInfo,
} from '../../../../src/services/logging/log-redaction';
describe('log redaction', () => {
it('redacts sensitive keys and preserves non-sensitive values', () => {
@@ -100,4 +104,31 @@ describe('log redaction', () => {
},
});
});
it('redacts proxy URL userinfo from nested error causes', () => {
const redacted = redactErrorInfo({
name: 'TypeError',
message: 'fetch failed',
cause: {
name: 'ProxyError',
code: 'ECONNREFUSED',
message: 'connect http://sentinel-user:sentinel-password@proxy.example:8080',
},
});
expect(redacted?.cause).toEqual({
name: 'ProxyError',
code: 'ECONNREFUSED',
message: 'connect http://[redacted]@proxy.example:8080',
});
expect(JSON.stringify(redacted)).not.toContain('sentinel-user');
expect(JSON.stringify(redacted)).not.toContain('sentinel-password');
});
it('redacts URL userinfo without changing non-credential URLs', () => {
expect(maskSecretTokens('https://user:pass@example.com/path')).toBe(
'https://[redacted]@example.com/path'
);
expect(maskSecretTokens('https://example.com/path')).toBe('https://example.com/path');
});
});
+25 -19
View File
@@ -4,7 +4,7 @@ import {
Agent,
Dispatcher,
ProxyAgent,
fetch,
fetch as undiciFetch,
getGlobalDispatcher,
setGlobalDispatcher,
} from 'undici';
@@ -26,18 +26,24 @@ const PROXY_ENV_KEYS = [
describe('global fetch proxy setup', () => {
const originalEnv = new Map<string, string | undefined>();
const originalFetch = globalThis.fetch;
const originalDispatcher = getGlobalDispatcher();
beforeEach(() => {
globalThis.fetch = originalFetch;
setGlobalDispatcher(originalDispatcher);
for (const key of PROXY_ENV_KEYS) {
originalEnv.set(key, process.env[key]);
delete process.env[key];
}
setGlobalDispatcher(originalDispatcher);
});
afterEach(() => {
afterEach(async () => {
const activeDispatcher = getGlobalDispatcher();
globalThis.fetch = originalFetch;
setGlobalDispatcher(originalDispatcher);
if (activeDispatcher !== originalDispatcher) {
await activeDispatcher.close();
}
for (const key of PROXY_ENV_KEYS) {
const value = originalEnv.get(key);
if (value === undefined) {
@@ -112,18 +118,6 @@ describe('global fetch proxy setup', () => {
});
});
it('rebinds globalThis.fetch to undici fetch when proxying is enabled', () => {
const originalFetch = globalThis.fetch;
process.env.HTTP_PROXY = 'http://proxy.example:8080';
try {
expect(applyGlobalFetchProxy()).toEqual({ enabled: true });
expect(globalThis.fetch).toBe(fetch);
} finally {
globalThis.fetch = originalFetch;
}
});
it('bypasses loopback fetches even when HTTP_PROXY is set', async () => {
process.env.HTTP_PROXY = 'http://127.0.0.1:9';
@@ -139,11 +133,14 @@ describe('global fetch proxy setup', () => {
}
try {
expect(applyGlobalFetchProxy()).toEqual({ enabled: true });
const response = await fetch(`http://127.0.0.1:${address.port}/health`);
const dispatcher = createGlobalFetchProxyDispatcher();
expect(dispatcher).not.toBeNull();
const response = await undiciFetch(`http://127.0.0.1:${address.port}/health`, {
dispatcher: dispatcher!,
});
expect(response.status).toBe(200);
expect(await response.text()).toBe('ok');
await dispatcher?.close();
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
@@ -158,6 +155,15 @@ describe('global fetch proxy setup', () => {
expect(result).toEqual({ directCalls: 0, proxyCalls: 1 });
});
it('applies the proxy transport only when explicitly invoked', () => {
process.env.HTTP_PROXY = 'http://proxy.example:8080';
expect(globalThis.fetch).toBe(originalFetch);
expect(applyGlobalFetchProxy()).toEqual({ enabled: true });
expect(globalThis.fetch).toBe(undiciFetch);
expect(getGlobalDispatcher()).not.toBe(originalDispatcher);
});
it('routes HTTPS requests through HTTPS_PROXY', async () => {
process.env.HTTPS_PROXY = 'http://proxy.example:8443';
+43
View File
@@ -1116,6 +1116,49 @@ describe('/summary force flag passed to getNativeAccountRows', () => {
expect(status).toBe(200);
expect(body.some((r) => r.provider === 'codex')).toBe(true);
});
it('enforces one absolute deadline across cost, quota, and blocked native work', async () => {
const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import(
'../../../src/web-server/routes/bar-routes'
);
const app = express();
app.use(express.json());
const router = createBarRouter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAllAccountsSummary: () => ({ agy: [makeAccountInfo()] }) as any,
getCachedQuota: () => makeQuotaResult(),
setCachedQuota: () => {},
invalidateQuotaCache: () => {},
fetchAccountQuota: () => new Promise(() => {}),
getTodayCostByAccount: () => ({}),
loadCliproxyDetails: () => new Promise((resolve) => setTimeout(() => resolve([]), 1_400)),
loadDailyUsage: async () => [],
loadHourlyUsage: async () => [],
getNativeAccountRows: () => new Promise(() => {}),
getCachedNativeRows: () => [],
});
app.use('/api/bar', router);
const srv = await new Promise<Server>((resolve, reject) => {
const instance = app.listen(0, '127.0.0.1');
instance.once('error', reject);
instance.once('listening', () => resolve(instance));
});
const addr = srv.address();
if (!addr || typeof addr === 'string') throw new Error('No server address');
resetDebounce();
const startedAt = Date.now();
const { status } = await getJson<BarSummaryRow[]>(
`http://127.0.0.1:${(addr as { port: number }).port}`,
'/api/bar/summary?refresh=true'
);
const elapsed = Date.now() - startedAt;
await new Promise<void>((resolve) => srv.close(() => resolve()));
expect(status).toBe(200);
expect(elapsed).toBeGreaterThanOrEqual(2_200);
expect(elapsed).toBeLessThan(3_200);
});
});
// ============================================================================
@@ -8,6 +8,8 @@
import { describe, expect, it } from 'bun:test';
import {
readClaudeCredentials,
readClaudeCredentialsForConfigDir,
claudeKeychainServiceForConfigDir,
getAccessToken,
getSubscriptionTier,
hasSupportedSubscription,
@@ -25,14 +27,14 @@ function makeCreds(overrides: Record<string, unknown> = {}): ClaudeNativeCredent
}
describe('readClaudeCredentials', () => {
it('parses the on-disk credentials file when present (file-first, no Keychain)', () => {
it('parses the on-disk credentials file when present (file-first, no Keychain)', async () => {
let keychainCalled = false;
const creds = readClaudeCredentials({
const creds = await readClaudeCredentials({
platform: 'darwin',
homedir: '/home/test',
existsSyncImpl: () => true,
readFileSyncImpl: () => JSON.stringify(makeCreds()),
execSyncImpl: () => {
execFileImpl: () => {
keychainCalled = true;
return '';
},
@@ -42,44 +44,52 @@ describe('readClaudeCredentials', () => {
expect(keychainCalled).toBe(false);
});
it('falls back to the macOS Keychain when the file is absent', () => {
const creds = readClaudeCredentials({
it('falls back to the macOS Keychain when the file is absent', async () => {
let executable = '';
let args: readonly string[] = [];
const creds = await readClaudeCredentials({
platform: 'darwin',
homedir: '/home/test',
existsSyncImpl: () => false,
readFileSyncImpl: () => {
throw new Error('should not read file');
},
execSyncImpl: () => JSON.stringify(makeCreds({ subscriptionType: 'pro' })),
execFileImpl: (file, receivedArgs, _options, callback) => {
executable = file;
args = receivedArgs;
callback(null, JSON.stringify(makeCreds({ subscriptionType: 'pro' })));
},
});
expect(creds?.claudeAiOauth?.subscriptionType).toBe('pro');
expect(executable).toBe('/usr/bin/security');
expect(args).toEqual(['find-generic-password', '-s', 'Claude Code-credentials', '-w']);
});
it('returns null when both file and Keychain are absent', () => {
const creds = readClaudeCredentials({
it('returns null when both file and Keychain are absent', async () => {
const creds = await readClaudeCredentials({
platform: 'darwin',
homedir: '/home/test',
existsSyncImpl: () => false,
readFileSyncImpl: () => {
throw new Error('no file');
},
execSyncImpl: () => {
throw new Error('no keychain entry');
execFileImpl: (_file, _args, _options, callback) => {
callback(new Error('no keychain entry'), '');
},
});
expect(creds).toBeNull();
});
it('does not consult the Keychain on non-darwin platforms', () => {
it('does not consult the Keychain on non-darwin platforms', async () => {
let keychainCalled = false;
const creds = readClaudeCredentials({
const creds = await readClaudeCredentials({
platform: 'linux',
homedir: '/home/test',
existsSyncImpl: () => false,
readFileSyncImpl: () => {
throw new Error('no file');
},
execSyncImpl: () => {
execFileImpl: () => {
keychainCalled = true;
return '';
},
@@ -124,3 +134,107 @@ describe('token + tier extraction', () => {
expect(getSubscriptionTier(null)).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Per-config-dir credential reading (isolated `ccs auth` profiles on macOS)
//
// Claude Code stores OAuth credentials for a non-default CLAUDE_CONFIG_DIR in
// a per-directory Keychain item: service "Claude Code-credentials-<hash>",
// where <hash> is the first 8 hex chars of sha256(configDir). These tests pin
// that derivation and the file-first / Keychain-fallback read order.
// ---------------------------------------------------------------------------
describe('claudeKeychainServiceForConfigDir', () => {
it('derives the service name from sha256 of the config dir path (first 8 hex chars)', () => {
// sha256("/home/test/.ccs/instances/work") = ffeb4b45...
expect(claudeKeychainServiceForConfigDir('/home/test/.ccs/instances/work')).toBe(
'Claude Code-credentials-ffeb4b45'
);
});
});
describe('readClaudeCredentialsForConfigDir', () => {
const configDir = '/home/test/.ccs/instances/work';
const credFile = `${configDir}/.credentials.json`;
it('reads <configDir>/.credentials.json when present (file-first, no Keychain)', async () => {
let keychainCalled = false;
const creds = await readClaudeCredentialsForConfigDir(configDir, {
platform: 'darwin',
existsSyncImpl: (p: string) => p === credFile,
readFileSyncImpl: (p: string) => {
expect(p).toBe(credFile);
return JSON.stringify(makeCreds());
},
execFileImpl: () => {
keychainCalled = true;
return '';
},
});
expect(creds?.claudeAiOauth?.accessToken).toBe('tok-abc');
expect(keychainCalled).toBe(false);
});
it('uses absolute security path and argument array for the per-config-dir Keychain item', async () => {
let executable = '';
let args: readonly string[] = [];
const creds = await readClaudeCredentialsForConfigDir(configDir, {
platform: 'darwin',
existsSyncImpl: () => false,
readFileSyncImpl: () => {
throw new Error('should not read file');
},
execFileImpl: (file, receivedArgs, _options, callback) => {
executable = file;
args = receivedArgs;
callback(null, JSON.stringify(makeCreds({ subscriptionType: 'team' })));
},
});
expect(creds?.claudeAiOauth?.subscriptionType).toBe('team');
expect(executable).toBe('/usr/bin/security');
expect(args).toEqual(['find-generic-password', '-s', 'Claude Code-credentials-ffeb4b45', '-w']);
});
it('returns null when both file and Keychain are absent', async () => {
const creds = await readClaudeCredentialsForConfigDir(configDir, {
platform: 'darwin',
existsSyncImpl: () => false,
readFileSyncImpl: () => {
throw new Error('no file');
},
execFileImpl: (_file, _args, _options, callback) => {
callback(new Error('no keychain entry'), '');
},
});
expect(creds).toBeNull();
});
it('bounds a blocked Keychain lookup without exposing a credential payload', async () => {
const startedAt = Date.now();
const creds = await readClaudeCredentialsForConfigDir(configDir, {
platform: 'darwin',
existsSyncImpl: () => false,
keychainTimeoutMs: 20,
execFileImpl: () => ({ kill: () => {} }),
});
expect(creds).toBeNull();
expect(Date.now() - startedAt).toBeLessThan(250);
});
it('does not consult the Keychain on non-darwin platforms', async () => {
let keychainCalled = false;
const creds = await readClaudeCredentialsForConfigDir(configDir, {
platform: 'linux',
existsSyncImpl: () => false,
readFileSyncImpl: () => {
throw new Error('no file');
},
execFileImpl: () => {
keychainCalled = true;
return '';
},
});
expect(creds).toBeNull();
expect(keychainCalled).toBe(false);
});
});
@@ -1032,7 +1032,7 @@ function makeMultiProfileDeps(opts: {
listCodexProfiles: () => codexProfiles,
defaultClaudeProfile: () => claudeDefault,
defaultCodexProfile: () => codexDefault,
// Credential seams (file-only, no keychain)
// Credential seams (fully injected; no real filesystem or Keychain access)
readClaudeCredentialsForProfile: credsForProfile,
readCodexNativeAuth: codexNativeAuth,
// Fetch seams
@@ -1170,8 +1170,10 @@ describe('multi-profile: account_id and wire fields', () => {
const rows = await getNativeAccountRows(deps);
expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length);
expect(deps.claudeFetchCount()).toBe(1);
expect(deps.codexNetworkCount()).toBe(1);
// Budget per pass: the default + one rotating non-default live slot per
// surface — constant regardless of profile count.
expect(deps.claudeFetchCount()).toBe(2);
expect(deps.codexNetworkCount()).toBe(2);
});
it('rows are sorted by (surface, profile)', async () => {
@@ -1193,7 +1195,7 @@ describe('multi-profile: account_id and wire fields', () => {
});
});
describe('multi-profile: Claude file-only reader', () => {
describe('multi-profile: Claude credential reader', () => {
it('profile with .credentials.json present -> live fetch row (paused:false when default)', async () => {
const clock = { now: 1_000_000 };
const deps = makeMultiProfileDeps({
@@ -1396,8 +1398,9 @@ describe('multi-profile: per-profile circuit breaker isolation', () => {
const ckRow = rows.find((r) => r.profile === 'ck');
const workRow = rows.find((r) => r.profile === 'work');
// 'ck' stays cache-only, independent of work's breaker.
expect(ckRow?.quotaStatus).toBe('unsupported');
// 'ck' gets its own live row via the rotating slot, independent of work's
// breaker history.
expect(ckRow?.quotaStatus).toBe('ok');
// 'work' is also fine after reset (no breaker state).
expect(workRow?.quotaStatus).toBe('ok');
});
@@ -1431,10 +1434,11 @@ describe('multi-profile: per-profile circuit breaker isolation', () => {
},
});
// First call: 'work' gets a 429, 'ck' remains cache-only.
// First call: 'work' gets a 429; 'ck' is refreshed by the rotating slot and
// succeeds — work's failures do not leak into ck's state.
const rows1 = await getNativeAccountRows(deps);
const ck1 = rows1.find((r) => r.profile === 'ck');
expect(ck1?.quotaStatus).toBe('unsupported');
expect(ck1?.quotaStatus).toBe('ok');
expect(workCall429Count).toBeGreaterThanOrEqual(1);
// Skip past cooldown for 'work' only; 'ck' is within TTL.
@@ -1443,8 +1447,8 @@ describe('multi-profile: per-profile circuit breaker isolation', () => {
// Second call past 'work' cooldown: work tries again (429 again); ck cached.
const rows2 = await getNativeAccountRows(deps);
const ck2 = rows2.find((r) => r.profile === 'ck');
// 'ck' remains cache-only and is not affected by work's breaker.
expect(ck2?.quotaStatus).toBe('unsupported');
// 'ck' keeps its healthy cached row and is not affected by work's breaker.
expect(ck2?.quotaStatus).toBe('ok');
});
});
@@ -1537,15 +1541,18 @@ describe('review focus areas: reauth caching + codex local fallback', () => {
codexNetworkFetch: async () => ({ success: false, needsReauth: true }) as CodexQuotaResult,
});
// The rotating slot polls 'ck' once; the 401 parks it into the reauth
// cooldown.
const first = await getNativeAccountRows(deps);
const r1 = first.find((r) => r.profile === 'ck');
expect(r1?.needsReauth).toBe(true);
expect(r1?.paused).toBe(true);
expect(deps.codexNetworkCount()).toBe(0);
expect(deps.codexNetworkCount()).toBe(1);
// Within the cooldown it is NOT re-polled (no repeated 401s), even forced.
const second = await getNativeAccountRows(deps, { force: true });
expect(second.find((r) => r.profile === 'ck')?.cached).toBe(true);
expect(deps.codexNetworkCount()).toBe(0);
expect(deps.codexNetworkCount()).toBe(1);
});
it('Codex named profile without on-disk auth is parked, never filled from global local data', async () => {
@@ -1655,7 +1662,7 @@ describe('review focus areas: reauth caching + codex local fallback', () => {
expect(deps.claudeFetchCount()).toBe(1); // re-checked -> fetched
});
it('Codex named profile with valid auth but sparse payload stays parked when not default', async () => {
it('Codex named profile with valid auth but sparse payload yields an active quota-less row', async () => {
resetNativeQuotaState();
const clock = { now: 7_000_000 };
const deps = makeMultiProfileDeps({
@@ -1668,13 +1675,15 @@ describe('review focus areas: reauth caching + codex local fallback', () => {
codexNetworkFetch: async () => ({ success: true }) as CodexQuotaResult,
});
// The rotating slot polls 'ck'; the token authenticated, so it is a valid
// active subscription with a sparse payload — an active quota-less row,
// still dimmed because it is not the default profile.
const rows = await getNativeAccountRows(deps);
const ck = rows.find((r) => r.profile === 'ck');
expect(ck).toBeDefined();
expect(ck?.paused).toBe(true); // cache-only, parked until selected as default
expect(ck?.needsReauth).toBe(true);
expect(ck?.quotaStatus).toBe('unsupported');
expect(ck?.quota_percentage).toBeNull(); // no windows while parked
expect(ck?.paused).toBe(true); // non-default rows always render dimmed
expect(ck?.needsReauth).toBe(false);
expect(ck?.quota_percentage).toBeNull(); // no windows in the payload
});
it('non-default cache-only rows do not overwrite the canonical live cache', async () => {
@@ -1691,9 +1700,11 @@ describe('review focus areas: reauth caching + codex local fallback', () => {
});
deps.defaultCodexProfile = () => codexDefault;
// First pass: 'ck' (default) is live-polled and the rotating slot also
// refreshes 'default' — two upstream calls.
const first = await getNativeAccountRows(deps);
expect(first.find((r) => r.profile === 'ck')?.paused).toBe(false);
expect(deps.codexNetworkCount()).toBe(1);
expect(deps.codexNetworkCount()).toBe(2);
codexDefault = 'default';
const second = await getNativeAccountRows(deps);
@@ -1710,3 +1721,234 @@ describe('review focus areas: reauth caching + codex local fallback', () => {
expect(deps.codexNetworkCount()).toBe(2);
});
});
// ============================================================================
// Multi-profile: rotating live slot for non-default profiles
//
// Non-default profiles used to be cache-only forever, so accounts other than
// the default never showed real quota — they sat parked ("needs re-auth") for
// the lifetime of the server. One rotating live slot per surface refreshes the
// stalest eligible non-default profile per pass, so every account converges to
// real data within a few polls while the per-pass upstream budget stays
// constant (<= 2 calls per surface) regardless of profile count.
// ============================================================================
describe('multi-profile: rotating live slot for non-default profiles', () => {
it('live-polls the default plus one stale non-default Claude profile per pass', async () => {
const clock = { now: 1_000_000 };
const deps = makeMultiProfileDeps({
clock,
claudeProfiles: ['work', 'personal', 'fc'],
codexProfiles: [],
claudeDefault: 'work',
credsForProfile: () => maxCreds(),
claudeFetch: async () => successQuota(),
});
// Pass 1: default (work) + one stale non-default get live data.
let rows = await getNativeAccountRows(deps);
expect(deps.claudeFetchCount()).toBe(2);
expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(2);
// Pass 2 after the parked TTL: the remaining profile gets its live row.
clock.now += 31_000;
rows = await getNativeAccountRows(deps);
expect(deps.claudeFetchCount()).toBe(3);
expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(3);
// Pass 3 while everything is within TTL: fully cached, zero upstream calls.
clock.now += 1_000;
rows = await getNativeAccountRows(deps);
expect(deps.claudeFetchCount()).toBe(3);
expect(rows.filter((r) => r.quotaStatus === 'ok').length).toBe(3);
});
it('rotated non-default rows keep paused:true (only the default renders active)', async () => {
const clock = { now: 1_000_000 };
const deps = makeMultiProfileDeps({
clock,
claudeProfiles: ['work', 'personal'],
codexProfiles: [],
claudeDefault: 'work',
credsForProfile: () => maxCreds(),
claudeFetch: async () => successQuota(),
});
const rows = await getNativeAccountRows(deps);
const personal = rows.find((r) => r.profile === 'personal');
expect(personal?.quotaStatus).toBe('ok');
expect(personal?.needsReauth).toBe(false);
expect(personal?.paused).toBe(true);
expect(personal?.is_default).toBe(false);
});
it('codex non-default profiles also get one rotating live slot per pass', async () => {
const clock = { now: 1_000_000 };
const deps = makeMultiProfileDeps({
clock,
claudeProfiles: [],
codexProfiles: ['personal', 'ck'],
codexDefault: 'personal',
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
codexNetworkFetch: async () => codexSuccessQuota(),
});
const rows = await getNativeAccountRows(deps);
expect(deps.codexNetworkCount()).toBe(2);
const ck = rows.find((r) => r.profile === 'ck');
expect(ck?.paused).toBe(true);
expect(ck?.needsReauth).toBe(false);
});
it('profiles in reauth cooldown are skipped by the rotating slot', async () => {
const clock = { now: 1_000_000 };
let failProfile: string | null = 'personal';
const deps = makeMultiProfileDeps({
clock,
claudeProfiles: ['work', 'personal'],
codexProfiles: [],
claudeDefault: 'work',
credsForProfile: () => maxCreds(),
claudeFetch: async (_token: string, accountId?: string) =>
accountId === `ccs:${failProfile}`
? ({ success: false, needsReauth: true, retryable: false } as ClaudeQuotaResult)
: successQuota(),
});
// Pass 1: personal is rotated in, 401s, and enters the reauth cooldown.
let rows = await getNativeAccountRows(deps);
expect(rows.find((r) => r.profile === 'personal')?.needsReauth).toBe(true);
expect(deps.claudeFetchCount()).toBe(2);
// Pass 2 inside the cooldown: the slot must NOT re-poll (and re-401) it.
clock.now += 31_000;
rows = await getNativeAccountRows(deps);
expect(deps.claudeFetchCount()).toBe(2);
expect(rows.find((r) => r.profile === 'personal')?.needsReauth).toBe(true);
});
});
// ============================================================================
// Quota reset invalidates cached rows
//
// A cached row whose next_reset has passed no longer describes the current
// window — the quota snapped back at the reset boundary. Serving it for the
// rest of the 10-min TTL makes the bar visibly wrong right after a reset, so
// a passed reset marks the row stale (guarded: only when the row was fetched
// BEFORE the reset, so a post-reset payload that still reports a past reset
// cannot cause a refetch loop).
// ============================================================================
describe('multi-profile: quota reset invalidates cached rows', () => {
function quotaResettingAt(resetIso: string): ClaudeQuotaResult {
const base = successQuota();
return {
...base,
coreUsage: {
fiveHour: { ...base.coreUsage!.fiveHour!, resetAt: resetIso },
weekly: base.coreUsage!.weekly,
},
};
}
it('re-fetches the default Claude profile once its next_reset passes, before TTL expiry', async () => {
const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') };
const resetIso = '2026-06-09T10:01:00.000Z'; // 60s ahead
const deps = makeMultiProfileDeps({
clock,
claudeProfiles: ['work'],
codexProfiles: [],
claudeDefault: 'work',
credsForProfile: () => maxCreds(),
claudeFetch: async () => quotaResettingAt(resetIso),
});
await getNativeAccountRows(deps);
expect(deps.claudeFetchCount()).toBe(1);
// 90s later: past the reset but far inside the 10-min TTL.
clock.now += 90_000;
await getNativeAccountRows(deps);
expect(deps.claudeFetchCount()).toBe(2);
});
it('does not refetch-loop when a post-reset payload still reports a past reset', async () => {
const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') };
const resetIso = '2026-06-09T10:01:00.000Z';
const deps = makeMultiProfileDeps({
clock,
claudeProfiles: ['work'],
codexProfiles: [],
claudeDefault: 'work',
credsForProfile: () => maxCreds(),
claudeFetch: async () => quotaResettingAt(resetIso),
});
await getNativeAccountRows(deps);
clock.now += 90_000;
await getNativeAccountRows(deps); // refetch fires; payload STILL says 10:01
expect(deps.claudeFetchCount()).toBe(2);
// Another pass within TTL: the row was fetched after the reset passed, so
// the stale-by-reset rule must not apply again.
clock.now += 30_000;
await getNativeAccountRows(deps);
expect(deps.claudeFetchCount()).toBe(2);
});
it('rotating slot treats a non-default profile with a passed reset as stale', async () => {
const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') };
const resetIso = '2026-06-09T10:01:00.000Z';
const farFuture = '2026-06-16T00:00:00.000Z';
const deps = makeMultiProfileDeps({
clock,
claudeProfiles: ['work', 'personal'],
codexProfiles: [],
claudeDefault: 'work',
credsForProfile: () => maxCreds(),
claudeFetch: async (_t, accountId) =>
quotaResettingAt(accountId === 'ccs:personal' ? resetIso : farFuture),
});
// Pass 1: work (default) + personal (rotating slot, never fetched).
await getNativeAccountRows(deps);
expect(deps.claudeFetchCount()).toBe(2);
// 90s later: personal's reset passed; work is fresh. The rotating slot
// must pick personal again even though its row is inside the 10-min TTL.
clock.now += 90_000;
await getNativeAccountRows(deps);
expect(deps.claudeFetchCount()).toBe(3);
});
it('Codex rows honour the same reset-staleness rule', async () => {
const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') };
const codexQuota: CodexQuotaResult = {
...codexSuccessQuota(),
coreUsage: {
fiveHour: {
label: 'Primary',
remainingPercent: 60,
resetAfterSeconds: 60,
resetAt: '2026-06-09T10:01:00.000Z',
},
weekly: codexSuccessQuota().coreUsage!.weekly,
},
};
const deps = makeMultiProfileDeps({
clock,
claudeProfiles: [],
codexProfiles: ['personal'],
codexDefault: 'personal',
codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }),
codexNetworkFetch: async () => codexQuota,
});
await getNativeAccountRows(deps);
expect(deps.codexNetworkCount()).toBe(1);
clock.now += 90_000;
await getNativeAccountRows(deps);
expect(deps.codexNetworkCount()).toBe(2);
});
});