Files
orca/src/main/codex/codex-structured-launch-resolution.ts
T
Brennan Benson 934a2d44a0 fix(codex): a native chat's thread opens on the model the chat chose (#23532)
* fix(codex): a native chat's thread opens on the model the chat chose

* fix(codex): a resumed thread keeps its own saved model, provider and effort
2026-09-27 21:54:37 -07:00

124 lines
6.2 KiB
TypeScript

// How a durable session record becomes a Codex process launch.
//
// Every input is read back from the record the store already made durable, not
// from the call that triggered the acquire. A client that attaches twice must
// land in the same working directory under the same account home, and a resume
// must name the thread this session actually proved — never one a caller asks
// for, which is how a resume becomes a fork wearing a resume's name.
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { resolveCodexCommand } from '../codex-cli/command'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof'
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
export type CodexStructuredLaunchResolverDeps = {
store: AgentSessionRecordStore
/** Absolute path of a workspace on this host. Rejects when the workspace no
* longer resolves, which is the case a stale mobile client hits. */
resolveWorkspacePath: (workspaceId: string) => Promise<string>
/** Overridden in tests; production scans the boot-cached PATH and version-manager dirs. */
resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
/** Fresh shell/configured environment for this spawn; never written to the session record. */
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
resolveRollout?: typeof resolvePinnedCodexRolloutProof
/** Test seam for the host capability; production uses the native process table. */
isWindowsProcessStartTimeAvailable?: () => boolean
/** The user's Agent Permissions setting as thread policy, re-read per acquisition.
* States both postures outright — a resume inherits the last one for any field left absent. */
resolvePermissionPolicy?: () => CodexStructuredPermissionPolicy
}
export type CodexStructuredInvocation = {
command: string
environment: NodeJS.ProcessEnv | undefined
}
/**
* The one place a structured Codex child's binary and environment are
* resolved. The session launch and the session-less catalog probe both build
* on it, so a probe can never list under a different binary or env than the
* session it stands in for. Env VALUES stay out of the catalog fingerprint:
* drift there heals on the next refresh.
*/
export async function resolveCodexStructuredInvocation(
deps: Pick<CodexStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnvironment'>
): Promise<CodexStructuredInvocation> {
const environment = await deps.resolveEnvironment?.()
const pathEnv = environment?.PATH ?? environment?.Path ?? null
const homePath = environment?.HOME ?? environment?.USERPROFILE
const command = (deps.resolveCommand ?? resolveCodexCommand)({
pathEnv,
...(homePath ? { homePath } : {})
})
return { command, environment }
}
export function createCodexStructuredLaunchResolver(
deps: CodexStructuredLaunchResolverDeps
): (input: { identity: AgentSessionJournalIdentity }) => Promise<CodexStructuredLaunch> {
return async ({ identity }) => {
const record = deps.store.getRecord(identity.sessionId)
if (!record) {
throw new Error(`no durable agent-session record for ${identity.sessionId}`)
}
const { location, accountHome } = record
if (record.provider !== 'codex') {
throw new Error(`session ${identity.sessionId} is a ${record.provider} session`)
}
// This adapter spawns a child on the machine the runtime itself runs on.
// A session pinned elsewhere belongs to that host's runtime, and quietly
// starting it here would put a second writer on the same thread.
if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) {
throw new Error(
`codex structured sessions run on the local host, not ${location.executionHostId}`
)
}
// Refuse before resolving launch data; a PID alone cannot prove Windows ownership.
if (
process.platform === 'win32' &&
!(deps.isWindowsProcessStartTimeAvailable ?? isWindowsProcessStartTimeAvailable)()
) {
throw new Error('codex structured sessions require Windows process creation-time proof')
}
if (accountHome.variable !== 'CODEX_HOME') {
throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`)
}
const { command, environment } = await resolveCodexStructuredInvocation(deps)
// `record.launchArgs` is deliberately not read: the configured CLI arguments are a terminal
// concern, and the permission posture they used to smuggle in is derived per acquisition.
const permissionPolicy = deps.resolvePermissionPolicy?.()
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null
// The same saved options every turn sends, so the thread and its turns name one model.
const model = record.options?.model
return {
command,
args: ['app-server'],
cwd: await deps.resolveWorkspacePath(location.workspaceId),
codexHome: accountHome.path,
...(environment ? { env: { ...environment } as Record<string, string> } : {}),
// An empty chain is a session that has never proved a thread, so it
// starts one; anything else resumes the last link this session proved.
resumeThreadId,
// Only a thread this session created may still be one Codex never saved: a resumed,
// forked or adopted head names a conversation Codex held.
...(resumeThreadId && head?.origin === 'created' ? { supersedeIfUnsaved: true } : {}),
...(permissionPolicy ? { permissionPolicy } : {}),
...(model ? { model } : {}),
...(resumeThreadId
? {
resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)(
accountHome.path,
resumeThreadId
)
}
: {})
}
}
}