fix(codex): a real-home resume starts at once, trusting Orca's entries for that process

A resume that must run in ~/.codex waited for Codex's background approval of
Orca's newly written hook entry: up to 30-40 s on a cold app-server. That made
the user's resume wait on bookkeeping, and the alternatives (start at 10 s with
Codex's hook review showing, or withdraw the entry and race Codex's own write)
were worse.

Codex reads hook trust from its session-flag config layer as well as the user's
config.toml, merged per key, and has since hook trust shipped. So the resume no
longer waits. When Orca's own frozen entries in ~/.codex are untrusted (or hold
a stale hash), the resume command carries
`-c hooks.state={'<key>'={trusted_hash='<hash>'},...}` for exactly those entries:
the key under both the logical and the real path of ~/.codex (Codex keys an
explicit CODEX_HOME by its real path), and the hash of that entry's content, so
it can trust nothing else at that slot. The user's hooks are never included,
nothing is written, and the background approval still runs for later plain
`codex` launches. An approved entry adds nothing; a Codex known to lack hook
trust gets nothing.

One inline table, because Codex splits a `-c` key on every `.` and the key holds
`.codex/hooks.json`. TOML literal strings keep `"` out of Windows native-argument
quoting. The flag goes before `resume <id>`, quoted for the pane's shell (portable
Unix, PowerShell or cmd), in the launch command and in the setup-sequenced copy of
it; a cmd line whose path cmd would expand, or a key with an apostrophe, is left
unchanged. SSH and WSL resumes get no preparation, so no local path reaches them.
This commit is contained in:
Brennan Benson
2026-09-28 22:43:36 -07:00
parent 93d354a292
commit 1bd30651d6
17 changed files with 517 additions and 160 deletions
@@ -116,16 +116,6 @@ export function ensureRealHomeCodexHookState(args: {
return ensureInFlight
}
/**
* For a resume that must run in the real home, with no managed home to fall
* back to: waits until a background grant settles, which its deadline bounds.
* Settled means Codex approved the entry or the grant withdrew it.
*/
export async function awaitRealHomeCodexHookTrust(): Promise<RealHomeCodexHookLane> {
await backgroundGrant
return currentLane
}
async function runRealHomeCodexHookEnsure(args: {
hooksEnabled: boolean
userDataPath: string
@@ -0,0 +1,98 @@
import { readHooksJsonWithRaw } from '../agent-hooks/installer-utils'
import {
codexAppServerCapabilityCache,
getCodexAppServerHostKey
} from './codex-app-server-capability-cache'
import { getCodexManagedHookInstallMaterial } from './codex-hook-definition'
import { createCodexHookTrustEntry } from './codex-hook-identity'
import { readOrcaEntryTrust } from './codex-real-home-entry-trust'
import { getRealHomeConfigTomlPath, getRealHomeHooksJsonPath } from './codex-real-home-hooks-json'
import {
computeTrustedHash,
computeTrustKey,
getCodexExplicitHomeHookSourcePath,
readHookTrustEntries
} from './config-toml-trust'
/** One `[hooks.state."<key>"]` trust record, held by a single Codex process only. */
export type CodexSessionHookTrust = { key: string; trustedHash: string }
/**
* Orca's own entries in the real ~/.codex that Codex would list for review, each
* with the hash of exactly that entry's content. A resume passes them as `-c`
* overrides, so it need not wait for the background approval; nothing is written.
*/
export function readRealHomeCodexSessionHookTrust(): CodexSessionHookTrust[] {
try {
// Why: a Codex known to lack hook trust has nothing to approve.
if (!codexAppServerCapabilityCache.shouldTry(getCodexAppServerHostKey({ kind: 'native' }))) {
return []
}
const hooksJsonPath = getRealHomeHooksJsonPath()
const hooks = readHooksJsonWithRaw(hooksJsonPath).config?.hooks
if (!hooks) {
return []
}
const command = getCodexManagedHookInstallMaterial().command
const trustStates = readHookTrustEntries(getRealHomeConfigTomlPath())
// Why both: Codex keys a default home by its logical path, an explicit CODEX_HOME by its real path.
const sourcePaths = new Set([hooksJsonPath, getCodexExplicitHomeHookSourcePath(hooksJsonPath)])
const trust = new Map<string, string>()
for (const [eventName, definitions] of Object.entries(hooks)) {
if (!Array.isArray(definitions)) {
continue
}
definitions.forEach((definition, groupIndex) =>
definition.hooks?.forEach((hook, handlerIndex) => {
const entry =
hook.command === command
? createCodexHookTrustEntry(
hooksJsonPath,
eventName,
groupIndex,
handlerIndex,
definition,
hook
)
: null
const state = entry ? readOrcaEntryTrust(entry, trustStates) : null
if (!entry || (state !== 'untrusted' && state !== 'stale')) {
return
}
const trustedHash = computeTrustedHash(entry)
for (const sourcePath of sourcePaths) {
trust.set(computeTrustKey({ ...entry, sourcePath }), trustedHash)
}
})
)
}
return [...trust].map(([key, trustedHash]) => ({ key, trustedHash }))
} catch (error) {
// Why: without overrides the resume still runs; Codex only lists the entry for review.
console.warn('[codex-real-home-hooks] could not read Orca entry trust for a resume:', error)
return []
}
}
// Why literal strings: a TOML literal string needs no escapes, so no `"` reaches a
// Windows shell's native-argument quoting; a key it cannot hold gets no override.
function fitsTomlLiteralString(value: string): boolean {
return [...value].every((char) => char !== "'" && char >= ' ' && char !== '\u007f')
}
/**
* The `-c` value for these records: one inline table, since Codex splits a `-c`
* key on every `.` and a hooks.json path contains one. Null when one cannot be spelled.
*/
export function formatCodexSessionHookTrustOverride(
trust: readonly CodexSessionHookTrust[]
): string | null {
if (
trust.length === 0 ||
!trust.every(({ key, trustedHash }) => fitsTomlLiteralString(key + trustedHash))
) {
return null
}
const records = trust.map(({ key, trustedHash }) => `'${key}'={trusted_hash='${trustedHash}'}`)
return `hooks.state={${records.join(',')}}`
}
@@ -4,6 +4,7 @@ import type * as Os from 'node:os'
import { dirname, join } from 'node:path'
import type { HookDefinition } from '../agent-hooks/installer-utils'
import type { CodexHookTrustGrantRequest } from './codex-app-server-client'
import type { CodexTrustEntry } from './config-toml-trust'
import { CodexAppServerTimeoutError } from './codex-app-server-session'
import {
CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS,
@@ -12,6 +13,7 @@ import {
import {
computeTrustedHash,
computeTrustKey,
getCodexExplicitHomeHookSourcePath,
normalizeHookTrustKeyForLookup,
parseTrustKey,
readHookTrustEntries,
@@ -34,7 +36,6 @@ vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexComman
import {
_internals as realHomeInternals,
awaitRealHomeCodexHookTrust,
ensureRealHomeCodexHookState,
isRealHomeCodexHookLaneUsable
} from './codex-real-home-hook-install'
@@ -43,6 +44,7 @@ import { getCodexManagedHookInstallMaterial } from './codex-hook-definition'
import { createCodexHookTrustEntry } from './codex-hook-identity'
import { getOrcaManagedCodexHomePath } from './codex-home-paths'
import { readOrcaEntryTrust } from './codex-real-home-entry-trust'
import { readRealHomeCodexSessionHookTrust } from './codex-real-home-session-hook-trust'
// Why this file (QA case 4): a cold `codex app-server` on a loaded Mac took over
// 10 s. A launch must never wait on that approval, the approval must still land,
@@ -73,28 +75,31 @@ function orcaHandlerCount(): number {
.filter((hook) => isCodexManagedCommand(hook.command)).length
}
function orcaEntryTrust(): string[] {
const trust = readHookTrustEntries(configPath())
function orcaEntries(): CodexTrustEntry[] {
return Object.entries(readHooks()).flatMap(([eventName, definitions]) =>
definitions.flatMap((definition, groupIndex) =>
(definition.hooks ?? []).flatMap((hook, handlerIndex) => {
if (!isCodexManagedCommand(hook.command)) {
return []
}
const entry = createCodexHookTrustEntry(
hooksPath(),
eventName,
groupIndex,
handlerIndex,
definition,
hook
)
return [entry ? readOrcaEntryTrust(entry, trust) : 'untrusted']
const entry = isCodexManagedCommand(hook.command)
? createCodexHookTrustEntry(
hooksPath(),
eventName,
groupIndex,
handlerIndex,
definition,
hook
)
: null
return entry ? [entry] : []
})
)
)
}
function orcaEntryTrust(): string[] {
const trust = readHookTrustEntries(configPath())
return orcaEntries().map((entry) => readOrcaEntryTrust(entry, trust))
}
type AppServer = { sessions: number; start: () => void }
/**
@@ -182,20 +187,31 @@ describe('a slow codex app-server start', () => {
expect(server.sessions).toBe(1)
})
it('lets a resume into the real home wait until the grant settles', async () => {
it("lets a resume trust exactly Orca's entries, with the hashes the grant then writes", async () => {
const server = installAppServer(15_000)
expect(await launch()).toBe('granting')
let settled = false
const resumed = awaitRealHomeCodexHookTrust().then((lane) => {
settled = true
return lane
})
await new Promise((resolve) => setTimeout(resolve, 50))
expect(settled).toBe(false)
const trust = readRealHomeCodexSessionHookTrust()
const explicitHooksPath = getCodexExplicitHomeHookSourcePath(hooksPath())
const expectedKeys = orcaEntries().flatMap((entry) => [
computeTrustKey(entry),
computeTrustKey({ ...entry, sourcePath: explicitHooksPath })
])
expect(trust.map(({ key }) => key).sort()).toEqual([...new Set(expectedKeys)].sort())
// Why: the user's own Stop hook is untrusted too, and must stay for the user to review.
expect(trust.some(({ key }) => key.endsWith(':stop:0:0'))).toBe(false)
server.start()
expect(await resumed).toBe('installed')
expect(await realHomeInternals.settledLaneForTesting()).toBe('installed')
const stored = readHookTrustEntries(configPath())
for (const entry of orcaEntries()) {
const key = computeTrustKey(entry)
expect(trust.find((record) => record.key === key)?.trustedHash).toBe(
stored.get(key)?.trustedHash
)
}
// Why: once approved, a resume passes nothing extra.
expect(readRealHomeCodexSessionHookTrust()).toEqual([])
})
it('starts no cooldown after a timeout: the next launch tries again at once', async () => {
+9 -2
View File
@@ -7,6 +7,7 @@ import {
} from '../../shared/cross-platform-path'
import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing'
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
import type { CodexSessionHookTrust } from './codex-real-home-session-hook-trust'
// Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials.
const CLAIMED_CODEX_ROLLOUT_TAIL = String.raw`\d{4}/\d{2}/\d{2}/rollout-[^/]+\.jsonl(?:\.zst)?`
@@ -20,9 +21,15 @@ const CODEX_ROLLOUT_LAYOUT_PATH = new RegExp(`(?:^|/)sessions/${CLAIMED_CODEX_RO
* unverifiable rollout must never resume under whichever account is selected now.
* `reconcileSharedRuntimeAuth` revalidates mutable shared-home auth before spawn.
* `claimedCodexProvenance` gates the user-facing notice: a path that claimed real
* Codex layout is worth reporting, stale cross-agent metadata is not. */
* Codex layout is worth reporting, stale cross-agent metadata is not.
* `sessionHookTrust` trusts Orca's own not-yet-approved entries for this one process. */
export type CodexSessionResumePreparation =
| { outcome: 'resume'; codexHomePath: string; reconcileSharedRuntimeAuth?: boolean }
| {
outcome: 'resume'
codexHomePath: string
reconcileSharedRuntimeAuth?: boolean
sessionHookTrust?: readonly CodexSessionHookTrust[]
}
| { outcome: 'fresh'; claimedCodexProvenance: boolean }
// Why: fold only Win32's extended drive spelling; \\.\ device namespaces and every other \\?\ form
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest'
import { buildAgentResumeLaunchCommand } from '../../../../shared/agent-resume-launch-command'
import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing'
import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell'
import type { CodexSessionHookTrust } from '../../../codex/codex-real-home-session-hook-trust'
import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home'
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } }))
const { formatCodexSessionHookTrustOverride } =
await import('../../../codex/codex-real-home-session-hook-trust')
const {
resolveCodexResumeLaunch,
resolveCodexResumeStartupShell,
rewriteSequencedStartupResumeArgv
} = await import('./codex-resume')
// Why this file: a resume into the real ~/.codex carries Orca's not-yet-approved
// hook trust as a `-c` flag typed into the pane's shell; each shell must hand
// Codex the exact bytes.
const SESSION: AgentProviderSessionMetadata = { key: 'session_id', id: '019abc' }
const POSIX_TRUST: CodexSessionHookTrust[] = [
{ key: '/Users/me/.codex/hooks.json:stop:1:0', trustedHash: 'sha256:ab12' }
]
const WINDOWS_TRUST: CodexSessionHookTrust[] = [
{ key: 'C:\\Users\\me\\.codex\\hooks.json:stop:1:0', trustedHash: 'sha256:ab12' }
]
function resumeCommand(shell: AgentStartupShell): string {
return buildAgentResumeLaunchCommand('codex', 'codex', ['codex', 'resume', SESSION.id], shell)
}
function launchWith(
shell: AgentStartupShell,
prepared: CodexSessionResumePreparation | null,
command = resumeCommand(shell)
): ReturnType<typeof resolveCodexResumeLaunch> {
return resolveCodexResumeLaunch(command, {
providerSession: SESSION,
preparation: Promise.resolve(prepared),
startupShell: shell
})
}
function resumeHome(sessionHookTrust?: CodexSessionHookTrust[]): CodexSessionResumePreparation {
return { outcome: 'resume', codexHomePath: '/Users/me/.codex', sessionHookTrust }
}
describe('the -c value that trusts Orca entries for one Codex process', () => {
it('is one inline table of TOML literal strings, since Codex splits a -c key on every dot', () => {
expect(
formatCodexSessionHookTrustOverride([
...POSIX_TRUST,
{ key: '/Users/me/.codex/hooks.json:session_start:2:0', trustedHash: 'sha256:cd34' }
])
).toBe(
"hooks.state={'/Users/me/.codex/hooks.json:stop:1:0'={trusted_hash='sha256:ab12'}," +
"'/Users/me/.codex/hooks.json:session_start:2:0'={trusted_hash='sha256:cd34'}}"
)
})
it('is withheld when a key cannot be a TOML literal string', () => {
expect(
formatCodexSessionHookTrustOverride([
{ key: "/Users/o'brien/.codex/hooks.json:stop:1:0", trustedHash: 'sha256:ab12' }
])
).toBeNull()
expect(formatCodexSessionHookTrustOverride([])).toBeNull()
})
})
describe('a resume command carrying session hook trust', () => {
it('quotes it for every Unix shell, before the resume argv', async () => {
const launch = await launchWith('posix', resumeHome(POSIX_TRUST))
expect(launch.command).toBe(
"codex '-c' 'hooks.state={'\"'\"'/Users/me/.codex/hooks.json:stop:1:0'\"'\"'" +
"={trusted_hash='\"'\"'sha256:ab12'\"'\"'}}' 'resume' '019abc'"
)
})
it('quotes it for PowerShell without a double quote in the argument', async () => {
const launch = await launchWith('powershell', resumeHome(WINDOWS_TRUST))
expect(launch.command).toBe(
"codex '-c' 'hooks.state={''C:\\Users\\me\\.codex\\hooks.json:stop:1:0''" +
"={trusted_hash=''sha256:ab12''}}' 'resume' '019abc'"
)
})
it('quotes it for cmd', async () => {
const launch = await launchWith('cmd', resumeHome(WINDOWS_TRUST))
expect(launch.command).toBe(
'codex "-c" "hooks.state={\'C:\\Users\\me\\.codex\\hooks.json:stop:1:0\'' +
'={trusted_hash=\'sha256:ab12\'}}" "resume" "019abc"'
)
})
it('leaves a cmd line unchanged when the path holds a character cmd would expand', async () => {
const trust = [{ ...WINDOWS_TRUST[0]!, key: 'C:\\Users\\me%x%\\.codex\\hooks.json:stop:1:0' }]
const launch = await launchWith('cmd', resumeHome(trust))
expect(launch.command).toBe(resumeCommand('cmd'))
expect(launch.sessionHookTrustArgs).toBeNull()
})
it('leaves the command unchanged with no trust to pass, or no resume argv to find', async () => {
expect((await launchWith('posix', resumeHome())).command).toBe(resumeCommand('posix'))
expect((await launchWith('posix', resumeHome(POSIX_TRUST), 'my-codex')).command).toBe(
'my-codex'
)
})
it('reaches the sequenced startup command too, which the pane runs instead', async () => {
const launch = await launchWith('posix', resumeHome(POSIX_TRUST))
const env = rewriteSequencedStartupResumeArgv(
{ [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: resumeCommand('posix') },
launch
)
expect(env[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe(launch.command)
})
})
describe("the pane shell's command dialect", () => {
it('follows the Windows shell, and is portable Unix quoting everywhere else', () => {
expect(resolveCodexResumeStartupShell('win32', undefined)).toBe('powershell')
expect(resolveCodexResumeStartupShell('win32', 'C:\\Windows\\system32\\cmd.exe')).toBe('cmd')
expect(resolveCodexResumeStartupShell('win32', 'git-bash')).toBe('posix')
expect(resolveCodexResumeStartupShell('darwin', '/opt/homebrew/bin/fish')).toBe('posix')
expect(resolveCodexResumeStartupShell('linux', undefined)).toBe('posix')
})
})
+85 -17
View File
@@ -4,10 +4,19 @@ import {
type AgentProviderSessionMetadata
} from '../../../../shared/agent-session-resume'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing'
import { dropAgentResumeArgvFromCommand } from '../../../../shared/agent-resume-argv-drop'
import {
dropAgentResumeArgvFromCommand,
findAgentResumeArgvSuffix
} from '../../../../shared/agent-resume-argv-drop'
import { quoteStartupArg, type AgentStartupShell } from '../../../../shared/tui-agent-startup-shell'
import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell'
import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection'
import { dropUnverifiedCodexResumeArgv } from '../../../codex/codex-unverified-resume-launch'
import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home'
import {
formatCodexSessionHookTrustOverride,
type CodexSessionHookTrust
} from '../../../codex/codex-real-home-session-hook-trust'
import { CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE, codexHomePathsEqual } from './codex-home'
import type { PrepareCodexSessionResume } from './types'
@@ -17,11 +26,14 @@ export type CodexResumeLaunch = {
notifyResumeUnavailable: boolean
droppedResumeArgv: boolean
providerSession: AgentProviderSessionMetadata | null
/** The quoted `-c` override spliced in front of the resume argv, if any. */
sessionHookTrustArgs: string | null
}
export type PreparedCodexResumeHome = {
providerSession: AgentProviderSessionMetadata
preparation: Promise<CodexSessionResumePreparation | null>
startupShell: AgentStartupShell
}
export type PrepareCodexResumeHomeArgs = {
@@ -31,6 +43,16 @@ export type PrepareCodexResumeHomeArgs = {
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
workspacePath?: string
/** The pane's resolved shell; on Windows it decides how the command line is quoted. */
shellOverride?: string
}
/** The dialect the pane's shell parses the launch command in. */
export function resolveCodexResumeStartupShell(
platform: NodeJS.Platform,
shellOverride: string | undefined
): AgentStartupShell {
return platform === 'win32' ? resolveWindowsShellStartupFamily(shellOverride) : 'posix'
}
export function prepareCodexResumeHome(
@@ -51,7 +73,8 @@ export function prepareCodexResumeHome(
target: args.target,
launchEnv: args.launchEnv,
workspacePath: args.workspacePath
})
}),
startupShell: resolveCodexResumeStartupShell(process.platform, args.shellOverride)
}
}
@@ -63,10 +86,35 @@ export function noCodexResumeLaunch(command: string | undefined): CodexResumeLau
command,
notifyResumeUnavailable: false,
droppedResumeArgv: false,
providerSession: null
providerSession: null,
sessionHookTrustArgs: null
}
}
// Why: cmd expands `%` and `!` even inside double quotes, and a caret there is literal.
const CMD_UNQUOTABLE = /[\^&|<>()%!"]/
function quoteSessionHookTrustArgs(
trust: readonly CodexSessionHookTrust[] | undefined,
shell: AgentStartupShell
): string | null {
const override = trust ? formatCodexSessionHookTrustOverride(trust) : null
if (!override || (shell === 'cmd' && CMD_UNQUOTABLE.test(override))) {
return null
}
return `${quoteStartupArg('-c', shell)} ${quoteStartupArg(override, shell)}`
}
/** Puts the override before `resume <id>`, where Codex reads it as a root flag. */
function insertBeforeCodexResumeArgv(
command: string,
providerSession: AgentProviderSessionMetadata,
args: string
): string | null {
const found = findAgentResumeArgvSuffix({ command, agent: 'codex', providerSession })
return found.status === 'found' ? `${found.base} ${args} ${found.suffix}` : null
}
/** The command a Codex launch actually runs: unchanged when provenance is verified,
* stripped of `resume <id>` when it is not. */
export function resolveCodexResumeLaunch(
@@ -76,12 +124,21 @@ export function resolveCodexResumeLaunch(
return preparation.preparation.then((prepared) => {
const providerSession = preparation.providerSession
if (prepared?.outcome !== 'fresh') {
const trustArgs = quoteSessionHookTrustArgs(
prepared?.sessionHookTrust,
preparation.startupShell
)
const trustedCommand =
trustArgs && command
? insertBeforeCodexResumeArgv(command, providerSession, trustArgs)
: null
return {
codexResumeHome: prepared ?? null,
command,
command: trustedCommand ?? command,
notifyResumeUnavailable: false,
droppedResumeArgv: false,
providerSession
providerSession,
sessionHookTrustArgs: trustedCommand ? trustArgs : null
}
}
const dropped = dropUnverifiedCodexResumeArgv({
@@ -98,7 +155,8 @@ export function resolveCodexResumeLaunch(
dropped.droppedResumeArgv &&
(prepared.claimedCodexProvenance || !providerSession.transcriptPath),
droppedResumeArgv: dropped.droppedResumeArgv,
providerSession
providerSession,
sessionHookTrustArgs: null
}
})
}
@@ -118,21 +176,31 @@ export async function reconcileSharedRuntimeResumeHome(
}
/** Why: buildPtyHostEnv prefers ORCA_SEQUENCED_STARTUP_COMMAND over the launch command
* and the sequenced wrapper `eval`s it, so a dropped resume argv has to go there too. */
export function stripSequencedStartupResumeArgv<T extends Record<string, string> | undefined>(
* and the sequenced wrapper `eval`s it, so a resume argv rewrite has to go there too. */
export function rewriteSequencedStartupResumeArgv<T extends Record<string, string> | undefined>(
env: T,
launch: CodexResumeLaunch
): T {
const sequenced = env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]
if (!env || !sequenced || !launch.droppedResumeArgv || !launch.providerSession) {
if (!env || !sequenced || !launch.providerSession) {
return env
}
const drop = dropAgentResumeArgvFromCommand({
command: sequenced,
agent: 'codex',
providerSession: launch.providerSession
})
return drop.status === 'dropped'
? { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: drop.command }
: env
let rewritten: string | null = null
if (launch.droppedResumeArgv) {
const drop = dropAgentResumeArgvFromCommand({
command: sequenced,
agent: 'codex',
providerSession: launch.providerSession
})
rewritten = drop.status === 'dropped' ? drop.command : null
} else if (launch.sessionHookTrustArgs) {
rewritten = insertBeforeCodexResumeArgv(
sequenced,
launch.providerSession,
launch.sessionHookTrustArgs
)
}
return rewritten === null
? env
: { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: rewritten }
}
+3 -2
View File
@@ -40,7 +40,8 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
providerSession: args.resumeProviderSession,
target: ctx.codexSelectionTarget,
launchEnv: ctx.baseEnv,
workspacePath: ctx.cwd
workspacePath: ctx.cwd,
shellOverride: ctx.effectiveShellOverride
})
ctx.codexResumeLaunch = codexResumePreparation
? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation)
@@ -51,7 +52,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
ctx.spawnTiming.mark('codex_resume')
const codexResumeHome = ctx.codexResumeLaunch.codexResumeHome
ctx.launchCommand = ctx.codexResumeLaunch.command
ctx.baseEnv = ctx.deps.stripSequencedStartupResumeArgv(ctx.baseEnv, ctx.codexResumeLaunch)
ctx.baseEnv = ctx.deps.rewriteSequencedStartupResumeArgv(ctx.baseEnv, ctx.codexResumeLaunch)
// Why: declared after the strip so a local-provider spawn cannot capture the
// pre-strip env — only the daemon branch below re-derives this from baseEnv.
ctx.env = ctx.baseEnv
+1 -1
View File
@@ -115,7 +115,7 @@ export type PtySpawnIpcDeps = {
resumeHome: Extract<CodexSessionResumePreparation, { outcome: 'resume' }>,
resolveCurrent: () => string | null | Promise<string | null>
) => Promise<string>
stripSequencedStartupResumeArgv: <T extends Record<string, string> | undefined>(
rewriteSequencedStartupResumeArgv: <T extends Record<string, string> | undefined>(
env: T,
launch: CodexResumeLaunch
) => T
+3 -3
View File
@@ -60,7 +60,7 @@ import {
prepareCodexResumeHome,
reconcileSharedRuntimeResumeHome,
resolveCodexResumeLaunch,
stripSequencedStartupResumeArgv
rewriteSequencedStartupResumeArgv
} from './host-env/codex-resume'
import { ensureLinuxTerminalOrcaCliShimDir } from '../../cli/linux-terminal-orca-cli-shim'
@@ -220,7 +220,7 @@ export function registerPtyHandlers(
resolveCodexResumeLaunch,
noCodexResumeLaunch,
reconcileSharedRuntimeResumeHome,
stripSequencedStartupResumeArgv,
rewriteSequencedStartupResumeArgv,
assertFolderWorkspacePtyPathUsable,
resolvePtySpawnStartupCwd,
requestSerializedBuffer: session.requestSerializedBuffer,
@@ -267,7 +267,7 @@ export function registerPtyHandlers(
noCodexResumeLaunch,
resolveCodexResumeLaunch,
reconcileSharedRuntimeResumeHome,
stripSequencedStartupResumeArgv,
rewriteSequencedStartupResumeArgv,
transitionSpawnHiddenRendererPtyDeliveryState:
session.transitionSpawnHiddenRendererPtyDeliveryState,
trustedTerminalHandleEnv: session.trustedTerminalHandleEnv,
+1 -1
View File
@@ -42,7 +42,7 @@ export type PtyRuntimeControllerDeps = {
resumeHome: Extract<CodexSessionResumePreparation, { outcome: 'resume' }>,
resolveCurrent: () => string | null | Promise<string | null>
) => Promise<string>
stripSequencedStartupResumeArgv: <T extends Record<string, string> | undefined>(
rewriteSequencedStartupResumeArgv: <T extends Record<string, string> | undefined>(
env: T,
launch: CodexResumeLaunch
) => T
@@ -16,7 +16,8 @@ function makeDeps(): PtyRuntimeControllerDeps {
command,
notifyResumeUnavailable: false,
droppedResumeArgv: false,
providerSession: null
providerSession: null,
sessionHookTrustArgs: null
})
return {
store: undefined,
@@ -31,7 +32,7 @@ function makeDeps(): PtyRuntimeControllerDeps {
resolveCodexResumeLaunch: async (command) => noCodexResumeLaunch(command),
noCodexResumeLaunch,
reconcileSharedRuntimeResumeHome: async (resumeHome) => resumeHome.codexHomePath,
stripSequencedStartupResumeArgv: (env) => env,
rewriteSequencedStartupResumeArgv: (env) => env,
assertFolderWorkspacePtyPathUsable: () => undefined,
resolvePtySpawnStartupCwd: (_worktreeId, cwd) => cwd,
requestSerializedBuffer: async () => null,
+3 -2
View File
@@ -137,7 +137,8 @@ export async function prepareRuntimePtySpawn(
providerSession: args.resumeProviderSession,
target: ctx.codexSelectionTarget,
launchEnv: args.env,
workspacePath: ctx.cwd
workspacePath: ctx.cwd,
shellOverride: ctx.terminalRuntimeOptions.shellOverride
})
const codexResumeLaunch = codexResumePreparation
? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation)
@@ -182,7 +183,7 @@ export async function prepareRuntimePtySpawn(
ctx.requestedAgentTeamsPath = ctx.env?.ORCA_AGENT_TEAMS_TEAM_ID
? ctx.env[resolvePathEnvKey(ctx.env, process.platform)]
: undefined
ctx.env = ctx.deps.stripSequencedStartupResumeArgv(ctx.env, codexResumeLaunch)
ctx.env = ctx.deps.rewriteSequencedStartupResumeArgv(ctx.env, codexResumeLaunch)
if (args.preAllocatedHandle) {
ctx.env = { ...ctx.env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
}
@@ -25,7 +25,7 @@ export function createPtySpawnCommitDependencies(
noCodexResumeLaunch: unexpectedPreflight,
resolveCodexResumeLaunch: unexpectedPreflight,
reconcileSharedRuntimeResumeHome: unexpectedPreflight,
stripSequencedStartupResumeArgv: unexpectedPreflight,
rewriteSequencedStartupResumeArgv: unexpectedPreflight,
transitionSpawnHiddenRendererPtyDeliveryState: unexpectedPreflight,
trustedTerminalHandleEnv: new Set(),
syncPtyBackgroundedDelivery: unexpectedPreflight,
@@ -1,14 +1,16 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import type * as Os from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CodexHookTrustGrantRequest } from '../codex/codex-app-server-client'
import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home'
import type { CodexTrustEntry } from '../codex/config-toml-trust'
import { isCodexManagedCommand, setupCodexHookHomes } from '../codex/hook-service-test-harness'
// Why this file (QA case 4, full launch path): Codex's approval of the real-home
// entry runs in the background. A launch during it must settle on the managed
// home at once, with that home's hook install and the project trust write done.
// A resume has no other home, so it must not spawn beside an unapproved entry.
// A resume has no other home, so it spawns at once and trusts Orca's entries for itself.
const { getPathMock, homedirMock, resolveCodexCommandMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
@@ -73,14 +75,17 @@ vi.mock('./main-process-state', async () => {
}
})
const { CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS, _internals: grantInternals } =
await import('../codex/codex-hook-trust-grant')
const { _internals: grantInternals } = await import('../codex/codex-hook-trust-grant')
const { codexAppServerCapabilityCache, getCodexAppServerHostKey } =
await import('../codex/codex-app-server-capability-cache')
const { _internals: realHomeInternals } = await import('../codex/codex-real-home-hook-install')
const { getOrcaManagedCodexHomePath } = await import('../codex/codex-home-paths')
const { prepareCodexRuntimeHomeForLaunch } = await import('./codex-launch-preparation')
const { prepareCodexSessionResumeForLaunch } = await import('./codex-session-resume-launch')
const {
computeTrustedHash,
computeTrustKey,
getCodexExplicitHomeHookSourcePath,
normalizeHookTrustKeyForLookup,
parseTrustKey,
readHookTrustEntries,
@@ -105,36 +110,44 @@ type HookDefinition = {
hooks?: { command?: string }[]
}
/** How Codex will treat each Orca entry in the real ~/.codex/hooks.json. */
function realHomeOrcaEntryTrust(): string[] {
/** Orca's entries in the real ~/.codex/hooks.json. */
function realHomeOrcaEntries(): CodexTrustEntry[] {
const hooksPath = join(homes.tmpHome, '.codex', 'hooks.json')
const hooks: Record<string, HookDefinition[]> = JSON.parse(readFileSync(hooksPath, 'utf-8')).hooks
const trust = readHookTrustEntries(join(homes.tmpHome, '.codex', 'config.toml'))
return Object.entries(hooks).flatMap(([eventName, definitions]) =>
definitions.flatMap((definition, groupIndex) =>
(definition.hooks ?? []).flatMap((hook, handlerIndex) => {
if (!isCodexManagedCommand(hook.command)) {
return []
}
const entry = createCodexHookTrustEntry(
hooksPath,
eventName,
groupIndex,
handlerIndex,
definition,
hook
)
return [entry ? readOrcaEntryTrust(entry, trust) : 'untrusted']
const entry = isCodexManagedCommand(hook.command)
? createCodexHookTrustEntry(
hooksPath,
eventName,
groupIndex,
handlerIndex,
definition,
hook
)
: null
return entry ? [entry] : []
})
)
)
}
function resume(): Promise<unknown> {
return prepareCodexSessionResumeForLaunch({
/** How Codex will treat each Orca entry in the real ~/.codex/hooks.json. */
function realHomeOrcaEntryTrust(): string[] {
const trust = readHookTrustEntries(join(homes.tmpHome, '.codex', 'config.toml'))
return realHomeOrcaEntries().map((entry) => readOrcaEntryTrust(entry, trust))
}
async function resume(): Promise<Extract<CodexSessionResumePreparation, { outcome: 'resume' }>> {
const prepared = await prepareCodexSessionResumeForLaunch({
providerSession: { key: 'session_id', id: 'abc' },
target: { runtime: 'host' }
})
if (prepared?.outcome !== 'resume') {
throw new Error('expected the session to resume')
}
return prepared
}
function launch(workspacePath: string): Promise<string | null> {
@@ -144,12 +157,9 @@ function launch(workspacePath: string): Promise<string | null> {
})
}
afterEach(() => {
vi.useRealTimers()
})
beforeEach(() => {
realHomeInternals.setLaneForTesting('pending')
codexAppServerCapabilityCache.clear()
resolveCodexCommandMock.mockReturnValue(process.execPath)
mkdirSync(join(homes.tmpHome, '.codex'), { recursive: true })
writeFileSync(join(homes.tmpHome, '.codex', 'hooks.json'), '{"hooks":{}}\n')
@@ -200,54 +210,65 @@ describe('a Codex launch while the real-home approval hangs', () => {
})
})
/** Codex approving every requested entry once `approval` resolves, as the grant session does. */
function installApprovingRunner(approval: Promise<void>): void {
grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => {
await approval
const entries = request.expectedTrustKeys.map((key) => {
const entry = { ...parseTrustKey(key)!, command: request.managedCommand, timeoutSec: 10 }
return { key, entry, trustedHash: computeTrustedHash(entry) }
})
upsertHookTrustEntries(
join(homes.tmpHome, '.codex', 'config.toml'),
entries.map(({ entry, trustedHash }) => ({ ...entry, trustedHash }))
)
return {
outcome: 'granted' as const,
wroteTrust: true,
entries: entries.map(({ key, trustedHash }) => ({
key,
normalizedKey: normalizeHookTrustKeyForLookup(key),
trustedHash
}))
}
})
}
describe('a Codex resume into the real ~/.codex while its approval runs', () => {
it('spawns once Codex approves the entry, never beside an unapproved one', async () => {
let approve: () => void = () => {}
const approval = new Promise<void>((resolve) => {
approve = resolve
})
grantInternals.setGrantSessionRunner(async (request: CodexHookTrustGrantRequest) => {
await approval
const entries = request.expectedTrustKeys.map((key) => {
const entry = { ...parseTrustKey(key)!, command: request.managedCommand, timeoutSec: 10 }
return { key, entry, trustedHash: computeTrustedHash(entry) }
})
upsertHookTrustEntries(
join(homes.tmpHome, '.codex', 'config.toml'),
entries.map(({ entry, trustedHash }) => ({ ...entry, trustedHash }))
)
return {
outcome: 'granted' as const,
wroteTrust: true,
entries: entries.map(({ key, trustedHash }) => ({
key,
normalizedKey: normalizeHookTrustKeyForLookup(key),
trustedHash
}))
}
})
it("spawns at once, trusting exactly Orca's entries for that one process", async () => {
writeFileSync(
join(homes.tmpHome, '.codex', 'hooks.json'),
`${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user.sh' }] }] } })}\n`
)
grantInternals.setGrantSessionRunner(() => new Promise(() => {}))
const resumed = resume()
expect(await settlesWithin(resumed, 200)).toBe(false)
approve()
await resumed
const trust = realHomeOrcaEntryTrust()
expect(trust.length).toBeGreaterThan(0)
expect(trust.every((state) => state === 'trusted')).toBe(true)
expect(await settlesWithin(resumed, 2_000)).toBe(true)
const trust = (await resumed).sessionHookTrust ?? []
expect(realHomeOrcaEntryTrust().every((state) => state === 'untrusted')).toBe(true)
const hooksPath = join(homes.tmpHome, '.codex', 'hooks.json')
const orcaKeys = realHomeOrcaEntries().flatMap((entry) => [
computeTrustKey(entry),
computeTrustKey({ ...entry, sourcePath: getCodexExplicitHomeHookSourcePath(hooksPath) })
])
expect(orcaKeys.length).toBeGreaterThan(0)
expect(trust.map(({ key }) => key).sort()).toEqual([...new Set(orcaKeys)].sort())
expect(trust.some(({ key }) => key.endsWith(':stop:0:0'))).toBe(false)
})
it('spawns at the approval deadline with the unapproved entries withdrawn', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
grantInternals.setGrantSessionRunner(() => new Promise(() => {}))
let spawned = false
const resumed = resume().then(() => {
spawned = true
})
it("passes nothing extra once Codex has approved Orca's entries", async () => {
installApprovingRunner(Promise.resolve())
await resume()
await realHomeInternals.settledLaneForTesting()
expect(realHomeOrcaEntryTrust().every((state) => state === 'trusted')).toBe(true)
await vi.advanceTimersByTimeAsync(CODEX_BACKGROUND_TRUST_GRANT_TIMEOUT_MS - 1)
expect(spawned).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await resumed
expect(realHomeOrcaEntryTrust()).toEqual([])
expect((await resume()).sessionHookTrust).toBeUndefined()
})
it('passes nothing to a Codex known to lack hook trust', async () => {
codexAppServerCapabilityCache.rememberUnsupported(getCodexAppServerHostKey({ kind: 'native' }))
grantInternals.setGrantSessionRunner(() => new Promise(() => {}))
expect((await resume()).sessionHookTrust).toBeUndefined()
})
})
@@ -20,7 +20,7 @@ const mocks = vi.hoisted(() => {
installForLaunchPrep: vi.fn(async () => {}),
refreshRuntimeUserHooksForLaunchPrep: vi.fn(async () => {}),
ensureRealHomeCodexHookState: vi.fn(async () => 'installed' as const),
awaitRealHomeCodexHookTrust: vi.fn(async () => 'installed' as const),
readRealHomeCodexSessionHookTrust: vi.fn(() => [{ key: 'k', trustedHash: 'sha256:h' }]),
prepareCodexSessionResume: vi.fn()
}
})
@@ -35,9 +35,11 @@ vi.mock('../codex/hook-service', () => ({
}
}))
vi.mock('../codex/codex-real-home-hook-install', () => ({
awaitRealHomeCodexHookTrust: mocks.awaitRealHomeCodexHookTrust,
ensureRealHomeCodexHookState: mocks.ensureRealHomeCodexHookState
}))
vi.mock('../codex/codex-real-home-session-hook-trust', () => ({
readRealHomeCodexSessionHookTrust: mocks.readRealHomeCodexSessionHookTrust
}))
// Why: the real predicate, without loading every agent's hook service.
vi.mock(
'../agent-hooks/managed-agent-hook-controls',
@@ -97,7 +99,7 @@ const HOOK_SETTINGS: readonly {
}
]
function resumeFrom(homePath: string): Promise<unknown> {
function resumeFrom(homePath: string): ReturnType<typeof prepareCodexSessionResumeForLaunch> {
mocks.prepareCodexSessionResume.mockImplementation(
async (args: {
resolveVerifiedResumeHome: (source: VerifiedCodexResumeSource) => Promise<string>
@@ -160,14 +162,16 @@ describe('Codex launch prep honours the per-agent hook opt-out', () => {
async ({ settings, codexHooksOn }) => {
mocks.settings = settings
await resumeFrom(SYSTEM_HOME)
const prepared = await resumeFrom(SYSTEM_HOME)
expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledTimes(1)
expect(mocks.ensureRealHomeCodexHookState).toHaveBeenCalledWith(
expect.objectContaining({ hooksEnabled: codexHooksOn, writePolicy: 'add-missing-only' })
)
// Why: a resume has no managed home to fall back to, so it waits for the grant to settle.
expect(mocks.awaitRealHomeCodexHookTrust).toHaveBeenCalledOnce()
// Why: with hooks off this Orca's entries are not its business to trust.
expect(prepared?.outcome === 'resume' ? prepared.sessionHookTrust : null).toEqual(
codexHooksOn ? [{ key: 'k', trustedHash: 'sha256:h' }] : undefined
)
expect(mocks.installForLaunchPrep).not.toHaveBeenCalled()
expect(mocks.refreshRuntimeUserHooksForLaunchPrep).not.toHaveBeenCalled()
}
@@ -6,10 +6,11 @@ import { prepareCodexSessionResume } from '../codex/codex-session-resume-prepara
import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume'
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
import { codexHookService } from '../codex/hook-service'
import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install'
import {
awaitRealHomeCodexHookTrust,
ensureRealHomeCodexHookState
} from '../codex/codex-real-home-hook-install'
readRealHomeCodexSessionHookTrust,
type CodexSessionHookTrust
} from '../codex/codex-real-home-session-hook-trust'
import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls'
import { markCodexProjectTrusted } from '../agent-trust-presets'
import { awaitAgentTrustWriteWithinDeadline } from '../agent-trust-write-deadline'
@@ -39,6 +40,7 @@ export async function prepareCodexSessionResumeForLaunch(args: {
// readable alias wins. A throw here refuses the whole resume instead
// (#STA-4422).
const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume()
let sessionHookTrust: CodexSessionHookTrust[] = []
// Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is
// no verified origin home to prepare, so the PTY layer drops the resume argv (#10793).
const preparation = await prepareCodexSessionResume({
@@ -106,9 +108,9 @@ export async function prepareCodexSessionResumeForLaunch(args: {
userDataPath: app.getPath('userData'),
writePolicy: 'add-missing-only'
})
// Why wait: an unapproved entry would show hook review in this pane, and
// the grant's own settle is the only one that cannot race Codex's write.
await awaitRealHomeCodexHookTrust()
// Why no wait: the session lives here, so instead of waiting on Codex's
// approval this process alone trusts Orca's entries while it runs.
sessionHookTrust = hooksEnabled ? readRealHomeCodexSessionHookTrust() : []
} else if (hooksEnabled) {
await codexHookService.installForLaunchPrep(resumeHome)
} else {
@@ -126,7 +128,8 @@ export async function prepareCodexSessionResumeForLaunch(args: {
...preparation,
reconcileSharedRuntimeAuth:
normalizeRuntimePathForComparison(preparation.codexHomePath) ===
normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath())
normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()),
...(sessionHookTrust.length > 0 ? { sessionHookTrust } : {})
}
: preparation
}
+30 -14
View File
@@ -33,6 +33,34 @@ function stripSuffix(command: string, suffix: string): string | null {
return trimmed.length > 0 ? trimmed : null
}
/** Where `buildAgentResumeStartupPlan` appended the resume argv: `base` is the
* plain agent launch and `suffix` the argv exactly as it was quoted. */
export type AgentResumeArgvSuffix =
| { status: 'found'; base: string; suffix: string }
| { status: 'absent' }
| { status: 'unrecognized' }
export function findAgentResumeArgvSuffix(args: {
command: string
agent: ResumableTuiAgent
providerSession: AgentProviderSessionMetadata
}): AgentResumeArgvSuffix {
const argv = getAgentResumeArgv(args.agent, args.providerSession)
const resumeArgs = argv?.slice(1) ?? []
const locator = resumeArgs.at(-1)
const command = args.command.trimEnd()
if (!locator || !command.includes(locator)) {
return { status: 'absent' }
}
for (const suffix of resumeArgvSuffixCandidates(resumeArgs)) {
const base = stripSuffix(command, suffix)
if (base !== null && !base.includes(locator)) {
return { status: 'found', base, suffix }
}
}
return { status: 'unrecognized' }
}
/**
* Remove the resume argv `buildAgentResumeStartupPlan` appended, leaving the plain
* agent launch. Main uses this when it cannot verify which account owns the session,
@@ -46,18 +74,6 @@ export function dropAgentResumeArgvFromCommand(args: {
agent: ResumableTuiAgent
providerSession: AgentProviderSessionMetadata
}): AgentResumeArgvDrop {
const argv = getAgentResumeArgv(args.agent, args.providerSession)
const resumeArgs = argv?.slice(1) ?? []
const locator = resumeArgs.at(-1)
const command = args.command.trimEnd()
if (!locator || !command.includes(locator)) {
return { status: 'absent' }
}
for (const suffix of resumeArgvSuffixCandidates(resumeArgs)) {
const base = stripSuffix(command, suffix)
if (base !== null && !base.includes(locator)) {
return { status: 'dropped', command: base }
}
}
return { status: 'unrecognized' }
const found = findAgentResumeArgvSuffix(args)
return found.status === 'found' ? { status: 'dropped', command: found.base } : found
}