From 460712f2a10f76531750209fce19462a8726b197 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Mon, 14 Sep 2026 18:50:17 +1000 Subject: [PATCH 1/3] feat(profile): add Codex profile adapter --- .../content/docs/docs/reference/clients.mdx | 27 + .../docs/docs/reference/configuration.mdx | 39 +- src/core/native/codex.ts | 681 ++++++++++++++++++ src/core/native/index.ts | 8 + src/core/profile/adapters/codex.ts | 319 ++++++++ src/core/profile/adapters/registry.ts | 3 + src/core/profile/launcher.ts | 29 +- src/core/profile/plan.ts | 24 + src/models/workspace-config.ts | 28 +- tests/unit/core/native/codex.test.ts | 294 ++++++++ tests/unit/core/profile-launcher.test.ts | 53 ++ tests/unit/core/profile/adapters.test.ts | 2 + tests/unit/core/profile/codex.test.ts | 198 +++++ .../models/workspace-config-profiles.test.ts | 47 ++ 14 files changed, 1748 insertions(+), 4 deletions(-) create mode 100644 src/core/native/codex.ts create mode 100644 src/core/profile/adapters/codex.ts create mode 100644 tests/unit/core/native/codex.test.ts create mode 100644 tests/unit/core/profile/codex.test.ts diff --git a/docs/src/content/docs/docs/reference/clients.mdx b/docs/src/content/docs/docs/reference/clients.mdx index b8d2838f..dafb1865 100644 --- a/docs/src/content/docs/docs/reference/clients.mdx +++ b/docs/src/content/docs/docs/reference/clients.mdx @@ -75,6 +75,33 @@ outside `COPILOT_HOME`, so this is configuration isolation, not a security sandbox. See GitHub's [Copilot CLI configuration directory reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference). +### Codex + +Codex global profiles require Codex CLI 0.149.0 or newer. AllAgents assigns an +owned `CODEX_HOME`, writes `.config.toml`, and generates a launcher +that verifies the named file exists before running `codex --profile `. +The launcher preserves arguments and the current working directory, so trusted +project configuration and instructions retain their documented higher +precedence. + +File installation writes selected skills beneath the isolated home. Strict +settings and MCP servers share the named TOML profile. Portable MCP +`${ENV_VAR}` references are translated to Codex's environment-name fields; the +secret value is never resolved or written. Reference shapes Codex cannot +represent natively fail before mutation. + +Native installation requires one authoritative marketplace identity. AllAgents +delegates marketplace registration, plugin installation, Git marketplace +upgrade, local plugin refresh, removal, and inventory inspection to Codex. +Removing an AllAgents-managed marketplace fails closed while another installed +plugin still depends on it. + +`CODEX_HOME` also contains Codex credentials, sessions, logs, and caches. +Recursive cleanup is limited to a client root AllAgents created for this +profile; removing that profile intentionally removes its isolated runtime +state. Ambient `~/.codex`, project files, and operating-system keyrings remain +outside the cleanup boundary. + ## Provider-Specific Clients These clients use their own skills directory. As above, Hooks lists diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index 09595639..cb69d136 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -140,15 +140,34 @@ profiles: command: review-mcp env: REVIEW_TOKEN: ${REVIEW_TOKEN} + + codex-review: + clients: + - name: codex + install: native + launcher: codex-review + settings: + model: gpt-5.6-sol + model_reasoning_effort: high + approval_policy: on-request + sandbox_mode: workspace-write + plugins: + - source: ./codex-marketplace + install: native + mcpServers: + review: + command: review-mcp + env: + REVIEW_TOKEN: ${REVIEW_TOKEN} ``` | Field | Required | Description | |-------|----------|-------------| | `profiles..clients` | Yes | One or more object-form profile clients | -| `clients[].name` | Yes | Supported profile client; currently `pi`, `omp`, `opencode`, or `copilot` | +| `clients[].name` | Yes | Supported profile client; currently `pi`, `omp`, `opencode`, `copilot`, or `codex` | | `clients[].install` | No | Default plugin mode, `file` by default; OpenCode rejects `native` because its CLI lacks a complete inspect/update/remove lifecycle | | `clients[].launcher` | No | Safe command basename written to the configured user bin directory | -| `clients[].settings` | No | Strict client settings object; Pi and OMP accept no settings, while OpenCode and Copilot accept their documented profile settings | +| `clients[].settings` | No | Strict client settings object; Pi and OMP accept no settings, while OpenCode, Copilot, and Codex accept their documented profile settings | | `profiles..plugins` | No | Profile plugin declarations; defaults to an empty list | | `plugins[].source` | Yes | npm, GitHub, marketplace, or local source supported by the selected adapter | | `plugins[].ref` | No | Requested Git ref for a GitHub source | @@ -194,6 +213,22 @@ entry. Native skill filters and native Git refs are rejected because Copilot cannot enforce either constraint. File installation supports skill selection and pinned Git sources instead. +Codex profiles require Codex CLI 0.149.0 or newer. Their launchers set an +isolated `CODEX_HOME`, require the generated `.config.toml`, select it +with `--profile `, and preserve the caller's working directory for +normal project discovery. Settings accept `model`, `model_reasoning_effort`, +`model_reasoning_summary`, `model_verbosity`, `approval_policy`, +`sandbox_mode`, `web_search`, and `personality`. All other keys fail +validation. + +Codex MCP declarations share the named TOML profile. Exact `${ENV_VAR}` +references become Codex environment-variable names (`env_vars` or +`env_http_headers`) instead of literal values. References in commands, arguments, +URLs, or remapped stdio variables fail before mutation because Codex does not +interpolate them. Native plugins use authoritative marketplace identities; +AllAgents delegates registration, install, Git marketplace upgrade, targeted +refresh, uninstall, and safe marketplace removal to Codex. + Install profiles explicitly with `allagents profile install --yes`. Ordinary `allagents update` reconciles installed, still-declared profiles; repeat `--profile ` to select only installed profiles. Removing a diff --git a/src/core/native/codex.ts b/src/core/native/codex.ts new file mode 100644 index 00000000..7658dd7f --- /dev/null +++ b/src/core/native/codex.ts @@ -0,0 +1,681 @@ +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + executeCommand, + type NativeClient, + type NativeCommandOptions, + type NativeCommandResult, + type NativeInspectionResult, + type NativeMutationResult, + type NativeOperationContext, + type NativeResource, + type NativeResourceObservation, + type NativeSourceResolution, +} from './types.js'; + +type CodexCommandRunner = ( + binary: string, + args: string[], + options?: NativeCommandOptions, +) => Promise; + +export interface CodexNativeClientOptions { + execute?: CodexCommandRunner; + minimumVersion?: readonly [number, number, number]; +} + +export interface CodexMarketplaceRegistrationInspection { + success: boolean; + present: boolean; + root?: string; + source?: string; + sourceType?: string; + error?: string; +} + +interface CodexPluginInventoryEntry { + readonly pluginId: string; + readonly name: string; + readonly marketplaceName: string; + readonly installed: boolean; + readonly enabled: boolean; + readonly marketplaceSource?: { + readonly sourceType: string; + readonly source: string; + }; +} + +interface CodexMarketplaceEntry { + readonly name: string; + readonly root: string; + readonly marketplaceSource?: { + readonly sourceType: string; + readonly source: string; + }; +} + +function commandOptions(context: NativeOperationContext): NativeCommandOptions { + return { + ...(context.cwd && { cwd: context.cwd }), + ...(context.env && { env: context.env }), + }; +} + +async function profileRootExists( + context: NativeOperationContext, +): Promise { + return access(context.root).then( + () => true, + (error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + }, + ); +} + +function commandError(result: NativeCommandResult): string { + if (result.error) return result.error; + if (result.signal) return `Codex CLI terminated by ${result.signal}`; + return `Codex CLI exited with code ${result.exitCode ?? 'unknown'}`; +} + +function versionTuple(output: string): readonly number[] | null { + const match = /(?:^|\s)v?(\d+)\.(\d+)\.(\d+)(?=\D|$)/.exec(output); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +} + +function compareVersion( + left: readonly number[], + right: readonly number[], +): number { + for (let index = 0; index < 3; index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +function parseJsonRecord(output: string): Record | null { + try { + const value = JSON.parse(output); + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; + } catch { + return null; + } +} + +function parseMarketplaceSource( + value: unknown, +): CodexMarketplaceEntry['marketplaceSource'] | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const source = value as Record; + return typeof source.sourceType === 'string' && + typeof source.source === 'string' + ? { sourceType: source.sourceType, source: source.source } + : undefined; +} + +function parseMarketplaceEntry(value: unknown): CodexMarketplaceEntry | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const entry = value as Record; + if (typeof entry.name !== 'string' || typeof entry.root !== 'string') { + return null; + } + const marketplaceSource = parseMarketplaceSource(entry.marketplaceSource); + return { + name: entry.name, + root: entry.root, + ...(marketplaceSource && { marketplaceSource }), + }; +} + +export function parseCodexMarketplaceInventory( + output: string, +): readonly CodexMarketplaceEntry[] | null { + const parsed = parseJsonRecord(output); + if (!parsed || !Array.isArray(parsed.marketplaces)) return null; + const entries = parsed.marketplaces.map(parseMarketplaceEntry); + return entries.every( + (entry): entry is CodexMarketplaceEntry => entry !== null, + ) + ? entries + : null; +} + +function parsePluginEntry(value: unknown): CodexPluginInventoryEntry | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const entry = value as Record; + if ( + typeof entry.pluginId !== 'string' || + typeof entry.name !== 'string' || + typeof entry.marketplaceName !== 'string' || + typeof entry.installed !== 'boolean' || + typeof entry.enabled !== 'boolean' + ) { + return null; + } + const marketplaceSource = parseMarketplaceSource(entry.marketplaceSource); + return { + pluginId: entry.pluginId, + name: entry.name, + marketplaceName: entry.marketplaceName, + installed: entry.installed, + enabled: entry.enabled, + ...(marketplaceSource && { marketplaceSource }), + }; +} + +export function parseCodexPluginInventory(output: string): { + readonly installed: readonly CodexPluginInventoryEntry[]; + readonly available: readonly CodexPluginInventoryEntry[]; +} | null { + const parsed = parseJsonRecord(output); + if ( + !parsed || + !Array.isArray(parsed.installed) || + !Array.isArray(parsed.available) + ) { + return null; + } + const installed = parsed.installed.map(parsePluginEntry); + const available = parsed.available.map(parsePluginEntry); + return installed.every( + (entry): entry is CodexPluginInventoryEntry => entry !== null, + ) && + available.every( + (entry): entry is CodexPluginInventoryEntry => entry !== null, + ) + ? { installed, available } + : null; +} + +export function parseCodexPluginId( + source: string, +): { plugin: string; marketplace: string } | null { + const atIndex = source.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === source.length - 1) return null; + const plugin = source.slice(0, atIndex); + const marketplace = source.slice(atIndex + 1); + if ( + plugin.includes('/') || + plugin.includes('\\') || + marketplace.includes('/') || + marketplace.includes('\\') || + marketplace.includes('://') + ) { + return null; + } + return { plugin, marketplace }; +} + +function parseMutationIdentity( + output: string, + expectedIdentity: string, +): boolean { + const parsed = parseJsonRecord(output); + return parsed?.pluginId === expectedIdentity; +} + +function parseMarketplaceAdd( + output: string, + expectedMarketplace: string, +): { alreadyAdded: boolean } | null { + const parsed = parseJsonRecord(output); + return parsed?.marketplaceName === expectedMarketplace && + typeof parsed.alreadyAdded === 'boolean' + ? { alreadyAdded: parsed.alreadyAdded } + : null; +} + +function parseMarketplaceUpgrade( + output: string, + expectedMarketplace: string, +): boolean { + const parsed = parseJsonRecord(output); + return Boolean( + parsed && + Array.isArray(parsed.selectedMarketplaces) && + parsed.selectedMarketplaces.includes(expectedMarketplace) && + Array.isArray(parsed.upgradedRoots) && + Array.isArray(parsed.errors) && + parsed.errors.length === 0, + ); +} + +export class CodexNativeClient implements NativeClient { + readonly client = 'codex'; + private readonly run: CodexCommandRunner; + private readonly minimumVersion: + | readonly [number, number, number] + | undefined; + + constructor(options: CodexNativeClientOptions = {}) { + this.run = options.execute ?? executeCommand; + this.minimumVersion = options.minimumVersion; + } + + private async runIsolated( + args: string[], + context?: NativeOperationContext, + ): Promise { + const temporaryRoot = await mkdtemp( + join(tmpdir(), 'allagents-codex-inspection-'), + ); + try { + return await this.run('codex', args, { + ...(context?.cwd && { cwd: context.cwd }), + env: { + ...context?.env, + CODEX_HOME: temporaryRoot, + }, + }); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } + } + + private async runForInspection( + args: string[], + context: NativeOperationContext, + ): Promise { + return (await profileRootExists(context)) + ? this.run('codex', args, commandOptions(context)) + : this.runIsolated(args, context); + } + + async isAvailable(context?: NativeOperationContext): Promise { + const version = await this.runIsolated(['--version'], context); + if (!version.success) return false; + const parsedVersion = versionTuple(version.output); + if ( + this.minimumVersion && + (!parsedVersion || compareVersion(parsedVersion, this.minimumVersion) < 0) + ) { + return false; + } + const pluginHelp = await this.runIsolated(['plugin', '--help'], context); + return ( + pluginHelp.success && + ['add', 'list', 'marketplace', 'remove'].every((command) => + pluginHelp.output.includes(command), + ) + ); + } + + supportsScope(scope: 'user' | 'project'): boolean { + return scope === 'user'; + } + + resolveSource( + source: string, + context: NativeOperationContext, + provenance: Readonly> = {}, + ): NativeSourceResolution { + const identity = parseCodexPluginId(source); + if (!identity) { + return { + success: false, + error: `Codex native install does not support source '${source}'`, + }; + } + return { + success: true, + resource: { + kind: 'plugin', + requestedIdentity: source, + resolvedIdentity: `${identity.plugin}@${identity.marketplace}`, + context, + provenance: { + ...provenance, + marketplaceName: provenance.marketplaceName ?? identity.marketplace, + }, + }, + }; + } + + async inspect( + context: NativeOperationContext, + ): Promise { + if (!(await profileRootExists(context))) { + return { success: true, resources: [] }; + } + const result = await this.runForInspection( + ['plugin', 'list', '--json'], + context, + ); + if (!result.success) { + return { success: false, resources: [], error: commandError(result) }; + } + const inventory = parseCodexPluginInventory(result.output); + if (!inventory) { + return { + success: false, + resources: [], + error: 'Could not parse Codex plugin inventory', + }; + } + const resources: NativeResource[] = []; + const observations: NativeResourceObservation[] = []; + for (const entry of inventory.installed) { + const resource: NativeResource = { + kind: 'plugin', + requestedIdentity: entry.pluginId, + resolvedIdentity: entry.pluginId, + context, + provenance: { + marketplaceName: entry.marketplaceName, + ...(entry.marketplaceSource?.source && { + marketplaceSource: entry.marketplaceSource.source, + }), + }, + }; + if (entry.installed && entry.enabled) resources.push(resource); + else { + observations.push({ + resource, + status: entry.installed ? 'disabled' : 'configured-missing', + }); + } + } + return { + success: true, + resources, + ...(observations.length > 0 && { observations }), + }; + } + + async inspectMarketplaceRegistration( + marketplaceName: string, + context: NativeOperationContext, + ): Promise { + const result = await this.runForInspection( + ['plugin', 'marketplace', 'list', '--json'], + context, + ); + if (!result.success) { + return { + success: false, + present: false, + error: commandError(result), + }; + } + const inventory = parseCodexMarketplaceInventory(result.output); + if (!inventory) { + return { + success: false, + present: false, + error: 'Could not parse Codex marketplace inventory', + }; + } + const marketplace = inventory.find( + (entry) => entry.name === marketplaceName, + ); + return { + success: true, + present: Boolean(marketplace), + ...(marketplace?.root && { root: marketplace.root }), + ...(marketplace?.marketplaceSource?.source && { + source: marketplace.marketplaceSource.source, + }), + ...(marketplace?.marketplaceSource?.sourceType && { + sourceType: marketplace.marketplaceSource.sourceType, + }), + }; + } + + async inspectMarketplacePlugin( + marketplaceName: string, + pluginName: string, + context: NativeOperationContext, + ): Promise<{ success: boolean; present: boolean; error?: string }> { + const result = await this.runForInspection( + [ + 'plugin', + 'list', + '--marketplace', + marketplaceName, + '--available', + '--json', + ], + context, + ); + if (!result.success) { + return { + success: false, + present: false, + error: commandError(result), + }; + } + const inventory = parseCodexPluginInventory(result.output); + if (!inventory) { + return { + success: false, + present: false, + error: `Could not parse Codex marketplace '${marketplaceName}'`, + }; + } + const expected = `${pluginName}@${marketplaceName}`; + return { + success: true, + present: [...inventory.installed, ...inventory.available].some( + (entry) => entry.pluginId === expected, + ), + }; + } + + async install( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + const marketplaceName = resource.provenance.marketplaceName; + if (!marketplaceName) { + return { success: false, error: 'Codex plugin marketplace is missing' }; + } + const registrations: string[] = []; + const inspection = await this.inspectMarketplaceRegistration( + marketplaceName, + context, + ); + if (!inspection.success) { + return { + success: false, + error: + inspection.error ?? + `Could not inspect Codex marketplace '${marketplaceName}'`, + }; + } + if (!inspection.present) { + const marketplaceSource = resource.provenance.marketplaceSource; + if (!marketplaceSource) { + return { + success: false, + error: `Codex marketplace '${marketplaceName}' is not registered and has no source`, + }; + } + const args = ['plugin', 'marketplace', 'add', marketplaceSource]; + const resolvedRef = resource.provenance.resolvedRef; + if (resolvedRef) args.push('--ref', resolvedRef); + const sparsePath = resource.provenance.marketplaceSparsePath; + if (sparsePath) args.push('--sparse', sparsePath); + args.push('--json'); + const registration = await this.run( + 'codex', + args, + commandOptions(context), + ); + if (!registration.success) { + return { success: false, error: commandError(registration) }; + } + const added = parseMarketplaceAdd(registration.output, marketplaceName); + if (!added) { + return { + success: false, + error: `Could not parse Codex marketplace '${marketplaceName}' registration result`, + }; + } + if ( + !added.alreadyAdded && + resource.provenance.managedMarketplaceRegistration === 'true' + ) { + registrations.push(marketplaceName); + } + } + const result = await this.run( + 'codex', + ['plugin', 'add', resource.resolvedIdentity, '--json'], + commandOptions(context), + ); + if (!result.success) { + return { + success: false, + error: commandError(result), + ...(registrations.length > 0 && { registrations }), + }; + } + if (!parseMutationIdentity(result.output, resource.resolvedIdentity)) { + return { + success: false, + error: `Could not parse Codex plugin '${resource.resolvedIdentity}' install result`, + ...(registrations.length > 0 && { registrations }), + }; + } + return { + success: true, + ...(registrations.length > 0 && { registrations }), + }; + } + + async update( + resource: NativeResource, + _current: NativeResource, + context: NativeOperationContext, + ): Promise { + const marketplaceName = resource.provenance.marketplaceName; + if (!marketplaceName) { + return { success: false, error: 'Codex plugin marketplace is missing' }; + } + const inspection = await this.inspectMarketplaceRegistration( + marketplaceName, + context, + ); + if (!inspection.success || !inspection.present) { + return { + success: false, + error: + inspection.error ?? + `Codex marketplace '${marketplaceName}' is not registered`, + }; + } + if (inspection.sourceType === 'git') { + const upgrade = await this.run( + 'codex', + ['plugin', 'marketplace', 'upgrade', marketplaceName, '--json'], + commandOptions(context), + ); + if (!upgrade.success) { + return { success: false, error: commandError(upgrade) }; + } + if (!parseMarketplaceUpgrade(upgrade.output, marketplaceName)) { + return { + success: false, + error: `Could not parse Codex marketplace '${marketplaceName}' upgrade result`, + }; + } + } + const result = await this.run( + 'codex', + ['plugin', 'add', resource.resolvedIdentity, '--json'], + commandOptions(context), + ); + return result.success && + parseMutationIdentity(result.output, resource.resolvedIdentity) + ? { success: true } + : { + success: false, + error: result.success + ? `Could not parse Codex plugin '${resource.resolvedIdentity}' update result` + : commandError(result), + }; + } + + async remove( + resource: NativeResource, + context: NativeOperationContext, + ): Promise { + const result = await this.run( + 'codex', + ['plugin', 'remove', resource.resolvedIdentity, '--json'], + commandOptions(context), + ); + return result.success && + parseMutationIdentity(result.output, resource.resolvedIdentity) + ? { success: true } + : { + success: false, + error: result.success + ? `Could not parse Codex plugin '${resource.resolvedIdentity}' removal result` + : commandError(result), + }; + } + + async removeMarketplaceRegistration( + marketplaceName: string, + context: NativeOperationContext, + ): Promise { + const inspection = await this.inspectMarketplaceRegistration( + marketplaceName, + context, + ); + if (!inspection.success) { + return { + success: false, + error: + inspection.error ?? + `Could not inspect Codex marketplace '${marketplaceName}'`, + }; + } + if (!inspection.present) return { success: true }; + + const plugins = await this.runForInspection( + ['plugin', 'list', '--marketplace', marketplaceName, '--json'], + context, + ); + if (!plugins.success) { + return { success: false, error: commandError(plugins) }; + } + const inventory = parseCodexPluginInventory(plugins.output); + if (!inventory) { + return { + success: false, + error: `Could not parse Codex marketplace '${marketplaceName}' plugin inventory`, + }; + } + if (inventory.installed.length > 0) { + return { + success: false, + error: `Codex marketplace '${marketplaceName}' is still used by installed plugins`, + }; + } + + const result = await this.run( + 'codex', + ['plugin', 'marketplace', 'remove', marketplaceName, '--json'], + commandOptions(context), + ); + if (!result.success) { + return { success: false, error: commandError(result) }; + } + const parsed = parseJsonRecord(result.output); + return parsed?.marketplaceName === marketplaceName + ? { success: true } + : { + success: false, + error: `Could not parse Codex marketplace '${marketplaceName}' removal result`, + }; + } +} diff --git a/src/core/native/index.ts b/src/core/native/index.ts index d04ba696..86123c17 100644 --- a/src/core/native/index.ts +++ b/src/core/native/index.ts @@ -29,6 +29,14 @@ export { type CopilotMarketplaceRegistrationInspection, type CopilotNativeClientOptions, } from './copilot.js'; +export { + CodexNativeClient, + parseCodexMarketplaceInventory, + parseCodexPluginId, + parseCodexPluginInventory, + type CodexMarketplaceRegistrationInspection, + type CodexNativeClientOptions, +} from './codex.js'; export { OmpNativeClient, inspectOmpMarketplaceRegistry, diff --git a/src/core/profile/adapters/codex.ts b/src/core/profile/adapters/codex.ts new file mode 100644 index 00000000..8b3e62fa --- /dev/null +++ b/src/core/profile/adapters/codex.ts @@ -0,0 +1,319 @@ +import { join, resolve } from 'node:path'; +import { + CodexProfileSettingsSchema, + ProfileMcpServerConfigSchema, + ProfileNameSchema, +} from '../../../models/workspace-config.js'; +import { + CodexNativeClient, + type NativeSourceResolution, +} from '../../native/index.js'; +import type { + ProfileAdapter, + ProfileClientContext, + ProfileContextOptions, + ProfilePlannedFile, + ProfileResolvedPlugin, + ProfileSerializationInput, +} from '../types.js'; +import { serializeProfileMcpServers } from './mcp.js'; + +const CODEX_MINIMUM_VERSION = [0, 149, 0] as const; +const FILE_MAPPING = Object.freeze({ + skillsPath: 'skills/', + agentFile: 'AGENTS.md', +}); +const CAPABILITIES = Object.freeze({ + nativeInstall: true, + fileInstall: true, + launchers: true, + skillFilters: true, + mcp: true, + settings: true, + status: true, + cleanup: true, + recursiveRootCleanup: true, +}); +const SECRET_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; +const POSSIBLE_REFERENCE = /\$\{[^}]+\}/; +const BARE_TOML_KEY = /^[A-Za-z0-9_-]+$/; + +type TomlValue = string | number | boolean | readonly string[] | TomlTable; +interface TomlTable { + readonly [key: string]: TomlValue; +} + +function assertCodexContext(context: ProfileClientContext): void { + if ( + context.client !== 'codex' || + context.operationContext.client !== 'codex' || + context.operationContext.nativeScope !== `profile:${context.profileName}` || + resolve(context.root) !== context.root || + context.operationContext.env?.CODEX_HOME !== context.root + ) { + throw new Error( + 'Codex profile adapter received a mismatched or non-absolute context', + ); + } +} + +function tomlKey(value: string): string { + return BARE_TOML_KEY.test(value) ? value : JSON.stringify(value); +} + +function tomlScalar(value: Exclude): string { + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return `[${value.map((entry) => JSON.stringify(entry)).join(', ')}]`; +} + +function isTomlTable(value: TomlValue): value is TomlTable { + return typeof value === 'object' && !Array.isArray(value); +} + +function renderTomlTable( + table: TomlTable, + path: readonly string[] = [], +): string[] { + const lines: string[] = []; + const entries = Object.entries(table).sort(([left], [right]) => + left.localeCompare(right), + ); + const scalars = entries.filter(([, value]) => !isTomlTable(value)); + const children = entries.filter(([, value]) => isTomlTable(value)); + + if (path.length > 0 && (scalars.length > 0 || children.length === 0)) { + lines.push(`[${path.map(tomlKey).join('.')}]`); + } + for (const [key, value] of scalars) { + lines.push( + `${tomlKey(key)} = ${tomlScalar(value as Exclude)}`, + ); + } + for (const [key, value] of children) { + const childLines = renderTomlTable(value as TomlTable, [...path, key]); + if (childLines.length === 0) continue; + if (lines.length > 0) lines.push(''); + lines.push(...childLines); + } + return lines; +} + +function referenceName(value: string): string | null { + return SECRET_REFERENCE.exec(value)?.[1] ?? null; +} + +function rejectUnsupportedReference(value: string, field: string): void { + if (POSSIBLE_REFERENCE.test(value)) { + throw new Error( + `Codex profile ${field} cannot interpolate portable secret references`, + ); + } +} + +function serializeCodexMcp( + input: ProfileSerializationInput, +): Readonly> | null { + const selected = serializeProfileMcpServers(input, 'codex'); + if (selected === null) return null; + const servers: Record = {}; + for (const [name, value] of Object.entries(selected)) { + const server = ProfileMcpServerConfigSchema.parse(value); + if ('url' in server) { + rejectUnsupportedReference(server.url, `MCP server '${name}' URL`); + const envHttpHeaders: Record = {}; + for (const [header, headerValue] of Object.entries( + server.headers ?? {}, + )) { + const environmentName = referenceName(headerValue); + if (!environmentName) { + throw new Error( + `Codex profile MCP server '${name}' header '${header}' requires an exact portable secret reference`, + ); + } + envHttpHeaders[header] = environmentName; + } + servers[name] = { + url: server.url, + ...(Object.keys(envHttpHeaders).length > 0 && { + env_http_headers: envHttpHeaders, + }), + }; + continue; + } + + rejectUnsupportedReference(server.command, `MCP server '${name}' command`); + for (const argument of server.args ?? []) { + rejectUnsupportedReference(argument, `MCP server '${name}' arguments`); + } + const forwardedEnvironment: string[] = []; + for (const [key, envValue] of Object.entries(server.env ?? {})) { + const environmentName = referenceName(envValue); + if (!environmentName) { + throw new Error( + `Codex profile MCP server '${name}' environment '${key}' requires an exact portable secret reference`, + ); + } + if (environmentName !== key) { + throw new Error( + `Codex profile MCP server '${name}' cannot remap \${${environmentName}} to '${key}'`, + ); + } + forwardedEnvironment.push(environmentName); + } + servers[name] = { + command: server.command, + ...(server.args && { args: server.args }), + ...(forwardedEnvironment.length > 0 && { + env_vars: forwardedEnvironment.sort(), + }), + }; + } + return Object.freeze(servers); +} + +export class CodexProfileAdapter implements ProfileAdapter { + readonly client = 'codex' as const; + readonly capabilities = CAPABILITIES; + readonly nativeClient = new CodexNativeClient({ + minimumVersion: CODEX_MINIMUM_VERSION, + }); + + resolveContext( + profileName: string, + options: ProfileContextOptions, + ): ProfileClientContext { + ProfileNameSchema.parse(profileName); + const homeDir = resolve(options.homeDir); + const workspaceDirectory = resolve(options.workspaceDirectory); + const root = join( + homeDir, + '.allagents', + 'profiles', + profileName, + 'clients', + 'codex', + 'home', + ); + const selectedEnvironment = Object.freeze({ CODEX_HOME: root }); + const operationContext = Object.freeze({ + client: this.client, + scope: 'user' as const, + nativeScope: `profile:${profileName}`, + root, + cwd: workspaceDirectory, + env: Object.freeze({ + ...options.environment, + ...selectedEnvironment, + }), + roots: Object.freeze({ config: root, agent: root, data: root }), + }); + return Object.freeze({ + profileName, + client: this.client, + mechanism: 'isolated-home-named-profile', + root, + operationContext, + fileMapping: FILE_MAPPING, + launcher: Object.freeze({ + command: 'codex', + args: Object.freeze(['--profile', profileName]), + env: selectedEnvironment, + requiredFiles: Object.freeze([ + join(root, `${profileName}.config.toml`), + ]), + }), + }); + } + + async isRuntimeAvailable(context: ProfileClientContext): Promise { + assertCodexContext(context); + return this.nativeClient.isAvailable(context.operationContext); + } + + resolveNativeSource( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + ): NativeSourceResolution { + assertCodexContext(context); + if (plugin.install !== 'native') { + return { + success: false, + error: + 'Codex profile native source resolution requires install mode native', + }; + } + if (plugin.skills !== undefined) { + return { + success: false, + error: + 'Codex native profile skill filtering cannot be enforced exactly', + }; + } + if (!plugin.marketplace || !plugin.pluginName) { + return { + success: false, + error: + 'Codex native profile installation requires authoritative marketplace metadata', + }; + } + return this.nativeClient.resolveSource( + `${plugin.pluginName}@${plugin.marketplace}`, + context.operationContext, + { + declarationIndex: String(plugin.declarationIndex), + marketplaceName: plugin.marketplace, + ...(plugin.marketplaceSource && { + marketplaceSource: plugin.marketplaceSource, + }), + ...(plugin.marketplaceSparsePath && { + marketplaceSparsePath: plugin.marketplaceSparsePath, + }), + ...(plugin.marketplaceRegistrationManaged !== undefined && { + managedMarketplaceRegistration: String( + plugin.marketplaceRegistrationManaged, + ), + }), + ...(plugin.requestedRef && { requestedRef: plugin.requestedRef }), + ...(plugin.resolvedRef && { resolvedRef: plugin.resolvedRef }), + ...(plugin.resolvedSha && { resolvedSha: plugin.resolvedSha }), + }, + ); + } + + serializeSettings( + context: ProfileClientContext, + input: ProfileSerializationInput, + ): ProfilePlannedFile | null { + assertCodexContext(context); + const settings = CodexProfileSettingsSchema.parse(input.settings ?? {}); + const mcpServers = serializeCodexMcp(input); + const document: TomlTable = { + ...(settings as TomlTable), + ...(mcpServers && { mcp_servers: mcpServers }), + }; + return Object.freeze({ + key: 'codex:profile-config', + client: this.client, + kind: 'settings' as const, + path: join(context.root, `${context.profileName}.config.toml`), + content: `${renderTomlTable(document).join('\n')}\n`, + mode: 0o600, + }); + } + + serializeMcp( + context: ProfileClientContext, + _input: ProfileSerializationInput, + ): ProfilePlannedFile | null { + assertCodexContext(context); + // Codex stores settings and MCP declarations in one named profile file. + return null; + } +} + +export const codexProfileAdapter: ProfileAdapter = Object.freeze( + new CodexProfileAdapter(), +); diff --git a/src/core/profile/adapters/registry.ts b/src/core/profile/adapters/registry.ts index 8c819e24..060a575b 100644 --- a/src/core/profile/adapters/registry.ts +++ b/src/core/profile/adapters/registry.ts @@ -1,6 +1,7 @@ import type { ClientType } from '../../../models/workspace-config.js'; import type { ProfileAdapter } from '../types.js'; import { copilotProfileAdapter } from './copilot.js'; +import { codexProfileAdapter } from './codex.js'; import { ompProfileAdapter } from './omp.js'; import { openCodeProfileAdapter } from './opencode.js'; import { piProfileAdapter } from './pi.js'; @@ -8,6 +9,7 @@ import { piProfileAdapter } from './pi.js'; const PROFILE_ADAPTERS: Readonly>> = Object.freeze({ copilot: copilotProfileAdapter, + codex: codexProfileAdapter, pi: piProfileAdapter, omp: ompProfileAdapter, opencode: openCodeProfileAdapter, @@ -21,6 +23,7 @@ export { CopilotProfileAdapter, copilotProfileAdapter, } from './copilot.js'; +export { CodexProfileAdapter, codexProfileAdapter } from './codex.js'; export { OmpProfileAdapter, ompProfileAdapter } from './omp.js'; export { OpenCodeProfileAdapter, diff --git a/src/core/profile/launcher.ts b/src/core/profile/launcher.ts index 7add6f90..50127559 100644 --- a/src/core/profile/launcher.ts +++ b/src/core/profile/launcher.ts @@ -1,5 +1,5 @@ import { readdir } from 'node:fs/promises'; -import { basename, delimiter, dirname, join, resolve, win32 } from 'node:path'; +import { basename, delimiter, dirname, isAbsolute, join, resolve, win32 } from 'node:path'; import { ProfileNameSchema } from '../../models/workspace-config.js'; import type { ProfileLauncherInvocation } from './types.js'; import { @@ -42,6 +42,12 @@ function quotePowerShell(value: string): string { return `'${value.replaceAll("'", "''")}'`; } + +function validateRequiredFile(path: string): void { + if (!isAbsolute(path) || path.includes('\0')) { + throw new Error('Profile launcher required files must be absolute paths'); + } +} function validateInvocation(invocation: ProfileLauncherInvocation): void { if (!invocation.command) throw new Error('Profile launcher command cannot be empty'); for (const argument of [invocation.command, ...invocation.args]) { @@ -53,6 +59,7 @@ function validateInvocation(invocation: ProfileLauncherInvocation): void { throw new Error('Profile launcher arguments cannot contain credentials or secret-bearing options'); } } + for (const path of invocation.requiredFiles ?? []) validateRequiredFile(path); for (const [name, value] of Object.entries(invocation.env)) { if (!ENVIRONMENT_NAME.test(name)) { throw new Error(`Invalid profile launcher environment name: ${name}`); @@ -82,6 +89,15 @@ function posixEnvironmentLines( }); } +function posixRequiredFileLines(paths: readonly string[]): string[] { + return paths.flatMap((path) => [ + `if [ ! -f ${quotePosix(path)} ]; then`, + ` printf '%s\\n' ${quotePosix(`Profile launcher prerequisite is missing: ${path}`)} >&2`, + ' exit 1', + 'fi', + ]); +} + function powerShellEnvironmentLines( environment: Readonly>, ): string[] { @@ -97,6 +113,15 @@ function powerShellEnvironmentLines( }); } +function powerShellRequiredFileLines(paths: readonly string[]): string[] { + return paths.flatMap((path) => [ + `if (-not (Test-Path -LiteralPath ${quotePowerShell(path)} -PathType Leaf)) {`, + ` Write-Error ${quotePowerShell(`Profile launcher prerequisite is missing: ${path}`)}`, + ' exit 1', + '}', + ]); +} + export interface RenderedProfileLauncher { readonly companion: 'posix' | 'powershell' | 'cmd'; readonly fileName: string; @@ -127,6 +152,7 @@ export function renderProfileLaunchers( content: [ '#!/bin/sh', ...posixEnvironmentLines(invocation.env), + ...posixRequiredFileLines(invocation.requiredFiles ?? []), `exec ${posixCommand} "$@"`, '', ].join('\n'), @@ -172,6 +198,7 @@ export function renderProfileLaunchers( ` return '"' + $escaped + '"'`, '}', ...powerShellEnvironmentLines(invocation.env), + ...powerShellRequiredFileLines(invocation.requiredFiles ?? []), `$profileCommand = Resolve-ProfileCommand ${quotePowerShell(invocation.command)}`, `$profileArguments = @(${powerShellArguments}) + @($args)`, '$allArguments = $profileCommand.Prefix + $profileArguments', diff --git a/src/core/profile/plan.ts b/src/core/profile/plan.ts index addfe918..43cc8d59 100644 --- a/src/core/profile/plan.ts +++ b/src/core/profile/plan.ts @@ -422,6 +422,29 @@ function findDisabledNativeResource( )?.resource; } +function findInstalledNativeResource( + inspection: NativeInspectionResult, + matches: (resource: NativeResource) => boolean, +): NativeResource | undefined { + return ( + inspection.resources.find(matches) ?? + inspection.observations?.find( + (observation) => + observation.status === 'disabled' && matches(observation.resource), + )?.resource + ); +} + +function findDisabledNativeResource( + inspection: NativeInspectionResult, + matches: (resource: NativeResource) => boolean, +): NativeResource | undefined { + return inspection.observations?.find( + (observation) => + observation.status === 'disabled' && matches(observation.resource), + )?.resource; +} + async function planManagedFile(input: { client: ClientType; kind: 'file' | 'settings' | 'mcp' | 'launcher'; @@ -578,6 +601,7 @@ function mcpDisclosures( } + async function planRoot( client: ClientType, context: ProfileClientContext, diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index c0062127..0330e49b 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -505,6 +505,30 @@ export type CopilotProfileSettings = z.infer< typeof CopilotProfileSettingsSchema >; +export const CodexProfileSettingsSchema = z + .object({ + model: z.string().min(1).optional(), + model_reasoning_effort: z + .enum(['minimal', 'low', 'medium', 'high', 'xhigh']) + .optional(), + model_reasoning_summary: z + .enum(['auto', 'concise', 'detailed', 'none']) + .optional(), + model_verbosity: z.enum(['low', 'medium', 'high']).optional(), + approval_policy: z.enum(['on-request', 'never']).optional(), + sandbox_mode: z + .enum(['read-only', 'workspace-write', 'danger-full-access']) + .optional(), + web_search: z.enum(['disabled', 'cached', 'indexed', 'live']).optional(), + personality: z.enum(['none', 'friendly', 'pragmatic']).optional(), + }) + .strict(); + +export type CodexProfileSettings = z.infer< + typeof CodexProfileSettingsSchema +>; + + /** * Profile clients deliberately use object form only. Unsupported clients still * parse with empty settings so orchestration can report an adapter capability @@ -524,7 +548,9 @@ export const ProfileClientSchema = z ? OpenCodeProfileSettingsSchema : client.name === 'copilot' ? CopilotProfileSettingsSchema - : EmptyProfileSettingsSchema; + : client.name === 'codex' + ? CodexProfileSettingsSchema + : EmptyProfileSettingsSchema; const result = settingsSchema.safeParse(client.settings); if (result.success) return; for (const issue of result.error.issues) { diff --git a/tests/unit/core/native/codex.test.ts b/tests/unit/core/native/codex.test.ts new file mode 100644 index 00000000..d18dece2 --- /dev/null +++ b/tests/unit/core/native/codex.test.ts @@ -0,0 +1,294 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + CodexNativeClient, + parseCodexMarketplaceInventory, + parseCodexPluginId, + parseCodexPluginInventory, +} from '../../../../src/core/native/codex.js'; +import type { + NativeCommandOptions, + NativeCommandResult, + NativeOperationContext, +} from '../../../../src/core/native/types.js'; + +const roots: string[] = []; + +function success(output = ''): NativeCommandResult { + return { success: true, output, exitCode: 0 }; +} + +async function fixture(): Promise { + const root = await mkdtemp(join(tmpdir(), 'allagents-codex-native-test-')); + roots.push(root); + return { + client: 'codex', + scope: 'user', + nativeScope: 'profile:review', + root, + cwd: '/work/project', + env: { CODEX_HOME: root }, + roots: { config: root, agent: root, data: root }, + }; +} + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('native/codex', () => { + test('parses exact plugin and marketplace JSON identities', () => { + expect(parseCodexPluginId('demo@tools')).toEqual({ + plugin: 'demo', + marketplace: 'tools', + }); + expect(parseCodexPluginId('demo@owner/tools')).toBeNull(); + expect( + parseCodexMarketplaceInventory( + JSON.stringify({ + marketplaces: [ + { + name: 'tools', + root: '/marketplace', + marketplaceSource: { + sourceType: 'local', + source: '/marketplace', + }, + }, + ], + }), + ), + ).toEqual([ + { + name: 'tools', + root: '/marketplace', + marketplaceSource: { + sourceType: 'local', + source: '/marketplace', + }, + }, + ]); + expect( + parseCodexPluginInventory( + JSON.stringify({ + installed: [ + { + pluginId: 'demo@tools', + name: 'demo', + marketplaceName: 'tools', + installed: true, + enabled: true, + }, + ], + available: [], + }), + )?.installed[0]?.pluginId, + ).toBe('demo@tools'); + expect(parseCodexPluginInventory('[]')).toBeNull(); + }); + + test('gates the JSON lifecycle and version in disposable Codex homes', async () => { + const selectedRoot = join(tmpdir(), 'allagents-codex-selected-missing'); + const homes: string[] = []; + const calls: string[][] = []; + const execute = async ( + _binary: string, + args: string[], + options?: NativeCommandOptions, + ) => { + calls.push(args); + homes.push(options?.env?.CODEX_HOME ?? ''); + return args[0] === '--version' + ? success('codex-cli 0.149.0\n') + : success('Commands: add list marketplace remove\n'); + }; + const client = new CodexNativeClient({ + minimumVersion: [0, 149, 0], + execute, + }); + const context: NativeOperationContext = { + client: 'codex', + scope: 'user', + nativeScope: 'profile:review', + root: selectedRoot, + env: { CODEX_HOME: selectedRoot }, + }; + + expect(await client.isAvailable(context)).toBe(true); + expect(calls).toEqual([['--version'], ['plugin', '--help']]); + expect(homes.every((home) => home !== selectedRoot)).toBe(true); + const oldClient = new CodexNativeClient({ + minimumVersion: [0, 149, 0], + execute: async () => success('codex-cli 0.148.0\n'), + }); + expect(await oldClient.isAvailable(context)).toBe(false); + expect(await client.inspect(context)).toEqual({ + success: true, + resources: [], + }); + }); + + test('reports enabled plugins and classifies disabled plugins separately', async () => { + const context = await fixture(); + const client = new CodexNativeClient({ + execute: async () => + success( + JSON.stringify({ + installed: [ + { + pluginId: 'enabled@tools', + name: 'enabled', + marketplaceName: 'tools', + installed: true, + enabled: true, + }, + { + pluginId: 'disabled@tools', + name: 'disabled', + marketplaceName: 'tools', + installed: true, + enabled: false, + }, + ], + available: [], + }), + ), + }); + + const inspection = await client.inspect(context); + expect(inspection.resources.map((entry) => entry.resolvedIdentity)).toEqual([ + 'enabled@tools', + ]); + expect(inspection.observations?.[0]).toMatchObject({ + status: 'disabled', + resource: { resolvedIdentity: 'disabled@tools' }, + }); + }); + + test('registers a missing marketplace before installing its exact plugin', async () => { + const context = await fixture(); + const calls: string[][] = []; + const client = new CodexNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + if (args.join(' ') === 'plugin marketplace list --json') { + return success('{"marketplaces":[]}'); + } + if (args[2] === 'add' && args[1] === 'marketplace') { + return success( + '{"marketplaceName":"tools","installedRoot":"/cache/tools","alreadyAdded":false}', + ); + } + return success( + '{"pluginId":"demo@tools","name":"demo","marketplaceName":"tools","version":"1.0.0","installedPath":"/cache/demo","authPolicy":"ON_USE"}', + ); + }, + }); + const resource = client.resolveSource('demo@tools', context, { + marketplaceName: 'tools', + marketplaceSource: 'owner/tools', + resolvedRef: 'main', + marketplaceSparsePath: 'catalog', + managedMarketplaceRegistration: 'true', + }).resource; + expect(resource).toBeDefined(); + + expect(await client.install(resource!, context)).toEqual({ + success: true, + registrations: ['tools'], + }); + expect(calls).toEqual([ + ['plugin', 'marketplace', 'list', '--json'], + [ + 'plugin', + 'marketplace', + 'add', + 'owner/tools', + '--ref', + 'main', + '--sparse', + 'catalog', + '--json', + ], + ['plugin', 'add', 'demo@tools', '--json'], + ]); + }); + + test('upgrades Git marketplaces and re-adds plugins during targeted update', async () => { + const context = await fixture(); + const calls: string[][] = []; + const client = new CodexNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + if (args.join(' ') === 'plugin marketplace list --json') { + return success( + '{"marketplaces":[{"name":"tools","root":"/cache/tools","marketplaceSource":{"sourceType":"git","source":"https://github.com/owner/tools.git"}}]}', + ); + } + if (args[2] === 'upgrade') { + return success( + '{"selectedMarketplaces":["tools"],"upgradedRoots":["/cache/tools"],"errors":[]}', + ); + } + return success( + '{"pluginId":"demo@tools","name":"demo","marketplaceName":"tools","version":"1.0.1","installedPath":"/cache/demo","authPolicy":"ON_USE"}', + ); + }, + }); + const resource = client.resolveSource('demo@tools', context, { + marketplaceName: 'tools', + }).resource!; + + expect(await client.update(resource, resource, context)).toEqual({ + success: true, + }); + expect(calls).toEqual([ + ['plugin', 'marketplace', 'list', '--json'], + ['plugin', 'marketplace', 'upgrade', 'tools', '--json'], + ['plugin', 'add', 'demo@tools', '--json'], + ]); + }); + + test('removes exact plugins but preserves marketplaces used by other plugins', async () => { + const context = await fixture(); + const calls: string[][] = []; + const client = new CodexNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + if (args[1] === 'remove' && args[2] === 'demo@tools') { + return success( + '{"pluginId":"demo@tools","name":"demo","marketplaceName":"tools"}', + ); + } + if (args.join(' ') === 'plugin marketplace list --json') { + return success( + '{"marketplaces":[{"name":"tools","root":"/tools","marketplaceSource":{"sourceType":"local","source":"/tools"}}]}', + ); + } + return success( + '{"installed":[{"pluginId":"other@tools","name":"other","marketplaceName":"tools","installed":true,"enabled":true}],"available":[]}', + ); + }, + }); + const resource = client.resolveSource('demo@tools', context).resource!; + + expect(await client.remove(resource, context)).toEqual({ success: true }); + expect(await client.removeMarketplaceRegistration('tools', context)).toEqual( + { + success: false, + error: "Codex marketplace 'tools' is still used by installed plugins", + }, + ); + expect(calls.at(-1)).toEqual([ + 'plugin', + 'list', + '--marketplace', + 'tools', + '--json', + ]); + }); +}); diff --git a/tests/unit/core/profile-launcher.test.ts b/tests/unit/core/profile-launcher.test.ts index 7bb11e7f..30916cda 100644 --- a/tests/unit/core/profile-launcher.test.ts +++ b/tests/unit/core/profile-launcher.test.ts @@ -159,6 +159,59 @@ describe('profile launchers', () => { }); }); + it('refuses to launch when an adapter-required profile file is missing', async () => { + const root = await temporaryRoot(); + const binRoot = join(root, 'bin'); + const requiredFile = join(root, 'review.config.toml'); + const marker = join(root, 'ran'); + const recorder = join(root, 'record.cjs'); + await writeFile( + recorder, + "require('node:fs').writeFileSync(process.argv[2], 'ran');", + 'utf8', + ); + await installProfileLaunchers({ + binRoot, + basename: 'review', + invocation: { + command: process.execPath, + args: [recorder, marker], + env: {}, + requiredFiles: [requiredFile], + }, + }); + + const blocked = spawn(join(binRoot, 'review'), [], { + stdio: ['ignore', 'ignore', 'pipe'], + }); + const stderr: Buffer[] = []; + blocked.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + const [blockedCode] = (await once(blocked, 'close')) as [number | null]; + expect(blockedCode).toBe(1); + expect(Buffer.concat(stderr).toString()).toContain( + 'Profile launcher prerequisite is missing', + ); + await expect(readFile(marker, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }); + + await writeFile(requiredFile, ''); + const allowed = spawn(join(binRoot, 'review'), [], { + stdio: 'ignore', + }); + const [allowedCode] = (await once(allowed, 'close')) as [number | null]; + expect(allowedCode).toBe(0); + expect(await readFile(marker, 'utf8')).toBe('ran'); + expect(() => + renderProfileLaunchers('unsafe', { + command: 'codex', + args: [], + env: {}, + requiredFiles: ['relative.toml'], + }), + ).toThrow('must be absolute'); + }); + it('forwards arbitrary POSIX arguments, cwd, environment, and exit status', async () => { const root = await temporaryRoot(); const binRoot = join(root, 'bin'); diff --git a/tests/unit/core/profile/adapters.test.ts b/tests/unit/core/profile/adapters.test.ts index 02546e1e..740153fb 100644 --- a/tests/unit/core/profile/adapters.test.ts +++ b/tests/unit/core/profile/adapters.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CopilotProfileAdapter } from '../../../../src/core/profile/adapters/copilot.js'; +import { CodexProfileAdapter } from '../../../../src/core/profile/adapters/codex.js'; import { OmpProfileAdapter } from '../../../../src/core/profile/adapters/omp.js'; import { OpenCodeProfileAdapter } from '../../../../src/core/profile/adapters/opencode.js'; import { PiProfileAdapter } from '../../../../src/core/profile/adapters/pi.js'; @@ -462,6 +463,7 @@ describe('profile adapter registry', () => { OpenCodeProfileAdapter, ); expect(getProfileAdapter('copilot')).toBeInstanceOf(CopilotProfileAdapter); + expect(getProfileAdapter('codex')).toBeInstanceOf(CodexProfileAdapter); expect(getProfileAdapter('claude')).toBeNull(); }); }); diff --git a/tests/unit/core/profile/codex.test.ts b/tests/unit/core/profile/codex.test.ts new file mode 100644 index 00000000..bc624722 --- /dev/null +++ b/tests/unit/core/profile/codex.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'bun:test'; +import { join } from 'node:path'; +import { CodexProfileAdapter } from '../../../../src/core/profile/adapters/codex.js'; + +describe('Codex profile adapter', () => { + it('isolates CODEX_HOME, selects an exact named config, and preserves cwd', () => { + const adapter = new CodexProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: '/home/test', + workspaceDirectory: '/work/project', + environment: { CODEX_HOME: '/ambient/codex' }, + }); + const root = '/home/test/.allagents/profiles/review/clients/codex/home'; + + expect(context).toEqual({ + profileName: 'review', + client: 'codex', + mechanism: 'isolated-home-named-profile', + root, + operationContext: { + client: 'codex', + scope: 'user', + nativeScope: 'profile:review', + root, + cwd: '/work/project', + env: { CODEX_HOME: root }, + roots: { config: root, agent: root, data: root }, + }, + fileMapping: { skillsPath: 'skills/', agentFile: 'AGENTS.md' }, + launcher: { + command: 'codex', + args: ['--profile', 'review'], + env: { CODEX_HOME: root }, + requiredFiles: [join(root, 'review.config.toml')], + }, + }); + expect(adapter.capabilities).toEqual({ + nativeInstall: true, + fileInstall: true, + launchers: true, + skillFilters: true, + mcp: true, + settings: true, + status: true, + cleanup: true, + recursiveRootCleanup: true, + }); + }); + + it('requires authoritative native identities and rejects native skill filters', () => { + const adapter = new CodexProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: '/home/test', + workspaceDirectory: '/work/project', + }); + expect( + adapter.resolveNativeSource( + { + declarationIndex: 0, + source: 'owner/tools', + marketplace: 'tools', + pluginName: 'demo', + marketplaceSource: 'owner/tools', + marketplaceRegistrationManaged: true, + marketplaceSparsePath: 'catalog', + requestedRef: 'main', + resolvedRef: 'main', + resolvedSha: 'a'.repeat(40), + install: 'native', + }, + context, + ).resource, + ).toMatchObject({ + requestedIdentity: 'demo@tools', + resolvedIdentity: 'demo@tools', + provenance: { + marketplaceName: 'tools', + marketplaceSource: 'owner/tools', + marketplaceSparsePath: 'catalog', + managedMarketplaceRegistration: 'true', + requestedRef: 'main', + resolvedRef: 'main', + resolvedSha: 'a'.repeat(40), + }, + }); + expect( + adapter.resolveNativeSource( + { + declarationIndex: 0, + source: 'owner/tools', + install: 'native', + }, + context, + ), + ).toMatchObject({ success: false, error: expect.stringContaining('authoritative') }); + expect( + adapter.resolveNativeSource( + { + declarationIndex: 0, + source: 'owner/tools', + marketplace: 'tools', + pluginName: 'demo', + install: 'native', + skills: ['one'], + }, + context, + ), + ).toMatchObject({ success: false, error: expect.stringContaining('filtering') }); + }); + + it('serializes deterministic strict settings and native MCP secret references', () => { + const adapter = new CodexProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: '/home/test', + workspaceDirectory: '/work/project', + }); + const planned = adapter.serializeSettings(context, { + plugins: [], + settings: { + personality: 'pragmatic', + web_search: 'cached', + model: 'gpt-5.6-sol', + approval_policy: 'never', + sandbox_mode: 'workspace-write', + model_reasoning_effort: 'high', + }, + mcpServers: { + local: { + command: 'node', + args: ['server.js'], + env: { LOCAL_TOKEN: '${LOCAL_TOKEN}' }, + }, + remote: { + url: 'https://mcp.example.test', + headers: { + Authorization: '${REMOTE_TOKEN}', + }, + }, + ignored: { command: 'ignored', clients: ['pi'] }, + }, + }); + + expect(planned?.path).toBe(join(context.root, 'review.config.toml')); + expect(planned?.mode).toBe(0o600); + expect(planned?.content).toBe( + 'approval_policy = "never"\n' + + 'model = "gpt-5.6-sol"\n' + + 'model_reasoning_effort = "high"\n' + + 'personality = "pragmatic"\n' + + 'sandbox_mode = "workspace-write"\n' + + 'web_search = "cached"\n\n' + + '[mcp_servers.local]\n' + + 'args = ["server.js"]\n' + + 'command = "node"\n' + + 'env_vars = ["LOCAL_TOKEN"]\n\n' + + '[mcp_servers.remote]\n' + + 'url = "https://mcp.example.test"\n\n' + + '[mcp_servers.remote.env_http_headers]\n' + + 'Authorization = "REMOTE_TOKEN"\n', + ); + expect(planned?.content).not.toContain('${LOCAL_TOKEN}'); + expect(planned?.content).not.toContain('${REMOTE_TOKEN}'); + expect(adapter.serializeSettings(context, { plugins: [] })?.content).toBe( + '\n', + ); + expect(adapter.serializeMcp(context, { plugins: [] })).toBeNull(); + }); + + it('fails closed when a portable MCP reference cannot be represented natively', () => { + const adapter = new CodexProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: '/home/test', + workspaceDirectory: '/work/project', + }); + expect(() => + adapter.serializeSettings(context, { + plugins: [], + mcpServers: { + remapped: { + command: 'server', + env: { DESTINATION: '${SOURCE_TOKEN}' }, + }, + }, + }), + ).toThrow("cannot remap ${SOURCE_TOKEN} to 'DESTINATION'"); + expect(() => + adapter.serializeSettings(context, { + plugins: [], + mcpServers: { + argument: { + command: 'server', + args: ['--token', '${TOKEN}'], + }, + }, + }), + ).toThrow('cannot interpolate portable secret references'); + }); +}); diff --git a/tests/unit/models/workspace-config-profiles.test.ts b/tests/unit/models/workspace-config-profiles.test.ts index d6f6d1d6..11ff2219 100644 --- a/tests/unit/models/workspace-config-profiles.test.ts +++ b/tests/unit/models/workspace-config-profiles.test.ts @@ -292,6 +292,53 @@ describe('profile workspace declarations', () => { } }); + it('accepts only the conservative documented Codex profile settings', () => { + const result = UserWorkspaceConfigSchema.parse( + userConfigWithProfile({ + clients: [ + { + name: 'codex', + settings: { + model: 'gpt-5.6-sol', + model_reasoning_effort: 'xhigh', + model_reasoning_summary: 'concise', + model_verbosity: 'low', + approval_policy: 'on-request', + sandbox_mode: 'workspace-write', + web_search: 'indexed', + personality: 'pragmatic', + }, + }, + ], + }), + ); + expect(result.profiles?.research?.clients[0]?.settings).toEqual({ + model: 'gpt-5.6-sol', + model_reasoning_effort: 'xhigh', + model_reasoning_summary: 'concise', + model_verbosity: 'low', + approval_policy: 'on-request', + sandbox_mode: 'workspace-write', + web_search: 'indexed', + personality: 'pragmatic', + }); + for (const settings of [ + { unknown: true }, + { approval_policy: 'untrusted' }, + { sandbox_mode: 'full' }, + { web_search: true }, + { model_reasoning_effort: 'extreme' }, + ]) { + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'codex', settings }], + }), + ).success, + ).toBe(false); + } + }); + it('rejects unknown and machine-generated fields throughout declarations', () => { for (const profile of [ { clients: [{ name: 'pi', scope: 'user' }] }, From 49ad6fc581ad1601fc8dd43f781ad6ab978fc630 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Tue, 15 Sep 2026 06:37:21 +1000 Subject: [PATCH 2/3] refactor(profile): encapsulate Codex planning --- src/core/native/codex.ts | 15 +-- src/core/profile/adapters/codex.ts | 125 ++++++++++++++++++- src/core/profile/native-metadata.ts | 178 +++++++++++++++++++++++++++- src/core/profile/plan.ts | 22 ---- 4 files changed, 301 insertions(+), 39 deletions(-) diff --git a/src/core/native/codex.ts b/src/core/native/codex.ts index 7658dd7f..beb0777c 100644 --- a/src/core/native/codex.ts +++ b/src/core/native/codex.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { executeCommand, + compareNativeVersions, type NativeClient, type NativeCommandOptions, type NativeCommandResult, @@ -85,17 +86,6 @@ function versionTuple(output: string): readonly number[] | null { return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; } -function compareVersion( - left: readonly number[], - right: readonly number[], -): number { - for (let index = 0; index < 3; index++) { - const difference = (left[index] ?? 0) - (right[index] ?? 0); - if (difference !== 0) return difference; - } - return 0; -} - function parseJsonRecord(output: string): Record | null { try { const value = JSON.parse(output); @@ -294,7 +284,8 @@ export class CodexNativeClient implements NativeClient { const parsedVersion = versionTuple(version.output); if ( this.minimumVersion && - (!parsedVersion || compareVersion(parsedVersion, this.minimumVersion) < 0) + (!parsedVersion || + compareNativeVersions(parsedVersion, this.minimumVersion) < 0) ) { return false; } diff --git a/src/core/profile/adapters/codex.ts b/src/core/profile/adapters/codex.ts index 8b3e62fa..ed36aa6d 100644 --- a/src/core/profile/adapters/codex.ts +++ b/src/core/profile/adapters/codex.ts @@ -1,4 +1,4 @@ -import { join, resolve } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import { CodexProfileSettingsSchema, ProfileMcpServerConfigSchema, @@ -8,10 +8,13 @@ import { CodexNativeClient, type NativeSourceResolution, } from '../../native/index.js'; +import { resolveCodexProfileMetadata } from '../native-metadata.js'; import type { - ProfileAdapter, + NativeProfileAdapter, ProfileClientContext, ProfileContextOptions, + ProfileNativeCommandRequest, + ProfileNativeMetadataOptions, ProfilePlannedFile, ProfileResolvedPlugin, ProfileSerializationInput, @@ -174,7 +177,7 @@ function serializeCodexMcp( return Object.freeze(servers); } -export class CodexProfileAdapter implements ProfileAdapter { +export class CodexProfileAdapter implements NativeProfileAdapter { readonly client = 'codex' as const; readonly capabilities = CAPABILITIES; readonly nativeClient = new CodexNativeClient({ @@ -233,6 +236,20 @@ export class CodexProfileAdapter implements ProfileAdapter { return this.nativeClient.isAvailable(context.operationContext); } + resolveNativeMetadata( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + options: ProfileNativeMetadataOptions, + ): Promise { + assertCodexContext(context); + return resolveCodexProfileMetadata( + plugin, + context, + options, + this.nativeClient, + ); + } + resolveNativeSource( plugin: ProfileResolvedPlugin, context: ProfileClientContext, @@ -283,6 +300,106 @@ export class CodexProfileAdapter implements ProfileAdapter { ); } + discloseNativeCommands( + request: ProfileNativeCommandRequest, + context: ProfileClientContext, + ) { + assertCodexContext(context); + if (!['create', 'update', 'remove'].includes(request.action)) return []; + if (request.kind === 'marketplace') { + if (request.action === 'remove') { + return [ + { + command: 'codex', + args: [ + 'plugin', + 'marketplace', + 'remove', + request.registration.name, + '--json', + ], + }, + ]; + } + const verb = request.action === 'create' ? 'add' : 'upgrade'; + return [ + { + command: 'codex', + args: [ + 'plugin', + 'marketplace', + verb, + request.action === 'create' + ? request.registration.source + : request.registration.name, + '--json', + ], + }, + ]; + } + + const commands = []; + const marketplaceName = request.resource.provenance.marketplaceName; + const marketplaceSource = request.resource.provenance.marketplaceSource; + if ( + request.action === 'create' && + marketplaceSource && + request.resource.provenance.managedMarketplaceRegistration === 'true' + ) { + const args = ['plugin', 'marketplace', 'add', marketplaceSource]; + if (request.resource.provenance.resolvedRef) { + args.push('--ref', request.resource.provenance.resolvedRef); + } + if (request.resource.provenance.marketplaceSparsePath) { + args.push( + '--sparse', + request.resource.provenance.marketplaceSparsePath, + ); + } + args.push('--json'); + commands.push({ command: 'codex', args }); + } else if ( + request.action === 'update' && + marketplaceName && + marketplaceSource && + !isAbsolute(marketplaceSource) + ) { + commands.push({ + command: 'codex', + args: ['plugin', 'marketplace', 'upgrade', marketplaceName, '--json'], + }); + } + + const verb = request.action === 'remove' ? 'remove' : 'add'; + commands.push({ + command: 'codex', + args: ['plugin', verb, request.resource.resolvedIdentity, '--json'], + }); + return commands; + } + + inspectMarketplaceRegistration( + marketplaceName: string, + context: ProfileClientContext, + ) { + assertCodexContext(context); + return this.nativeClient.inspectMarketplaceRegistration( + marketplaceName, + context.operationContext, + ); + } + + removeMarketplaceRegistration( + marketplaceName: string, + context: ProfileClientContext, + ) { + assertCodexContext(context); + return this.nativeClient.removeMarketplaceRegistration( + marketplaceName, + context.operationContext, + ); + } + serializeSettings( context: ProfileClientContext, input: ProfileSerializationInput, @@ -314,6 +431,6 @@ export class CodexProfileAdapter implements ProfileAdapter { } } -export const codexProfileAdapter: ProfileAdapter = Object.freeze( +export const codexProfileAdapter: NativeProfileAdapter = Object.freeze( new CodexProfileAdapter(), ); diff --git a/src/core/profile/native-metadata.ts b/src/core/profile/native-metadata.ts index b1612b3e..ed277a70 100644 --- a/src/core/profile/native-metadata.ts +++ b/src/core/profile/native-metadata.ts @@ -1,7 +1,13 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { isAbsolute, join, resolve, sep } from 'node:path'; -import type { CopilotNativeClient } from '../native/index.js'; +import type { + CodexNativeClient, + CopilotNativeClient, +} from '../native/index.js'; import { inspectOmpMarketplaceRegistry, + parseCodexPluginId, parseCopilotPluginId, parseOmpPluginId, } from '../native/index.js'; @@ -247,3 +253,173 @@ export async function resolveCopilotProfileMetadata( await source.cleanup?.(); } } + +const CODEX_MARKETPLACE_MANIFESTS = [ + '.agents/plugins/marketplace.json', + '.claude-plugin/marketplace.json', +] as const; + +async function readCodexMarketplaceCatalog( + marketplaceRoot: string, +): Promise<{ name: string; plugins: readonly string[] }> { + const path = CODEX_MARKETPLACE_MANIFESTS.map((relativePath) => + join(marketplaceRoot, relativePath), + ).find((candidate) => existsSync(candidate)); + if (!path) { + throw new Error( + `Codex marketplace manifest not found (checked ${CODEX_MARKETPLACE_MANIFESTS.join(', ')})`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, 'utf8')); + } catch { + throw new Error('Failed to parse Codex marketplace manifest as JSON'); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Codex marketplace manifest must be an object'); + } + const catalog = parsed as Record; + if (typeof catalog.name !== 'string' || !Array.isArray(catalog.plugins)) { + throw new Error('Codex marketplace manifest requires name and plugins'); + } + const plugins = catalog.plugins.map((entry) => + entry && typeof entry === 'object' && !Array.isArray(entry) + ? (entry as Record).name + : undefined, + ); + if ( + !catalog.name || + plugins.some((name) => typeof name !== 'string' || name.length === 0) + ) { + throw new Error('Codex marketplace manifest contains an invalid identity'); + } + return { name: catalog.name, plugins: plugins as string[] }; +} + +function normalizedCodexMarketplaceSource(source: string): string { + if (isAbsolute(source)) return `directory:${resolve(source)}`; + const parsed = isGitHubUrl(source) ? parseGitHubUrl(source) : null; + return parsed + ? `git:https://github.com/${parsed.owner}/${parsed.repo}`.toLowerCase() + : `git:${source.replace(/\.git$/i, '').toLowerCase()}`; +} + +export async function resolveCodexProfileMetadata( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + options: ProfileNativeMetadataOptions, + nativeClient: CodexNativeClient, +): Promise { + const exact = parseCodexPluginId(plugin.source); + if (exact) { + if (plugin.requestedRef || plugin.resolvedRef) { + throw new Error( + `Codex plugin identity '${plugin.source}' cannot select a marketplace ref`, + ); + } + const registration = await nativeClient.inspectMarketplaceRegistration( + exact.marketplace, + context.operationContext, + ); + if (!registration.success) { + throw new Error( + registration.error ?? + 'Could not inspect the selected Codex profile marketplace registry', + ); + } + if (!registration.present) { + throw new Error( + `Codex plugin '${plugin.source}' references an unregistered marketplace`, + ); + } + const catalog = await nativeClient.inspectMarketplacePlugin( + exact.marketplace, + exact.plugin, + context.operationContext, + ); + if (!catalog.success || !catalog.present) { + throw new Error( + catalog.error ?? + `Codex plugin '${plugin.source}' is not an authoritative catalog identity`, + ); + } + return Object.freeze({ + ...plugin, + marketplace: exact.marketplace, + pluginName: exact.plugin, + ...(registration.source && { marketplaceSource: registration.source }), + }); + } + + const source = await resolveProfileFileSource( + { ...plugin, install: 'file' }, + options, + ); + try { + const catalog = await readCodexMarketplaceCatalog(source.path); + if (catalog.plugins.length !== 1 || !catalog.plugins[0]) { + throw new Error( + `Codex marketplace source '${plugin.source}' must expose exactly one catalog plugin`, + ); + } + const registration = await nativeClient.inspectMarketplaceRegistration( + catalog.name, + context.operationContext, + ); + if (!registration.success) { + throw new Error( + registration.error ?? + 'Could not inspect the selected Codex profile marketplace registry', + ); + } + const parsedSource = isGitHubUrl(source.source) + ? parseGitHubUrl(source.source) + : null; + const registrationSource = parsedSource + ? `${parsedSource.owner}/${parsedSource.repo}` + : resolve(source.path); + if (registration.present) { + if ( + registration.source && + normalizedCodexMarketplaceSource(registration.source) !== + normalizedCodexMarketplaceSource(registrationSource) + ) { + throw new Error( + `Codex marketplace '${catalog.name}' is already registered from a different source`, + ); + } + if (source.resolvedRef) { + throw new Error( + `Codex marketplace '${catalog.name}' is already registered and its ref cannot be verified`, + ); + } + const liveCatalog = await nativeClient.inspectMarketplacePlugin( + catalog.name, + catalog.plugins[0], + context.operationContext, + ); + if (!liveCatalog.success || !liveCatalog.present) { + throw new Error( + liveCatalog.error ?? + `Codex marketplace '${catalog.name}' does not expose plugin '${catalog.plugins[0]}'`, + ); + } + } + return Object.freeze({ + ...plugin, + marketplace: catalog.name, + pluginName: catalog.plugins[0], + path: source.path, + marketplaceSource: registration.source ?? registrationSource, + marketplaceRegistrationManaged: !registration.present, + ...(parsedSource?.subpath && { + marketplaceSparsePath: parsedSource.subpath, + }), + ...(source.resolvedRef && { resolvedRef: source.resolvedRef }), + ...(source.resolvedSha && { resolvedSha: source.resolvedSha }), + }); + } finally { + await source.cleanup?.(); + } +} diff --git a/src/core/profile/plan.ts b/src/core/profile/plan.ts index 43cc8d59..0e2cb3af 100644 --- a/src/core/profile/plan.ts +++ b/src/core/profile/plan.ts @@ -399,28 +399,6 @@ function sameNativeIdentity( ); } -function findInstalledNativeResource( - inspection: NativeInspectionResult, - matches: (resource: NativeResource) => boolean, -): NativeResource | undefined { - return ( - inspection.resources.find(matches) ?? - inspection.observations?.find( - (observation) => - observation.status === 'disabled' && matches(observation.resource), - )?.resource - ); -} - -function findDisabledNativeResource( - inspection: NativeInspectionResult, - matches: (resource: NativeResource) => boolean, -): NativeResource | undefined { - return inspection.observations?.find( - (observation) => - observation.status === 'disabled' && matches(observation.resource), - )?.resource; -} function findInstalledNativeResource( inspection: NativeInspectionResult, From 8372243a32ad045c511eacb62db363301580df5f Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Tue, 15 Sep 2026 11:19:49 +1000 Subject: [PATCH 3/3] docs(clients): clarify Codex hook handling --- docs/src/content/docs/docs/reference/clients.mdx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/docs/reference/clients.mdx b/docs/src/content/docs/docs/reference/clients.mdx index dafb1865..719ca51b 100644 --- a/docs/src/content/docs/docs/reference/clients.mdx +++ b/docs/src/content/docs/docs/reference/clients.mdx @@ -14,7 +14,7 @@ inventory of each runtime's native extension APIs. | Client | Skills | Agent File | Hooks | Commands | GitHub Overrides | |--------|--------|------------|-------|----------|------------------| | Copilot | `.github/skills/` | `AGENTS.md` | `.github/hooks/` | No | `.github/` | -| Codex | `.agents/skills/` | `AGENTS.md` | No | No | No | +| Codex | `.codex/skills/` | `AGENTS.md` | `.codex/hooks.json` (generated) | No | No | | OpenCode | `.agents/skills/` | `AGENTS.md` | No | `.opencode/commands/` | No | | Gemini | `.agents/skills/` | `GEMINI.md` | No | No | No | | Amp Code | `.agents/skills/` | `AGENTS.md` | No | No | No | @@ -96,6 +96,11 @@ upgrade, local plugin refresh, removal, and inventory inspection to Codex. Removing an AllAgents-managed marketplace fails closed while another installed plugin still depends on it. +At project scope, AllAgents combines supported plugin hook declarations in +`.codex/hooks.json`. Native Codex plugins can also bundle `hooks/hooks.json`. +Isolated profile file installation does not currently translate hook +declarations; use native installation when a profile plugin requires them. + `CODEX_HOME` also contains Codex credentials, sessions, logs, and caches. Recursive cleanup is limited to a client root AllAgents created for this profile; removing that profile intentionally removes its isolated runtime