fix(codex): install remote hooks into the CODEX_HOME the host actually uses

The execution-host hook runtime resolved GROK_HOME from the host but never
resolved CODEX_HOME, so the Codex installer fell back to ${remoteHome}/.codex.
A host whose codex launcher exports CODEX_HOME inside the wrapper script reads
its hooks from somewhere else entirely, and no lifecycle events ever arrive.

Resolve the home the same way the agent does: ask the host's own Codex through
a short app-server handshake, which reports the home it resolved and needs no
wrapper parsing. Both agent homes now come from one execution-host module.

The resolved home is passed on only when it differs from the default, and the
installer's home argument no longer doubles as 'use the runtime-installer hook
contract'. A host that does not redirect therefore keeps byte-identical
behaviour, and a redirected one moves only where the config lives.
This commit is contained in:
Merge Sim
2026-09-09 04:09:39 -07:00
parent 18b2db3320
commit cfa0e26714
8 changed files with 356 additions and 75 deletions
@@ -0,0 +1,129 @@
import { execFile } from 'node:child_process'
import { basename } from 'node:path'
import { userInfo } from 'node:os'
import { promisify } from 'node:util'
import { initializeCodexAppServerConnection } from '../codex/codex-app-server-handshake'
import { runCodexAppServerSession } from '../codex/codex-app-server-session'
// Why: this module runs on the execution host, so every home it returns is a
// path on that host. Agent config homes are resolved here rather than by each
// installer, which otherwise assumes `~/.<agent>` and writes where the agent
// will never read.
const execFileAsync = promisify(execFile)
const AGENT_HOME_MAX_LENGTH = 4096
const HOME_PROBE_TIMEOUT_MS = 8_000
export function hasControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const code = character.charCodeAt(0)
return code <= 0x1f || code === 0x7f
})
}
/** Rejects anything that is not a plain absolute POSIX directory path. The
* execution host for these installers is POSIX; a Windows-shaped or
* control-character path is a probe that went wrong, not a home. */
export function normalizePosixAgentHome(candidate: string): string | null {
if (
candidate.length === 0 ||
candidate.length > AGENT_HOME_MAX_LENGTH ||
candidate !== candidate.trim() ||
!candidate.startsWith('/') ||
candidate.includes('\\') ||
hasControlCharacter(candidate)
) {
return null
}
return candidate.replace(/\/+$/, '') || '/'
}
export function resolveLoginShell(): string {
const candidate = process.env.SHELL || userInfo().shell || '/bin/sh'
if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) {
return '/bin/sh'
}
return candidate
}
function loginShellFlag(shell: string): string {
const shellName = basename(shell)
return shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc'
}
function defaultAgentHome(home: string, directoryName: string): string {
return `${home.replace(/\/+$/, '') || home}/${directoryName}`
}
export async function resolveExecutionHostGrokHome(
home: string,
signal?: AbortSignal
): Promise<string> {
const fallback = defaultAgentHome(home, '.grok')
try {
const shell = resolveLoginShell()
// Why: agent PTYs start login shells, so read the same profile-derived
// GROK_HOME without opening two additional SSH exec channels.
const { stdout } = await execFileAsync(
shell,
[loginShellFlag(shell), `printenv GROK_HOME | head -c ${AGENT_HOME_MAX_LENGTH + 1}`],
{ encoding: 'utf8', timeout: HOME_PROBE_TIMEOUT_MS, signal }
)
return normalizePosixAgentHome(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback
} catch {
signal?.throwIfAborted()
return fallback
}
}
/**
* Asks the host's own Codex where its CODEX_HOME is.
*
* Reading the environment the way the Grok probe does is not enough here: a
* `codex` launcher commonly exports CODEX_HOME inside the wrapper script, so it
* exists only for the Codex process and a login shell reports nothing. The
* app-server handshake returns the home the server actually resolved, which
* costs no wrapper parsing and stays correct for any launcher shape.
*/
export async function resolveExecutionHostCodexHome(
home: string,
signal?: AbortSignal
): Promise<string> {
const fallback = defaultAgentHome(home, '.codex')
try {
const shell = resolveLoginShell()
const initializeResult = await runCodexAppServerSession(
{
// Why: launch through the login shell so the probe resolves `codex` the
// same way the PTY does, wrapper included. That leaves no host CLI path
// to pair a node runtime against, which is what `cliPath: null` means.
command: shell,
args: [loginShellFlag(shell), 'exec codex app-server'],
cliPath: null,
timeoutMs: HOME_PROBE_TIMEOUT_MS
},
async (rpc) => await initializeCodexAppServerConnection(rpc)
)
const reported = (initializeResult as { codexHome?: unknown } | null)?.codexHome
return (typeof reported === 'string' ? normalizePosixAgentHome(reported) : null) ?? fallback
} catch {
// Why: an absent, old, or hung Codex must not fail hook installation. The
// default home is still right for every launcher that does not redirect.
signal?.throwIfAborted()
return fallback
}
}
/**
* The Codex home only when the host redirects it away from `~/.codex`.
*
* `undefined` means "the default home", which every installer already assumes,
* so a host with no redirection stays on exactly the path it uses today.
*/
export async function resolveRedirectedExecutionHostCodexHome(
home: string,
signal?: AbortSignal
): Promise<string | undefined> {
const resolved = await resolveExecutionHostCodexHome(home, signal)
return resolved === defaultAgentHome(home, '.codex') ? undefined : resolved
}
@@ -0,0 +1,172 @@
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
resolveExecutionHostCodexHome,
resolveRedirectedExecutionHostCodexHome
} from './execution-host-agent-homes'
import { installManagedHooks } from './managed-hook-runtime'
const tempHomes: string[] = []
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
async function createTempHome(): Promise<string> {
const home = await mkdtemp(join(tempRoot, 'orca-execution-host-codex-'))
tempHomes.push(home)
return home
}
/**
* A login shell whose `codex` resolves to a stub app-server, standing in for the
* host wrapper in the report: CODEX_HOME exists only for the Codex process, so
* the handshake is the only place the real home is observable.
*/
async function stubLoginShellWithCodex(
home: string,
{ reportedHome }: { reportedHome: string | null }
): Promise<void> {
const server = [
"const readline = require('node:readline')",
"readline.createInterface({ input: process.stdin }).on('line', (line) => {",
' const message = JSON.parse(line)',
" if (typeof message.id !== 'number') return",
` const result = ${reportedHome === null ? '{}' : `{ codexHome: ${JSON.stringify(reportedHome)} }`}`,
" process.stdout.write(JSON.stringify({ id: message.id, result }) + '\\n')",
'})',
''
].join('\n')
const serverPath = join(home, 'codex-app-server.js')
await writeFile(serverPath, server, 'utf8')
const shell = join(home, 'login-shell')
await writeFile(
shell,
[
'#!/bin/sh',
'case "$2" in',
` *"codex app-server"*) exec ${JSON.stringify(process.execPath)} ${JSON.stringify(serverPath)} ;;`,
' *) exit 0 ;;',
'esac',
''
].join('\n'),
'utf8'
)
await chmod(shell, 0o755)
vi.stubEnv('HOME', home)
vi.stubEnv('SHELL', shell)
}
/** A login shell with no `codex` at all: the probe must degrade, not throw. */
async function stubLoginShellWithoutCodex(home: string): Promise<void> {
const shell = join(home, 'login-shell')
await writeFile(shell, '#!/bin/sh\nexit 127\n', 'utf8')
await chmod(shell, 0o755)
vi.stubEnv('HOME', home)
vi.stubEnv('SHELL', shell)
}
afterEach(async () => {
vi.unstubAllEnvs()
await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
})
describe.runIf(process.platform !== 'win32')('resolveExecutionHostCodexHome', () => {
it('reports the home the host Codex resolved, not the default', async () => {
const home = await createTempHome()
await stubLoginShellWithCodex(home, { reportedHome: `${home}/.codex-openai` })
await expect(resolveExecutionHostCodexHome(home)).resolves.toBe(`${home}/.codex-openai`)
})
it('normalizes a trailing separator on the reported home', async () => {
const home = await createTempHome()
await stubLoginShellWithCodex(home, { reportedHome: `${home}/.codex-openai///` })
await expect(resolveExecutionHostCodexHome(home)).resolves.toBe(`${home}/.codex-openai`)
})
it('falls back when the reported home is not an absolute POSIX path', async () => {
const home = await createTempHome()
await stubLoginShellWithCodex(home, { reportedHome: '../relative' })
await expect(resolveExecutionHostCodexHome(home)).resolves.toBe(`${home}/.codex`)
})
it('falls back when the handshake reports no home at all', async () => {
const home = await createTempHome()
await stubLoginShellWithCodex(home, { reportedHome: null })
await expect(resolveExecutionHostCodexHome(home)).resolves.toBe(`${home}/.codex`)
})
it('falls back when the host has no usable codex', async () => {
const home = await createTempHome()
await stubLoginShellWithoutCodex(home)
await expect(resolveExecutionHostCodexHome(home)).resolves.toBe(`${home}/.codex`)
})
})
describe.runIf(process.platform !== 'win32')('resolveRedirectedExecutionHostCodexHome', () => {
it('returns nothing when the host uses the default home', async () => {
const home = await createTempHome()
await stubLoginShellWithCodex(home, { reportedHome: `${home}/.codex` })
await expect(resolveRedirectedExecutionHostCodexHome(home)).resolves.toBeUndefined()
})
it('returns the home only when it is genuinely redirected', async () => {
const home = await createTempHome()
await stubLoginShellWithCodex(home, { reportedHome: `${home}/.codex-openai` })
await expect(resolveRedirectedExecutionHostCodexHome(home)).resolves.toBe(
`${home}/.codex-openai`
)
})
})
describe.runIf(process.platform !== 'win32')('installManagedHooks on a redirecting host', () => {
it('installs Codex hooks into the home Codex actually reads', async () => {
const home = await createTempHome()
const redirected = join(home, '.codex-openai')
await mkdir(redirected, { recursive: true })
await stubLoginShellWithCodex(home, { reportedHome: redirected })
await expect(installManagedHooks({ agents: ['codex'] })).resolves.toEqual({
installers: 1,
errors: 0
})
const installed = JSON.parse(await readFile(join(redirected, 'hooks.json'), 'utf8')) as {
hooks: Record<string, unknown>
}
expect(Object.keys(installed.hooks).length).toBeGreaterThan(0)
expect(JSON.stringify(installed)).toContain('codex-hook.sh')
// Why: the whole defect is hooks landing in a home Codex never reads.
expect(existsSync(join(home, '.codex', 'hooks.json'))).toBe(false)
// Why: only the config location moves. A redirected home on an SSH host is
// the user's own, with no Orca runtime installer writing it, so it keeps the
// guest-home script contract rather than the runtime-installer one.
expect(existsSync(join(home, '.orca', 'agent-hooks', 'codex-hook.sh'))).toBe(true)
expect(existsSync(join(redirected, '.orca', 'agent-hooks', 'codex-hook.sh'))).toBe(false)
})
it('leaves a host that does not redirect on its existing install contract', async () => {
const home = await createTempHome()
await stubLoginShellWithCodex(home, { reportedHome: `${home}/.codex` })
await expect(installManagedHooks({ agents: ['codex'] })).resolves.toEqual({
installers: 1,
errors: 0
})
expect(existsSync(join(home, '.codex', 'hooks.json'))).toBe(true)
// Why: the guest-home script location is the unchanged plain-SSH contract.
// Passing the default home through as a redirect would move it and silently
// reorder every user's hooks, so pin where it lands.
expect(existsSync(join(home, '.orca', 'agent-hooks', 'codex-hook.sh'))).toBe(true)
expect(await readdir(join(home, '.codex'))).not.toContain('.orca')
})
})
+19 -61
View File
@@ -1,7 +1,4 @@
import { execFile } from 'node:child_process'
import { basename } from 'node:path'
import { homedir, userInfo } from 'node:os'
import { promisify } from 'node:util'
import { homedir } from 'node:os'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
@@ -10,68 +7,19 @@ import {
readManagedHookHostIdentity,
scopeManagedHookHostIdentity
} from './managed-hook-owner-identity'
const execFileAsync = promisify(execFile)
const GROK_HOME_MAX_LENGTH = 4096
const GROK_HOME_PROBE_TIMEOUT_MS = 8_000
import {
resolveExecutionHostGrokHome,
resolveRedirectedExecutionHostCodexHome
} from './execution-host-agent-homes'
export type ManagedHookInstallSummary = {
installers: number
errors: number
}
function defaultGrokHome(home: string): string {
return `${home.replace(/\/+$/, '') || home}/.grok`
}
function hasControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const code = character.charCodeAt(0)
return code <= 0x1f || code === 0x7f
})
}
function normalizeGrokHome(candidate: string): string | null {
if (
candidate.length === 0 ||
candidate.length > GROK_HOME_MAX_LENGTH ||
candidate !== candidate.trim() ||
!candidate.startsWith('/') ||
candidate.includes('\\') ||
hasControlCharacter(candidate)
) {
return null
}
return candidate.replace(/\/+$/, '') || '/'
}
function resolveLoginShell(): string {
const candidate = process.env.SHELL || userInfo().shell || '/bin/sh'
if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) {
return '/bin/sh'
}
return candidate
}
export async function resolveRelayGrokHome(home: string, signal?: AbortSignal): Promise<string> {
const fallback = defaultGrokHome(home)
try {
const shell = resolveLoginShell()
const shellName = basename(shell)
const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc'
// Why: agent PTYs start login shells, so read the same profile-derived
// GROK_HOME without opening two additional SSH exec channels.
const { stdout } = await execFileAsync(
shell,
[mode, `printenv GROK_HOME | head -c ${GROK_HOME_MAX_LENGTH + 1}`],
{ encoding: 'utf8', timeout: GROK_HOME_PROBE_TIMEOUT_MS, signal }
)
return normalizeGrokHome(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback
} catch {
signal?.throwIfAborted()
return fallback
}
}
/** Kept as the relay-facing name for the Grok probe; the resolution itself now
* lives with every other execution-host home. */
export const resolveRelayGrokHome = resolveExecutionHostGrokHome
export async function installManagedHooks(options?: {
signal?: AbortSignal
@@ -85,7 +33,16 @@ export async function installManagedHooks(options?: {
return { installers: 0, errors: 0 }
}
const home = homedir()
const grokHomeDir = await resolveRelayGrokHome(home, options?.signal)
const grokHomeDir = await resolveExecutionHostGrokHome(home, options?.signal)
options?.signal?.throwIfAborted()
// Why: gated on Codex being present so a host without it never pays for an
// app-server handshake, and only a home that is actually redirected is passed
// on — handing the installer the default `~/.codex` would change nothing about
// where hooks land while flipping every ordinary SSH host onto the
// redirected-runtime contract.
const codexHomeDir = agents.includes('codex')
? await resolveRedirectedExecutionHostCodexHome(home, options?.signal)
: undefined
options?.signal?.throwIfAborted()
const hostIdentity = scopeManagedHookHostIdentity(
await readManagedHookHostIdentity(),
@@ -99,6 +56,7 @@ export async function installManagedHooks(options?: {
createManagedHookLocalFilesystem(),
home,
{
...(codexHomeDir ? { codexHomeDir, useRuntimeInstallerHookContract: false } : {}),
grokHomeDir,
signal: options?.signal,
agents
@@ -20,6 +20,8 @@ export type RemoteManagedHookInstallOptions = {
codexHomeDir?: string
/** Skip the trust write when a redirected runtime config is seeded by the launch path. */
deferTrustUntilConfigToml?: boolean
/** Whether `codexHomeDir` is a home Orca's own runtime installer also writes. */
useRuntimeInstallerHookContract?: boolean
/** Explicit GROK_HOME for remote runtimes that redirect Grok's config. */
grokHomeDir?: string
/** Stops before starting the next installer when the owning relay request
@@ -47,7 +49,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
(sftp, remoteHome, options) =>
codexHookService.installRemote(sftp, remoteHome, {
codexHomeDir: options?.codexHomeDir,
deferTrustUntilConfigToml: options?.deferTrustUntilConfigToml
deferTrustUntilConfigToml: options?.deferTrustUntilConfigToml,
useRuntimeInstallerHookContract: options?.useRuntimeInstallerHookContract
})
],
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
+6 -3
View File
@@ -2,10 +2,12 @@ import type { CodexAppServerConnection } from './codex-app-server-connection-typ
const HANDSHAKE_TIMEOUT_MS = 15_000
/** Returns the `initialize` result, which carries the CODEX_HOME the server
* itself resolved — the only wrapper-aware answer available to a caller. */
export async function initializeCodexAppServerConnection(
connection: CodexAppServerConnection
): Promise<void> {
await connection.request(
connection: Pick<CodexAppServerConnection, 'request' | 'notify'>
): Promise<unknown> {
const result = await connection.request(
'initialize',
{
clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' },
@@ -19,4 +21,5 @@ export async function initializeCodexAppServerConnection(
{ timeoutMs: HANDSHAKE_TIMEOUT_MS }
)
connection.notify('initialized')
return result
}
@@ -1,5 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { appendFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import {
appendFileSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import type * as NodeOs from 'node:os'
import { join } from 'node:path'
@@ -218,12 +225,7 @@ describe('ownership between the source config and the managed home', () => {
})
it('keeps an added server through the same pass that honours a deletion', () => {
writeSystemConfig(
'model = "system-model"',
'',
'[mcp_servers.retired]',
'command = "retired"'
)
writeSystemConfig('model = "system-model"', '', '[mcp_servers.retired]', 'command = "retired"')
syncSystemConfigIntoManagedCodexHome()
addServerInsideManagedHome('[mcp_servers.added-in-orca]', 'command = "added"')
+12 -2
View File
@@ -26,7 +26,14 @@ import { getManagedScript } from './codex-hook-script'
export async function installCodexHooksRemote(
sftp: SFTPWrapper,
remoteHome: string,
options?: { codexHomeDir?: string; deferTrustUntilConfigToml?: boolean }
options?: {
codexHomeDir?: string
deferTrustUntilConfigToml?: boolean
/** Whether this home is also written by Orca's own runtime installer.
* Defaults to the historical coupling with `codexHomeDir`; an SSH host
* whose Codex merely points elsewhere keeps the guest-home contract. */
useRuntimeInstallerHookContract?: boolean
}
): Promise<AgentHookInstallStatus> {
const codexHomeBase =
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
@@ -35,7 +42,10 @@ export async function installCodexHooksRemote(
// Redirected WSL homes must use the same script location and command shape
// as the runtime installer; two representations of one hooks.json race
// into stale trust keys. Plain SSH keeps its guest-home script contract.
const redirectedCodexHome = options?.codexHomeDir?.replace(/\/$/, '')
const redirectedCodexHome =
(options?.useRuntimeInstallerHookContract ?? options?.codexHomeDir !== undefined)
? options?.codexHomeDir?.replace(/\/$/, '')
: undefined
const remoteScriptPath = redirectedCodexHome
? `${redirectedCodexHome}/.orca/agent-hooks/codex-hook.sh`
: `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
@@ -240,7 +240,11 @@ export class CodexHookService {
installRemote(
sftp: SFTPWrapper,
remoteHome: string,
options?: { codexHomeDir?: string; deferTrustUntilConfigToml?: boolean }
options?: {
codexHomeDir?: string
deferTrustUntilConfigToml?: boolean
useRuntimeInstallerHookContract?: boolean
}
): Promise<AgentHookInstallStatus> {
return installCodexHooksRemote(sftp, remoteHome, options)
}