diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index 63e10b45d5f..fd3a9012cd5 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -4,6 +4,7 @@ // because it shares the contract with the local installer (script body, // hook-event shape, atomic-rename semantics) and any drift between them is // exactly the bug we want to avoid. +/* eslint-disable max-lines -- Why: SFTP primitives and remote installer wrappers must share atomic-write and error-classification behavior. */ // // We deliberately keep the JSON merge logic in the existing // `installer-utils.ts` and only swap fs primitives — the JSON shape and @@ -137,6 +138,18 @@ export async function readTextFileRemote( } } +export async function realpathRemote(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.realpath(remotePath, (err, resolvedPath) => { + if (err) { + reject(err) + return + } + resolve(resolvedPath) + }) + }) +} + export async function writeTextFileRemoteAtomic( sftp: SFTPWrapper, remotePath: string, diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 86eb35a9f83..d087fed3cd7 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -1,5 +1,8 @@ /* eslint-disable max-lines -- Why: this fixture verifies the shared remote hook installer fake across every managed agent so SSH regressions are caught together. */ import { describe, expect, it, vi } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { SFTPWrapper } from 'ssh2' vi.mock('electron', () => ({ @@ -22,10 +25,15 @@ type FakeFs = { files: Map dirs: Set modes: Map + realpaths: Map + failWriteTo: Set failRenameTo: Set } -function createFakeSftp(initialFiles: Record = {}): { +function createFakeSftp( + initialFiles: Record = {}, + options: { realpaths?: Record } = {} +): { sftp: SFTPWrapper fs: FakeFs } { @@ -33,6 +41,8 @@ function createFakeSftp(initialFiles: Record = {}): { files: new Map(Object.entries(initialFiles)), dirs: new Set(['/']), modes: new Map(), + realpaths: new Map(Object.entries(options.realpaths ?? {})), + failWriteTo: new Set(), failRenameTo: new Set() } const noEntryError = (path: string): { code: number; message: string } => ({ @@ -56,6 +66,10 @@ function createFakeSftp(initialFiles: Record = {}): { options: string | { mode?: number }, cb: (err: unknown) => void ): void => { + if (fs.failWriteTo.has(path)) { + cb({ code: 4, message: `write failed ${path}` }) + return + } fs.files.set(path, content) if (typeof options !== 'string' && options.mode !== undefined) { fs.modes.set(path, options.mode) @@ -64,7 +78,7 @@ function createFakeSftp(initialFiles: Record = {}): { }, rename: (src: string, dst: string, cb: (err: unknown) => void): void => { if (fs.failRenameTo.has(dst)) { - cb({ code: 4, message: `rename failed ${dst}` }) + cb(Object.assign(new Error(`rename failed ${dst}`), { code: 4 })) return } const v = fs.files.get(src) @@ -97,6 +111,13 @@ function createFakeSftp(initialFiles: Record = {}): { } cb(null, fakeStats(fs.modes.get(path) ?? 0o100644)) }, + realpath: (path: string, cb: (err: unknown, resolvedPath?: string) => void): void => { + if (!fs.files.has(path)) { + cb(noEntryError(path)) + return + } + cb(null, fs.realpaths.get(path) ?? path) + }, readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { if (fs.dirs.has(path)) { cb(null, []) @@ -155,7 +176,9 @@ describe('remote hook service installers', () => { ] for (const { install, path } of installers) { - const { sftp, fs } = createFakeSftp() + const { sftp, fs } = createFakeSftp({ + '/home/dev/.codex/auth.json': '{"account":"remote-system"}\n' + }) const status = await install(sftp) expect(status.state).toBe('installed') const script = fs.files.get(path) @@ -171,13 +194,61 @@ describe('remote hook service installers', () => { }) it('installs remote Codex hooks with matching trust entries', async () => { - const { sftp, fs } = createFakeSftp() + const { sftp, fs } = createFakeSftp({ + '/home/dev/.codex/auth.json': '{"account":"remote-system"}\n', + '/home/dev/.codex/config.toml': [ + 'model = "remote-model"', + '', + '[[hooks.Stop]]', + '[[hooks.Stop.hooks]]', + 'type = "command"', + 'command = "system-inline-hook"', + '', + '[projects."/repo"]', + 'trust_level = "trusted"', + '' + ].join('\n'), + '/home/dev/.codex/hooks.json': JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ command: 'user-hook' }] }, + { + hooks: [ + { + command: + "if [ -x '/home/dev/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/home/dev/.orca/agent-hooks/codex-hook.sh'; fi" + } + ] + } + ] + } + }), + '/home/dev/.orca/codex-runtime-home/home/hooks.json': + '{"hooks":{"Stop":[{"hooks":[{"command":"stale-runtime-hook"}]}]}}\n', + '/home/dev/.orca/codex-runtime-home/home/config.toml': [ + 'model = "stale-runtime-model"', + '', + '[[hooks.Stop]]', + '[[hooks.Stop.hooks]]', + 'type = "command"', + 'command = "stale-inline-hook"', + '', + '[hooks.state."/home/dev/.orca/codex-runtime-home/home/hooks.json:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:stale"', + '' + ].join('\n') + }) const status = await new CodexHookService().installRemote(sftp, '/home/dev/') - expect(status.state).toBe('installed') - expect(status.configPath).toBe('/home/dev/.codex/hooks.json') - const hooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as { + expect(status.state).toBe('partial') + expect(status.detail).toContain('Dropped 1 non-Orca Codex hook group(s)') + expect(status.detail).toContain('Dropped 2 non-Orca Codex hook declaration section(s)') + expect(status.configPath).toBe('/home/dev/.orca/codex-runtime-home/home/hooks.json') + const hooks = JSON.parse( + fs.files.get('/home/dev/.orca/codex-runtime-home/home/hooks.json')! + ) as { hooks: Record } for (const eventName of [ @@ -192,26 +263,134 @@ describe('remote hook service installers', () => { expect(command).toContain('/home/dev/.orca/agent-hooks/codex-hook.sh') expect(command).toMatch(/^if \[ -x /) } + expect(JSON.stringify(hooks)).not.toContain('stale-runtime-hook') expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') expect(fs.modes.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toBe(0o755) - const toml = fs.files.get('/home/dev/.codex/config.toml') - expect(toml).toContain('/home/dev/.codex/hooks.json:permission_request:0:0') + const toml = fs.files.get('/home/dev/.orca/codex-runtime-home/home/config.toml') + expect(toml).toContain( + '/home/dev/.orca/codex-runtime-home/home/hooks.json:permission_request:0:0' + ) expect(toml).toContain('trusted_hash = "sha256:') + expect(toml).toContain('model = "remote-model"') + expect(toml).toContain('[projects."/repo"]') + expect(toml).not.toContain('system-inline-hook') + expect(toml).not.toContain('stale-inline-hook') + expect(toml).not.toContain('sha256:stale') + expect(fs.files.get('/home/dev/.orca/codex-runtime-home/home/auth.json')).toBe( + '{"account":"remote-system"}\n' + ) + expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('user-hook') + expect(fs.files.get('/home/dev/.codex/hooks.json')).not.toContain('codex-hook.sh') + expect(fs.files.get('/home/dev/.codex/config.toml')).toContain('system-inline-hook') + expect(fs.files.has('/home/dev/.orca/codex-runtime-home/home/.orca-runtime-ready')).toBe(false) }) it('reports Codex trust-write failures without rolling back installed hooks', async () => { - const { sftp, fs } = createFakeSftp() - fs.failRenameTo.add('/home/dev/.codex/config.toml') + const { sftp, fs } = createFakeSftp({ + '/home/dev/.codex/auth.json': '{"account":"remote-system"}\n' + }) + fs.failRenameTo.add('/home/dev/.orca/codex-runtime-home/home/config.toml') const status = await new CodexHookService().installRemote(sftp, '/home/dev') expect(status.state).toBe('error') expect(status.managedHooksPresent).toBe(true) expect(status.detail).toContain('trust entries could not be written') - expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('codex-hook.sh') + expect(fs.files.get('/home/dev/.orca/codex-runtime-home/home/hooks.json')).toContain( + 'codex-hook.sh' + ) expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') }) + it('reports remote Codex auth copy failures before enabling managed runtime home', async () => { + const { sftp, fs } = createFakeSftp({ + '/home/dev/.codex/auth.json': '{"account":"remote-system"}\n' + }) + fs.failRenameTo.add('/home/dev/.orca/codex-runtime-home/home/auth.json') + + const status = await new CodexHookService().installRemote(sftp, '/home/dev') + + expect(status.state).toBe('error') + expect(status.managedHooksPresent).toBe(false) + expect(status.detail).toContain('rename failed') + expect(fs.files.has('/home/dev/.orca/codex-runtime-home/home/hooks.json')).toBe(false) + }) + + it('reports missing remote Codex auth before enabling managed runtime home', async () => { + const { sftp, fs } = createFakeSftp({ + '/home/dev/.orca/codex-runtime-home/home/auth.json': '{"account":"stale"}\n' + }) + + const status = await new CodexHookService().installRemote(sftp, '/home/dev') + + expect(status.state).toBe('error') + expect(status.managedHooksPresent).toBe(false) + expect(status.detail).toContain('auth.json is missing') + expect(fs.files.has('/home/dev/.orca/codex-runtime-home/home/hooks.json')).toBe(false) + }) + + it('reports legacy remote real-home cleanup failures before enabling managed runtime home', async () => { + const legacyCommand = + "if [ -x '/home/dev/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/home/dev/.orca/agent-hooks/codex-hook.sh'; fi" + const { sftp, fs } = createFakeSftp({ + '/home/dev/.codex/auth.json': '{"account":"remote-system"}\n', + '/home/dev/.codex/hooks.json': JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: legacyCommand }] }] } + }) + }) + fs.failRenameTo.add('/home/dev/.codex/hooks.json') + + const status = await new CodexHookService().installRemote(sftp, '/home/dev') + + expect(status.state).toBe('error') + expect(status.managedHooksPresent).toBe(false) + expect(status.detail).toContain('rename failed') + expect(fs.files.has('/home/dev/.orca/codex-runtime-home/home/hooks.json')).toBe(false) + }) + + it('reports malformed legacy remote real-home hooks before enabling managed runtime home', async () => { + const { sftp, fs } = createFakeSftp({ + '/home/dev/.codex/auth.json': '{"account":"remote-system"}\n', + '/home/dev/.codex/hooks.json': '{not json' + }) + + const status = await new CodexHookService().installRemote(sftp, '/home/dev') + + expect(status.state).toBe('error') + expect(status.managedHooksPresent).toBe(false) + expect(status.detail).toContain('Could not parse legacy remote Codex hooks.json') + expect(fs.files.has('/home/dev/.orca/codex-runtime-home/home/hooks.json')).toBe(false) + }) + + it('uses the remote canonical hooks path for remote Codex trust entries', async () => { + const localRoot = mkdtempSync(join(tmpdir(), 'orca-remote-codex-trust-')) + const localActual = join(localRoot, 'actual') + const localLink = join(localRoot, 'link') + mkdirSync(localActual) + symlinkSync(localActual, localLink) + try { + const remoteCanonicalHooksPath = `${localLink}/hooks.json` + const { sftp, fs } = createFakeSftp( + { '/home/dev/.codex/auth.json': '{"account":"remote-system"}\n' }, + { + realpaths: { + '/home/dev/.orca/codex-runtime-home/home/hooks.json': remoteCanonicalHooksPath + } + } + ) + + const status = await new CodexHookService().installRemote(sftp, '/home/dev') + + expect(status.state).toBe('installed') + const toml = fs.files.get('/home/dev/.orca/codex-runtime-home/home/config.toml') + expect(toml).toContain(`${remoteCanonicalHooksPath}:stop:0:0`) + expect(toml).not.toContain(`${localActual}/hooks.json:stop:0:0`) + expect(toml).not.toContain('/home/dev/.orca/codex-runtime-home/home/hooks.json:stop:0:0') + } finally { + rmSync(localRoot, { recursive: true, force: true }) + } + }) + it('installs remote Gemini, Antigravity, Cursor, Command Code, and Grok configs using their CLI-specific schemas', async () => { const gemini = createFakeSftp() const antigravity = createFakeSftp() diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 17865439a5d..1f56305d08a 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: config mirror cases share mocked home/userData setup and fragile TOML section fixtures. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -41,6 +42,10 @@ function getRuntimeConfigPath(): string { return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml') } +function getRuntimeHooksPath(): string { + return join(userDataDir, 'codex-runtime-home', 'home', 'hooks.json') +} + beforeEach(() => { fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-user-data-')) @@ -93,6 +98,42 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') }) + it('does not copy system hook declarations into the runtime config', () => { + writeFileSync( + getSystemConfigPath(), + [ + 'model = "system-model"', + '', + '[[hooks.Stop]]', + 'matcher = "*"', + '[[hooks.Stop.hooks]]', + 'type = "command"', + 'command = "${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.sh"', + '', + '[[hooks.UserPromptSubmit]]', + '[[hooks.UserPromptSubmit.hooks]]', + 'type = "command"', + 'command = "plain-system-hook"', + '', + '[hooks.state."system-hooks:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:system"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).not.toContain('[[hooks.Stop]]') + expect(runtimeConfig).not.toContain('CLAUDE_PLUGIN_ROOT') + expect(runtimeConfig).not.toContain('[[hooks.UserPromptSubmit]]') + expect(runtimeConfig).not.toContain('plain-system-hook') + expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + }) + it('normalizes deprecated codex_hooks feature flag only in runtime config', () => { writeFileSync( getSystemConfigPath(), @@ -129,10 +170,15 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { [ 'model = "runtime-model"', '', - '[hooks.state."runtime-hooks:stop:0:0"]', + `[hooks.state."${getRuntimeHooksPath()}:stop:0:0"]`, 'enabled = false', 'trusted_hash = "sha256:runtime"', '', + '[[hooks.Stop]]', + '[[hooks.Stop.hooks]]', + 'type = "command"', + 'command = "stale-runtime-hook"', + '', '[projects."/repo"]', 'trust_level = "trusted"', '', @@ -156,6 +202,11 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { '[hooks.state."system-hooks:stop:0:0"]', 'enabled = true', 'trusted_hash = "sha256:system"', + '', + '[[hooks.UserPromptSubmit]]', + '[[hooks.UserPromptSubmit.hooks]]', + 'type = "command"', + 'command = "system-user-hook"', '' ].join('\n'), 'utf-8' @@ -169,18 +220,126 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig).toContain('[projects."/repo"]') expect(runtimeConfig).toContain('[projects."/runtime-only"]') expect(runtimeConfig).toContain('[projects."/system-only"]') - expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') + expect(runtimeConfig).toContain(`[hooks.state."${getRuntimeHooksPath()}:stop:0:0"]`) expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') + expect(runtimeConfig).not.toContain('stale-runtime-hook') + expect(runtimeConfig).not.toContain('system-user-hook') expect(runtimeConfig).toContain('trust_level = "untrusted"') expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1) }) + it('drops runtime-local inline hook declarations during system config sync', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + 'model = "runtime-model"', + '', + '[[hooks.Stop]]', + '[[hooks.Stop.hooks]]', + 'type = "command"', + 'command = "runtime-local-hook"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') + + const repairDetails = syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(repairDetails.repairDetails).toEqual([ + 'Dropped 2 non-Orca Codex hook declaration section(s) from managed runtime config.toml.' + ]) + expect(repairDetails.error).toBeNull() + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).not.toContain('runtime-local-hook') + }) + + it('drops stale runtime inline plain hooks', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + 'model = "runtime-model"', + '', + '[[hooks.Stop]]', + '[[hooks.Stop.hooks]]', + 'type = "command"', + 'command = "old-mirrored-inline-hook"', + '', + '[hooks.state."config.toml:stop:0:0"]', + 'enabled = true', + 'trusted_hash = "sha256:inline"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).not.toContain('old-mirrored-inline-hook') + expect(runtimeConfig).not.toContain('[hooks.state."config.toml:stop:0:0"]') + }) + + it('drops stale runtime inline hooks without trusting body equality', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + 'model = "runtime-model"', + '', + '[[hooks.Stop]]', + '[[hooks.Stop.hooks]]', + 'type = "command"', + 'command = "old-mirrored-inline-hook"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).not.toContain('old-mirrored-inline-hook') + }) + + it('drops pre-marker runtime inline hooks that require plugin environment', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + 'model = "runtime-model"', + '', + '[[hooks.Stop]]', + '[[hooks.Stop.hooks]]', + 'type = "command"', + 'command = "${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.sh"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('model = "system-model"') + expect(runtimeConfig).not.toContain('CLAUDE_PLUGIN_ROOT') + expect(runtimeConfig).not.toContain('[[hooks.Stop]]') + }) + it('does not treat TOML table headers inside multiline strings as sections', () => { mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) writeFileSync( getRuntimeConfigPath(), [ - '[hooks.state."runtime-hooks:stop:0:0"]', + `[hooks.state."${getRuntimeHooksPath()}:stop:0:0"]`, 'enabled = true', 'trusted_hash = "sha256:runtime"', '' @@ -212,7 +371,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig).toContain('[hooks.state."inside-basic-string"]') expect(runtimeConfig).toContain('[hooks.state."inside-literal-string"]') expect(runtimeConfig).toContain('[model_providers.openai]') - expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') + expect(runtimeConfig).toContain(`[hooks.state."${getRuntimeHooksPath()}:stop:0:0"]`) }) it('does not let triple quotes in comments affect runtime-owned section mirroring', () => { @@ -224,7 +383,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { "# example: ''' in a comment", 'model = "runtime-model"', '', - '[hooks.state."runtime-hooks:stop:0:0"]', + `[hooks.state."${getRuntimeHooksPath()}:stop:0:0"]`, 'enabled = true', 'trusted_hash = "sha256:runtime"', '' @@ -250,7 +409,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') expect(runtimeConfig).toContain('model = "system-model"') - expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]') + expect(runtimeConfig).toContain(`[hooks.state."${getRuntimeHooksPath()}:stop:0:0"]`) expect(runtimeConfig).toContain('trusted_hash = "sha256:runtime"') expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') expect(runtimeConfig).not.toContain('trusted_hash = "sha256:system"') diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 3e35511bfa2..7ca2c0f63a4 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -1,48 +1,66 @@ +/* eslint-disable max-lines -- Why: this file mirrors TOML without reserializing user config, so section parsing and merge rules need to stay together for auditability. */ import { existsSync, readFileSync } from 'fs' import { join } from 'path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { getCodexCanonicalTrustPath, parseTrustKey } from './config-toml-trust' + +export type CodexConfigSyncResult = { + repairDetails: string[] + error: string | null +} function getRuntimeCodexConfigTomlPath(): string { return join(getOrcaManagedCodexHomePath(), 'config.toml') } +function getRuntimeCodexHooksJsonPath(): string { + return join(getOrcaManagedCodexHomePath(), 'hooks.json') +} + function getSystemCodexConfigTomlPath(): string { return join(getSystemCodexHomePath(), 'config.toml') } -export function syncSystemConfigIntoManagedCodexHome(): void { +export function syncSystemConfigIntoManagedCodexHome(): CodexConfigSyncResult { try { - syncSystemConfigIntoManagedCodexHomeUnsafe() + return syncSystemConfigIntoManagedCodexHomeUnsafe() } catch (error) { console.warn('[codex-config] Failed to mirror system Codex config:', error) + return { + repairDetails: [], + error: error instanceof Error ? error.message : String(error) + } } } -function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { +function syncSystemConfigIntoManagedCodexHomeUnsafe(): CodexConfigSyncResult { const systemConfigPath = getSystemCodexConfigTomlPath() const runtimeConfigPath = getRuntimeCodexConfigTomlPath() const systemConfigExists = existsSync(systemConfigPath) const runtimeConfigExists = existsSync(runtimeConfigPath) if (!systemConfigExists && !runtimeConfigExists) { - return + return { repairDetails: [], error: null } } - const systemConfig = normalizeDeprecatedCodexHookFeatureFlag( - systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '' - ) + const systemConfig = systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '' if (!runtimeConfigExists) { - // Why: trust blocks reference a hooks.json path, so system-home hook trust - // entries are not valid in Orca's runtime CODEX_HOME until install remaps them. - writeFileAtomically(runtimeConfigPath, stripRuntimeOwnedTomlSections(systemConfig)) - return + // Why: trust blocks carry source identity. Runtime hook trust is keyed to + // Orca's managed files and should not inherit hashes from ~/.codex. + writeFileAtomically(runtimeConfigPath, seedCodexRuntimeConfigFromSystemConfig(systemConfig)) + return { repairDetails: [], error: null } } const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig) + const { config: mergedConfig, repairDetails } = mergeCodexConfigIntoManagedRuntime( + runtimeConfig, + systemConfig, + getRuntimeCodexHooksJsonPath() + ) if (mergedConfig !== runtimeConfig) { writeFileAtomically(runtimeConfigPath, mergedConfig) } + return { repairDetails, error: null } } function normalizeDeprecatedCodexHookFeatureFlag(config: string): string { @@ -109,7 +127,17 @@ function normalizeFeatureSectionLines(lines: string[], start: number, end: numbe } } -function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string { +export function seedCodexRuntimeConfigFromSystemConfig(systemConfig: string): string { + return stripRuntimeOwnedTomlSections(normalizeDeprecatedCodexHookFeatureFlag(systemConfig)) +} + +export function mergeCodexConfigIntoManagedRuntime( + runtimeConfig: string, + systemConfig: string, + runtimeHooksJsonPath: string, + options: { preserveRuntimeHookTrust?: boolean } = {} +): { config: string; repairDetails: string[] } { + const preserveRuntimeHookTrust = options.preserveRuntimeHookTrust !== false const runtimeSections = getTomlSections(runtimeConfig) const runtimeProjectHeaders = new Set( runtimeSections @@ -122,21 +150,41 @@ function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') .map((section) => getTomlSectionHeaderKey(section.header)) ) - // Why: ordinary Codex settings should mirror ~/.codex exactly; runtime hook - // trust and project trust are written under Orca's managed CODEX_HOME and - // must survive the copy unless the user explicitly revoked project trust in - // the system config. - return joinTomlBlocks([ - stripRuntimeOwnedTomlSections(systemConfig, runtimeProjectHeaders), - ...runtimeSections - .filter((section) => isRuntimePreservedTomlSection(section.header)) - .filter( - (section) => - !isRuntimeProjectTomlSection(section.header) || - !systemUntrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) - ) - .map((section) => section.block) - ]) + const droppedRuntimeHookDeclarationCount = runtimeSections.filter((section) => + isHookDeclarationTomlSection(section.header) + ).length + const repairDetails: string[] = [] + if (droppedRuntimeHookDeclarationCount > 0) { + repairDetails.push( + `Dropped ${droppedRuntimeHookDeclarationCount} non-Orca Codex hook declaration section(s) from managed runtime config.toml.` + ) + console.warn( + `[codex-config] dropped ${droppedRuntimeHookDeclarationCount} non-Orca Codex hook declaration section(s) from managed runtime config.toml; move them to a source-preserving Codex layer when available.` + ) + } + const preservedRuntimeBlocks = runtimeSections + .filter((section) => + isRuntimePreservedTomlSection(section.header, runtimeHooksJsonPath, preserveRuntimeHookTrust) + ) + .filter( + (section) => + !isRuntimeProjectTomlSection(section.header) || + !systemUntrustedProjectHeaders.has(getTomlSectionHeaderKey(section.header)) + ) + .map((section) => section.block) + // Why: ordinary Codex settings should mirror ~/.codex. Runtime hook trust and + // project trust are written under Orca's managed CODEX_HOME and must survive + // the copy unless the user explicitly revoked project trust in the system config. + return { + config: joinTomlBlocks([ + stripRuntimeOwnedTomlSections( + normalizeDeprecatedCodexHookFeatureFlag(systemConfig), + runtimeProjectHeaders + ), + ...preservedRuntimeBlocks + ]), + repairDetails + } } type TomlSection = { @@ -160,18 +208,18 @@ function stripRuntimeOwnedTomlSections( const sections = getTomlSections(config) const firstSectionIndex = sections[0]?.start ?? -1 const preamble = firstSectionIndex === -1 ? config : lines.slice(0, firstSectionIndex).join('\n') - return joinTomlBlocks([ - preamble, - ...sections - .filter((section) => !isRuntimeHookTrustTomlSection(section.header)) - .filter( - (section) => - !isRuntimeProjectTomlSection(section.header) || - !runtimeProjectHeaders.has(getTomlSectionHeaderKey(section.header)) || - getProjectTrustLevel(section.block) === 'untrusted' - ) - .map((section) => section.block) - ]) + const blocks = sections + .filter((section) => !isRuntimeHookTrustTomlSection(section.header)) + // Why: inline hook declarations carry source path, key, trust, and plugin + // identity. Copying them into Orca's managed home rewrites that identity. + .filter((section) => !isHookDeclarationTomlSection(section.header)) + .filter( + (section) => + !isRuntimeProjectTomlSection(section.header) || + !runtimeProjectHeaders.has(getTomlSectionHeaderKey(section.header)) || + getProjectTrustLevel(section.block) === 'untrusted' + ) + return joinTomlBlocks([preamble, ...blocks.map((section) => section.block)]) } function getTomlSections(config: string): TomlSection[] { @@ -212,14 +260,73 @@ function getTomlSections(config: string): TomlSection[] { return sections } -function isRuntimePreservedTomlSection(header: string): boolean { - return isRuntimeHookTrustTomlSection(header) || isRuntimeProjectTomlSection(header) +function isRuntimePreservedTomlSection( + header: string, + runtimeHooksJsonPath: string, + preserveRuntimeHookTrust: boolean +): boolean { + return ( + (preserveRuntimeHookTrust && + shouldPreserveRuntimeHookTrustTomlSection(header, runtimeHooksJsonPath)) || + isRuntimeProjectTomlSection(header) + ) } function isRuntimeHookTrustTomlSection(header: string): boolean { return header.trimStart().startsWith('[hooks.state.') } +function shouldPreserveRuntimeHookTrustTomlSection( + header: string, + runtimeHooksJsonPath: string +): boolean { + if (!isRuntimeHookTrustTomlSection(header)) { + return false + } + const key = getRuntimeHookTrustKeyFromHeader(header) + const parsed = key ? parseTrustKey(key) : null + return ( + parsed !== null && + getCodexCanonicalTrustPath(parsed.sourcePath) === + getCodexCanonicalTrustPath(runtimeHooksJsonPath) + ) +} + +function getRuntimeHookTrustKeyFromHeader(header: string): string | null { + const match = /^[ \t]*\[hooks\.state\."((?:[^"\\]|\\.)*)"\][ \t]*$/.exec(header) + return match ? unescapeTomlBasicString(match[1]!) : null +} + +function unescapeTomlBasicString(escaped: string): string { + let result = '' + for (let index = 0; index < escaped.length; index += 1) { + const char = escaped[index] + if (char !== '\\') { + result += char + continue + } + index += 1 + const next = escaped[index] + if (next === undefined) { + result += '\\' + break + } + result += + ({ b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' } as Record)[next] ?? next + } + return result +} + +function isHookDeclarationTomlSection(header: string): boolean { + if (isRuntimeHookTrustTomlSection(header)) { + return false + } + const trimmed = header.trimStart() + return ( + trimmed.startsWith('[hooks]') || trimmed.startsWith('[hooks.') || trimmed.startsWith('[[hooks.') + ) +} + function isRuntimeProjectTomlSection(header: string): boolean { return header.trimStart().startsWith('[projects.') } diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index d4f2026e155..44467da78f0 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -60,6 +60,10 @@ export type CodexHookTrustState = { export type CodexProjectTrustLevel = 'trusted' | 'untrusted' +type HookTrustKeyOptions = { + canonicalizeSourcePath?: boolean +} + // Why: matches Codex's canonical_json. Sorts object keys recursively before // SHA-256ing; arrays preserve order. function canonicalize(value: unknown): unknown { @@ -102,8 +106,12 @@ export function computeTrustedHash(entry: CodexTrustEntry): string { return `sha256:${createHash('sha256').update(serialized).digest('hex')}` } -export function computeTrustKey(entry: CodexTrustEntry): string { - return `${getCodexCanonicalTrustPath(entry.sourcePath)}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` +export function computeTrustKey(entry: CodexTrustEntry, options: HookTrustKeyOptions = {}): string { + const sourcePath = + options.canonicalizeSourcePath === false + ? entry.sourcePath + : getCodexCanonicalTrustPath(entry.sourcePath) + return `${sourcePath}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` } export function getCodexCanonicalTrustPath(sourcePath: string): string { @@ -214,13 +222,14 @@ export function upsertHookTrustEntries( export function upsertHookTrustEntriesInContent( existingContent: string, - entries: readonly CodexTrustEntry[] + entries: readonly CodexTrustEntry[], + options: HookTrustKeyOptions = {} ): string { const existing = existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent let updated = existing for (const entry of entries) { - updated = upsertTrustBlock(updated, computeTrustKey(entry), computeTrustedHash(entry)) + updated = upsertTrustBlock(updated, computeTrustKey(entry, options), computeTrustedHash(entry)) } return updated } diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index fb2374f688d..e71915beeeb 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -165,8 +165,8 @@ describe('CodexHookService', () => { const prodHooks = JSON.parse(readFileSync(prodHooksPath, 'utf-8')) as { hooks: Record } - expect(devHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') - expect(prodHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') + expect(JSON.stringify(devHooks)).not.toContain('user-hook') + expect(JSON.stringify(prodHooks)).not.toContain('user-hook') expect( devHooks.hooks.Stop?.some((definition) => definition.hooks?.[0]?.command?.includes('codex-hook') @@ -185,7 +185,7 @@ describe('CodexHookService', () => { } }) - it('mirrors trusted system user hook approvals into the runtime CODEX_HOME', () => { + it('does not mirror trusted system user hook approvals into the runtime CODEX_HOME', () => { const systemCodexHome = join(tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -243,75 +243,43 @@ describe('CodexHookService', () => { { matcher?: string; hooks?: { command?: string; statusMessage?: string }[] }[] > } - expect(runtimeHooks.hooks.Stop?.[0]?.matcher).toBe('*') - expect(runtimeHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toBe('user-hook') - expect(runtimeHooks.hooks.Stop?.[0]?.hooks?.[0]?.statusMessage).toBe('Running user hook') + const stopCommands = + runtimeHooks.hooks.Stop?.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + expect(stopCommands).not.toContain('user-hook') + expect(JSON.stringify(runtimeHooks)).not.toContain('Running user hook') + expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) }) - it('mirrors system user hook approvals when the system trust indices are stale', () => { - const systemCodexHome = join(tmpHome, '.codex') - const systemHooksPath = join(systemCodexHome, 'hooks.json') - mkdirSync(systemCodexHome, { recursive: true }) - writeFileSync( - systemHooksPath, - `${JSON.stringify( - { - hooks: { - Stop: [ - { hooks: [{ type: 'command', command: 'first-stop-hook' }] }, - { hooks: [{ type: 'command', command: 'second-stop-hook' }] } - ] - } - }, - null, - 2 - )}\n`, - 'utf-8' - ) - writeFileSync( - join(systemCodexHome, 'config.toml'), - upsertHookTrustEntriesInContent('model = "system-model"\n', [ - { - sourcePath: systemHooksPath, - eventLabel: 'stop', - groupIndex: 0, - handlerIndex: 0, - command: 'second-stop-hook' - }, - { - sourcePath: systemHooksPath, - eventLabel: 'stop', - groupIndex: 1, - handlerIndex: 0, - command: 'first-stop-hook' - } - ]), - 'utf-8' - ) - - expect(new CodexHookService().install().state).toBe('installed') - - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const managedHooksPath = join(managedCodexHome, 'hooks.json') - const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) - expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) - expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) - expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:1:0`)) - }) - - it('skips plugin-placeholder system hooks when mirroring into runtime CODEX_HOME', () => { + it('keeps all system hooks out of runtime CODEX_HOME without mutating them', () => { const pluginCommands = [ + 'node "${CODEX_PLUGIN_ROOT}/scripts/on-stop.mjs"', + 'node "${CODEX_PLUGIN_DATA}/scripts/on-stop.mjs"', 'node "${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.mjs"', 'node "${CLAUDE_PLUGIN_DATA}/scripts/on-stop.mjs"', 'node "${PLUGIN_ROOT}/scripts/on-stop.mjs"', - 'node "${PLUGIN_DATA}/scripts/on-stop.mjs"' + 'node "${PLUGIN_DATA}/scripts/on-stop.mjs"', + 'node "$CLAUDE_PLUGIN_ROOT/scripts/on-stop.mjs"', + 'node "${CODEX_PLUGIN_ROOT:-/missing}/scripts/on-stop.mjs"', + 'node "${CODEX_PLUGIN_ROOT-/missing}/scripts/on-stop.mjs"', + 'node "${CODEX_PLUGIN_ROOT+/present}/scripts/on-stop.mjs"', + 'node "${CODEX_PLUGIN_ROOT?missing}/scripts/on-stop.mjs"', + 'node "${CODEX_PLUGIN_ROOT%/}/scripts/on-stop.mjs"', + 'node "${CODEX_PLUGIN_ROOT#prefix}/scripts/on-stop.mjs"', + 'node "${CODEX_PLUGIN_ROOT/scripts/hooks}/on-stop.mjs"', + 'node "${CODEX_PLUGIN_ROOT=/missing}/scripts/on-stop.mjs"', + 'powershell -File "$env:CLAUDE_PLUGIN_ROOT\\scripts\\on-stop.ps1"', + 'powershell -File "${env:CLAUDE_PLUGIN_ROOT}\\scripts\\on-stop.ps1"', + 'cmd /c "%CLAUDE_PLUGIN_ROOT%\\scripts\\on-stop.cmd"', + 'cmd /v:on /c "!CLAUDE_PLUGIN_ROOT!\\scripts\\on-stop.cmd"' ] const userCommand = 'user-stop-hook' + const windowsPluginFallbackCommand = 'fallback-command-with-plugin-command-windows' const stopEventLabel = 'stop' as const const systemCodexHome = join(tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') @@ -325,6 +293,11 @@ describe('CodexHookService', () => { { hooks: [ ...pluginCommands.map((command) => ({ type: 'command', command })), + { + type: 'command', + command: windowsPluginFallbackCommand, + commandWindows: '%CLAUDE_PLUGIN_ROOT%\\scripts\\on-stop.cmd' + }, { type: 'command', command: userCommand } ] } @@ -353,7 +326,7 @@ describe('CodexHookService', () => { sourcePath: systemHooksPath, eventLabel: stopEventLabel, groupIndex: 0, - handlerIndex: pluginCommands.length, + handlerIndex: pluginCommands.length + 1, command: userCommand } ]), @@ -373,7 +346,8 @@ describe('CodexHookService', () => { (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] ) ?? [] - expect(stopCommands).toContain(userCommand) + expect(stopCommands).not.toContain(userCommand) + expect(stopCommands).not.toContain(windowsPluginFallbackCommand) expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) expect(runtimeHooks.hooks.PreCompact).toBeUndefined() for (const command of pluginCommands) { @@ -385,9 +359,124 @@ describe('CodexHookService', () => { for (const command of pluginCommands) { expect(runtimeToml).not.toContain(command) } + expect(readFileSync(systemHooksPath, 'utf-8')).toContain('${CLAUDE_PLUGIN_ROOT}') }) - it('mirrors compact-event user hook approvals and disabled trust entries', () => { + it('does not remove ambiguous plugin-placeholder groups from system hooks', () => { + const pluginCommand = '$CLAUDE_PLUGIN_ROOT/scripts/on-stop.sh' + const mixedPluginCommand = '%CLAUDE_PLUGIN_ROOT%\\scripts\\on-prompt-submit.cmd' + const userCommand = 'user-stop-hook' + const mixedUserCommand = 'user-prompt-hook' + const windowsPluginFallbackCommand = 'fallback-command-with-plugin-command-windows' + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify( + { + hooks: { + Stop: [ + { + hooks: [{ type: 'command', command: pluginCommand }] + }, + { + hooks: [ + { + type: 'command', + command: windowsPluginFallbackCommand, + commandWindows: '%CLAUDE_PLUGIN_ROOT%\\scripts\\on-stop.cmd' + } + ] + }, + { + hooks: [{ type: 'command', command: userCommand }] + } + ], + UserPromptSubmit: [ + { + hooks: [ + { type: 'command', command: mixedPluginCommand }, + { type: 'command', command: mixedUserCommand } + ] + } + ] + } + }, + null, + 2 + )}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: pluginCommand + }, + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 1, + handlerIndex: 0, + command: windowsPluginFallbackCommand + }, + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 2, + handlerIndex: 0, + command: userCommand + }, + { + sourcePath: systemHooksPath, + eventLabel: 'user_prompt_submit', + groupIndex: 0, + handlerIndex: 0, + command: mixedPluginCommand + }, + { + sourcePath: systemHooksPath, + eventLabel: 'user_prompt_submit', + groupIndex: 0, + handlerIndex: 1, + command: mixedUserCommand + } + ]), + 'utf-8' + ) + + expect(new CodexHookService().install().state).toBe('installed') + + const systemHooksText = readFileSync(systemHooksPath, 'utf-8') + const systemHooks = JSON.parse(systemHooksText) as { + hooks: Record + } + const systemCommands = + Object.values(systemHooks.hooks).flatMap( + (definitions) => + definitions.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + ) ?? [] + expect(systemHooksText).toContain(pluginCommand) + expect(systemHooksText).toContain(windowsPluginFallbackCommand) + expect(systemCommands).toContain(mixedPluginCommand) + expect(systemCommands).toContain(userCommand) + expect(systemCommands).toContain(mixedUserCommand) + + const systemToml = readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8') + expect(systemToml).toContain(hookTrustHeader(`${systemHooksPath}:stop:1:0`)) + expect(systemToml).toContain(hookTrustHeader(`${systemHooksPath}:stop:2:0`)) + expect(systemToml).toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`)) + expect(systemToml).toContain(hookTrustHeader(`${systemHooksPath}:user_prompt_submit:0:0`)) + }) + + it('does not mirror compact-event user hook approvals or disabled trust entries', () => { const systemCodexHome = join(tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -437,21 +526,75 @@ describe('CodexHookService', () => { const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { hooks: Record } - expect(runtimeHooks.hooks.PreCompact?.[0]?.hooks?.[0]?.command).toBe('pre-compact-user') - expect(runtimeHooks.hooks.PostCompact?.[0]?.hooks?.[0]?.command).toBe('post-compact-disabled') + expect(runtimeHooks.hooks.PreCompact).toBeUndefined() + expect(runtimeHooks.hooks.PostCompact).toBeUndefined() const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(runtimeToml).toContain( - `${hookTrustHeader(`${managedHooksPath}:pre_compact:0:0`)}\nenabled = true` - ) - expect(runtimeToml).toContain( - `${hookTrustHeader(`${managedHooksPath}:post_compact:0:0`)}\nenabled = false` - ) + expect(runtimeToml).not.toContain(hookTrustHeader(`${managedHooksPath}:pre_compact:0:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${managedHooksPath}:post_compact:0:0`)) expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:pre_compact:0:0`)) expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:post_compact:0:0`)) }) - it('removes runtime user hook trust after system approval is revoked', () => { + it('drops trusted runtime-local user hooks during install', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }] } + })}\n`, + 'utf-8' + ) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + writeFileSync( + managedHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'stale-runtime-user-hook' }] }] } + })}\n`, + 'utf-8' + ) + writeFileSync( + join(managedCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "runtime-model"\n', [ + { + sourcePath: managedHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'stale-runtime-user-hook' + } + ]), + 'utf-8' + ) + + const reinstallStatus = service.install() + expect(reinstallStatus.state).toBe('partial') + expect(reinstallStatus.detail).toContain('Dropped 1 non-Orca Codex hook group(s)') + + const runtimeUserTrustHeader = hookTrustHeader(`${managedHooksPath}:stop:0:0`) + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain(runtimeUserTrustHeader) + expect(runtimeToml).not.toContain('stale-runtime-user-hook') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + const stopCommands = + runtimeHooks.hooks.Stop?.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + expect(stopCommands).not.toContain('stale-runtime-user-hook') + expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) + }) + + it('keeps only managed runtime trust when system hook approval changes', () => { const systemCodexHome = join(tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -476,25 +619,210 @@ describe('CodexHookService', () => { 'utf-8' ) const service = new CodexHookService() - expect(service.install().state).toBe('installed') - const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') - const managedHooksPath = join(managedCodexHome, 'hooks.json') - const runtimeUserTrustHeader = hookTrustHeader(`${managedHooksPath}:stop:0:0`) - expect(readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')).toContain( - runtimeUserTrustHeader - ) - writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') expect(service.install().state).toBe('installed') + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + const stopCommands = + runtimeHooks.hooks.Stop?.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + expect(stopCommands).not.toContain('user-hook') + expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(runtimeToml).not.toContain(runtimeUserTrustHeader) - expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(runtimeToml).not.toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`)) }) - it('refreshes mirrored system user hooks when the system hooks file changes', () => { + it('drops stale runtime hooks during install', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + mkdirSync(managedCodexHome, { recursive: true }) + writeFileSync( + managedHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'old-mirrored-user-hook' }] }] } + })}\n`, + 'utf-8' + ) + writeFileSync( + join(managedCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "runtime-model"\n', [ + { + sourcePath: managedHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'old-mirrored-user-hook' + } + ]), + 'utf-8' + ) + + const status = new CodexHookService().install() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Dropped 1 non-Orca Codex hook group(s)') + + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + const stopCommands = + runtimeHooks.hooks.Stop?.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + expect(stopCommands).not.toContain('old-mirrored-user-hook') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).not.toContain('old-mirrored-user-hook') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + }) + + it('drops stale inline hook declarations during install', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + mkdirSync(managedCodexHome, { recursive: true }) + writeFileSync( + join(managedCodexHome, 'config.toml'), + [ + 'model = "runtime-model"', + '', + '[[hooks.Stop]]', + '[[hooks.Stop.hooks]]', + 'type = "command"', + 'command = "old-mirrored-inline-hook"', + '' + ].join('\n'), + 'utf-8' + ) + + const status = new CodexHookService().install() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Dropped 2 non-Orca Codex hook declaration section(s)') + + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain('model = "system-model"') + expect(runtimeToml).not.toContain('old-mirrored-inline-hook') + }) + + it('drops stale runtime hooks without requiring mirror state', () => { + const systemCodexHome = join(tmpHome, '.codex') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + mkdirSync(managedCodexHome, { recursive: true }) + writeFileSync( + join(managedCodexHome, 'orca-system-hook-mirror-state.json'), + '{not json', + 'utf-8' + ) + writeFileSync( + managedHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'old-mirrored-user-hook' }] }] } + })}\n`, + 'utf-8' + ) + + const status = new CodexHookService().install() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Dropped 1 non-Orca Codex hook group(s)') + + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + const stopCommands = + runtimeHooks.hooks.Stop?.flatMap( + (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] + ) ?? [] + expect(stopCommands).not.toContain('old-mirrored-user-hook') + }) + + it('drops runtime-local hooks instead of rekeying them after removing mirrors', () => { + const systemCodexHome = join(tmpHome, '.codex') + const systemHooksPath = join(systemCodexHome, 'hooks.json') + mkdirSync(systemCodexHome, { recursive: true }) + writeFileSync( + systemHooksPath, + `${JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'system-hook' }] }] } + })}\n`, + 'utf-8' + ) + writeFileSync( + join(systemCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "system-model"\n', [ + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: 'system-hook' + } + ]), + 'utf-8' + ) + const service = new CodexHookService() + expect(service.install().state).toBe('installed') + + const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') + writeFileSync( + managedHooksPath, + `${JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'system-hook' }] }, + { hooks: [{ type: 'command', command: 'runtime-local-hook' }] } + ] + } + })}\n`, + 'utf-8' + ) + writeFileSync( + join(managedCodexHome, 'config.toml'), + upsertHookTrustEntriesInContent('model = "runtime-model"\n', [ + { + sourcePath: managedHooksPath, + eventLabel: 'stop', + groupIndex: 1, + handlerIndex: 0, + command: 'runtime-local-hook' + } + ]), + 'utf-8' + ) + + writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') + const reinstallStatus = service.install() + expect(reinstallStatus.state).toBe('partial') + expect(reinstallStatus.detail).toContain('Dropped 2 non-Orca Codex hook group(s)') + + const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as { + hooks: Record + } + expect(runtimeHooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain('codex-hook') + const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') + expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(runtimeToml).not.toContain('runtime-local-hook') + }) + + it('ignores system user hook changes while keeping managed status hooks installed', () => { const systemCodexHome = join(tmpHome, '.codex') const systemHooksPath = join(systemCodexHome, 'hooks.json') mkdirSync(systemCodexHome, { recursive: true }) @@ -526,8 +854,9 @@ describe('CodexHookService', () => { runtimeHooks.hooks.Stop?.flatMap( (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] ) ?? [] - expect(stopCommands).toContain('user-hook-new') + expect(stopCommands).not.toContain('user-hook-new') expect(stopCommands).not.toContain('user-hook-old') + expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) }) it('refreshes runtime user hooks without installing Orca-managed hooks', () => { @@ -571,13 +900,11 @@ describe('CodexHookService', () => { const runtimeCommands = Object.values(runtimeHooks.hooks).flatMap((definitions) => definitions.flatMap((definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? []) ) - expect(runtimeCommands).toEqual(['user-stop-hook']) + expect(runtimeCommands).toEqual([]) expect(runtimeCommands.some((command) => command.includes('codex-hook'))).toBe(false) const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') - expect(runtimeToml).toContain( - `${hookTrustHeader(`${managedHooksPath}:stop:0:0`)}\nenabled = false` - ) + expect(runtimeToml).not.toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) expect(runtimeToml).not.toContain(':permission_request:0:0') }) @@ -592,6 +919,18 @@ describe('CodexHookService', () => { ) const legacyCommand = process.platform === 'win32' ? legacyScriptPath : wrapPosixHookCommand(legacyScriptPath) + const unrelatedAgentHooksCommand = + process.platform === 'win32' + ? join(tmpHome, 'user-tools', 'agent-hooks', 'codex-hook.cmd') + : wrapPosixHookCommand(join(tmpHome, 'user-tools', 'agent-hooks', 'codex-hook.sh')) + const oldSharedScriptPath = join( + tmpHome, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' + ) + const oldSharedCommand = + process.platform === 'win32' ? oldSharedScriptPath : `/bin/sh '${oldSharedScriptPath}'` mkdirSync(systemCodexHome, { recursive: true }) writeFileSync( systemHooksPath, @@ -600,7 +939,9 @@ describe('CodexHookService', () => { hooks: { Stop: [ { hooks: [{ type: 'command', command: 'user-hook' }] }, - { hooks: [{ type: 'command', command: legacyCommand }] } + { hooks: [{ type: 'command', command: unrelatedAgentHooksCommand }] }, + { hooks: [{ type: 'command', command: legacyCommand }] }, + { hooks: [{ type: 'command', command: oldSharedCommand }] } ], SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }] } @@ -626,6 +967,13 @@ describe('CodexHookService', () => { groupIndex: 0, handlerIndex: 0, command: legacyCommand + }, + { + sourcePath: systemHooksPath, + eventLabel: 'stop', + groupIndex: 3, + handlerIndex: 0, + command: oldSharedCommand } ]), 'utf-8' @@ -636,11 +984,15 @@ describe('CodexHookService', () => { const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as { hooks: Record } - expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }]) + expect(systemHooks.hooks.Stop).toEqual([ + { hooks: [{ type: 'command', command: 'user-hook' }] }, + { hooks: [{ type: 'command', command: unrelatedAgentHooksCommand }] } + ]) expect(systemHooks.hooks.SessionStart).toBeUndefined() const systemToml = readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8') expect(systemToml).toContain('model = "system-model"') - expect(systemToml).not.toContain(':stop:1:0') + expect(systemToml).not.toContain(':stop:2:0') + expect(systemToml).not.toContain(':stop:3:0') expect(systemToml).not.toContain(':session_start:0:0') }) @@ -836,7 +1188,7 @@ describe('CodexHookService', () => { runtimeHooks.hooks.Stop?.flatMap( (definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? [] ) ?? [] - expect(stopCommands).toContain(userCommand) + expect(stopCommands).not.toContain(userCommand) expect(stopCommands.some((command) => command.includes('codex-hook'))).toBe(true) expect(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command).toContain('codex-hook') @@ -879,19 +1231,20 @@ describe('CodexHookService', () => { expect(runtimeToml).not.toContain(':stop:0:0') }) - it('mirrors system Codex config while preserving runtime hook trust on hook install', () => { + it('mirrors system Codex config while removing stale runtime hook trust on hook install', () => { const systemCodexHome = join(tmpHome, '.codex') mkdirSync(systemCodexHome, { recursive: true }) writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8') const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home') + const managedHooksPath = join(managedCodexHome, 'hooks.json') mkdirSync(managedCodexHome, { recursive: true }) writeFileSync( join(managedCodexHome, 'config.toml'), [ 'model = "runtime-model"', '', - '[hooks.state."runtime-hook"]', + `[hooks.state."${managedHooksPath}:stop:0:0"]`, 'enabled = false', 'trusted_hash = "sha256:runtime"', '' @@ -904,9 +1257,8 @@ describe('CodexHookService', () => { expect(status.state).toBe('installed') const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8') expect(trustConfig).toContain('model = "system-model"') - expect(trustConfig).toContain('[hooks.state."runtime-hook"]') - expect(trustConfig).toContain('enabled = false') - expect(trustConfig).toContain('trusted_hash = "sha256:runtime"') + expect(trustConfig).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`)) + expect(trustConfig).not.toContain('trusted_hash = "sha256:runtime"') expect(trustConfig).toContain(':permission_request:0:0') expect(trustConfig).not.toContain('model = "runtime-model"') }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index bda41ad1b22..e53fbc3a881 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync, unlinkSync } from 'fs' import { join } from 'path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { getRemoteOrcaCodexHomePath } from '../../shared/remote-codex-home' import { createManagedCommandMatcher, buildWindowsAgentHookPostCommand, @@ -17,6 +18,7 @@ import { type HookDefinition } from '../agent-hooks/installer-utils' import { + realpathRemote, readHooksJsonRemote, readTextFileRemote, writeHooksJsonRemote, @@ -26,7 +28,6 @@ import { import { computeTrustKey, computeTrustedHash, - escapeTomlString, getCodexCanonicalTrustPath, parseTrustKey, readHookTrustEntries, @@ -39,7 +40,12 @@ import { type CodexTrustEntry } from './config-toml-trust' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' -import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { + mergeCodexConfigIntoManagedRuntime, + seedCodexRuntimeConfigFromSystemConfig, + syncSystemConfigIntoManagedCodexHome, + type CodexConfigSyncResult +} from './codex-config-mirror' // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. @@ -82,20 +88,13 @@ const CODEX_HOOK_EVENT_LABEL: Record = { PostCompact: 'post_compact' } -const CODEX_PLUGIN_ONLY_HOOK_PLACEHOLDERS = [ - '${CLAUDE_PLUGIN_ROOT}', - '${CLAUDE_PLUGIN_DATA}', - '${PLUGIN_ROOT}', - '${PLUGIN_DATA}' -] as const - const LEGACY_ORCA_PROFILE_NAME = 'orca-agent-status' const LEGACY_ORCA_PROFILE_BLOCK_START = '# BEGIN ORCA AGENT STATUS HOOKS' const LEGACY_ORCA_PROFILE_BLOCK_END = '# END ORCA AGENT STATUS HOOKS' -type MirroredRuntimeUserHookTrustEntry = { - entry: CodexTrustEntry - enabled: boolean +type RuntimeHookPlan = { + hooks: Record + repairDetails: string[] } function getManagedScriptFileName(): string { @@ -110,6 +109,46 @@ function getManagedCommand(scriptPath: string): string { return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath) } +function createExactManagedCommandMatcher( + scriptPath: string, + command: string +): (candidate: string | undefined) => boolean { + const normalizedScriptPath = scriptPath.replaceAll('\\', '/') + const normalizedCommand = command.replaceAll('\\', '/') + return (candidate) => { + const normalizedCandidate = candidate?.replaceAll('\\', '/') + return normalizedCandidate === normalizedCommand || normalizedCandidate === normalizedScriptPath + } +} + +function createLegacyOrcaCodexCommandMatcher( + scriptPath: string, + command: string, + scriptFileName: string +): (candidate: string | undefined) => boolean { + const exact = createExactManagedCommandMatcher(scriptPath, command) + const normalizedUserDataPath = process.env.ORCA_USER_DATA_PATH?.replaceAll('\\', '/') + const ownedPathFragments = [ + `/.orca/agent-hooks/${scriptFileName}`, + `/orca/agent-hooks/${scriptFileName}`, + `/orca-dev/agent-hooks/${scriptFileName}` + ] + return (candidate) => { + const normalizedCandidate = candidate?.replaceAll('\\', '/') + if (!normalizedCandidate) { + return false + } + return ( + exact(candidate) || + ownedPathFragments.some((fragment) => normalizedCandidate.includes(fragment)) || + Boolean( + normalizedUserDataPath && + normalizedCandidate.includes(`${normalizedUserDataPath}/agent-hooks/${scriptFileName}`) + ) + ) + } +} + function getSystemConfigPath(): string { return join(getSystemCodexHomePath(), 'hooks.json') } @@ -122,17 +161,21 @@ function getLegacyCodexProfileTomlPath(): string { return join(getSystemCodexHomePath(), `${LEGACY_ORCA_PROFILE_NAME}.config.toml`) } -function collectManagedTrustEntries( +function collectMatchingTrustEntries( sourcePath: string, eventName: string, definitions: readonly HookDefinition[], - isManagedCommand: (command: string | undefined) => boolean + isMatchingCommand: (command: string | undefined) => boolean, + shouldCollectDefinition: (definition: HookDefinition) => boolean = () => true ): CodexTrustEntry[] { const entries: CodexTrustEntry[] = [] definitions.forEach((definition, groupIndex) => { + if (!shouldCollectDefinition(definition)) { + return + } const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] hooks.forEach((hook, handlerIndex) => { - if (!isManagedCommand(hook.command)) { + if (!isMatchingCommand(hook.command)) { return } const entry = createHookTrustEntry( @@ -219,250 +262,68 @@ function removeStaleRuntimeHookTrustEntries( } } -function getTrustSignature(entry: CodexTrustEntry): string { - return JSON.stringify({ - eventLabel: entry.eventLabel, - command: entry.command, - timeoutSec: Math.max(1, entry.timeoutSec ?? 600), - async: entry.async ?? false, - matcher: entry.matcher ?? null, - statusMessage: entry.statusMessage ?? null - }) -} - -function commandUsesCodexPluginOnlyPlaceholder(command: string | undefined): boolean { - return ( - typeof command === 'string' && - CODEX_PLUGIN_ONLY_HOOK_PLACEHOLDERS.some((placeholder) => command.includes(placeholder)) - ) -} - -function removeCodexPluginEnvironmentCommands(definitions: HookDefinition[]): HookDefinition[] { - // Why: Orca mirrors system hooks into a plain runtime hooks.json. Plugin - // placeholders only work for Codex plugin hook sources, so copying those - // commands here strips the environment they require and turns them into 127s. - return removeManagedCommands(definitions, commandUsesCodexPluginOnlyPlaceholder) -} - -function getRuntimeHooksWithSystemUserHooks( +function getRuntimeHooksWithoutExternalSourceMirrors( runtimeHooks: Record | undefined, isManagedCommand: (command: string | undefined) => boolean -): { - hooks: Record - trustEntries: MirroredRuntimeUserHookTrustEntry[] -} { - const systemConfigPath = getSystemConfigPath() - const runtimeConfigPath = getConfigPath() - if (systemConfigPath === getConfigPath()) { - return { hooks: { ...runtimeHooks }, trustEntries: [] } - } - - const systemConfig = readHooksJson(systemConfigPath) - if (!systemConfig?.hooks) { - return { hooks: {}, trustEntries: [] } - } - +): RuntimeHookPlan { const nextHooks: Record = {} - const trustedSystemHookSignatures = getTrustedSystemUserHookSignatures( - systemConfigPath, - systemConfig.hooks, - isManagedCommand - ) - for (const [eventName, systemDefinitions] of Object.entries(systemConfig.hooks)) { - if (!Array.isArray(systemDefinitions)) { + const repairDetails: string[] = [] + let droppedHookGroupCount = 0 + for (const [eventName, definitions] of Object.entries(runtimeHooks ?? {})) { + if (!Array.isArray(definitions)) { continue } - - const systemUserDefinitions = removeCodexPluginEnvironmentCommands( - removeManagedCommands(systemDefinitions, isManagedCommand) - ) - if (systemUserDefinitions.length === 0) { + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length > 0) { + droppedHookGroupCount += cleaned.length + repairDetails.push( + `Dropped ${cleaned.length} non-Orca Codex hook group(s) for ${eventName} from managed runtime hooks.json.` + ) + console.warn( + `[codex-hook-service] dropped ${cleaned.length} non-Orca Codex hook group(s) for ${eventName} from managed runtime hooks.json` + ) continue } - - // Why: runtime hooks are derived from the user's system hooks plus Orca's - // managed hooks. Reusing old runtime user-hook copies would keep deleted or - // edited ~/.codex/hooks.json entries alive for new Orca-launched sessions. - nextHooks[eventName] = dedupeHookDefinitions(systemUserDefinitions) } + if (droppedHookGroupCount > 0) { + console.warn( + `[codex-hook-service] dropped ${droppedHookGroupCount} non-Orca Codex hook group(s) from managed runtime hooks.json; re-approve or move them to a source-preserving Codex layer when available.` + ) + } + return { hooks: nextHooks, repairDetails } +} +function withRuntimeRepairDetails( + status: AgentHookInstallStatus, + repairDetails: readonly string[] +): AgentHookInstallStatus { + if (repairDetails.length === 0) { + return status + } + const detail = [...repairDetails, status.detail] + .filter((part): part is string => Boolean(part)) + .join(' ') return { - hooks: nextHooks, - trustEntries: collectMirroredRuntimeUserHookTrustEntries( - runtimeConfigPath, - nextHooks, - trustedSystemHookSignatures, - isManagedCommand - ) + ...status, + state: status.state === 'installed' ? 'partial' : status.state, + detail } } -function getTrustedSystemUserHookSignatures( - systemConfigPath: string, - systemHooks: Record, - isManagedCommand: (command: string | undefined) => boolean -): Map { - const signatures = new Map() - let trustEntries: Map - try { - trustEntries = readHookTrustEntries(getSystemCodexConfigTomlPath()) - } catch (error) { - // Why: a hand-broken system config.toml should only disable user-hook - // trust mirroring; Orca's managed runtime hooks can still be installed. - console.warn('[codex-hook-service] failed to read system hook trust entries', error) - return signatures +function configSyncFailedStatus( + configPath: string, + syncResult: CodexConfigSyncResult +): AgentHookInstallStatus | null { + if (syncResult.error === null) { + return null } - const trustedHashesByEvent = getTrustedSystemHookHashesByEvent(systemConfigPath, trustEntries) - for (const [eventName, definitions] of Object.entries(systemHooks)) { - if (!Array.isArray(definitions)) { - continue - } - definitions.forEach((definition, groupIndex) => { - const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] - hooks.forEach((hook, handlerIndex) => { - if (isManagedCommand(hook.command)) { - return - } - const entry = createHookTrustEntry( - systemConfigPath, - eventName, - groupIndex, - handlerIndex, - definition, - hook - ) - if (!entry) { - return - } - const expectedHash = computeTrustedHash(entry) - const state = trustEntries.get(computeTrustKey(entry)) - const enabled = - state?.trustedHash === expectedHash - ? state.enabled !== false - : trustedHashesByEvent.get(entry.eventLabel)?.get(expectedHash) - if (enabled === undefined) { - return - } - const signature = getTrustSignature(entry) - // Why: runtime deduping collapses identical system hook definitions; - // if any duplicate remains enabled, keep the mirrored hook enabled. - if (enabled || !signatures.has(signature)) { - signatures.set(signature, enabled) - } - }) - }) + return { + agent: 'codex', + state: 'error', + configPath, + managedHooksPresent: false, + detail: `Managed Codex config could not be synced: ${syncResult.error}` } - return signatures -} - -function getTrustedSystemHookHashesByEvent( - systemConfigPath: string, - trustEntries: ReadonlyMap -): Map> { - const trustedHashesByEvent = new Map>() - const canonicalSystemConfigPath = getCodexCanonicalTrustPath(systemConfigPath) - for (const [key, state] of trustEntries) { - const parsed = parseTrustKey(key) - if (!parsed || !state.trustedHash) { - continue - } - if (getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalSystemConfigPath) { - continue - } - let hashes = trustedHashesByEvent.get(parsed.eventLabel) - if (!hashes) { - hashes = new Map() - trustedHashesByEvent.set(parsed.eventLabel, hashes) - } - const enabled = state.enabled !== false - // Why: Codex trust keys include hook indices. If a user reorders hooks, - // the hash still proves the same event+command identity was approved. - if (enabled || !hashes.has(state.trustedHash)) { - hashes.set(state.trustedHash, enabled) - } - } - return trustedHashesByEvent -} - -function collectMirroredRuntimeUserHookTrustEntries( - runtimeConfigPath: string, - runtimeHooks: Record, - trustedSystemHookSignatures: ReadonlyMap, - isManagedCommand: (command: string | undefined) => boolean -): MirroredRuntimeUserHookTrustEntry[] { - if (trustedSystemHookSignatures.size === 0) { - return [] - } - - const entries: MirroredRuntimeUserHookTrustEntry[] = [] - for (const [eventName, definitions] of Object.entries(runtimeHooks)) { - if (!Array.isArray(definitions)) { - continue - } - definitions.forEach((definition, groupIndex) => { - const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] - hooks.forEach((hook, handlerIndex) => { - if (isManagedCommand(hook.command)) { - return - } - const entry = createHookTrustEntry( - runtimeConfigPath, - eventName, - groupIndex, - handlerIndex, - definition, - hook - ) - if (!entry) { - return - } - const signature = getTrustSignature(entry) - const enabled = trustedSystemHookSignatures.get(signature) - if (enabled !== undefined) { - entries.push({ entry, enabled }) - } - }) - }) - } - return entries -} - -function escapeRegex(value: string): string { - return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -function applyMirroredRuntimeUserHookTrustStates( - tomlPath: string, - entries: readonly MirroredRuntimeUserHookTrustEntry[] -): void { - if (entries.length === 0 || !existsSync(tomlPath)) { - return - } - - const existing = readFileSync(tomlPath, 'utf-8') - let updated = existing - for (const { entry, enabled } of entries) { - const escapedKey = escapeRegex(escapeTomlString(computeTrustKey(entry))) - const pattern = new RegExp( - `(\\[hooks\\.state\\."${escapedKey}"\\]\\r?\\n[ \\t]*enabled[ \\t]*=[ \\t]*)(true|false)` - ) - updated = updated.replace(pattern, `$1${enabled}`) - } - if (updated !== existing) { - writeConfigAtomically(tomlPath, updated) - } -} - -function dedupeHookDefinitions(definitions: readonly HookDefinition[]): HookDefinition[] { - const seen = new Set() - return definitions.filter((definition) => { - const key = JSON.stringify(definition) - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) } function cleanupLegacySystemManagedHooks(): void { @@ -477,15 +338,21 @@ function cleanupLegacySystemManagedHooks(): void { return } - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const scriptPath = getManagedScriptPath() + const command = getManagedCommand(scriptPath) + const isManagedCommand = createLegacyOrcaCodexCommandMatcher( + scriptPath, + command, + getManagedScriptFileName() + ) const nextHooks = { ...config.hooks } const trustEntries: CodexTrustEntry[] = [] - let removedManagedHook = false + let removedSystemHook = false for (const [eventName, definitions] of Object.entries(nextHooks)) { if (!Array.isArray(definitions)) { continue } - const eventTrustEntries = collectManagedTrustEntries( + const eventTrustEntries = collectMatchingTrustEntries( legacyConfigPath, eventName, definitions, @@ -493,7 +360,7 @@ function cleanupLegacySystemManagedHooks(): void { ) trustEntries.push(...eventTrustEntries) const cleaned = removeManagedCommands(definitions, isManagedCommand) - removedManagedHook ||= definitions.some((definition) => + removedSystemHook ||= definitions.some((definition) => hookDefinitionHasManagedCommand(definition, isManagedCommand) ) if (cleaned.length === 0) { @@ -505,7 +372,7 @@ function cleanupLegacySystemManagedHooks(): void { // Why: Codex hooks moved to Orca's managed CODEX_HOME; old entries in // ~/.codex would keep external Codex sessions reporting into Orca. - if (removedManagedHook) { + if (removedSystemHook) { writeHooksJson(legacyConfigPath, { ...config, hooks: nextHooks }) } removeMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries) @@ -558,6 +425,108 @@ function cleanupLegacyManagedHookRepresentations(): void { } } +async function syncRemoteSystemCodexAuthIntoRuntimeHome( + sftp: SFTPWrapper, + remoteHome: string, + remoteRuntimeCodexHome: string +): Promise { + const systemAuth = await readTextFileRemote( + sftp, + `${remoteHome.replace(/\/$/, '')}/.codex/auth.json` + ) + if (systemAuth === null) { + throw new Error('Remote Codex auth.json is missing; managed runtime home was not enabled.') + } + // Why: remote Orca PTYs use a managed CODEX_HOME so status hooks do not touch + // ~/.codex, but Codex still needs the remote user's selected account. + await writeTextFileRemoteAtomic(sftp, `${remoteRuntimeCodexHome}/auth.json`, systemAuth) +} + +async function syncRemoteSystemCodexConfigIntoRuntimeHome( + sftp: SFTPWrapper, + remoteHome: string, + remoteConfigPath: string, + remoteTomlPath: string +): Promise { + const systemToml = await readTextFileRemote( + sftp, + `${remoteHome.replace(/\/$/, '')}/.codex/config.toml` + ) + const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? '' + if (existingToml.length === 0) { + const seededToml = seedCodexRuntimeConfigFromSystemConfig(systemToml ?? '') + if (seededToml.length > 0) { + await writeTextFileRemoteAtomic(sftp, remoteTomlPath, seededToml) + } + return [] + } + + const { config: nextToml, repairDetails } = mergeCodexConfigIntoManagedRuntime( + existingToml, + systemToml ?? '', + remoteConfigPath, + { preserveRuntimeHookTrust: false } + ) + if (nextToml !== existingToml) { + await writeTextFileRemoteAtomic(sftp, remoteTomlPath, nextToml) + } + return repairDetails +} + +async function cleanupLegacyRemoteSystemManagedHooks( + sftp: SFTPWrapper, + remoteHome: string, + remoteScriptPath: string +): Promise { + const legacyConfigPath = `${remoteHome.replace(/\/$/, '')}/.codex/hooks.json` + const config = await readHooksJsonRemote(sftp, legacyConfigPath) + if (config === null) { + throw new Error('Could not parse legacy remote Codex hooks.json') + } + if (!config?.hooks) { + return + } + + const isManagedCommand = createLegacyOrcaCodexCommandMatcher( + remoteScriptPath, + wrapPosixHookCommand(remoteScriptPath), + 'codex-hook.sh' + ) + const nextHooks = { ...config.hooks } + let removedSystemHook = false + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + removedSystemHook ||= definitions.some((definition) => + hookDefinitionHasManagedCommand(definition, isManagedCommand) + ) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + if (removedSystemHook) { + // Why: older SSH installs wrote Orca status hooks to the remote user's real + // Codex home. Remove only the exact Orca script path so outside-Orca Codex + // sessions stop reporting into Orca without deleting user hooks. + await writeHooksJsonRemote(sftp, legacyConfigPath, { ...config, hooks: nextHooks }) + } +} + +export async function cleanupLegacyRemoteCodexSystemHooks( + sftp: SFTPWrapper, + remoteHome: string +): Promise { + await cleanupLegacyRemoteSystemManagedHooks( + sftp, + remoteHome, + `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh` + ) +} + function removeRuntimeManagedHookTrustEntries(configPath: string): void { try { const tomlPath = getCodexConfigTomlPath() @@ -786,13 +755,21 @@ export class CodexHookService { } } + const configSync = syncSystemConfigIntoManagedCodexHome() + const syncFailureStatus = configSyncFailedStatus(configPath, configSync) + if (syncFailureStatus) { + return syncFailureStatus + } + const repairDetails = configSync.repairDetails + // Why: match by script filename (not exact command string) so a fresh // install sweeps stale entries left by older builds or a different // Electron userData path (dev vs. prod). Without this, repeated installs // accumulate duplicate hook entries pointing at defunct scripts. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) const command = getManagedCommand(scriptPath) - const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) + const hookPlan = getRuntimeHooksWithoutExternalSourceMirrors(config.hooks, isManagedCommand) + repairDetails.push(...hookPlan.repairDetails) const nextHooks = hookPlan.hooks const managedEvents = new Set(CODEX_EVENTS) @@ -819,12 +796,10 @@ export class CodexHookService { } } - // Why: Codex 0.129+ requires a per-hook trust entry in config.toml or the - // hook sits in the "review required" pile. We compute the trust hash for - // each managed entry as we install it and persist it alongside hooks.json - // so the user does not have to /hooks-approve after every install. - const mirroredUserTrustEntries = hookPlan.trustEntries - const trustEntries: CodexTrustEntry[] = mirroredUserTrustEntries.map(({ entry }) => entry) + // Why: host-managed Codex hook registration is not available in Orca yet. + // Until it is, this runtime hooks.json entry is a legacy fallback carrier + // for Orca status only; do not mix user/project/plugin hooks into it. + const trustEntries: CodexTrustEntry[] = [] for (const eventName of CODEX_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) @@ -853,13 +828,10 @@ export class CodexHookService { // getStatus would report green for a hook Codex won't actually fire. try { const tomlPath = getCodexConfigTomlPath() - syncSystemConfigIntoManagedCodexHome() - // Why: system user hook approvals are mirrored into runtime CODEX_HOME. - // If the user later revokes approval in ~/.codex/config.toml, preserving - // all old runtime [hooks.state.*] blocks would keep Orca Codex trusted. + // Why: stale runtime trust blocks for previously mirrored user hooks + // would re-trust source-erased handlers if an old hooks.json returned. removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) upsertHookTrustEntries(tomlPath, trustEntries) - applyMirroredRuntimeUserHookTrustStates(tomlPath, mirroredUserTrustEntries) } catch (error) { return { agent: 'codex', @@ -875,14 +847,26 @@ export class CodexHookService { } catch (error) { console.warn('[codex-hook-service] failed to clean legacy Codex hooks', error) } - return this.getStatus() + return withRuntimeRepairDetails(this.getStatus(), repairDetails) } async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.codex/hooks.json` - const remoteTomlPath = `${remoteHome.replace(/\/$/, '')}/.codex/config.toml` + // Why: Orca does not yet have Codex host-managed hook registration for SSH. + // This is a legacy fallback carrier, and every path is resolved on the + // remote host so local userData never leaks into remote hook metadata. + const remoteRuntimeCodexHome = getRemoteOrcaCodexHomePath(remoteHome) + const remoteConfigPath = `${remoteRuntimeCodexHome}/hooks.json` + const remoteTomlPath = `${remoteRuntimeCodexHome}/config.toml` const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh` try { + await syncRemoteSystemCodexAuthIntoRuntimeHome(sftp, remoteHome, remoteRuntimeCodexHome) + await cleanupLegacyRemoteCodexSystemHooks(sftp, remoteHome) + const repairDetails = await syncRemoteSystemCodexConfigIntoRuntimeHome( + sftp, + remoteHome, + remoteConfigPath, + remoteTomlPath + ) const config = await readHooksJsonRemote(sftp, remoteConfigPath) if (!config) { return { @@ -895,12 +879,13 @@ export class CodexHookService { } const command = wrapPosixHookCommand(remoteScriptPath) - const nextHooks = { ...config.hooks } - const managedEvents = new Set(CODEX_EVENTS) const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + const hookPlan = getRuntimeHooksWithoutExternalSourceMirrors(config.hooks, isManagedCommand) + repairDetails.push(...hookPlan.repairDetails) + const nextHooks = hookPlan.hooks for (const [eventName, definitions] of Object.entries(nextHooks)) { - if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + if (!Array.isArray(definitions)) { continue } const cleaned = removeManagedCommands(definitions, isManagedCommand) @@ -911,7 +896,6 @@ export class CodexHookService { } } - const trustEntries: CodexTrustEntry[] = [] for (const eventName of CODEX_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) @@ -919,13 +903,6 @@ export class CodexHookService { hooks: [{ type: 'command', command }] } nextHooks[eventName] = [...cleaned, definition] - trustEntries.push({ - sourcePath: remoteConfigPath, - eventLabel: CODEX_EVENT_LABEL[eventName], - groupIndex: cleaned.length, - handlerIndex: 0, - command - }) } config.hooks = nextHooks @@ -936,8 +913,20 @@ export class CodexHookService { await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeHooksJsonRemote(sftp, remoteConfigPath, config) try { + const remoteCanonicalConfigPath = await realpathRemote(sftp, remoteConfigPath) + const trustEntries: CodexTrustEntry[] = CODEX_EVENTS.map((eventName) => ({ + // Why: Codex computes SSH trust keys on the remote host. Local + // realpathSync would miss remote symlinks in /home or .orca. + sourcePath: remoteCanonicalConfigPath, + eventLabel: CODEX_EVENT_LABEL[eventName], + groupIndex: Math.max(0, (nextHooks[eventName]?.length ?? 1) - 1), + handlerIndex: 0, + command + })) const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? '' - const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries) + const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries, { + canonicalizeSourcePath: false + }) if (updatedToml !== existingToml) { await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml) } @@ -955,10 +944,10 @@ export class CodexHookService { return { agent: 'codex', - state: 'installed', + state: repairDetails.length > 0 ? 'partial' : 'installed', configPath: remoteConfigPath, managedHooksPresent: true, - detail: null + detail: repairDetails.length > 0 ? repairDetails.join(' ') : null } } catch (err) { return { @@ -987,32 +976,38 @@ export class CodexHookService { } } + const configSync = syncSystemConfigIntoManagedCodexHome() + const syncFailureStatus = configSyncFailedStatus(configPath, configSync) + if (syncFailureStatus) { + return syncFailureStatus + } + const repairDetails = configSync.repairDetails + const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) - const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) + const hookPlan = getRuntimeHooksWithoutExternalSourceMirrors(config.hooks, isManagedCommand) + repairDetails.push(...hookPlan.repairDetails) config.hooks = hookPlan.hooks writeHooksJson(configPath, config) try { const tomlPath = getCodexConfigTomlPath() - const trustEntries = hookPlan.trustEntries.map(({ entry }) => entry) - syncSystemConfigIntoManagedCodexHome() - // Why: this path is used when Orca status hooks are disabled. The - // runtime CODEX_HOME should keep user hooks, but not Orca-managed trust. + const trustEntries: CodexTrustEntry[] = [] + // Why: this path is used when Orca status hooks are disabled. Runtime + // CODEX_HOME should not retain source-erased user hook trust either. removeStaleRuntimeHookTrustEntries(tomlPath, configPath, trustEntries) upsertHookTrustEntries(tomlPath, trustEntries) - applyMirroredRuntimeUserHookTrustStates(tomlPath, hookPlan.trustEntries) } catch (error) { return { agent: 'codex', state: 'error', configPath, managedHooksPresent: false, - detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + detail: `Runtime hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` } } cleanupLegacyManagedHookRepresentations() - return this.getStatus() + return withRuntimeRepairDetails(this.getStatus(), repairDetails) } remove(): AgentHookInstallStatus { diff --git a/src/main/index.ts b/src/main/index.ts index 1a8b8908046..da8215d88ad 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -310,29 +310,15 @@ if (hasSingleInstanceLock) { function prepareCodexRuntimeHomeForLaunch(): string { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch() const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) - try { - // Why: launch prep is reachable after startup via PTY/runtime paths; honor - // the persisted off switch so those launches cannot reinstall removed hooks. - const status = hooksEnabled - ? codexHookService.install() - : codexHookService.refreshRuntimeUserHooks() - if (status.state === 'error') { - console.warn( - `[codex-hook-service] failed to ${ - hooksEnabled ? 'refresh' : 'refresh user' - } runtime hooks before launch`, - status.detail - ) - } - } catch (error) { - // Why: hook install/removal is best-effort launch prep. A malformed hooks file - // should not block the Codex process from starting with its prepared auth. - console.warn( - `[codex-hook-service] failed to ${ - hooksEnabled ? 'refresh' : 'refresh user' - } runtime hooks before launch`, - error - ) + // Why: launch prep is reachable after startup via PTY/runtime paths; honor + // the persisted off switch so those launches cannot reinstall removed hooks. + const status = hooksEnabled + ? codexHookService.install() + : codexHookService.refreshRuntimeUserHooks() + if (status.state === 'error') { + // Why: launching Codex with a half-prepared managed CODEX_HOME can either + // skip Orca status hooks or run stale source-erased hook declarations. + throw new Error(status.detail ?? 'Codex runtime hooks could not be prepared before launch') } return runtimeHomePath } @@ -1170,7 +1156,11 @@ app.whenReady().then(async () => { // Why: these appearance settings are default-on for older profiles, so // a missing persisted value must toggle from visible -> hidden. const next = getNextDefaultOnAppearanceSettingValue(current[key]) - store.updateSettings({ [key]: next }, { notifyListeners: true }) + store.updateSettings({ [key]: next }) + // Why: settings:get returns the current snapshot; renderer tracks + // settings through window.api.settings.get(). Push the new value so + // the sidebar/titlebar re-render without waiting for a round-trip. + mainWindow?.webContents.send('settings:changed', { [key]: next }) rebuildAppMenu() }, getAppearanceState: () => { diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index ee4fbb8bb9a..cec25f342a2 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -8,10 +8,16 @@ import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' +import { REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD } from '../../shared/remote-codex-home' -const { muxRequestMock, installRemoteManagedAgentHooksMock } = vi.hoisted(() => ({ +const { + muxRequestMock, + installRemoteManagedAgentHooksMock, + cleanupLegacyRemoteCodexSystemHooksMock +} = vi.hoisted(() => ({ muxRequestMock: vi.fn(), - installRemoteManagedAgentHooksMock: vi.fn() + installRemoteManagedAgentHooksMock: vi.fn(), + cleanupLegacyRemoteCodexSystemHooksMock: vi.fn() })) vi.mock('./ssh-relay-deploy', () => ({ @@ -35,6 +41,10 @@ vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ installRemoteManagedAgentHooks: installRemoteManagedAgentHooksMock })) +vi.mock('../codex/hook-service', () => ({ + cleanupLegacyRemoteCodexSystemHooks: cleanupLegacyRemoteCodexSystemHooksMock +})) + vi.mock('../providers/ssh-pty-provider', () => ({ isSshPtyNotFoundError: (err: unknown) => (err instanceof Error ? err.message : String(err)).includes('not found'), @@ -135,6 +145,8 @@ describe('SshRelaySession', () => { muxRequestMock.mockResolvedValue([]) installRemoteManagedAgentHooksMock.mockReset() installRemoteManagedAgentHooksMock.mockResolvedValue([]) + cleanupLegacyRemoteCodexSystemHooksMock.mockReset() + cleanupLegacyRemoteCodexSystemHooksMock.mockResolvedValue(undefined) mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) }) @@ -161,6 +173,15 @@ describe('SshRelaySession', () => { it('installs remote managed hooks and relay-owned plugin assets before registering the SSH PTY provider', async () => { process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + installRemoteManagedAgentHooksMock.mockResolvedValue([ + { + agent: 'codex', + state: 'installed', + configPath: '/home/orca/.orca/codex-runtime-home/home/hooks.json', + managedHooksPresent: true, + detail: null + } + ]) muxRequestMock.mockImplementation(async (method: string) => { if (method === 'session.resolveHome') { return { resolvedPath: '/home/orca' } @@ -179,6 +200,9 @@ describe('SshRelaySession', () => { const installPluginsCallIndex = muxRequestMock.mock.calls.findIndex( ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD ) + const codexRuntimeConfigCalls = muxRequestMock.mock.calls.filter( + ([method]) => method === REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD + ) expect(installPluginsCallIndex).toBeGreaterThanOrEqual(0) const installPluginsParams = muxRequestMock.mock.calls[installPluginsCallIndex]?.[1] expect(installPluginsParams).toMatchObject({ @@ -187,7 +211,20 @@ describe('SshRelaySession', () => { }) expect(mockConn.sftp).toHaveBeenCalledTimes(1) expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca') + expect(codexRuntimeConfigCalls).toEqual([ + [REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD, { codexHomePath: null }], + [ + REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD, + { codexHomePath: '/home/orca/.orca/codex-runtime-home/home' } + ] + ]) expect(sftp.end).toHaveBeenCalledTimes(1) + const firstCodexRuntimeConfigCallIndex = muxRequestMock.mock.calls.findIndex( + ([method]) => method === REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD + ) + expect(muxRequestMock.mock.invocationCallOrder[firstCodexRuntimeConfigCallIndex]).toBeLessThan( + installRemoteManagedAgentHooksMock.mock.invocationCallOrder[0] + ) expect(installRemoteManagedAgentHooksMock.mock.invocationCallOrder[0]).toBeLessThan( muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex] ) @@ -196,6 +233,177 @@ describe('SshRelaySession', () => { ) }) + it('disables relay Codex runtime home before remote hook prep can fail', async () => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + let resolveHomeCalls = 0 + muxRequestMock.mockImplementation(async (method: string) => { + if (method === REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD) { + return { codexHomePath: null } + } + if (method === 'session.resolveHome') { + resolveHomeCalls += 1 + if (resolveHomeCalls === 1) { + return { resolvedPath: '/home/orca' } + } + throw new Error('home unavailable') + } + return { ok: true } + }) + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(mockConn) + + expect(muxRequestMock).toHaveBeenCalledWith(REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD, { + codexHomePath: null + }) + expect(installRemoteManagedAgentHooksMock).not.toHaveBeenCalled() + expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) + }) + + it('leaves relay Codex runtime home disabled when remote Codex hook install fails', async () => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + installRemoteManagedAgentHooksMock.mockResolvedValue([ + { + agent: 'codex', + state: 'error', + configPath: '/home/orca/.orca/codex-runtime-home/home/hooks.json', + managedHooksPresent: false, + detail: 'trust write failed' + } + ]) + muxRequestMock.mockImplementation(async (method: string) => { + if (method === 'session.resolveHome') { + return { resolvedPath: '/home/orca' } + } + return { ok: true } + }) + const sftp = { end: vi.fn() } + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const mockConn = { + sftp: vi.fn().mockResolvedValue(sftp) + } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(mockConn) + + const codexRuntimeConfigCalls = muxRequestMock.mock.calls.filter( + ([method]) => method === REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD + ) + expect(codexRuntimeConfigCalls).toEqual([ + [REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD, { codexHomePath: null }] + ]) + expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) + }) + + it('cleans legacy remote Codex hooks without reinstalling when status hooks are disabled', async () => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + muxRequestMock.mockImplementation(async (method: string) => { + if (method === 'session.resolveHome') { + return { resolvedPath: '/home/orca' } + } + return { ok: true } + }) + const sftp = { end: vi.fn() } + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + ;( + mockStore as unknown as { getSettings: () => { agentStatusHooksEnabled: boolean } } + ).getSettings = vi.fn(() => ({ agentStatusHooksEnabled: false })) + const mockConn = { + sftp: vi.fn().mockResolvedValue(sftp) + } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(mockConn) + + expect(cleanupLegacyRemoteCodexSystemHooksMock).toHaveBeenCalledWith(sftp, '/home/orca') + expect(installRemoteManagedAgentHooksMock).not.toHaveBeenCalled() + expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) + }) + + it('does not register providers when disabled-hook remote Codex cleanup fails', async () => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + cleanupLegacyRemoteCodexSystemHooksMock.mockRejectedValue(new Error('cleanup failed')) + muxRequestMock.mockImplementation(async (method: string) => { + if (method === 'session.resolveHome') { + return { resolvedPath: '/home/orca' } + } + return { ok: true } + }) + const sftp = { end: vi.fn() } + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + ;( + mockStore as unknown as { getSettings: () => { agentStatusHooksEnabled: boolean } } + ).getSettings = vi.fn(() => ({ agentStatusHooksEnabled: false })) + const mockConn = { + sftp: vi.fn().mockResolvedValue(sftp) + } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await expect(session.establish(mockConn)).rejects.toThrow('cleanup failed') + + expect(registerSshPtyProvider).not.toHaveBeenCalled() + }) + + it('does not register providers when stale relay Codex runtime home cannot be cleared', async () => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + muxRequestMock.mockImplementation(async (method: string) => { + if (method === 'session.resolveHome') { + return { resolvedPath: '/home/orca' } + } + if (method === REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD) { + throw new Error('configure failed') + } + return { ok: true } + }) + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await expect(session.establish(mockConn)).rejects.toThrow('configure failed') + + expect(installRemoteManagedAgentHooksMock).not.toHaveBeenCalled() + expect(registerSshPtyProvider).not.toHaveBeenCalled() + }) + + it('does not register providers when relay Codex runtime home cannot be enabled', async () => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + installRemoteManagedAgentHooksMock.mockResolvedValue([ + { + agent: 'codex', + state: 'installed', + configPath: '/home/orca/.orca/codex-runtime-home/home/hooks.json', + managedHooksPresent: true, + detail: null + } + ]) + let configureCalls = 0 + muxRequestMock.mockImplementation(async (method: string) => { + if (method === 'session.resolveHome') { + return { resolvedPath: '/home/orca' } + } + if (method === REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD) { + configureCalls += 1 + if (configureCalls === 2) { + throw new Error('enable failed') + } + return { codexHomePath: null } + } + return { ok: true } + }) + const sftp = { end: vi.fn() } + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const mockConn = { + sftp: vi.fn().mockResolvedValue(sftp) + } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await expect(session.establish(mockConn)).rejects.toThrow('enable failed') + + expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca') + expect(sftp.end).toHaveBeenCalledTimes(1) + expect(registerSshPtyProvider).not.toHaveBeenCalled() + }) + it('does not register providers if dispose wins during initial plugin sync', async () => { process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' let resolvePluginInstall!: () => void diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 73b72b8647b..36460033526 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -26,8 +26,13 @@ import { AGENT_HOOK_REQUEST_REPLAY_METHOD, isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' +import { + getRemoteOrcaCodexHomePath, + REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD +} from '../../shared/remote-codex-home' import { _internals as openCodeInternals } from '../opencode/hook-service' import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source' +import { cleanupLegacyRemoteCodexSystemHooks } from '../codex/hook-service' import { registerSshPtyProvider, unregisterSshPtyProvider, @@ -518,10 +523,16 @@ export class SshRelaySession { // configs before registering the PTY provider so newly spawned agent panes // report status from their first prompt. private async installManagedHooksOnRemote(mux: SshChannelMultiplexer): Promise { - if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { + const remoteAgentHooksEnabled = isRemoteAgentHooksEnabled() + if (!remoteAgentHooksEnabled) { + await this.configureRemoteCodexRuntimeHomeOnRelay(mux, null) return } + const canConfigureCodexRuntimeHome = await this.configureRemoteCodexRuntimeHomeOnRelay( + mux, + null + ) let remoteHome: string try { const result = (await mux.request('session.resolveHome', { path: '~' })) as { @@ -544,18 +555,60 @@ export class SshRelaySession { } let sftp: Awaited> | null = null + let shouldEnableCodexRuntimeHome = false + const hooksEnabled = this.areAgentStatusHooksEnabled() try { + if (!hooksEnabled) { + sftp = await this.requireReadyConnection().sftp() + await cleanupLegacyRemoteCodexSystemHooks(sftp, remoteHome) + return + } sftp = await this.requireReadyConnection().sftp() - await installRemoteManagedAgentHooks(sftp, remoteHome) + const results = await installRemoteManagedAgentHooks(sftp, remoteHome) + const codexResult = results.find((result) => result.agent === 'codex') + if (canConfigureCodexRuntimeHome && codexResult && codexResult.state !== 'error') { + shouldEnableCodexRuntimeHome = true + } } catch (error) { console.warn( `[ssh-relay-session] remote managed hook install failed for ${this.targetId}: ${ error instanceof Error ? error.message : String(error) }` ) + if (!hooksEnabled) { + throw error + } } finally { ;(sftp as { end?: () => void } | null)?.end?.() } + if (shouldEnableCodexRuntimeHome) { + await this.configureRemoteCodexRuntimeHomeOnRelay(mux, getRemoteOrcaCodexHomePath(remoteHome)) + } + } + + private async configureRemoteCodexRuntimeHomeOnRelay( + mux: SshChannelMultiplexer, + codexHomePath: string | null + ): Promise { + try { + await mux.request(REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD, { codexHomePath }) + return true + } catch (error) { + const code = (error as { code?: unknown })?.code + if ( + code === -32601 || + code === 'CONNECTION_LOST' || + code === 'DISPOSED' || + mux.isDisposed() + ) { + return false + } + throw new Error( + `[ssh-relay-session] failed to configure remote Codex runtime home for ${this.targetId}: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } } // Why: ship the OpenCode plugin / Pi extension source bodies to the relay @@ -797,9 +850,10 @@ export class SshRelaySession { } private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { + const getWin = this.getMainWindow ptyProvider.onData((payload) => { const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) - const win = this.getMainWindow() + const win = getWin() if (win && !win.isDestroyed()) { win.webContents.send('pty:data', { ...payload, @@ -808,7 +862,7 @@ export class SshRelaySession { } }) ptyProvider.onReplay((payload) => { - const win = this.getMainWindow() + const win = getWin() if (win && !win.isDestroyed()) { win.webContents.send('pty:replay', payload) } @@ -819,7 +873,7 @@ export class SshRelaySession { deletePtyOwnership(payload.id) this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') this.runtime?.onPtyExit(payload.id, payload.code) - const win = this.getMainWindow() + const win = getWin() if (win && !win.isDestroyed()) { win.webContents.send('pty:exit', payload) } diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index 3993dbf085f..13fd53cbd42 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -81,6 +81,26 @@ describe('getRelayShellLaunchConfig', () => { } ) + it.skipIf(process.platform === 'win32')('restores relay Codex home after shell startup', () => { + const config = getRelayShellLaunchConfig('/bin/zsh', { + HOME: homeDir, + ORCA_CODEX_HOME: '/home/dev/.orca/codex-runtime-home/home' + }) + const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') + + expect(config.args).toEqual(['-l']) + expect(config.env.ZDOTDIR).toBe(zshRoot) + expect(readFileSync(join(zshRoot, '.zshrc'), 'utf8')).toContain( + 'export CODEX_HOME="${ORCA_CODEX_HOME}"' + ) + expect(readFileSync(join(zshRoot, '.zlogin'), 'utf8')).toContain( + 'export CODEX_HOME="${ORCA_CODEX_HOME}"' + ) + expect( + readFileSync(join(homeDir, '.orca-relay', 'shell-ready', 'bash', 'rcfile'), 'utf8') + ).toContain('export CODEX_HOME="${ORCA_CODEX_HOME}"') + }) + it.skipIf(process.platform === 'win32')('rewrites stale persistent wrapper files', () => { const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') mkdirSync(zshRoot, { recursive: true }) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 9eb69b3962c..8d537c1de47 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -17,7 +17,10 @@ function quotePosixSingle(value: string): string { function hasOverlayRestoreEnv(env: Record): boolean { return Boolean( - env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR || env.ORCA_OMP_CODING_AGENT_DIR + env.ORCA_OPENCODE_CONFIG_DIR || + env.ORCA_PI_CODING_AGENT_DIR || + env.ORCA_OMP_CODING_AGENT_DIR || + env.ORCA_CODEX_HOME ) } @@ -81,6 +84,7 @@ if [[ ! -o login ]]; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" + [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" fi @@ -98,6 +102,7 @@ fi # Why: .zlogin is the final zsh login startup file before the prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" fi @@ -115,6 +120,7 @@ fi # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}" +[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}" fi diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 213d71623da..aaf4a700333 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -46,6 +46,7 @@ import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types' +import { REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD } from '../shared/remote-codex-home' import { assertPluginSourceUnderByteCap } from './plugin-source-limit' import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind' @@ -361,11 +362,26 @@ async function main(): Promise { ) } + let configuredCodexHome: string | null = null + dispatcher.onRequest(REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD, async (params) => { + configuredCodexHome = typeof params.codexHomePath === 'string' ? params.codexHomePath : null + return { codexHomePath: configuredCodexHome } + }) + // Why: every relay-spawned PTY needs the live ORCA_AGENT_HOOK_* coords. The - // augmenter is read on every spawn so a hook-server bind that succeeded - // late (or after a stop/start) lands in the next PTY's env without a - // restart. - ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv()) + // augmenter is read on every spawn so a hook-server bind or Codex runtime-home + // configure that succeeded late lands in the next PTY's env without a restart. + ptyHandler.addEnvAugmenter(() => { + const env = hookServer.buildPtyEnv() + if (!configuredCodexHome) { + return env + } + return { + ...env, + CODEX_HOME: configuredCodexHome, + ORCA_CODEX_HOME: configuredCodexHome + } + }) // Why: per-PTY plugin overlays for OpenCode and Pi. `OPENCODE_CONFIG_DIR` // and `PI_CODING_AGENT_DIR` only make sense on the relay's own filesystem diff --git a/src/shared/remote-codex-home.ts b/src/shared/remote-codex-home.ts new file mode 100644 index 00000000000..ef978b41204 --- /dev/null +++ b/src/shared/remote-codex-home.ts @@ -0,0 +1,7 @@ +const REMOTE_ORCA_CODEX_HOME_RELATIVE_PATH = '.orca/codex-runtime-home/home' + +export const REMOTE_CODEX_RUNTIME_HOME_CONFIGURE_METHOD = 'codex.runtimeHome.configure' + +export function getRemoteOrcaCodexHomePath(remoteHome: string): string { + return `${remoteHome.replace(/\/+$/, '')}/${REMOTE_ORCA_CODEX_HOME_RELATIVE_PATH}` +}