mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 03:27:44 +00:00
* 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
137 lines
6.3 KiB
TypeScript
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
|
|
]
|
|
}
|
|
}
|
|
}
|