mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
* refactor(native-chat): keep the provider resume handle opaque to shared code
Shared structured-chat code parsed each provider's resume handle: Claude's
session id and branch leaf, Codex's thread id, through a 'claude' | 'codex'
union every new agent had to widen. The in-memory handle is now
{ transport, agent, nativeId, providerData? }: shared readers use nativeId,
lease and handle-chain checks compare transport and agent, and only the
Claude adapter reads its leaf (providerData).
Stored and wire forms are unchanged for Claude and Codex. One encoding
module writes their typed shapes and decodes both those and the neutral
shape a new transport uses, which an older build refuses as unreadable
rather than reading as Codex. Key and root strings, which fork seeds,
superseded creations and resume offers persist, stay byte-identical.
The journal's own handle type becomes the journal-row and attach-wire
encoding of the same handle, and the journal identity carries the
neutral handle (null before the provider proves one).
No user-visible change.
* fix(native-chat): derive journal-row provider handles from the journal identity
The journal row converter now takes the identity every caller already holds,
so a row's handle has one obvious constructor. Tests that wrote the in-memory
handle straight into journal rows now build it through that converter, and the
processless Claude fixture names a not-yet-proved handle as null.
* fix(native-chat): refuse a stored provider handle written in both forms
A typed Claude or Codex handle that also carries the neutral form's
transport, agent, native id or provider data named two identities; it
was read as Claude or Codex and the next write dropped the other one.
Such a row now stays unreadable and is set aside untouched.
* test(native-chat): use opaque handle in queued rejection fixture
* test(native-chat): share one Codex journal identity in the integration suite
Main grew the suite to the 800-line limit; the opaque-handle import pushed it
over. The two tests built the same identity inline.
* refactor(agent-session): name the handle's adapter state resumeCursor
Rename the neutral provider handle's providerData to resumeCursor before any
row persists the neutral form: it is an adapter-owned resume position (Claude's
transcript leaf), never identity. Claude/Codex stored and wire bytes are
unchanged; their typed shapes never carried the field.
State the stored-form contract (a handle's field set is closed; later per-link
data goes on the chain link, which every build preserves) and pin it with a
record round-trip test. Document that transport records the id space the
native id was minted in, which can differ from the agent's current transport.
125 lines
6.3 KiB
TypeScript
125 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 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)
|
|
// A Codex record's chain holds only Codex handles; the record store 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
|
|
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
|
|
)
|
|
}
|
|
: {})
|
|
}
|
|
}
|
|
}
|