mirror of
https://github.com/stablyai/orca.git
synced 2026-10-14 08:02:56 +00:00
Fix Codex hook ownership in Orca runtime
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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: () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user