Fix Codex hook ownership in Orca runtime

This commit is contained in:
brennanb2025
2026-05-28 22:03:09 -07:00
parent 476f9084ec
commit 5d8f41cccc
14 changed files with 1598 additions and 483 deletions
@@ -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<string> {
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,
@@ -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<string, string>
dirs: Set<string>
modes: Map<string, number>
realpaths: Map<string, string>
failWriteTo: Set<string>
failRenameTo: Set<string>
}
function createFakeSftp(initialFiles: Record<string, string> = {}): {
function createFakeSftp(
initialFiles: Record<string, string> = {},
options: { realpaths?: Record<string, string> } = {}
): {
sftp: SFTPWrapper
fs: FakeFs
} {
@@ -33,6 +41,8 @@ function createFakeSftp(initialFiles: Record<string, string> = {}): {
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<string, string> = {}): {
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<string, string> = {}): {
},
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<string, string> = {}): {
}
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<string, { hooks: { command: string }[] }[]>
}
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()
+165 -6
View File
@@ -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"')
+149 -42
View File
@@ -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<string, string>)[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.')
}
+13 -4
View File
@@ -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
}
+449 -97
View File
@@ -165,8 +165,8 @@ describe('CodexHookService', () => {
const prodHooks = JSON.parse(readFileSync(prodHooksPath, 'utf-8')) as {
hooks: Record<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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<string, { hooks?: { command?: string }[] }[]>
}
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"')
})
+281 -286
View File
@@ -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<string, CodexEventLabel> = {
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<string, HookDefinition[]>
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<string, HookDefinition[]> | undefined,
isManagedCommand: (command: string | undefined) => boolean
): {
hooks: Record<string, HookDefinition[]>
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<string, HookDefinition[]> = {}
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<string, HookDefinition[]>,
isManagedCommand: (command: string | undefined) => boolean
): Map<string, boolean> {
const signatures = new Map<string, boolean>()
let trustEntries: Map<string, CodexHookTrustState>
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<string, CodexHookTrustState>
): Map<CodexEventLabel, Map<string, boolean>> {
const trustedHashesByEvent = new Map<CodexEventLabel, Map<string, boolean>>()
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<string, HookDefinition[]>,
trustedSystemHookSignatures: ReadonlyMap<string, boolean>,
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<string>()
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<void> {
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<string[]> {
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<void> {
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<void> {
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<string>(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<AgentHookInstallStatus> {
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<string>(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 {
+14 -24
View File
@@ -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: () => {
+210 -2
View File
@@ -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
+59 -5
View File
@@ -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<void> {
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<ReturnType<SshConnection['sftp']>> | 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<boolean> {
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)
}
+20
View File
@@ -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 })
+7 -1
View File
@@ -17,7 +17,10 @@ function quotePosixSingle(value: string): string {
function hasOverlayRestoreEnv(env: Record<string, string>): 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
+20 -4
View File
@@ -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<void> {
)
}
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
+7
View File
@@ -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}`
}