UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

224 lines 8.73 kB
/** * Codex Per-Profile Runtime-Home Adapter * * Codex has no native per-session config flag (unlike `claude --mcp-config`). * This adapter implements the sysops `codex-role.sh` pattern: * * 1. Create ~/.codex/roles-runtime/<profile>/ per profile * 2. Symlink shared state (history, sessions) into the runtime home * 3. Write a profile-scoped config.toml (stripped global MCP, only profile servers) * 4. Launch with HOME=<runtime-home> codex * 5. Auth flows execute against the runtime home — OAuth tokens are isolated per profile * * Reference: roctinam/sysops:scripts/mcp-roles/codex-role.sh * * @implements #892 */ import { readFile, writeFile, mkdir, symlink, access, readdir } from 'fs/promises'; import { join } from 'path'; import { homedir } from 'os'; import { existsSync } from 'fs'; import { spawnSync } from 'child_process'; const DEFAULT_SHARED_STATE = { // Shared: operator history and session index are cross-profile shared: ['history.jsonl', 'sessions'], // Isolated: auth tokens and per-profile config are scoped to the runtime home isolated: ['auth.json', '.credentials', 'config.toml'], }; // ───────────────────────────────────────────── // Paths // ───────────────────────────────────────────── function codexHome() { return join(homedir(), '.codex'); } function runtimeHomesDir() { return join(codexHome(), 'roles-runtime'); } function runtimeHomePath(profile) { return join(runtimeHomesDir(), profile); } function runtimeConfigPath(profile) { return join(runtimeHomePath(profile), 'config.toml'); } // ───────────────────────────────────────────── // Runtime home management // ───────────────────────────────────────────── /** * Ensure the runtime home directory exists for a profile. * Creates the directory and sets up shared-state symlinks. * Returns the runtime home path. */ export async function ensureRuntimeHome(profile, policy = DEFAULT_SHARED_STATE) { const rtHome = runtimeHomePath(profile); await mkdir(rtHome, { recursive: true }); const globalHome = codexHome(); // Create symlinks for shared state (only if source exists in global home) for (const name of policy.shared) { const source = join(globalHome, name); const link = join(rtHome, name); // Skip if already linked or already exists try { await access(link); continue; // already exists } catch { // doesn't exist — create symlink if source exists } if (existsSync(source)) { try { await symlink(source, link); } catch { // ignore symlink errors (e.g., cross-device — not fatal) } } } return rtHome; } /** * Write a profile-scoped config.toml into the runtime home. * Strips all [mcp_servers.*] blocks from any existing global config.toml * and writes only the profile's servers. */ export async function writeProfileConfig(profile, servers) { const rtHome = runtimeHomePath(profile); await mkdir(rtHome, { recursive: true }); // Load global config.toml as base (strip existing mcp_servers blocks) let baseConfig = ''; const globalConfigPath = join(codexHome(), 'config.toml'); try { const raw = await readFile(globalConfigPath, 'utf-8'); // Remove all [mcp_servers.*] sections and their content baseConfig = raw .replace(/\[mcp_servers\.[^\]]+\][\s\S]*?(?=\n\[|\s*$)/g, '') .trimEnd(); } catch { // No global config — start empty } // Build profile-specific [mcp_servers.*] TOML sections const mcpSections = []; for (const server of servers) { const lines = [`[mcp_servers.${server.name}]`]; if (server.type === 'stdio') { lines.push(`command = "${server.command}"`); if (server.args && server.args.length > 0) { const argsStr = server.args.map((a) => `"${a}"`).join(', '); lines.push(`args = [${argsStr}]`); } if (server.env) { for (const [k, v] of Object.entries(server.env)) { lines.push(`env.${k} = "${v}"`); } } } else { lines.push(`url = "${server.url}"`); if (server.headers) { for (const [k, v] of Object.entries(server.headers)) { lines.push(`headers.${k} = "${v}"`); } } } lines.push(`startup_timeout_sec = 10.0`); lines.push(`tool_timeout_sec = 60.0`); mcpSections.push(lines.join('\n')); } const configContent = (baseConfig ? baseConfig + '\n\n' : '') + mcpSections.join('\n\n') + '\n'; await writeFile(runtimeConfigPath(profile), configContent, 'utf-8'); } /** * Launch Codex with a profile's runtime home. * Sets HOME=<runtime-home> so Codex reads its profile-scoped config and * OAuth tokens are written to the runtime home (isolated from other profiles). */ export function launchWithProfile(profile, extraArgs = []) { const rtHome = runtimeHomePath(profile); if (!existsSync(rtHome)) { throw new Error(`Runtime home for profile "${profile}" does not exist.\n` + `Run "aiwg mcp profile add ${profile}" or "aiwg session --provider codex --profile ${profile}" to create it.`); } const env = { ...process.env, HOME: rtHome, }; return spawnSync('codex', extraArgs, { stdio: 'inherit', env, }); } /** * Run `codex login` within a profile's runtime home to isolate OAuth tokens. */ export async function loginInProfile(profile) { await ensureRuntimeHome(profile); const rtHome = runtimeHomePath(profile); console.log(`\n Running codex login in profile "${profile}" runtime home...`); console.log(` Auth tokens will be isolated to: ${rtHome}\n`); const result = spawnSync('codex', ['login'], { stdio: 'inherit', env: { ...process.env, HOME: rtHome, }, }); if (result.status !== 0) { throw new Error(`codex login failed with exit code ${result.status ?? 'unknown'}`); } } // ───────────────────────────────────────────── // Introspection // ───────────────────────────────────────────── /** * List all runtime homes (one per profile that has been set up). */ export async function listRuntimeHomes() { const dir = runtimeHomesDir(); if (!existsSync(dir)) return []; let entries = []; try { entries = await readdir(dir); } catch { return []; } return Promise.all(entries.map(async (name) => { const rtPath = join(dir, name); const configPath = join(rtPath, 'config.toml'); const authPath = join(rtPath, 'auth.json'); return { profile: name, path: rtPath, exists: true, hasConfig: existsSync(configPath), hasOrphanedAuth: existsSync(authPath), }; })); } /** * Remove a profile's runtime home (destructive — deletes auth tokens). * Returns false if the home does not exist. */ export async function removeRuntimeHome(profile) { const rtHome = runtimeHomePath(profile); if (!existsSync(rtHome)) return false; const result = spawnSync('rm', ['-rf', rtHome], { stdio: 'inherit' }); return result.status === 0; } /** * Detect orphaned runtime homes: runtime homes where the corresponding * MCP profile no longer exists in the profile registry. */ export async function detectOrphanedRuntimes(knownProfiles) { const homes = await listRuntimeHomes(); return homes.filter((h) => !knownProfiles.includes(h.profile)); } // ───────────────────────────────────────────── // Re-exports (convenience) // ───────────────────────────────────────────── export { DEFAULT_SHARED_STATE }; //# sourceMappingURL=codex-runtime.js.map