From 37a14525cc24b8709261eca0bdcf81a1e289be45 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 22 May 2026 16:27:00 -0400 Subject: [PATCH] fix: repair ccsx codex profile resources --- docs/codex-auth.md | 33 +++-- docs/project-roadmap.md | 6 +- src/bin/codex-runtime-router.ts | 13 ++ src/codex-auth/codex-profile-resources.ts | 116 ++++++++++++++++++ src/codex-auth/commands/create-command.ts | 32 +++-- src/codex-auth/index.ts | 1 + tests/unit/bin/codex-runtime-router.test.ts | 36 +++++- .../codex-profile-resources.test.ts | 110 +++++++++++++++++ .../commands/create-command.test.ts | 16 ++- 9 files changed, 346 insertions(+), 17 deletions(-) create mode 100644 src/codex-auth/codex-profile-resources.ts create mode 100644 tests/unit/codex-auth/codex-profile-resources.test.ts diff --git a/docs/codex-auth.md b/docs/codex-auth.md index 3858ef2c..4dd2c5eb 100644 --- a/docs/codex-auth.md +++ b/docs/codex-auth.md @@ -10,8 +10,8 @@ refresh in one session overwrites the other's credentials. `ccsx auth` solves this by giving each account its own profile directory under `~/.ccs/codex-instances//`. Each profile holds its own `auth.json` and -`history.jsonl`. A shared `config.toml` is linked via symlink so model/provider settings -stay in sync. +`history.jsonl`. Shared `config.toml`, `agents/`, and `skills/` resources are linked +via symlink so model/provider settings and relative agent role config files stay in sync. ## Quick start (4 commands) @@ -147,20 +147,39 @@ No OAuth tokens are ever returned by the API endpoint or shown in the UI. ├── auth.json # OAuth credentials (Codex writes here) ├── history.jsonl # Per-profile prompt history (optional) ├── sessions/ # Per-profile chat session dirs (optional) - └── config.toml -> ~/.codex/config.toml (symlink — shared) + ├── config.toml -> ~/.codex/config.toml (symlink — shared) + ├── agents/ -> ~/.codex/agents/ (symlink — shared) + └── skills/ -> ~/.codex/skills/ (symlink — shared) ~/.codex/ -└── config.toml # Single shared model/provider config +├── config.toml # Single shared model/provider config +├── agents/ # Shared Codex agent role config files +└── skills/ # Shared Codex skills ``` +`ccsx auth create ` and `ccsx ` both repair these links idempotently. +This keeps relative Codex config entries such as `agents/foo.toml` valid inside +each isolated `CODEX_HOME`. + ## Caveats ### Windows symlinks On Windows, creating symlinks requires Developer Mode or elevated privileges. -If symlink creation fails, CCS falls back to copying `config.toml`. In this case, -changes to `~/.codex/config.toml` are **not** automatically reflected in the profile — -you must re-run `ccsx auth create --force` to refresh the copy. +If symlink creation fails, CCS falls back to copying `config.toml`, `agents/`, +and `skills/`. In this case, changes to `~/.codex/` resources are **not** +automatically reflected in the profile; re-run `ccsx auth create --force` +to refresh the copy. + +### Native Codex project-local config warnings + +`ccsx` preserves your current working directory. If you launch from your home directory, +native Codex can also see `~/.codex/config.toml` as `./.codex/config.toml`, a +project-local config file. Codex rejects user-level-only keys such as `model_providers` +and `notify` in project-local config. That warning comes from native Codex config +layering, not from the `ccsx auth` profile resource links. Launch from a project +directory or move project-local Codex config out of `$HOME/.codex/config.toml` if the +warning is noisy. ### `ccsx` vs `ccsxp` diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index f7753478..33f018b5 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-05-19 +Last Updated: 2026-05-22 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,10 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-05-22**: **#1332** `ccsx auth` profiles now link native Codex + `agents/` and `skills/` resources alongside `config.toml`, and `ccsx ` + repairs missing resource links at launch so relative agent role `config_file` + entries do not warn on existing profiles. - **2026-05-20**: **#1285** Codex model picker aliases now include `minimal` and `low` reasoning effort suffixes alongside `medium`, `high`, and `xhigh`, and the dashboard separates base recommendations, reasoning variants, and fast variants so low-effort selections such as `gpt-5.5-low` are visible. - **2026-05-19**: **#1252** Claude extension persistence now rejects Codex CLIProxy profiles instead of writing `ANTHROPIC_BASE_URL=/api/provider/codex` into Claude Code settings. Native Codex subscription use stays on `ccsxp` or `ccs codex --target codex`, and `ccs doctor` warns when stale Claude settings still point at the Codex translator. - **2026-05-18**: **#1287** `ccsx resume` now passes through to the native Codex `resume` subcommand before CCS profile detection, preserving Codex session continuation while keeping `ccsx auth` on the managed Codex profile namespace. diff --git a/src/bin/codex-runtime-router.ts b/src/bin/codex-runtime-router.ts index 287ac491..e656b800 100644 --- a/src/bin/codex-runtime-router.ts +++ b/src/bin/codex-runtime-router.ts @@ -111,6 +111,19 @@ export async function main(argv: string[]): Promise { `[!] codex-auth: shared config symlink failed (${msg}), continuing\n` ); } + + try { + const { ensureCodexProfileResources } = + require('../codex-auth/codex-profile-resources') as { + ensureCodexProfileResources: (dir: string) => void; + }; + ensureCodexProfileResources(resolved.dir); + } catch (resourceErr) { + const msg = resourceErr instanceof Error ? resourceErr.message : String(resourceErr); + process.stderr.write( + `[!] codex-auth: shared resource repair failed (${msg}), continuing\n` + ); + } } } } catch (resolverErr) { diff --git a/src/codex-auth/codex-profile-resources.ts b/src/codex-auth/codex-profile-resources.ts new file mode 100644 index 00000000..7e230ae5 --- /dev/null +++ b/src/codex-auth/codex-profile-resources.ts @@ -0,0 +1,116 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createLogger } from '../services/logging'; + +const logger = createLogger('codex-auth:resources'); + +export const SHARED_CODEX_RESOURCE_DIRS = ['agents', 'skills'] as const; + +export interface EnsureCodexProfileResourcesOptions { + sharedCodexHome?: string; +} + +export function ensureCodexProfileResources( + profileDir: string, + options: EnsureCodexProfileResourcesOptions = {} +): void { + const sharedCodexHome = options.sharedCodexHome ?? path.join(os.homedir(), '.codex'); + + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + fs.mkdirSync(sharedCodexHome, { recursive: true, mode: 0o700 }); + + for (const resourceName of SHARED_CODEX_RESOURCE_DIRS) { + ensureSharedResourceDir(profileDir, sharedCodexHome, resourceName); + } +} + +function ensureSharedResourceDir( + profileDir: string, + sharedCodexHome: string, + resourceName: (typeof SHARED_CODEX_RESOURCE_DIRS)[number] +): void { + const targetPath = path.join(sharedCodexHome, resourceName); + const linkPath = path.join(profileDir, resourceName); + + fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 }); + + let existingStat: fs.Stats | null = null; + try { + existingStat = fs.lstatSync(linkPath); + } catch { + // Missing resource path: create it below. + } + + if (existingStat !== null) { + if (existingStat.isSymbolicLink()) { + const currentTarget = fs.readlinkSync(linkPath); + if (currentTarget === targetPath) { + return; + } + fs.unlinkSync(linkPath); + } else if (existingStat.isDirectory()) { + if (isDirectoryEmpty(linkPath)) { + fs.rmSync(linkPath, { recursive: true, force: true }); + } else { + copyMissingResourceEntries(targetPath, linkPath); + return; + } + } else { + process.stderr.write( + `[!] codex-auth: preserving existing non-directory ${resourceName} at ${linkPath}\n` + ); + return; + } + } + + try { + fs.symlinkSync(targetPath, linkPath, 'dir'); + logger.stage('dispatch', 'codex.resource.symlink.created', 'Created shared resource symlink', { + link: linkPath, + target: targetPath, + resource: resourceName, + }); + } catch (err) { + fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 }); + copyMissingResourceEntries(targetPath, linkPath); + process.stderr.write( + `[!] codex-auth: symlink unavailable; copied shared ${resourceName} to ${linkPath}. ` + + `Resource edits won't propagate automatically.\n` + ); + logger.warn( + 'codex-auth.resource-copy-fallback', + 'Copied shared resource after symlink failure', + { + link: linkPath, + target: targetPath, + resource: resourceName, + error: err instanceof Error ? err.message : String(err), + } + ); + } +} + +function isDirectoryEmpty(dirPath: string): boolean { + try { + return fs.readdirSync(dirPath).length === 0; + } catch { + return false; + } +} + +function copyMissingResourceEntries(sourceDir: string, targetDir: string): void { + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + if (fs.existsSync(targetPath)) { + continue; + } + fs.cpSync(sourcePath, targetPath, { + recursive: true, + force: false, + errorOnExist: false, + preserveTimestamps: true, + }); + } +} diff --git a/src/codex-auth/commands/create-command.ts b/src/codex-auth/commands/create-command.ts index c0e510f9..f9fc6061 100644 --- a/src/codex-auth/commands/create-command.ts +++ b/src/codex-auth/commands/create-command.ts @@ -12,7 +12,11 @@ import { createLogger } from '../../services/logging'; import { initUI, info, ok } from '../../utils/ui'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { resolveCodexProfileDir, ensureSharedConfigSymlink } from '../index'; +import { + resolveCodexProfileDir, + ensureSharedConfigSymlink, + ensureCodexProfileResources, +} from '../index'; import { decodeAccountIdentity } from '../codex-account-identity'; import { detectCodexCli } from '../../targets/codex-detector'; import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types'; @@ -46,14 +50,14 @@ export async function handleCreateCodex(ctx: CodexCommandContext, args: string[] if (registry.hasProfile(profileName)) { if (force) { // --force: only re-link config.toml, preserve auth.json - console.log(info(`Profile already exists: ${profileName} (re-linking config.toml)`)); - _ensureSymlinkSafe(profileDir, true); - console.log(ok(`Profile config.toml re-linked.`)); + console.log(info(`Profile already exists: ${profileName} (repairing shared resources)`)); + _ensureProfileResourcesSafe(profileDir, true); + console.log(ok(`Profile shared resources repaired.`)); console.log(` Profile dir: ${profileDir}`); } else { - _ensureSymlinkSafe(profileDir); + _ensureProfileResourcesSafe(profileDir); console.log(info(`Profile already exists: ${profileName}`)); - console.log(ok(`Profile config.toml is ready.`)); + console.log(ok(`Profile shared resources are ready.`)); console.log(` Profile dir: ${profileDir}`); console.log(` Run: ccsx auth login ${profileName}`); } @@ -64,7 +68,7 @@ export async function handleCreateCodex(ctx: CodexCommandContext, args: string[] // Avoids registry orphan if mkdir hits EACCES/ENOSPC. try { fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); - _ensureSymlinkSafe(profileDir); + _ensureProfileResourcesSafe(profileDir); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if ((err as NodeJS.ErrnoException).code === 'EACCES') { @@ -121,6 +125,20 @@ function _ensureSymlinkSafe(profileDir: string, overwriteRegularFile = false): v } } +function _ensureProfileResourcesSafe(profileDir: string, overwriteRegularFile = false): void { + _ensureSymlinkSafe(profileDir, overwriteRegularFile); + try { + ensureCodexProfileResources(profileDir); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('codex-auth.create.resource-repair-failed', 'Resource repair failed', { + profileDir, + error: msg, + }); + exitWithError(`Failed to prepare profile resources: ${msg}`, ExitCode.CONFIG_ERROR); + } +} + async function _spawnLogin( profileName: string, profileDir: string, diff --git a/src/codex-auth/index.ts b/src/codex-auth/index.ts index 7666e4b9..15fa89b0 100644 --- a/src/codex-auth/index.ts +++ b/src/codex-auth/index.ts @@ -6,6 +6,7 @@ export { getSharedCodexConfigPath, } from './codex-profile-paths'; export { ensureSharedConfigSymlink } from './codex-config-symlink'; +export { ensureCodexProfileResources, SHARED_CODEX_RESOURCE_DIRS } from './codex-profile-resources'; export { decodeAccountIdentity } from './codex-account-identity'; export { decodeIdToken } from './decode-id-token'; export type { CodexProfileMetadata, CodexProfileData, CodexAccountIdentity } from './types'; diff --git a/tests/unit/bin/codex-runtime-router.test.ts b/tests/unit/bin/codex-runtime-router.test.ts index 615d6fa0..e9380831 100644 --- a/tests/unit/bin/codex-runtime-router.test.ts +++ b/tests/unit/bin/codex-runtime-router.test.ts @@ -5,7 +5,7 @@ * each test so the module re-evaluates with updated stubs and env state. * Stub runCodexAuth and require('../ccs') via require.cache injection. */ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -16,6 +16,7 @@ const ccsPath = require.resolve('../../../src/ccs.ts'); const codexAuthRouterPath = require.resolve('../../../src/codex-auth/codex-auth-router.ts'); const resolveProfilePath = require.resolve('../../../src/codex-auth/resolve-active-profile.ts'); const symlinkPath = require.resolve('../../../src/codex-auth/codex-config-symlink.ts'); +const resourcePath = require.resolve('../../../src/codex-auth/codex-profile-resources.ts'); const ORIGINAL_CCS_HOME = process.env.CCS_HOME; const ORIGINAL_CODEX_HOME = process.env.CODEX_HOME; @@ -23,6 +24,7 @@ const ORIGINAL_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE; let tempDir: string; let ccsHome: string; +let homeDir: string; let registryPath: string; let instancesDir: string; @@ -31,6 +33,7 @@ function flushRouterCache() { delete require.cache[codexAuthRouterPath]; delete require.cache[resolveProfilePath]; delete require.cache[symlinkPath]; + delete require.cache[resourcePath]; // Keep ccsPath stub intact — tests inject it explicitly each time } @@ -61,8 +64,14 @@ async function withCapturedStderr(fn: () => Promise): Promise<{ result: T; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-')); + homeDir = path.join(tempDir, 'home'); ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(homeDir, '.codex', 'agents'), { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(homeDir, '.codex', 'agents', 'brainstormer.toml'), 'name = "b"\n'); + fs.mkdirSync(path.join(homeDir, '.codex', 'skills'), { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(homeDir, '.codex', 'skills', 'review.md'), '# Review\n'); fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 }); + spyOn(os, 'homedir').mockReturnValue(homeDir); process.env.CCS_HOME = ccsHome; registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml'); instancesDir = path.join(ccsHome, '.ccs', 'codex-instances'); @@ -83,6 +92,7 @@ afterEach(() => { flushRouterCache(); delete require.cache[ccsPath]; + mock.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); }); @@ -165,9 +175,33 @@ describe('codex-runtime router — non-auth profile resolution', () => { expect(code).toBe(-1); expect(process.env.CCS_CODEX_PROFILE).toBe('ck'); expect(process.env.CODEX_HOME).toBe(profileDir); + expect(fs.existsSync(path.join(profileDir, 'agents', 'brainstormer.toml'))).toBe(true); expect(argv).toEqual(['node', 'codex-runtime', 'default', 'fix failing tests']); }); + it('self-heals missing Codex profile resources during launch', async () => { + const profileDir = makeProfileDir('ck'); + writeRegistry({ + version: '1.0', + default: null, + profiles: { ck: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } }, + }); + + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + flushRouterCache(); + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + + const { main } = require(routerPath) as { main: (argv: string[]) => Promise }; + await main(['node', 'codex-runtime', 'ck']); + + const agentsPath = path.join(profileDir, 'agents'); + const skillsPath = path.join(profileDir, 'skills'); + expect(fs.lstatSync(agentsPath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(path.join(agentsPath, 'brainstormer.toml'))).toBe(true); + expect(fs.lstatSync(skillsPath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(path.join(skillsPath, 'review.md'))).toBe(true); + }); + it('leaves CODEX_HOME unset when no registry exists and no env profile set', async () => { // No registry file, no CCS_CODEX_PROFILE require.cache[ccsPath] = { exports: {} } as NodeJS.Module; diff --git a/tests/unit/codex-auth/codex-profile-resources.test.ts b/tests/unit/codex-auth/codex-profile-resources.test.ts new file mode 100644 index 00000000..506a9207 --- /dev/null +++ b/tests/unit/codex-auth/codex-profile-resources.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let tempDir: string; +let profileDir: string; +let sharedCodexHome: string; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resources-test-')); + profileDir = path.join(tempDir, 'profile'); + sharedCodexHome = path.join(tempDir, 'shared-codex'); + fs.mkdirSync(path.join(sharedCodexHome, 'agents'), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(sharedCodexHome, 'agents', 'brainstormer.toml'), + 'name = "brainstormer"\n' + ); + fs.mkdirSync(path.join(sharedCodexHome, 'skills'), { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(sharedCodexHome, 'skills', 'review.md'), '# Review\n'); +}); + +afterEach(() => { + mock.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('ensureCodexProfileResources', () => { + it('links shared agents and skills into a fresh Codex profile', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + for (const resourceName of ['agents', 'skills']) { + const resourcePath = path.join(profileDir, resourceName); + expect(fs.lstatSync(resourcePath).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(resourcePath)).toBe(path.join(sharedCodexHome, resourceName)); + } + }); + + it('repairs a missing resource link without changing existing shared files', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + fs.unlinkSync(path.join(profileDir, 'agents')); + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + const agentsPath = path.join(profileDir, 'agents'); + expect(fs.lstatSync(agentsPath).isSymbolicLink()).toBe(true); + expect(fs.existsSync(path.join(agentsPath, 'brainstormer.toml'))).toBe(true); + }); + + it('is idempotent for repeated repair calls', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + const firstTarget = fs.readlinkSync(path.join(profileDir, 'agents')); + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + expect(fs.readlinkSync(path.join(profileDir, 'agents'))).toBe(firstTarget); + expect(fs.readFileSync(path.join(profileDir, 'agents', 'brainstormer.toml'), 'utf8')).toContain( + 'brainstormer' + ); + }); + + it('copies missing resource files into an existing profile-local directory', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const agentsPath = path.join(profileDir, 'agents'); + fs.mkdirSync(agentsPath, { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(agentsPath, 'local.toml'), 'name = "local"\n'); + + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + + expect(fs.lstatSync(agentsPath).isDirectory()).toBe(true); + expect(fs.existsSync(path.join(agentsPath, 'local.toml'))).toBe(true); + expect(fs.existsSync(path.join(agentsPath, 'brainstormer.toml'))).toBe(true); + }); + + it('falls back to copying resources when directory symlinks are unavailable', async () => { + const { ensureCodexProfileResources } = await import( + '../../../src/codex-auth/codex-profile-resources' + ); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => { + throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' }); + }); + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = () => true; + + try { + ensureCodexProfileResources(profileDir, { sharedCodexHome }); + } finally { + process.stderr.write = origWrite; + symlinkSpy.mockRestore(); + } + + const agentsPath = path.join(profileDir, 'agents'); + expect(fs.lstatSync(agentsPath).isDirectory()).toBe(true); + expect(fs.existsSync(path.join(agentsPath, 'brainstormer.toml'))).toBe(true); + }); +}); diff --git a/tests/unit/codex-auth/commands/create-command.test.ts b/tests/unit/codex-auth/commands/create-command.test.ts index 4f58df78..39d90e83 100644 --- a/tests/unit/codex-auth/commands/create-command.test.ts +++ b/tests/unit/codex-auth/commands/create-command.test.ts @@ -10,13 +10,20 @@ import * as childProcess from 'child_process'; let tempDir: string; let ccsHome: string; +let homeDir: string; const ORIG_CCS_HOME = process.env.CCS_HOME; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-create-test-')); + homeDir = path.join(tempDir, 'home'); ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(homeDir, '.codex', 'agents'), { recursive: true }); + fs.writeFileSync(path.join(homeDir, '.codex', 'agents', 'brainstormer.toml'), 'name = "b"\n'); + fs.mkdirSync(path.join(homeDir, '.codex', 'skills'), { recursive: true }); + fs.writeFileSync(path.join(homeDir, '.codex', 'skills', 'review.md'), '# Review\n'); fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true }); process.env.CCS_HOME = ccsHome; + spyOn(os, 'homedir').mockReturnValue(homeDir); }); afterEach(() => { @@ -87,6 +94,8 @@ describe('handleCreateCodex — happy path', () => { expect(ctx.registry.hasProfile('myprofile')).toBe(true); const instancesDir = path.join(ccsHome, '.ccs', 'codex-instances', 'myprofile'); expect(fs.existsSync(instancesDir)).toBe(true); + expect(fs.lstatSync(path.join(instancesDir, 'agents')).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(path.join(instancesDir, 'skills')).isSymbolicLink()).toBe(true); }); }); @@ -109,10 +118,14 @@ describe('handleCreateCodex — idempotent re-run', () => { const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'dupprofile'); const configPath = path.join(profileDir, 'config.toml'); + const agentsPath = path.join(profileDir, 'agents'); const authJsonPath = path.join(profileDir, 'auth.json'); - const authJson = JSON.stringify({ tokens: { id_token: buildToken({ email: 'idempotent@test' }) } }); + const authJson = JSON.stringify({ + tokens: { id_token: buildToken({ email: 'idempotent@test' }) }, + }); fs.writeFileSync(authJsonPath, authJson); fs.rmSync(configPath, { force: true }); + fs.rmSync(agentsPath, { recursive: true, force: true }); const restore2 = silenceConsole(); try { @@ -124,6 +137,7 @@ describe('handleCreateCodex — idempotent re-run', () => { // Profile still has exactly one entry expect(ctx.registry.listProfiles().filter((n) => n === 'dupprofile').length).toBe(1); expect(fs.existsSync(configPath)).toBe(true); + expect(fs.existsSync(path.join(agentsPath, 'brainstormer.toml'))).toBe(true); expect(fs.readFileSync(authJsonPath, 'utf8')).toBe(authJson); }); });