fix: repair ccsx codex profile resources

This commit is contained in:
Tam Nhu Tran committed 2026-05-22 16:27:00 -04:00
1 parent 17e221ac2c
commit 37a14525cc
9 files changed
+346 -17

No files matched your search

+26 -7
View File
@@ -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 `ccsx auth` solves this by giving each account its own profile directory under
`~/.ccs/codex-instances/<name>/`. Each profile holds its own `auth.json` and `~/.ccs/codex-instances/<name>/`. Each profile holds its own `auth.json` and
`history.jsonl`. A shared `config.toml` is linked via symlink so model/provider settings `history.jsonl`. Shared `config.toml`, `agents/`, and `skills/` resources are linked
stay in sync. via symlink so model/provider settings and relative agent role config files stay in sync.
## Quick start (4 commands) ## 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) ├── auth.json # OAuth credentials (Codex writes here)
├── history.jsonl # Per-profile prompt history (optional) ├── history.jsonl # Per-profile prompt history (optional)
├── sessions/ # Per-profile chat session dirs (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/ ~/.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 <name>` and `ccsx <name>` both repair these links idempotently.
This keeps relative Codex config entries such as `agents/foo.toml` valid inside
each isolated `CODEX_HOME`.
## Caveats ## Caveats
### Windows symlinks ### Windows symlinks
On Windows, creating symlinks requires Developer Mode or elevated privileges. On Windows, creating symlinks requires Developer Mode or elevated privileges.
If symlink creation fails, CCS falls back to copying `config.toml`. In this case, If symlink creation fails, CCS falls back to copying `config.toml`, `agents/`,
changes to `~/.codex/config.toml` are **not** automatically reflected in the profile — and `skills/`. In this case, changes to `~/.codex/` resources are **not**
you must re-run `ccsx auth create <name> --force` to refresh the copy. automatically reflected in the profile; re-run `ccsx auth create <name> --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` ### `ccsx` vs `ccsxp`
+5 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap # 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. 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 ### Recent Fixes
- **2026-05-22**: **#1332** `ccsx auth` profiles now link native Codex
`agents/` and `skills/` resources alongside `config.toml`, and `ccsx <profile>`
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-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-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. - **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.
+13
View File
@@ -111,6 +111,19 @@ export async function main(argv: string[]): Promise<number> {
`[!] codex-auth: shared config symlink failed (${msg}), continuing\n` `[!] 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) { } catch (resolverErr) {
+116
View File
@@ -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,
});
}
}
+25 -7
View File
@@ -12,7 +12,11 @@ import { createLogger } from '../../services/logging';
import { initUI, info, ok } from '../../utils/ui'; import { initUI, info, ok } from '../../utils/ui';
import { exitWithError } from '../../errors'; import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes'; import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir, ensureSharedConfigSymlink } from '../index'; import {
resolveCodexProfileDir,
ensureSharedConfigSymlink,
ensureCodexProfileResources,
} from '../index';
import { decodeAccountIdentity } from '../codex-account-identity'; import { decodeAccountIdentity } from '../codex-account-identity';
import { detectCodexCli } from '../../targets/codex-detector'; import { detectCodexCli } from '../../targets/codex-detector';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types'; import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
@@ -46,14 +50,14 @@ export async function handleCreateCodex(ctx: CodexCommandContext, args: string[]
if (registry.hasProfile(profileName)) { if (registry.hasProfile(profileName)) {
if (force) { if (force) {
// --force: only re-link config.toml, preserve auth.json // --force: only re-link config.toml, preserve auth.json
console.log(info(`Profile already exists: ${profileName} (re-linking config.toml)`)); console.log(info(`Profile already exists: ${profileName} (repairing shared resources)`));
_ensureSymlinkSafe(profileDir, true); _ensureProfileResourcesSafe(profileDir, true);
console.log(ok(`Profile config.toml re-linked.`)); console.log(ok(`Profile shared resources repaired.`));
console.log(` Profile dir: ${profileDir}`); console.log(` Profile dir: ${profileDir}`);
} else { } else {
_ensureSymlinkSafe(profileDir); _ensureProfileResourcesSafe(profileDir);
console.log(info(`Profile already exists: ${profileName}`)); 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(` Profile dir: ${profileDir}`);
console.log(` Run: ccsx auth login ${profileName}`); 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. // Avoids registry orphan if mkdir hits EACCES/ENOSPC.
try { try {
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
_ensureSymlinkSafe(profileDir); _ensureProfileResourcesSafe(profileDir);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
if ((err as NodeJS.ErrnoException).code === 'EACCES') { 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( async function _spawnLogin(
profileName: string, profileName: string,
profileDir: string, profileDir: string,
+1
View File
@@ -6,6 +6,7 @@ export {
getSharedCodexConfigPath, getSharedCodexConfigPath,
} from './codex-profile-paths'; } from './codex-profile-paths';
export { ensureSharedConfigSymlink } from './codex-config-symlink'; export { ensureSharedConfigSymlink } from './codex-config-symlink';
export { ensureCodexProfileResources, SHARED_CODEX_RESOURCE_DIRS } from './codex-profile-resources';
export { decodeAccountIdentity } from './codex-account-identity'; export { decodeAccountIdentity } from './codex-account-identity';
export { decodeIdToken } from './decode-id-token'; export { decodeIdToken } from './decode-id-token';
export type { CodexProfileMetadata, CodexProfileData, CodexAccountIdentity } from './types'; export type { CodexProfileMetadata, CodexProfileData, CodexAccountIdentity } from './types';
+35 -1
View File
@@ -5,7 +5,7 @@
* each test so the module re-evaluates with updated stubs and env state. * each test so the module re-evaluates with updated stubs and env state.
* Stub runCodexAuth and require('../ccs') via require.cache injection. * 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 fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; 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 codexAuthRouterPath = require.resolve('../../../src/codex-auth/codex-auth-router.ts');
const resolveProfilePath = require.resolve('../../../src/codex-auth/resolve-active-profile.ts'); const resolveProfilePath = require.resolve('../../../src/codex-auth/resolve-active-profile.ts');
const symlinkPath = require.resolve('../../../src/codex-auth/codex-config-symlink.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_CCS_HOME = process.env.CCS_HOME;
const ORIGINAL_CODEX_HOME = process.env.CODEX_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 tempDir: string;
let ccsHome: string; let ccsHome: string;
let homeDir: string;
let registryPath: string; let registryPath: string;
let instancesDir: string; let instancesDir: string;
@@ -31,6 +33,7 @@ function flushRouterCache() {
delete require.cache[codexAuthRouterPath]; delete require.cache[codexAuthRouterPath];
delete require.cache[resolveProfilePath]; delete require.cache[resolveProfilePath];
delete require.cache[symlinkPath]; delete require.cache[symlinkPath];
delete require.cache[resourcePath];
// Keep ccsPath stub intact — tests inject it explicitly each time // Keep ccsPath stub intact — tests inject it explicitly each time
} }
@@ -61,8 +64,14 @@ async function withCapturedStderr<T>(fn: () => Promise<T>): Promise<{ result: T;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-')); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-'));
homeDir = path.join(tempDir, 'home');
ccsHome = path.join(tempDir, 'ccs'); 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 }); fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 });
spyOn(os, 'homedir').mockReturnValue(homeDir);
process.env.CCS_HOME = ccsHome; process.env.CCS_HOME = ccsHome;
registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml'); registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
instancesDir = path.join(ccsHome, '.ccs', 'codex-instances'); instancesDir = path.join(ccsHome, '.ccs', 'codex-instances');
@@ -83,6 +92,7 @@ afterEach(() => {
flushRouterCache(); flushRouterCache();
delete require.cache[ccsPath]; delete require.cache[ccsPath];
mock.restore();
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
}); });
@@ -165,9 +175,33 @@ describe('codex-runtime router — non-auth profile resolution', () => {
expect(code).toBe(-1); expect(code).toBe(-1);
expect(process.env.CCS_CODEX_PROFILE).toBe('ck'); expect(process.env.CCS_CODEX_PROFILE).toBe('ck');
expect(process.env.CODEX_HOME).toBe(profileDir); 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']); 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<number> };
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 () => { it('leaves CODEX_HOME unset when no registry exists and no env profile set', async () => {
// No registry file, no CCS_CODEX_PROFILE // No registry file, no CCS_CODEX_PROFILE
require.cache[ccsPath] = { exports: {} } as NodeJS.Module; require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
@@ -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);
});
});
@@ -10,13 +10,20 @@ import * as childProcess from 'child_process';
let tempDir: string; let tempDir: string;
let ccsHome: string; let ccsHome: string;
let homeDir: string;
const ORIG_CCS_HOME = process.env.CCS_HOME; const ORIG_CCS_HOME = process.env.CCS_HOME;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-create-test-')); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-create-test-'));
homeDir = path.join(tempDir, 'home');
ccsHome = path.join(tempDir, 'ccs'); 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 }); fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome; process.env.CCS_HOME = ccsHome;
spyOn(os, 'homedir').mockReturnValue(homeDir);
}); });
afterEach(() => { afterEach(() => {
@@ -87,6 +94,8 @@ describe('handleCreateCodex — happy path', () => {
expect(ctx.registry.hasProfile('myprofile')).toBe(true); expect(ctx.registry.hasProfile('myprofile')).toBe(true);
const instancesDir = path.join(ccsHome, '.ccs', 'codex-instances', 'myprofile'); const instancesDir = path.join(ccsHome, '.ccs', 'codex-instances', 'myprofile');
expect(fs.existsSync(instancesDir)).toBe(true); 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 profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'dupprofile');
const configPath = path.join(profileDir, 'config.toml'); const configPath = path.join(profileDir, 'config.toml');
const agentsPath = path.join(profileDir, 'agents');
const authJsonPath = path.join(profileDir, 'auth.json'); 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.writeFileSync(authJsonPath, authJson);
fs.rmSync(configPath, { force: true }); fs.rmSync(configPath, { force: true });
fs.rmSync(agentsPath, { recursive: true, force: true });
const restore2 = silenceConsole(); const restore2 = silenceConsole();
try { try {
@@ -124,6 +137,7 @@ describe('handleCreateCodex — idempotent re-run', () => {
// Profile still has exactly one entry // Profile still has exactly one entry
expect(ctx.registry.listProfiles().filter((n) => n === 'dupprofile').length).toBe(1); expect(ctx.registry.listProfiles().filter((n) => n === 'dupprofile').length).toBe(1);
expect(fs.existsSync(configPath)).toBe(true); expect(fs.existsSync(configPath)).toBe(true);
expect(fs.existsSync(path.join(agentsPath, 'brainstormer.toml'))).toBe(true);
expect(fs.readFileSync(authJsonPath, 'utf8')).toBe(authJson); expect(fs.readFileSync(authJsonPath, 'utf8')).toBe(authJson);
}); });
}); });