mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 18:18:21 +00:00
chore: merge origin/dev into issue #1688
# Conflicts: # docs/reports/hardening-inventory.json # docs/reports/hardening-inventory.md
This commit is contained in:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user