mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
Adds opt-in `ccsx auth import-default <name>` to migrate the existing
~/.codex/auth.json into a new profile, plus the cross-system integration
tests and user-facing documentation.
- import-default-command (C3 torn-write protection):
- readFileSync + JSON.parse with 3x retry / 100ms backoff to survive
Codex's truncate-then-write auth.json refresh race
- decode-id-token sanity-check on JWT shape (catches mid-write JWT
corruption that JSON.parse alone wouldn't notice)
- pgrep -f codex best-effort detection; warns + refuses without
--force-while-running flag if a live codex process is found
- rejects cliproxy-format auth files ({type: "codex", ...} wrapper)
with a clear "use ccs cliproxy ..." pointer
- atomic write to <dest>.tmp.<pid>.<rand> + rename
- --with-history defaults to false per D8 (auth-only is the safer
default; opt in for bulkier data)
- --force backs up existing auth.json to .bak-<ts> before overwrite
- non-destructive — never modifies ~/.codex/; legacy mode keeps
working without ever running this command
- integration tests:
- two-terminal-isolation: two profiles with separate CODEX_HOMEs
write to their own auth.json/history.jsonl with no crosstalk
- ccsxp-independence: codex-auth profile set; ccsxp still uses its
own CCSXP_CODEX_HOME / ~/.codex pool (H5 stderr notice present)
- legacy-fallback: no profiles registered → codex-runtime-router
leaves CODEX_HOME unset → codex falls back to ~/.codex
- import-default.integration: real fs copy + decode + register
- docs/codex-auth.md: user guide covering quick start, two-terminal
example, migration, dashboard, and caveats (cmd.exe, Windows
symlinks, ccsx vs ccsxp distinction)
155 codex-auth-scope tests green (45 Phase 1 + 57 Phase 2 + 19 Phase 3
+ 15 Phase 4 + 19 Phase 5). Full suite 3051/3082 — the 1 failure is a
pre-existing test-pollution issue between ccsxp-runtime.test.ts and
codex-runtime-integration.test.ts that exists on dev today; the test
passes in isolation.
109 lines
4.2 KiB
TypeScript
109 lines
4.2 KiB
TypeScript
/**
|
|
* Integration tests: two-terminal profile isolation.
|
|
*
|
|
* Verifies that two profiles with separate CODEX_HOME dirs write to their own
|
|
* auth.json/history.jsonl with zero crosstalk. Uses real filesystem.
|
|
*
|
|
* Cases:
|
|
* - Profiles A and B have independent auth.json (writing A does not touch B)
|
|
* - Profiles A and B have independent history.jsonl (writing A does not touch B)
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
let tempDir: string;
|
|
let ccsHome: string;
|
|
const ORIG_CCS_HOME = process.env.CCS_HOME;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-two-terminal-'));
|
|
ccsHome = path.join(tempDir, 'ccs');
|
|
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
|
|
process.env.CCS_HOME = ccsHome;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
|
|
else process.env.CCS_HOME = ORIG_CCS_HOME;
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function makeJwt(email: string): string {
|
|
const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url');
|
|
const body = Buffer.from(JSON.stringify({ email })).toString('base64url');
|
|
return `${header}.${body}.sig`;
|
|
}
|
|
|
|
async function createProfile(name: string) {
|
|
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
|
|
const { resolveCodexProfileDir } = await import('../../../src/codex-auth/codex-profile-paths');
|
|
const registry = new CodexProfileRegistry();
|
|
const dir = resolveCodexProfileDir(name);
|
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
registry.createProfile(name, { created: new Date().toISOString(), last_used: null });
|
|
return { dir, registry };
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('two-terminal isolation — auth.json independence', () => {
|
|
it('writing auth.json to profile A does not affect profile B', async () => {
|
|
const { dir: dirA } = await createProfile('terminal-a');
|
|
const { dir: dirB } = await createProfile('terminal-b');
|
|
|
|
const authA = path.join(dirA, 'auth.json');
|
|
const authB = path.join(dirB, 'auth.json');
|
|
|
|
// Simulate Codex writing auth.json for profile A (token refresh)
|
|
const tokenA = JSON.stringify({ tokens: { id_token: makeJwt('a@example.com') } });
|
|
fs.writeFileSync(authA, tokenA, { mode: 0o600 });
|
|
|
|
// Profile B auth.json must not exist (untouched)
|
|
expect(fs.existsSync(authB)).toBe(false);
|
|
|
|
// Now simulate a login for profile B
|
|
const tokenB = JSON.stringify({ tokens: { id_token: makeJwt('b@example.com') } });
|
|
fs.writeFileSync(authB, tokenB, { mode: 0o600 });
|
|
|
|
// Verify A's auth.json content is unchanged — same token that was written for A
|
|
const readA = fs.readFileSync(authA, 'utf8');
|
|
expect(readA).toBe(tokenA);
|
|
|
|
// Verify each profile dir is fully independent
|
|
expect(dirA).not.toBe(dirB);
|
|
expect(dirA.endsWith('terminal-a')).toBe(true);
|
|
expect(dirB.endsWith('terminal-b')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('two-terminal isolation — history.jsonl independence', () => {
|
|
it('writing history.jsonl to profile A does not affect profile B', async () => {
|
|
const { dir: dirA } = await createProfile('hist-a');
|
|
const { dir: dirB } = await createProfile('hist-b');
|
|
|
|
const histA = path.join(dirA, 'history.jsonl');
|
|
const histB = path.join(dirB, 'history.jsonl');
|
|
|
|
// Profile A writes history
|
|
fs.writeFileSync(histA, '{"prompt":"hello from A"}\n');
|
|
|
|
// Profile B history must not exist
|
|
expect(fs.existsSync(histB)).toBe(false);
|
|
|
|
// Profile B writes its own history
|
|
fs.writeFileSync(histB, '{"prompt":"hello from B"}\n');
|
|
|
|
// A's history unchanged
|
|
const contentA = fs.readFileSync(histA, 'utf8');
|
|
expect(contentA).toContain('hello from A');
|
|
expect(contentA).not.toContain('hello from B');
|
|
|
|
// B's history has its own entry only
|
|
const contentB = fs.readFileSync(histB, 'utf8');
|
|
expect(contentB).toContain('hello from B');
|
|
expect(contentB).not.toContain('hello from A');
|
|
});
|
|
});
|