mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
fix: repair ccsx codex profile resources
This commit is contained in:
1 parent
17e221ac2c
commit
37a14525cc
9 files changed
+346
-17
No files matched your search
+26
-7
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user