Files
orca/src/main/codex/codex-structured-launch-resolution.ts
T
Brennan Benson 9b1e83a7d8 refactor(native-chat): rename the chat visuals folder to the chat scratch folder (#28389)
* fix(files): never write through a dangling symlink

A path that doesn't exist yet was authorized by walking up to the nearest
existing folder. A dangling symlink fails realpath with ENOENT, so the walk
stepped over it and approved the link's own path; the write then followed
the link and created its target outside the allowed folders. The walk now
continues from the link's target, and returns that real destination.

* refactor(native-chat): rename the chat visuals folder to the chat scratch folder

The per-chat folder agents write into is the chat's scratch space, not only
a visuals folder. Its root becomes <state>/chat-scratch/<hash> (the hash is
unchanged) and the agent variable becomes ORCA_CHAT_SCRATCH_DIR. The old
ORCA_CHAT_VISUALS_DIR is never set and is still stripped from what every
agent child inherits, Pi included. The visuals skill names the new variable
and says the folder is temporary.

* test(native-chat): keep the delivery test to the rename

The launch parity snapshot already pins that every agent drops the
pre-rename variable.

* fix(files): refuse any dangling symlink in the missing-path walk

Following a dangling link by hand resolved relative targets against the
lexical parent, not the link's real folder: through a folder link the walk
could loop forever or authorize the wrong file. Refuse instead: realpath
can't say where a dangling link leads, but a write would follow it.

* test(files): drop the loop case that duplicated the dangling-link refusal
2026-10-13 22:08:57 -07:00

137 lines
6.3 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 { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { resolveCodexCommand } from '../codex-cli/command'
import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution'
import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof'
import { codexStructuredLaunchArgs } from './codex-structured-launch-args'
import {
LEGACY_NATIVE_CHAT_VISUALS_DIR_ENV,
NATIVE_CHAT_SCRATCH_DIR_ENV,
withNativeChatVisualsEnv
} from '../native-chat/native-chat-visuals-delivery'
export type CodexStructuredLaunchPartDeps = {
resolveLaunchArgs: () => Promise<string[]> | string[]
/** Overridden in tests; production scans the boot-cached PATH and version-manager dirs. */
resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
resolveRollout?: typeof resolvePinnedCodexRolloutProof
}
export type CodexStructuredInvocationDeps = Pick<
CodexStructuredLaunchPartDeps,
'resolveCommand'
> & {
/** Fresh shell/configured environment for this spawn; never written to the session record. */
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
/** Rereads that environment when Codex is not found in it; absent never retries. */
refreshEnvironmentAfterMiss?: (askedAt: number) => Promise<void>
/** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */
resolveWorkingDirectory?: () => Promise<string> | string
}
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 function resolveCodexStructuredInvocation(
deps: CodexStructuredInvocationDeps
): Promise<CodexStructuredInvocation> {
return resolveAgainAfterRefreshOnMissingProgram(
() => readCodexStructuredInvocation(deps),
deps.refreshEnvironmentAfterMiss,
{ env: ({ environment }) => environment, cwd: deps.resolveWorkingDirectory }
)
}
async function readCodexStructuredInvocation(
deps: CodexStructuredInvocationDeps
): 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 }
}
/** Codex's part of a launch: its binary, thread arguments, account home, resume proof and visuals. */
export function codexStructuredLaunchPart(
deps: CodexStructuredLaunchPartDeps
): StructuredAgentLaunchPartResolver<CodexStructuredLaunch> {
return async (basis) => {
const { record } = basis
const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome)
const { command } = await resolveCodexStructuredInvocation({
...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}),
refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram,
resolveWorkingDirectory: basis.launchDirectory,
resolveEnvironment: basis.environment
})
const environment = await basis.environment()
const args = codexStructuredLaunchArgs(await deps.resolveLaunchArgs())
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
// A Codex record's chain holds only Codex handles; the launch admission refuses anything else.
const resumeThreadId = head?.handle.nativeId ?? null
// The same saved options every turn sends, so the thread and its turns name one model.
const model = record.options?.model
const visuals = await basis.visuals()
// Before the rollout scan, so a missing floating folder is still the refusal that wins.
await basis.launchDirectory()
return {
launch: {
command,
args: [...args, 'app-server'],
codexHome: accountHome.path,
// 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 } : {}),
// States every posture outright: a resume inherits the last one for any field left absent.
permissionPolicy: codexStructuredPermissionPolicy(basis.permissionMode ?? 'ask'),
...(model ? { model } : {}),
...(visuals ? { visuals } : {}),
...(resumeThreadId
? {
resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)(
accountHome.path,
resumeThreadId
)
}
: {})
},
env: withNativeChatVisualsEnv(
{ ...environment, ...(accountHome.path ? { CODEX_HOME: accountHome.path } : {}) },
visuals
),
// Without visuals, a folder Orca itself inherited (started from a chat) names another chat's.
inheritedEnvToDelete: [
...(visuals ? [] : [NATIVE_CHAT_SCRATCH_DIR_ENV]),
LEGACY_NATIVE_CHAT_VISUALS_DIR_ENV
]
}
}
}