diff --git a/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts index 5e0e686651c..dcbda1113a7 100644 --- a/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts +++ b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts @@ -148,3 +148,19 @@ describe('discoverFiles containment for a stalled WSL root', () => { } }) }) + +it('execution admission preserves native access errors while confirmed missing roots remain absent', async () => { + for (const code of ['EACCES', 'ENOENT']) { + const failure = Object.assign(new Error(code), { code }) + const search = walkSessionFiles('/account/sessions', 'codex', [], { + extensions: new Set(['.jsonl']), + strictErrors: true, + readDirectory: async () => { + throw failure + } + }) + await (code === 'ENOENT' + ? expect(search).resolves.toEqual([]) + : expect(search).rejects.toBe(failure)) + } +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 63027d97376..353cfb34b3e 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -258,6 +258,7 @@ export type SessionFileWalkOptions = { // Return false to skip descending into a directory; depth 0 is a child of // rootDir, so pruned subtrees are never stat'd or parsed. directoryPredicate?: (name: string, depth: number) => boolean + strictErrors?: boolean readDirectory?: (dirPath: string) => Promise signal?: AbortSignal } @@ -295,7 +296,7 @@ export async function forEachSessionFile( options.signal?.throwIfAborted() // Why: a gate refusal means the scan could not run, not that the tree is // empty — swallowing it would misreport a stalled distro as "no transcript". - if (error instanceof WslTranscriptFsError) { + if (error instanceof WslTranscriptFsError || (options.strictErrors && !isMissingSidecarError(error))) { throw error } return diff --git a/src/main/ai-vault/structured-chat-cli-fork.ts b/src/main/ai-vault/structured-chat-cli-fork.ts index d3af3e78aa8..aeed4b694b4 100644 --- a/src/main/ai-vault/structured-chat-cli-fork.ts +++ b/src/main/ai-vault/structured-chat-cli-fork.ts @@ -136,12 +136,10 @@ export async function prepareStructuredChatCliFork( throw new Error('The saved conversation does not match this chat.') } } else { - filePath = await importer.findTranscript({ - providerSessionId, - accountHome: record.accountHome, - launchEnv, - workspaceCwd: cwd - }) + const store = importer.conversationStore({ accountHome: record.accountHome, launchEnv }) + filePath = store + ? await importer.findTranscript({ providerSessionId, store, launchEnv, workspaceCwd: cwd }) + : null } if (!filePath || !isAbsolute(filePath) || isWslUncPath(filePath)) { throw new Error('This chat has no saved conversation to copy.') diff --git a/src/main/claude/claude-cli-conversations.ts b/src/main/claude/claude-cli-conversations.ts index 47e44e00fc5..c3391e69f23 100644 --- a/src/main/claude/claude-cli-conversations.ts +++ b/src/main/claude/claude-cli-conversations.ts @@ -11,15 +11,14 @@ import { } from '../native-chat/structured-agent-cli-conversations' const CLAUDE_TRANSCRIPT_IMPORT = directoryTranscriptImport({ + conversationStore: ({ accountHomePath }) => join(accountHomePath, 'projects'), accountHomeCandidates: ({ selectedAccountHomePath }) => [ selectedAccountHomePath, join(homedir(), '.claude'), ...(getClaudeProfileRouter()?.accountHomes() ?? []) ], - findTranscript: ({ providerSessionId, accountHomePath }) => - resolveSessionFilePath('claude', providerSessionId, { - claudeProjectsDir: join(accountHomePath, 'projects') - }) + findTranscript: ({ providerSessionId, store, strictErrors }) => + resolveSessionFilePath('claude', providerSessionId, { claudeProjectsDir: store, strictErrors }) }) const CLAUDE_SESSION_HISTORY: StructuredAgentSessionHistory = { diff --git a/src/main/codex/codex-ai-vault-session-resume.ts b/src/main/codex/codex-ai-vault-session-resume.ts index 3a4201060dc..90858782994 100644 --- a/src/main/codex/codex-ai-vault-session-resume.ts +++ b/src/main/codex/codex-ai-vault-session-resume.ts @@ -27,6 +27,24 @@ type CodexAiVaultResumeOptions = { preparePinnedLaunchHome: (home: string) => Promise } +export type NativeCodexResumePreparation = ( + args: AiVaultPrepareSessionResumeArgs, + nativeSystemHomePath?: string +) => Promise + +/** Native adoption supplies its execution target; History keeps its configured storage target. */ +export function codexAiVaultSessionResumePreparation( + getOptions: () => CodexAiVaultResumeOptions +): NativeCodexResumePreparation { + return (args, nativeSystemHomePath) => { + const options = getOptions() + return prepareCodexAiVaultSessionResume(args, { + ...options, + systemCodexHomePath: nativeSystemHomePath ?? options.systemCodexHomePath + }) + } +} + /** Keeps window and serve AI Vault resumes behind the same refusing account-home gate. */ export async function prepareCodexAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs, diff --git a/src/main/codex/codex-cli-conversations.ts b/src/main/codex/codex-cli-conversations.ts index 49f1e0c6f6e..29316254efa 100644 --- a/src/main/codex/codex-cli-conversations.ts +++ b/src/main/codex/codex-cli-conversations.ts @@ -10,6 +10,7 @@ import { import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' const CODEX_TRANSCRIPT_IMPORT = directoryTranscriptImport({ + conversationStore: ({ accountHomePath }) => join(accountHomePath, 'sessions'), accountHomeCandidates: ({ settings, selectedAccountHomePath }) => [ selectedAccountHomePath, ...(settings.codexManagedAccounts ?? []).map((account) => account.managedHomePath), @@ -17,10 +18,8 @@ const CODEX_TRANSCRIPT_IMPORT = directoryTranscriptImport({ getOrcaManagedCodexHomePath(), getSystemCodexHomePath() ], - findTranscript: ({ providerSessionId, accountHomePath }) => - resolveSessionFilePath('codex', providerSessionId, { - codexSessionsDirs: [join(accountHomePath, 'sessions')] - }) + findTranscript: ({ providerSessionId, store, strictErrors }) => + resolveSessionFilePath('codex', providerSessionId, { codexSessionsDirs: [store], strictErrors }) }) const CODEX_SESSION_HISTORY: StructuredAgentSessionHistory = { diff --git a/src/main/grok/grok-transcript-import.ts b/src/main/grok/grok-transcript-import.ts index 7590bef9dc3..dab984da73f 100644 --- a/src/main/grok/grok-transcript-import.ts +++ b/src/main/grok/grok-transcript-import.ts @@ -4,19 +4,13 @@ import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { directoryTranscriptImport } from '../native-chat/structured-agent-cli-conversations' export const GROK_TRANSCRIPT_IMPORT = directoryTranscriptImport({ + conversationStore: ({ accountHomePath }) => join(accountHomePath, 'sessions'), // The chat's own home, then the one Session History lists Grok's conversations from. accountHomeCandidates: ({ selectedAccountHomePath }) => [ selectedAccountHomePath, resolveGrokHomeDir() ], - findTranscript: async ({ providerSessionId, accountHomePath, workspaceCwd }) => { - const sessionsDir = join(accountHomePath, 'sessions') - return ( - (await findGrokChatHistoryByCwd({ - sessionId: providerSessionId, - cwd: workspaceCwd, - sessionsDir - })) ?? resolveSessionFilePath('grok', providerSessionId, { grokSessionsDir: sessionsDir }) - ) - } + findTranscript: async ({ providerSessionId, store, workspaceCwd, strictErrors }) => + (await findGrokChatHistoryByCwd({ sessionId: providerSessionId, cwd: workspaceCwd, sessionsDir: store })) ?? + resolveSessionFilePath('grok', providerSessionId, { grokSessionsDir: store, strictErrors }) }) diff --git a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts new file mode 100644 index 00000000000..7cdedc6c12e --- /dev/null +++ b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts @@ -0,0 +1,96 @@ +import { dirname, join } from 'node:path' +import { sessionIdFromFileName } from '../../ai-vault/session-scanner-accumulator' +import type { AgentType } from '../../../shared/agent-status-types' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import { readNodeFileWithinLimit } from '../../../shared/node-bounded-file-reader' +import { asRecord, extractString, parseJsonObject } from '../../ai-vault/session-scanner-values' +import { readOpenCodeTranscriptSignalViaWorker } from '../../ai-vault/session-scanner-opencode-sqlite-worker-spawn' +import { samePiDirectory } from '../../pi/rpc-launch-resolution' +import { admitTranscriptPath } from '../transcript-path-admission' + +export type AdoptedTranscriptSource = { providerSessionId: string; workspaceCwd: string } + +export function assertAdoptedSourceIdentity( + source: AdoptedTranscriptSource, + actual: { sessionId: string | null; cwd: string | null }, + requireCwd = true +): void { + if (actual.sessionId !== source.providerSessionId) { + throw new Error('The saved conversation does not match the requested provider identity.') + } + if ( + requireCwd && + (!actual.cwd || + normalizeRuntimePathForComparison(actual.cwd.trim()) !== + normalizeRuntimePathForComparison(source.workspaceCwd.trim())) + ) { + throw new Error('This conversation belongs to another directory.') + } +} + +/** Metadata is observed from the same lines that become the imported journal. */ +export function adoptedTranscriptSourceObserver(input: { + agent: AgentType + filePath: string + source: AdoptedTranscriptSource + maxSourceBytes: number +}) { + let sessionId: string | null = + input.agent === 'claude' || input.agent === 'codex' ? sessionIdFromFileName(input.filePath) : null + let cwd: string | null = null + let sawCodexMetadata = false + return { + observe(line: string) { + const record = parseJsonObject(line) + if (!record) { + return + } + if (input.agent === 'claude') { + sessionId = extractString(record.sessionId) ?? sessionId + cwd ??= extractString(record.cwd) + } else if (input.agent === 'codex' && record.type === 'session_meta' && !sawCodexMetadata) { + sawCodexMetadata = true + const payload = asRecord(record.payload) + sessionId = extractString(payload?.id) ?? sessionId + } else if ((input.agent === 'omp' || input.agent === 'pi') && record.type === 'session') { + sessionId = extractString(record.id) + cwd = extractString(record.cwd) + } + }, + async validate() { + if (input.agent === 'grok') { + const summaryPath = join(dirname(input.filePath), 'summary.json') + if (!(await admitTranscriptPath(dirname(input.filePath), summaryPath))) { + throw new Error('The conversation summary is outside its storage directory.') + } + const { buffer } = await readNodeFileWithinLimit(summaryPath, input.maxSourceBytes, { + regularFileOnly: true + }) + const summary = asRecord(asRecord(JSON.parse(buffer.toString('utf8')))?.info) + sessionId = extractString(summary?.id) ?? sessionIdFromFileName(dirname(input.filePath)) + cwd = extractString(summary?.cwd) + } + if (input.agent === 'pi' && sessionIdFromFileName(input.filePath) !== sessionId) { + throw new Error('The saved conversation header does not match its file identity.') + } + if (input.agent === 'pi' && cwd && (await samePiDirectory(cwd, input.source.workspaceCwd))) { + cwd = input.source.workspaceCwd + } + assertAdoptedSourceIdentity(input.source, { sessionId, cwd }, input.agent !== 'codex') + } + } +} + +export async function validateAdoptedOpenCodeSource( + dbPath: string, + source: AdoptedTranscriptSource +): Promise { + const signal = await readOpenCodeTranscriptSignalViaWorker({ + dbPath, + sessionId: source.providerSessionId + }) + assertAdoptedSourceIdentity(source, { + sessionId: signal ? source.providerSessionId : null, + cwd: signal?.cwd ?? null + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 31f9671dcee..80ad7ab6bfd 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -10,6 +10,7 @@ // never be spliced into a sequence space that a structured session is also // writing; a later structured resume rolls the epoch again and rebuilds. +import { adoptedTranscriptSourceObserver, validateAdoptedOpenCodeSource, type AdoptedTranscriptSource } from './adopted-transcript-source' import { createReadStream } from 'node:fs' import type { AgentType } from '../../../shared/agent-status-types' import type { @@ -31,6 +32,7 @@ import { readOpenCodeNativeChatTranscriptFull } from '../transcript-opencode' import { boundSubagentEntryId } from '../subagent-entry-id-bounds' import { createLegacyIdentityTracker } from './journal-legacy-identity' import { createPiTranscriptCollector } from './journal-pi-active-branch' +import { createNewestItemWindow } from './journal-newest-item-window' import type { JournalReplacementItem } from './journal-epoch-replacement' import { boundInlineText, @@ -43,6 +45,7 @@ import type { AgentSessionJournal } from './journal-store' export type LegacyImportOptions = ResolveSessionFileOptions & { /** Resolve directly to this file, skipping path discovery. */ filePath?: string + source?: AdoptedTranscriptSource limits?: JournalPayloadLimits decodedMessageIdentities?: true } @@ -118,6 +121,7 @@ export async function prepareLegacyTranscriptImport(input: { transcriptAgent, agent: input.agent, sessionId: input.sessionId, + source: options.source, decodedMessageIdentities: options.decodedMessageIdentities, limits }) @@ -136,6 +140,16 @@ async function prepareOpenCodeImport(input: { limits: JournalPayloadLimits }): Promise { const dbPath = input.options?.filePath + if (input.options?.source) { + if (!dbPath) { + return { ok: false, error: 'The conversation database is unavailable.' } + } + try { + await validateAdoptedOpenCodeSource(dbPath, input.options.source) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + } const read = await readOpenCodeNativeChatTranscriptFull( input.sessionId, dbPath ? { resolveDbPath: async () => dbPath } : {} @@ -198,8 +212,13 @@ async function decodeReplacementItems(input: { agent: AgentType sessionId: string decodedMessageIdentities?: true + source?: AdoptedTranscriptSource limits: JournalPayloadLimits }): Promise<{ items: JournalReplacementItem[]; consumedBytes: number }> { + const source = input.source ? adoptedTranscriptSourceObserver({ + agent: input.agent, filePath: input.filePath, source: input.source, + maxSourceBytes: MAX_LEGACY_IMPORT_RECORD_BYTES + }) : null const tracker = createLegacyIdentityTracker({ transcriptAgent: input.transcriptAgent, agent: input.agent, @@ -220,6 +239,7 @@ async function decodeReplacementItems(input: { input.filePath, 0, (line, fallbackId) => { + source?.observe(line) const currentLineIndex = lineIndex lineIndex += 1 if (pi) { @@ -247,6 +267,9 @@ async function decodeReplacementItems(input: { true, MAX_LEGACY_IMPORT_RECORD_BYTES ) + if (consumedBytes > 0) { + await source?.validate() + } if (pi) { await pi.project(retained.push) } @@ -256,34 +279,6 @@ async function decodeReplacementItems(input: { } } -/** The newest complete items that fit `maxBytes`; the latest item is always kept. */ -function createNewestItemWindow(maxBytes: number): { - push(item: JournalReplacementItem): void - items(): JournalReplacementItem[] -} { - let entries: ({ item: JournalReplacementItem; bytes: number } | undefined)[] = [] - let head = 0 - let bytes = 0 - return { - push(item) { - const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') - entries.push({ item, bytes: itemBytes }) - bytes += itemBytes - while (bytes > maxBytes && head < entries.length - 1) { - bytes -= entries[head]?.bytes ?? 0 - entries[head] = undefined - head += 1 - } - // Drop evicted entries in batches so a long source stays linear. - if (head > 1024 && head * 2 > entries.length) { - entries = entries.slice(head) - head = 0 - } - }, - items: () => entries.slice(head).flatMap((entry) => (entry ? [entry.item] : [])) - } -} - /** * A message whose only content is a tool invocation becomes a tool-call item so * the reducer renders it as one. Everything else stays a message item with its diff --git a/src/main/native-chat/agent-session-journal/journal-newest-item-window.ts b/src/main/native-chat/agent-session-journal/journal-newest-item-window.ts new file mode 100644 index 00000000000..39b975de2ee --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-newest-item-window.ts @@ -0,0 +1,29 @@ +import type { JournalReplacementItem } from './journal-epoch-replacement' + +/** The newest complete items that fit `maxBytes`; the latest item is always kept. */ +export function createNewestItemWindow(maxBytes: number): { + push(item: JournalReplacementItem): void + items(): JournalReplacementItem[] +} { + let entries: ({ item: JournalReplacementItem; bytes: number } | undefined)[] = [] + let head = 0 + let bytes = 0 + return { + push(item) { + const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') + entries.push({ item, bytes: itemBytes }) + bytes += itemBytes + while (bytes > maxBytes && head < entries.length - 1) { + bytes -= entries[head]?.bytes ?? 0 + entries[head] = undefined + head += 1 + } + // Drop evicted entries in batches so a long source stays linear. + if (head > 1024 && head * 2 > entries.length) { + entries = entries.slice(head) + head = 0 + } + }, + items: () => entries.slice(head).flatMap((entry) => (entry ? [entry.item] : [])) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index 376b532770f..7c6c1b5c17e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -50,7 +50,7 @@ async function readAdoptedTranscript( const prepared = await prepareLegacyTranscriptImport({ agent: params.agent, sessionId: adopt.providerHandle.nativeId, - options: { filePath: adopt.transcriptPath } + options: { filePath: adopt.transcriptPath, source: adopt.source } }) if (!prepared.ok) { throw new Error(prepared.error) @@ -111,7 +111,7 @@ async function applyAdoptedTranscript( agent: params.agent, sessionId: adopt.providerHandle.nativeId, fence: record.lease.runtimeFence, - options: { filePath: adopt.transcriptPath } + options: { filePath: adopt.transcriptPath, source: adopt.source } }) if (!imported.ok) { throw new Error(imported.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index c0ce0923e43..756d0fc9558 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -81,6 +81,7 @@ export type AgentSessionAttachParams = { providerHandle: AgentSessionProviderHandle /** Omitted only when the exact committed operation replays an already-imported journal. */ transcriptPath?: string + source?: { providerSessionId: string; workspaceCwd: string } } } diff --git a/src/main/native-chat/codex-adoption-preparation.ts b/src/main/native-chat/codex-adoption-preparation.ts new file mode 100644 index 00000000000..0e86904abdd --- /dev/null +++ b/src/main/native-chat/codex-adoption-preparation.ts @@ -0,0 +1,64 @@ +import { stat } from 'node:fs/promises' +import { join } from 'node:path' +import { + isLegacyAgentSessionAccountHome, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import type { NativeCodexResumePreparation } from '../codex/codex-ai-vault-session-resume' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import type { StructuredAgentSessionAdoption } from './structured-agent-session-history-adoption' +import { admitTranscriptPath } from './transcript-path-admission' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' + +/** Use the existing host preparation before pinning credentials and importing history. */ +export async function prepareCodexAdoption( + source: StructuredAgentSessionAdoption, + providerSessionId: string, + prepare: NativeCodexResumePreparation | undefined, + recognizedHomes: readonly AgentSessionAccountHome[] +): Promise { + if (!prepare || !isLegacyAgentSessionAccountHome(source.accountHome)) { + return source + } + const nativeSystemHomePath = getSystemCodexHomePath() + const result = await prepare({ + agent: 'codex', + sessionId: providerSessionId, + filePath: source.transcriptPath, + codexHome: source.accountHome.path, + executionHostId: 'local' + }, nativeSystemHomePath) + const home = result.useRealCodexHome ? nativeSystemHomePath : result.substituteCodexHome + if ( + !home || + normalizeRuntimePathForComparison(home) === + normalizeRuntimePathForComparison(source.accountHome.path) + ) { + return source + } + if ( + !result.useRealCodexHome && + !recognizedHomes.some( + (candidate) => + isLegacyAgentSessionAccountHome(candidate) && + normalizeRuntimePathForComparison(candidate.path) === normalizeRuntimePathForComparison(home) + ) + ) { + throw new Error('The prepared conversation account is not recognized on this host.') + } + const original = await admitTranscriptPath( + join(source.accountHome.path, 'sessions'), + source.transcriptPath + ) + if (!original?.relativePath) { + throw new Error('The conversation path could not be verified.') + } + const prepared = await admitTranscriptPath( + join(home, 'sessions'), + join(home, 'sessions', original.relativePath) + ) + if (!prepared || !(await stat(prepared.path)).isFile()) { + throw new Error('The prepared conversation history is unavailable.') + } + return { accountHome: { variable: 'CODEX_HOME', path: home }, transcriptPath: prepared.path } +} diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index d7f674e6eae..49d4ab5869e 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -63,6 +63,8 @@ function grokSessionsDir(): string { } export type ResolveSessionFileOptions = { + /** Execution admission must distinguish an unreadable tree from an absent source. */ + strictErrors?: boolean /** Override the Claude projects root (used by tests / isolated scans). */ claudeProjectsDir?: string /** Override the Codex sessions roots, searched in order (tests / isolated @@ -177,7 +179,8 @@ async function resolveSessionFileById( const override = options.claudeProjectsDir return resolveTiered('claude', trimmedId, override ? [override] : claudeProjectsDirs(), { wslTier: !override, - signal + signal, + strictErrors: options.strictErrors }) } if (transcriptAgent === 'codex') { @@ -185,14 +188,16 @@ async function resolveSessionFileById( return resolveTiered('codex', trimmedId, override ?? codexSessionsDirs(), { wslTier: !override, ...(override ? {} : { accountDirs: codexAccountSessionsDirs }), - signal + signal, + strictErrors: options.strictErrors }) } if (transcriptAgent === 'grok') { const override = options.grokSessionsDir return resolveTiered('grok', trimmedId, [override ?? grokSessionsDir()], { wslTier: !override, - signal + signal, + strictErrors: options.strictErrors }) } if (transcriptAgent === 'omp') { @@ -200,7 +205,8 @@ async function resolveSessionFileById( const root = resolveOmpSessionsDir({ sessionsDir: override }) return resolveTiered('omp', trimmedId, root ? [root] : [], { wslTier: override === undefined, - signal + signal, + strictErrors: options.strictErrors }) } // Why: a new transcript agent must pick its own resolver. Falling through to @@ -218,15 +224,15 @@ async function resolveTiered( agent: SessionIdLookupAgent, sessionId: string, hostDirs: readonly string[], - options: { wslTier: boolean; accountDirs?: () => readonly string[]; signal?: AbortSignal } + options: { wslTier: boolean; accountDirs?: () => readonly string[]; signal?: AbortSignal; strictErrors?: boolean } ): Promise { const { signal } = options - const hit = await findFirstInDirs(agent, sessionId, hostDirs, signal) + const hit = await findFirstInDirs(agent, sessionId, hostDirs, signal, options.strictErrors) if (hit) { return hit } const accountDirs = (options.accountDirs?.() ?? []).filter((dir) => !hostDirs.includes(dir)) - const accountHit = await findFirstInDirs(agent, sessionId, accountDirs, signal) + const accountHit = await findFirstInDirs(agent, sessionId, accountDirs, signal, options.strictErrors) if (accountHit || !options.wslTier) { return accountHit } @@ -235,27 +241,31 @@ async function resolveTiered( // user left stopped. Only pay that after this host's own roots miss (#10326, #13789). const wslDirs = (await wslAgentSessionsDirs(agent)).filter((dir) => !hostDirs.includes(dir)) signal?.throwIfAborted() - return findFirstInDirs(agent, sessionId, wslDirs, signal) + return findFirstInDirs(agent, sessionId, wslDirs, signal, options.strictErrors) } async function findFirstInDirs( agent: SessionIdLookupAgent, sessionId: string, dirs: readonly string[], - signal?: AbortSignal + signal?: AbortSignal, + strictErrors?: boolean ): Promise { let unavailable: WslTranscriptFsError | undefined for (const dir of dirs) { try { const hit = isWslUncPath(dir) ? await findWslSessionPath(agent, dir, sessionId, signal) - : await findHostSessionFile(agent, dir, sessionId, signal) + : await findHostSessionFile(agent, dir, sessionId, signal, strictErrors) if (hit) { return hit } } catch (error) { // A caller abort that races the refusal stays authoritative. signal?.throwIfAborted() + if (strictErrors) { + throw error + } // Why: one stalled distro must not hide another root's hit. unavailable = wslTranscriptFsRefusal(error) } @@ -271,9 +281,10 @@ async function findHostSessionFile( agent: SessionIdLookupAgent, root: string, sessionId: string, - signal?: AbortSignal + signal?: AbortSignal, + strictErrors?: boolean ): Promise { - if (agent === 'grok') { + if (agent === 'grok' && !strictErrors) { // Why: Native Chat runs on the main thread; use the bounded async direct-layout // lookup instead of a recursive full-tree scan. signal?.throwIfAborted() @@ -291,7 +302,8 @@ async function findHostSessionFile( directoryPredicate: directoryPredicate ? (name, depth) => directoryPredicate(name, depth, wanted) : undefined, - signal + signal, + strictErrors }) return files[0] ?? null } diff --git a/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts index dead0bb0cf0..4edef884235 100644 --- a/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts +++ b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -21,6 +21,7 @@ import { writeOpenCodeSqliteDatabase } from '../ai-vault/session-scanner-opencod import { resolveAcpLaunchInvocation } from '../acp/acp-structured-launch-resolution' import { acpLaunchSpecFor } from '../acp/acp-launch-specs' import { resolveOpenCodeDatabasePath } from '../opencode/opencode-data-directory' +import * as hostReadableTranscriptPath from './host-readable-transcript-path' // The OpenCode reader's worker, run in process. vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async (importOriginal) => { @@ -38,6 +39,10 @@ vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async (impor const SESSION_ID = '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' let root: string | null = null +async function createAdoptionTestDirectory(prefix: string): Promise { + return realpath(await mkdtemp(prefix)) +} + beforeEach(() => { // Grok and Pi also look where Session History lists them from; keep that off the real home. vi.stubEnv('GROK_HOME', join(tmpdir(), 'orca-adopt-no-grok-home')) @@ -168,7 +173,7 @@ describe('which chats can take over a CLI conversation', () => { it.each([false, true])( 'finds a Grok cwd beyond the generic cap (long path: %s)', async (longPath) => { - root = await mkdtemp(join(tmpdir(), 'orca-grok-adopt-cwd-')) + root = await createAdoptionTestDirectory(join(tmpdir(), 'orca-grok-adopt-cwd-')) const workspaceCwd = join(root, longPath ? '中'.repeat(80) : 'workspace') const recorded = await RECORDED.grok(root, 'CURRENT-WORKSPACE') const group = grokEncodedCwdDirName(workspaceCwd) @@ -208,7 +213,7 @@ it.each([false, true])( ) it('looks up OMP history under the configured launch XDG and profile root', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-omp-adopt-env-')) + root = await createAdoptionTestDirectory(join(tmpdir(), 'orca-omp-adopt-env-')) const ambientData = join(root, 'ambient-data') const launchData = join(root, 'launch-data') const profile = 'work' @@ -244,7 +249,7 @@ it('looks up OMP history under the configured launch XDG and profile root', asyn }) it('uses the configured OMP profile when the ambient profile names another session', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-omp-adopt-profile-')) + root = await createAdoptionTestDirectory(join(tmpdir(), 'orca-omp-adopt-profile-')) const xdgData = join(root, 'data') const ambientProfile = 'ambient' const launchProfile = 'configured' @@ -279,7 +284,7 @@ it('uses the configured OMP profile when the ambient profile names another sessi it.each(['win32', 'linux', 'darwin'] as const)( 'follows the child home precedence for a named OMP profile on %s', async (platform) => { - root = await mkdtemp(join(tmpdir(), 'orca-omp-adopt-home-')) + root = await createAdoptionTestDirectory(join(tmpdir(), 'orca-omp-adopt-home-')) const home = join(root, 'home') const userProfile = join(root, 'user-profile') const profile = 'work' @@ -288,6 +293,12 @@ it.each(['win32', 'linux', 'darwin'] as const)( join(userProfile, '.omp', 'profiles', profile, 'agent'), 'USERPROFILE' ) + const nativePlatform = process.platform + const needsWslResolution = hostReadableTranscriptPath.needsWslHostResolution + // Simulated home precedence still reads fixture paths native to the actual host. + vi.spyOn(hostReadableTranscriptPath, 'needsWslHostResolution').mockImplementation((path) => + needsWslResolution(path, nativePlatform) + ) vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) const adoption = await adopt( @@ -306,7 +317,7 @@ it.each(['win32', 'linux', 'darwin'] as const)( ) it('refuses OMP history from another workspace without creating a blank chat', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-omp-adopt-other-cwd-')) + root = await createAdoptionTestDirectory(join(tmpdir(), 'orca-omp-adopt-other-cwd-')) await RECORDED.omp(root, 'OTHER-WORKSPACE') await expect( adopt( @@ -323,7 +334,7 @@ it('refuses OMP history from another workspace without creating a blank chat', a describe.each(['grok', 'omp', 'pi'] as const)('adopting a %s CLI conversation', (agent) => { it('finds the conversation under the chat account and imports its history', async () => { - root = await mkdtemp(join(tmpdir(), `orca-${agent}-adopt-`)) + root = await createAdoptionTestDirectory(join(tmpdir(), `orca-${agent}-adopt-`)) const recorded = await RECORDED[agent](root, `ORCA-${agent}-ADOPT`) if (agent === 'pi') { vi.stubEnv('PI_CODING_AGENT_DIR', root) @@ -348,7 +359,7 @@ describe.each(['grok', 'omp', 'pi'] as const)('adopting a %s CLI conversation', }) it('refuses a conversation the account does not hold, rather than opening a blank chat', async () => { - root = await mkdtemp(join(tmpdir(), `orca-${agent}-adopt-missing-`)) + root = await createAdoptionTestDirectory(join(tmpdir(), `orca-${agent}-adopt-missing-`)) await expect( adopt(agent, { variable: ACCOUNT_VARIABLE[agent], path: root }) ).rejects.toMatchObject({ @@ -359,7 +370,7 @@ describe.each(['grok', 'omp', 'pi'] as const)('adopting a %s CLI conversation', describe('adopting a Pi CLI conversation', () => { it('pins the Session History account when another Pi home has the same id', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-pi-adopt-homes-')) + root = await createAdoptionTestDirectory(join(tmpdir(), 'orca-pi-adopt-homes-')) const selected = join(root, 'selected') const history = join(root, 'history') await RECORDED.pi(selected, 'SELECTED') @@ -386,7 +397,7 @@ describe('adopting an OpenCode CLI conversation', () => { } it('imports from the configured database the resumed OpenCode child opens', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-opencode-configured-adopt-')) + root = await createAdoptionTestDirectory(join(tmpdir(), 'orca-opencode-configured-adopt-')) const defaultDb = openCodeData(root) const configuredDb = join(root, 'data', 'opencode', 'work.db') writeOpenCodeSqliteDatabase(defaultDb, [ diff --git a/src/main/native-chat/structured-agent-cli-conversations.ts b/src/main/native-chat/structured-agent-cli-conversations.ts index 3e84138c0c7..c56e77add99 100644 --- a/src/main/native-chat/structured-agent-cli-conversations.ts +++ b/src/main/native-chat/structured-agent-cli-conversations.ts @@ -1,6 +1,8 @@ // How a structured agent's chats meet the conversations its own CLI records: adopting one into a new // chat, and owning the Session History rows a chat's conversation shows as. +import { isAbsolute } from 'node:path' +import { admitTranscriptPath } from './transcript-path-admission' import { isLegacyAgentSessionAccountHome, type AgentSessionAccountHome @@ -22,33 +24,59 @@ export type StructuredAgentTranscriptImport = { settings: StructuredAgentTranscriptImportSettings selectedAccountHome: AgentSessionAccountHome }) => AgentSessionAccountHome[] - /** Where the transcript is (its file, or OpenCode's database); null when the home lacks it. */ - findTranscript: (input: { - providerSessionId: string + /** Where this account keeps its conversations (a directory, or OpenCode's database); null when + * it has none. Admission and lookup both take this one answer, so they cannot disagree. */ + conversationStore: (input: { accountHome: AgentSessionAccountHome /** The execution host's effective agent environment before account binding. */ launchEnv: NodeJS.ProcessEnv + }) => string | null + /** Admit hook and fallback paths against the account's store. */ + admitTranscriptPath: (input: { store: string; transcriptPath: string }) => Promise + /** Where the transcript is (its file, or OpenCode's database); null when the store lacks it. */ + findTranscript: (input: { + providerSessionId: string + store: string + launchEnv: NodeJS.ProcessEnv workspaceCwd: string + strictErrors?: boolean }) => Promise /** The adopted chat's handle names the transcript's path, not the conversation id (Pi). */ handleNamesTranscript?: true } /** The import of an agent whose account is one directory, named by the chat's own variable. */ +/** A store is an absolute path; anything else is none, so lookup and admission agree on it. */ +export function absoluteConversationStore(path: string | null | undefined): string | null { + const trimmed = path?.trim() + return trimmed && isAbsolute(trimmed) ? trimmed : null +} + export function directoryTranscriptImport(directories: { + conversationStore: (input: { accountHomePath: string; launchEnv: NodeJS.ProcessEnv }) => string | null accountHomeCandidates: (input: { settings: StructuredAgentTranscriptImportSettings selectedAccountHomePath: string }) => string[] + /** Searches only `store`; it never derives a root of its own. */ findTranscript: (input: { providerSessionId: string - accountHomePath: string + store: string launchEnv: NodeJS.ProcessEnv workspaceCwd: string + strictErrors?: boolean }) => Promise handleNamesTranscript?: true }): StructuredAgentTranscriptImport { return { + conversationStore: ({ accountHome, launchEnv }) => + isLegacyAgentSessionAccountHome(accountHome) + ? absoluteConversationStore( + directories.conversationStore({ accountHomePath: accountHome.path, launchEnv }) + ) + : null, + admitTranscriptPath: async ({ store, transcriptPath }) => + (await admitTranscriptPath(store, transcriptPath))?.path ?? null, ...(directories.handleNamesTranscript ? { handleNamesTranscript: true } : {}), accountHomeCandidates: ({ settings, selectedAccountHome }) => isLegacyAgentSessionAccountHome(selectedAccountHome) @@ -58,15 +86,7 @@ export function directoryTranscriptImport(directories: { .filter((path) => path.length > 0) .map((path) => ({ variable: selectedAccountHome.variable, path })) : [], - findTranscript: async ({ providerSessionId, accountHome, launchEnv, workspaceCwd }) => - isLegacyAgentSessionAccountHome(accountHome) - ? directories.findTranscript({ - providerSessionId, - accountHomePath: accountHome.path, - launchEnv, - workspaceCwd - }) - : null + findTranscript: (input) => directories.findTranscript(input) } } diff --git a/src/main/native-chat/structured-agent-cli-source.ts b/src/main/native-chat/structured-agent-cli-source.ts new file mode 100644 index 00000000000..244a84dd315 --- /dev/null +++ b/src/main/native-chat/structured-agent-cli-source.ts @@ -0,0 +1,92 @@ +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { StructuredAgentSessionResumeSource } from '../../shared/structured-agent-session-create' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { + adoptsCliConversations, + CLI_CONVERSATION_ADOPTING_AGENTS +} from '../../shared/structured-agent-cli-conversation-adoption' +import { isWslUncPath } from '../../shared/wsl-paths' +import { needsWslHostResolution } from './host-readable-transcript-path' +import { parseSshTranscriptPath } from './ssh-transcript-path' + +export type StructuredAgentCliSourceEvidence = + | { readonly rows: readonly Readonly[] } + | { readonly error: unknown } + +export function captureStructuredAgentCliSource( + readRows: () => readonly AgentStatusIpcPayload[] +): StructuredAgentCliSourceEvidence { + try { + return Object.freeze({ + rows: Object.freeze( + readRows().map((row) => Object.freeze({ + ...row, + ...(row.providerSession + ? { providerSession: Object.freeze({ ...row.providerSession }) } + : {}) + })) + ) + }) + } catch (error) { + // Capture cannot override an existing owner or committed replay. + return Object.freeze({ error }) + } +} + +export function structuredAgentCliSourcePathFromEvidence(input: { + agent: StructuredAgentId + resumeFrom: StructuredAgentSessionResumeSource + workspaceId: string + evidence?: StructuredAgentCliSourceEvidence +}): string | undefined { + const evidence = input.evidence + if (input.resumeFrom.cliSource && evidence && 'error' in evidence) { + throw evidence.error + } + return structuredAgentCliSourcePath({ + ...input, + rows: evidence && 'rows' in evidence ? evidence.rows : [] + }) +} + +/** A client names a pane; only this host supplies its provider path. */ +export function structuredAgentCliSourcePath(input: { + agent: StructuredAgentId + resumeFrom: StructuredAgentSessionResumeSource + workspaceId: string + rows: readonly AgentStatusIpcPayload[] +}): string | undefined { + const source = input.resumeFrom.cliSource + if (!source) { + return undefined + } + const row = input.rows.find( + (candidate) => + source.workspaceId === input.workspaceId && + candidate.worktreeId === source.workspaceId && + candidate.paneKey === source.paneKey && + !candidate.connectionId && + !candidate.structuredHost && + adoptsCliConversations(candidate.agentType) && + CLI_CONVERSATION_ADOPTING_AGENTS[candidate.agentType] === input.agent && + candidate.providerSession?.id === input.resumeFrom.providerSessionId + ) + if (!row) { + throw agentSessionRefusalError( + 'agent_session_identity_required', + { reason: 'transcriptUnreadable' }, + 'The source conversation is no longer available.' + ) + } + const path = row.providerSession?.transcriptPath?.trim() + if ( + path && + (isWslUncPath(path) || needsWslHostResolution(path) || parseSshTranscriptPath(path)) + ) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + return path || undefined +} diff --git a/src/main/native-chat/structured-agent-session-source-admission.test.ts b/src/main/native-chat/structured-agent-session-source-admission.test.ts new file mode 100644 index 00000000000..3474e02b1fc --- /dev/null +++ b/src/main/native-chat/structured-agent-session-source-admission.test.ts @@ -0,0 +1,201 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { CLAUDE_CLI_CONVERSATIONS } from '../claude/claude-cli-conversations' +import { resolveStructuredAgentSessionAdoptionForCreate } from '../runtime/structured-agent-session-create-adoption' +import { structuredAgentCliSourcePath } from './structured-agent-cli-source' +import { prepareAdoptedTranscript } from './agent-session-wire/structured-agent-session-adopted-import' +import type { AgentSessionAttachParams } from './agent-session-wire/structured-agent-session-attach' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +import { prepareLegacyTranscriptImport } from './agent-session-journal/journal-legacy-import' +import { prepareCodexAdoption } from './codex-adoption-preparation' +import { codexAiVaultSessionResumePreparation } from '../codex/codex-ai-vault-session-resume' +import * as codexHomes from '../codex/codex-home-paths' + +let root: string +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-source-admission-'))) + vi.stubEnv('ORCA_USER_DATA_PATH', join(root, 'user-data')) +}) +afterEach(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) +const id = 'provider-session' +async function file(path: string, records: unknown[]) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`) + return path +} +function params(agent: StructuredAgentId, transcriptPath: string): AgentSessionAttachParams { + return { + envelope: { sessionId: 'new-chat', clientOperationId: 'create', expectedRuntimeFence: null, payloadFingerprint: '' }, + location: { executionHostId: 'local', workspaceId: 'folder', workspaceKind: 'folder', wslDistro: null }, + agent, provider: agent, runtimeKind: 'native', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, + adopt: { providerHandle: { agent, transport: 'terminal-transcript', nativeId: id }, transcriptPath, + source: { providerSessionId: id, workspaceCwd: root } } + } +} + +it('creates from the host hook path when Claude filename differs, then checks actual recorded cwd', async () => { + const transcriptPath = await file(join(root, 'projects', 'folder', 'different-uuid.jsonl'), [ + { type: 'user', uuid: 'first', sessionId: id, cwd: root, message: { role: 'user', content: 'hello' } }, + { type: 'assistant', uuid: 'reply', sessionId: id, cwd: '/later-drift', message: { role: 'assistant', content: 'reply' } } + ]) + const sourcePath = () => structuredAgentCliSourcePath({ + agent: 'claude', workspaceId: 'folder', resumeFrom: { providerSessionId: id, + cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } }, + rows: [{ paneKey: 'tab:leaf', worktreeId: 'folder', agentType: 'claude', state: 'waiting', prompt: '', + connectionId: null, receivedAt: 1, stateStartedAt: 1, + providerSession: { key: 'session_id', id, transcriptPath } }] + }) + const importer = CLAUDE_CLI_CONVERSATIONS.transcriptImport! + const selectedAccountHome = { variable: 'CLAUDE_CONFIG_DIR', path: root } + vi.spyOn(importer, 'accountHomeCandidates').mockReturnValue([ + selectedAccountHome, { ...selectedAccountHome, path: join(root, 'locked-account') } + ]) + const admit = importer.admitTranscriptPath.bind(importer) + const admission = vi.spyOn(importer, 'admitTranscriptPath').mockImplementation((args) => { + if (args.store !== join(root, 'projects')) { + throw Object.assign(new Error('Account inaccessible'), { code: 'EACCES' }) + } + return admit(args) + }) + const find = vi.spyOn(importer, 'findTranscript') + const adoption = await resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, + selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, + launchEnv: {}, workspaceCwd: root, resolveTranscriptPath: sourcePath + }) + expect(adoption.transcriptPath).toBe(transcriptPath) + expect(admission).toHaveBeenCalledOnce() + expect(find).not.toHaveBeenCalled() + expect(await prepareAdoptedTranscript(params('claude', adoption.transcriptPath))).toMatchObject({ ok: true }) + const wrongDirectory = params('claude', adoption.transcriptPath) + if (wrongDirectory.adopt?.source) { + wrongDirectory.adopt.source.workspaceCwd = '/another-folder' + } + expect(await prepareAdoptedTranscript(wrongDirectory)).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) + + const foreign = await file(join(root, 'foreign.jsonl'), []) + const escaped = process.platform === 'win32' ? foreign : join(root, 'projects', 'escaped.jsonl') + if (process.platform !== 'win32') { + await symlink(foreign, escaped) + } + await expect(resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, + selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, + launchEnv: {}, workspaceCwd: root, resolveTranscriptPath: () => escaped + })).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) + admission.mockRejectedValueOnce(Object.assign(new Error('Selected account inaccessible'), { code: 'EACCES' })) + await expect(resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, + selectedAccountHome, launchEnv: {}, workspaceCwd: root, resolveTranscriptPath: sourcePath + })).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) + expect(find).not.toHaveBeenCalled() +}) + +it('judges the lookup and its re-admission against one store resolved once per account', async () => { + const transcriptPath = await file(join(root, 'projects', 'folder', `${id}.jsonl`), []) + const importer = CLAUDE_CLI_CONVERSATIONS.transcriptImport! + const selectedAccountHome = { variable: 'CLAUDE_CONFIG_DIR', path: root } + vi.spyOn(importer, 'accountHomeCandidates').mockReturnValue([selectedAccountHome]) + const store = vi.spyOn(importer, 'conversationStore') + const find = vi.spyOn(importer, 'findTranscript') + const admission = vi.spyOn(importer, 'admitTranscriptPath') + const adoption = await resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, + selectedAccountHome, launchEnv: {}, workspaceCwd: root + }) + expect(adoption.transcriptPath).toBe(transcriptPath) + expect(store).toHaveBeenCalledOnce() + expect(find.mock.calls[0]?.[0].store).toBe(join(root, 'projects')) + expect(admission.mock.calls[0]?.[0].store).toBe(join(root, 'projects')) +}) + +it.skipIf(process.platform === 'win32')('reports a not-yet-saved conversation under a linked account folder as unsaved', async () => { + const linked = join(root, 'linked-account') + await mkdir(join(root, 'projects'), { recursive: true }) + await symlink(root, linked) + await expect(resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: CLAUDE_CLI_CONVERSATIONS.transcriptImport!, + providerSessionId: id, selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: linked }, + launchEnv: {}, workspaceCwd: root, + resolveTranscriptPath: () => join(linked, 'projects', 'folder', `${id}.jsonl`) + })).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptNotFound' } } }) +}) + +it.each(['claude', 'codex', 'grok', 'omp', 'pi'] as const)('retains empty versus unreadable source facts for %s', async (agent) => { + const transcript = join(root, `${id}.jsonl`) + await writeFile(transcript, '') + expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptNotFound' } } }) + for (const content of ['{broken json}\n', '{"type":"future_record"}\n']) { + await writeFile(transcript, content) + expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) + } +}) + +it('checks canonical provider metadata during the existing import, including Codex copied parent metadata', async () => { + const codex = await file(join(root, `${id}.jsonl`), [ + { type: 'session_meta', payload: { id } }, + { type: 'session_meta', payload: { id: 'copied-parent' } }, + { type: 'response_item', payload: { type: 'message', role: 'user', content: 'hello' } } + ]) + expect(await prepareAdoptedTranscript(params('codex', codex))).toMatchObject({ ok: true }) + for (const agent of ['omp', 'pi'] as const) { + const transcript = await file(join(root, `${id}.jsonl`), [ + { type: 'session', id, cwd: root }, + { type: 'message', id: 'turn', message: { role: 'user', content: [{ type: 'text', text: 'hello' }] } } + ]) + expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: true }) + await file(transcript, [{ type: 'session', id: 'foreign', cwd: root }]) + expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: false }) + } + const grok = await file(join(root, id, 'chat_history.jsonl'), [{ type: 'user', content: 'hello' }]) + await file(join(root, id, 'summary.json'), [{ info: { id, cwd: root } }]) + expect(await prepareAdoptedTranscript(params('grok', grok))).toMatchObject({ ok: true }) + await rm(join(root, id, 'summary.json')) + expect(await prepareAdoptedTranscript(params('grok', grok))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) + await file(codex, [{ type: 'session_meta', payload: { id } }]) + expect(await prepareLegacyTranscriptImport({ agent: 'codex', sessionId: id, + options: { filePath: codex } })).toMatchObject({ ok: true, items: [], sourceEmpty: false }) + expect(await prepareAdoptedTranscript(params('codex', codex))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) +}) + +it('materializes legacy Codex into the system home and pins a verified selected account alias', async () => { + const legacy = codexHomes.getOrcaManagedCodexHomePath() + const system = join(root, 'system') + const historyOnly = join(root, 'history-only') + const selected = join(root, 'codex-accounts', 'selected', 'home') + const suffix = join('sessions', '2026', '10', '15', `rollout-stamp-${id}.jsonl`) + const transcriptPath = await file(join(legacy, suffix), [{ type: 'session_meta', payload: { id } }]) + vi.spyOn(codexHomes, 'getSystemCodexHomePath').mockReturnValue(system) + const source = { accountHome: { variable: 'CODEX_HOME' as const, path: legacy }, transcriptPath } + const prepare = codexAiVaultSessionResumePreparation(() => ({ + runtimeHome: { + isHostSystemDefaultRealHomeSelected: () => true, + resolveSelectedHostAccountCodexHomePathForResume: () => null, + getHostCodexHomePathsForSessionDiscovery: () => [legacy] + }, + systemCodexHomePath: historyOnly, preparePinnedLaunchHome: async () => {} + })) + const migrated = await prepareCodexAdoption(source, id, prepare, [source.accountHome]) + expect(migrated).toEqual({ accountHome: { variable: 'CODEX_HOME', path: system }, transcriptPath: join(system, suffix) }) + await expect(prepareLegacyTranscriptImport({ agent: 'codex', sessionId: id, + options: { filePath: join(historyOnly, suffix) } })).resolves.toMatchObject({ ok: false }) + await prepare({ agent: 'codex', sessionId: id, filePath: transcriptPath, codexHome: legacy, executionHostId: 'local' }) + expect(await prepareLegacyTranscriptImport({ agent: 'codex', sessionId: id, + options: { filePath: join(historyOnly, suffix) } })).toMatchObject({ ok: true }) + await file(join(selected, suffix), [{ type: 'session_meta', payload: { id } }]) + expect(await prepareCodexAdoption(migrated, id, async () => ({ useRealCodexHome: false, substituteCodexHome: selected }), + [{ variable: 'CODEX_HOME', path: selected }])).toEqual({ accountHome: { variable: 'CODEX_HOME', path: selected }, transcriptPath: join(selected, suffix) }) + await expect(prepareCodexAdoption(source, id, async () => { throw new Error('EACCES selected account') }, + [source.accountHome])).rejects.toThrow('EACCES selected account') +}) diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts index 6e500b7b974..79d05fa6914 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts @@ -503,8 +503,10 @@ describe('readOpenCodeTranscriptSignal', () => { insertPart(db, { id: 'prt-1', messageId: 'msg-1', time: 10, data: { type: 'text', text: 'a' } }) insertPart(db, { id: 'prt-2', messageId: 'msg-2', time: 20, data: { type: 'text', text: 'b' } }) + db.exec("ALTER TABLE session ADD COLUMN directory TEXT; UPDATE session SET directory = '/folder'") const signal = readOpenCodeTranscriptSignal(path, 'ses-1') expect(signal).toEqual({ + cwd: '/folder', messageCount: 2, partCount: 2, maxMessageRowId: 2, diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index 99b1c0d1577..3f207216698 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -7,7 +7,8 @@ import { } from './transcript-opencode2-sqlite' import type { NativeChatMessage } from '../../shared/native-chat-types' import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit' -import { extractString, parseJsonObject } from '../ai-vault/session-scanner-values' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import { asRecord, extractString, parseJsonObject } from '../ai-vault/session-scanner-values' import type SyncDatabase from '../sqlite/sync-database' type BindValue = SyncDatabase.BindValue @@ -31,6 +32,7 @@ export type OpenCodeTranscriptPage = { } export type OpenCodeTranscriptSignal = { + cwd?: string | null messageCount: number partCount: number maxMessageRowId: number @@ -40,10 +42,10 @@ export type OpenCodeTranscriptSignal = { const PART_ID_BATCH = 100 function sessionExists(db: SyncDatabase, sessionId: string): boolean { - const table = rowsOf<{ name: string }>( - db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'") + return ( + tableExists(db, 'session') && + db.prepare('SELECT 1 FROM session WHERE id = ?').get(sessionId) != null ) - return table.length > 0 && db.prepare('SELECT 1 FROM session WHERE id = ?').get(sessionId) != null } function rowsOf(statement: SqliteStatement, ...params: BindValue[]): T[] { @@ -113,6 +115,11 @@ export function readOpenCodeTranscriptSignal( ...(agent === 'zcode' ? [sessionId] : []) ) return { + cwd: columnExists(db, 'session', 'directory') + ? extractString( + asRecord(db.prepare('SELECT directory FROM session WHERE id = ?').get(sessionId))?.directory + ) + : null, messageCount: messageRow?.message_count ?? 0, partCount: partRow?.part_count ?? 0, maxMessageRowId: messageRow?.max_message_rowid ?? 0, @@ -296,10 +303,11 @@ function mapMessageRows( ? [{ message_id: row.id, time_updated: row.time_updated, data: null }] : partList ) + const updated = partList.reduce((max, row) => (row.time_updated > max ? row.time_updated : max), 0) for (const message of messages.toReversed()) { items.push({ rowid: row.message_rowid, - fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`, + fingerprint: `${row.time_updated}:${partList.length}:${updated}`, message: { ...message, transcriptOffset: row.message_rowid, @@ -310,13 +318,3 @@ function mapMessageRows( } return items } - -function maxPartTimeUpdated(partRows: PartRow[]): number { - let max = 0 - for (const partRow of partRows) { - if (partRow.time_updated > max) { - max = partRow.time_updated - } - } - return max -} diff --git a/src/main/native-chat/transcript-opencode2-sqlite.test.ts b/src/main/native-chat/transcript-opencode2-sqlite.test.ts index df36bbc78a1..9637aefbfe3 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.test.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.test.ts @@ -25,7 +25,9 @@ function fixture() { db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, data TEXT, time_created INTEGER, time_updated INTEGER); - INSERT INTO session_v2 VALUES ('session');`) + INSERT INTO session_v2 VALUES ('session'); + ALTER TABLE session_v2 ADD COLUMN directory TEXT; + UPDATE session_v2 SET directory = '/folder';`) const insertMessage = db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)') let sequence = 0 const insert = (id: string, type: string, data: unknown, time = 1) => @@ -41,6 +43,7 @@ describe('OpenCode 2 native transcript', () => { insert('two', 'assistant', { content: [{ type: 'text', text: 'first reply' }] }) insert('three', 'user', { text: 'second prompt' }) insert('last-empty', 'idle', { outcome: 'succeeded' }) + expect(readOpenCodeTranscriptSignal(path, 'session')?.cwd).toBe('/folder') const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 2 }) expect(page?.items.map((item) => item.message.id)).toEqual(['opencode:two', 'opencode:three']) expect(page?.hasMore).toBe(true) diff --git a/src/main/native-chat/transcript-opencode2-sqlite.ts b/src/main/native-chat/transcript-opencode2-sqlite.ts index 1b4f01033e9..4a4c5a490d1 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import type SyncDatabase from '../sqlite/sync-database' import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' -import { asRecord, parseJsonObject } from '../ai-vault/session-scanner-values' +import { asRecord, extractString, parseJsonObject } from '../ai-vault/session-scanner-values' import { opencodeMessages, OPENCODE_TRANSCRIPT_MAX_ROW_BYTES @@ -48,7 +48,11 @@ export function readOpenCode2TranscriptSignal( ) { throw new Error('OpenCode transcript signal is invalid') } + const source = columnExists(db, 'session_v2', 'directory') + ? asRecord(db.prepare('SELECT directory FROM session_v2 WHERE id = ?').get(sessionId)) + : null return { + cwd: extractString(source?.directory), messageCount: row.count, partCount: 0, maxMessageRowId: row.latest, diff --git a/src/main/native-chat/transcript-path-admission.ts b/src/main/native-chat/transcript-path-admission.ts new file mode 100644 index 00000000000..bbf924abca3 --- /dev/null +++ b/src/main/native-chat/transcript-path-admission.ts @@ -0,0 +1,61 @@ +import { lstat, realpath } from 'node:fs/promises' +import { basename, dirname, isAbsolute, relative, resolve } from 'node:path' +import { isDescendantOrEqual, isENOENT } from '../ipc/filesystem-path-containment' +import { isWslUncPath } from '../../shared/wsl-paths' +import { needsWslHostResolution } from './host-readable-transcript-path' + +/** Account containment must survive symlinks and host-native path translation. */ +export async function admitTranscriptPath(root: string, candidate: string) { + if ( + !isAbsolute(root) || + !isAbsolute(candidate) || + isWslUncPath(root) || + isWslUncPath(candidate) || + needsWslHostResolution(candidate) + ) { + return null + } + const [canonicalRoot, canonicalCandidate] = await Promise.all([ + canonicalPath(resolve(root)), + canonicalPath(resolve(candidate)) + ]) + return canonicalRoot && + canonicalCandidate && + isDescendantOrEqual(canonicalCandidate, canonicalRoot) + ? { path: canonicalCandidate, relativePath: relative(canonicalRoot, canonicalCandidate) } + : null +} + +/** A not-yet-written transcript resolves through its deepest existing ancestor, so a linked + * account folder compares equal before the agent's first save. */ +async function canonicalPath(path: string): Promise { + const missing: string[] = [] + let existing = path + for (;;) { + try { + return resolve(await realpath(existing), ...missing) + } catch (error) { + if (!isENOENT(error)) { + throw error + } + const parent = dirname(existing) + // A dangling link gives no evidence of where a later read would land. + if (parent === existing || (await isDanglingLink(existing))) { + return null + } + missing.unshift(basename(existing)) + existing = parent + } + } +} + +async function isDanglingLink(path: string): Promise { + try { + return (await lstat(path)).isSymbolicLink() + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } +} diff --git a/src/main/omp/omp-session-cwd-lookup.test.ts b/src/main/omp/omp-session-cwd-lookup.test.ts index 44a2f72ff39..691275f09fc 100644 --- a/src/main/omp/omp-session-cwd-lookup.test.ts +++ b/src/main/omp/omp-session-cwd-lookup.test.ts @@ -39,19 +39,21 @@ async function record(bucket: string, file = FILE): Promise { return path } -function lookup(cwd = '/repo/orca', homeDir = join(root, 'home'), temp = join(root, 'temp')) { - return OMP_TRANSCRIPT_IMPORT.findTranscript({ +async function lookup(cwd = '/repo/orca', homeDir = join(root, 'home'), temp = join(root, 'temp')) { + const launchEnv = { + OMP_CODING_AGENT_DIR: sessionsDir, + HOME: homeDir, + USERPROFILE: homeDir, + TMPDIR: temp, + TEMP: temp + } + const store = OMP_TRANSCRIPT_IMPORT.conversationStore({ accountHome: { variable: 'PI_CODING_AGENT_DIR', path: join(root, 'account') }, - providerSessionId: ID, - workspaceCwd: cwd, - launchEnv: { - OMP_CODING_AGENT_DIR: sessionsDir, - HOME: homeDir, - USERPROFILE: homeDir, - TMPDIR: temp, - TEMP: temp - } + launchEnv }) + return store + ? OMP_TRANSCRIPT_IMPORT.findTranscript({ providerSessionId: ID, store, workspaceCwd: cwd, launchEnv }) + : null } describe('OMP known-workspace transcript lookup', () => { diff --git a/src/main/omp/omp-session-cwd-lookup.ts b/src/main/omp/omp-session-cwd-lookup.ts index 023fe67292e..18fd11b3c96 100644 --- a/src/main/omp/omp-session-cwd-lookup.ts +++ b/src/main/omp/omp-session-cwd-lookup.ts @@ -54,6 +54,7 @@ export async function findOmpSessionByCwd(input: { cwd: string homeDir: string env: NodeJS.ProcessEnv + strictErrors?: boolean }): Promise { const sessionId = input.sessionId.trim() if (!sessionId || !isAbsolute(input.cwd)) { @@ -80,7 +81,17 @@ export async function findOmpSessionByCwd(input: { return file } } - } catch { + } catch (error) { + if ( + input.strictErrors && + !( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) + ) { + throw error + } // A stale or unreadable bucket must not replace a missing history with a blank chat. } } diff --git a/src/main/omp/omp-transcript-import.ts b/src/main/omp/omp-transcript-import.ts index eb0c4ca1cd3..4404c9671ef 100644 --- a/src/main/omp/omp-transcript-import.ts +++ b/src/main/omp/omp-transcript-import.ts @@ -3,24 +3,24 @@ import { resolveOmpSessionsDir } from '../ai-vault/omp-session-root' import { directoryTranscriptImport } from '../native-chat/structured-agent-cli-conversations' import { findOmpSessionByCwd } from './omp-session-cwd-lookup' +function sessionsRoot(accountHomePath: string, launchEnv: NodeJS.ProcessEnv) { + return resolveOmpSessionsDir({ + env: { ...launchEnv, PI_CODING_AGENT_DIR: accountHomePath }, + homeDir: (process.platform === 'win32' ? launchEnv.USERPROFILE : launchEnv.HOME) || homedir() + }) +} + export const OMP_TRANSCRIPT_IMPORT = directoryTranscriptImport({ + conversationStore: ({ accountHomePath, launchEnv }) => sessionsRoot(accountHomePath, launchEnv), accountHomeCandidates: ({ selectedAccountHomePath }) => [selectedAccountHomePath], // Where OMP itself keeps the account's sessions, which is not always under it (XDG_DATA_HOME). - findTranscript: async ({ providerSessionId, accountHomePath, launchEnv, workspaceCwd }) => { - const homeDir = - (process.platform === 'win32' ? launchEnv.USERPROFILE : launchEnv.HOME) || homedir() - const sessionsDir = resolveOmpSessionsDir({ - env: { ...launchEnv, PI_CODING_AGENT_DIR: accountHomePath }, - homeDir + findTranscript: ({ providerSessionId, store, launchEnv, workspaceCwd, strictErrors }) => + findOmpSessionByCwd({ + sessionsDir: store, + sessionId: providerSessionId, + cwd: workspaceCwd, + homeDir: (process.platform === 'win32' ? launchEnv.USERPROFILE : launchEnv.HOME) || homedir(), + env: launchEnv, + strictErrors }) - return sessionsDir - ? findOmpSessionByCwd({ - sessionsDir, - sessionId: providerSessionId, - cwd: workspaceCwd, - homeDir, - env: launchEnv - }) - : null - } }) diff --git a/src/main/opencode/opencode-transcript-import.ts b/src/main/opencode/opencode-transcript-import.ts index 359cef811c1..daed142ffe4 100644 --- a/src/main/opencode/opencode-transcript-import.ts +++ b/src/main/opencode/opencode-transcript-import.ts @@ -1,32 +1,64 @@ -import { existsSync } from 'node:fs' +import { stat } from 'node:fs/promises' +import { admitTranscriptPath } from '../native-chat/transcript-path-admission' +import { dirname } from 'node:path' import { homedir } from 'node:os' import { isLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { getManagedDataAccountService } from '../managed-data-accounts/service' -import type { StructuredAgentTranscriptImport } from '../native-chat/structured-agent-cli-conversations' +import { + absoluteConversationStore, + type StructuredAgentTranscriptImport +} from '../native-chat/structured-agent-cli-conversations' import { resolveOpenCodeDatabasePath } from './opencode-data-directory' import { environmentForStructuredOpenCodeAccountHome } from './opencode-structured-account-home' +function accountDatabase( + accountHome: Parameters[0]['accountHome'], + launchEnv: NodeJS.ProcessEnv +) { + if (isLegacyAgentSessionAccountHome(accountHome)) { + return null + } + const env = environmentForStructuredOpenCodeAccountHome(accountHome, { + managedAccounts: getManagedDataAccountService(), + baseEnvironment: launchEnv + }) + return absoluteConversationStore( + resolveOpenCodeDatabasePath( + env, + (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir() + ) + ) +} + export const OPENCODE_TRANSCRIPT_IMPORT: StructuredAgentTranscriptImport = { + // The account's store is its exact database, which is the only one its chat can load from. + conversationStore: ({ accountHome, launchEnv }) => accountDatabase(accountHome, launchEnv), + admitTranscriptPath: async ({ store: dbPath, transcriptPath }) => { + const [expected, actual] = await Promise.all([ + admitTranscriptPath(dirname(dbPath), dbPath), + admitTranscriptPath(dirname(dbPath), transcriptPath) + ]) + return expected && actual && expected.path === actual.path ? actual.path : null + }, // The selected account, then the unmanaged environment this chat would launch with. accountHomeCandidates: ({ selectedAccountHome }) => [ selectedAccountHome, { kind: 'opencode', locator: { kind: 'unmanaged' } } ], - // The database the account's OpenCode opens, which is the only one its chat can load from. - findTranscript: async ({ providerSessionId, accountHome, launchEnv }) => { - if (isLegacyAgentSessionAccountHome(accountHome)) { - return null - } - const env = environmentForStructuredOpenCodeAccountHome(accountHome, { - managedAccounts: getManagedDataAccountService(), - baseEnvironment: launchEnv - }) - const dbPath = resolveOpenCodeDatabasePath( - env, - (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir() - ) - if (!dbPath || !existsSync(dbPath)) { - return null + findTranscript: async ({ providerSessionId, store: dbPath }) => { + try { + if (!(await stat(dbPath)).isFile()) { + return null + } + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) { + return null + } + throw error } const { readOpenCodeTranscriptSignalViaWorker } = await import('../ai-vault/session-scanner-opencode-sqlite-worker-spawn') diff --git a/src/main/pi/pi-transcript-import.ts b/src/main/pi/pi-transcript-import.ts index d2db4dca167..0d439f9e34c 100644 --- a/src/main/pi/pi-transcript-import.ts +++ b/src/main/pi/pi-transcript-import.ts @@ -7,14 +7,16 @@ import { directoryTranscriptImport } from '../native-chat/structured-agent-cli-c import { readPiSessionHeader } from './rpc-launch-resolution' export const PI_TRANSCRIPT_IMPORT = directoryTranscriptImport({ + conversationStore: () => piSessionHistorySessionsDir(), // Session History's host source is authoritative, including when the chat chose another home. accountHomeCandidates: () => [ process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent') ], // The file Session History lists under this id. - findTranscript: async ({ providerSessionId }) => { - const files = await walkSessionFiles(piSessionHistorySessionsDir(), 'pi', [], { + findTranscript: async ({ providerSessionId, store, strictErrors }) => { + const files = await walkSessionFiles(store, 'pi', [], { extensions: new Set(['.jsonl']), + strictErrors, filePredicate: (path) => sessionIdFromFileName(path) === providerSessionId }) if (files.length !== 1) { diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 6284c30602a..746341ead83 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -1,3 +1,4 @@ +import { prepareAdoptedTranscript } from '../native-chat/agent-session-wire/structured-agent-session-adopted-import' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -103,7 +104,7 @@ describe('structured Codex folder trust', () => { }) describe('structured agent-session create intent', () => { - it('returns the recorded owner before any new-launch support or account preparation', async () => { + it('returns the recorded owner before CLI source lookup, new-launch support or account preparation', async () => { const record = agentSessionRecordFixture() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: early ownership admission only reads the host's record list. setStructuredAgentSessionHost({ @@ -113,12 +114,16 @@ describe('structured agent-session create intent', () => { const support = vi .spyOn(runtime, 'getStructuredAgentSessionCreateSupport') .mockRejectedValue(new Error('fresh launch is unavailable')) + const sourceRows = vi.spyOn(runtime, 'getAgentProviderSessionRows').mockImplementation(() => { + throw new Error('source is unavailable') + }) await expect( runtime.resolveStructuredAgentSessionCreateIntent({ envelope: { sessionId: 'new-chat', clientOperationId: 'operation-1' }, worktree: 'id:another-workspace', agent: 'claude', - resumeFrom: { providerSessionId: 'provider-session-alpha-1' } + resumeFrom: { providerSessionId: 'provider-session-alpha-1', + cliSource: { workspaceId: 'another-workspace', paneKey: 'missing-pane' } } }) ).rejects.toMatchObject({ refusal: { @@ -130,6 +135,7 @@ describe('structured agent-session create intent', () => { } }) expect(support).not.toHaveBeenCalled() + expect(sourceRows).not.toHaveBeenCalled() }) it('pins the selected Codex launch home after normal launch preparation', async () => { @@ -489,18 +495,18 @@ describe('adopting a conversation recorded outside Orca', () => { expect(intent.accountHome).toEqual({ variable: 'PI_CODING_AGENT_DIR', path: accountHome }) expect(intent.adopt).toEqual({ providerHandle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, - transcriptPath: file + transcriptPath: file, + source: { providerSessionId: '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a', workspaceCwd: '/repos/workspace-1' } }) workspaceDirectory = '/different-folder' - await expect( - runtime.resolveStructuredAgentSessionCreateIntent({ + const refused = await runtime.resolveStructuredAgentSessionCreateIntent({ envelope: { sessionId: 'session-2', clientOperationId: 'operation-2' }, worktree: 'id:workspace-1', agent: 'pi', resumeFrom: { providerSessionId: '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' } }) - ).rejects.toMatchObject({ refusal: { code: 'agent_session_identity_required' } }) + expect(await prepareAdoptedTranscript(refused)).toMatchObject({ ok: false, refusal: { code: 'agent_session_identity_required' } }) } finally { vi.unstubAllEnvs() await rm(accountHome, { recursive: true, force: true }) diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 4f12fe7a82b..43ed51de7fe 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -2,6 +2,7 @@ import { ZodError, type ZodType } from 'zod' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime' +import type { StructuredAgentCliSourceEvidence } from '../../native-chat/structured-agent-cli-source' import type { DeviceCredentialInstalled, PairingGetEndpointsParams, @@ -93,6 +94,7 @@ export type RpcContext = { connectionId?: string // An unsubscribe cannot retire a registration created after its dispatch began. subscriptionRegistrationVersion?: number + structuredAgentCliSourceEvidence?: StructuredAgentCliSourceEvidence // Why: shared-control multiplexes many logical streams over one socket; the frame id lets handlers register cleanup per logical stream. requestId?: string // Why: paired mobile device token; state-owning handlers use it to clean up when that device disconnects. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index df94afa7af9..b79d87f9512 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -1,4 +1,5 @@ import type { OrcaRuntimeService } from '../orca-runtime' +import { captureStructuredAgentCliSource } from '../../native-chat/structured-agent-cli-source' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' import { isRegistrationFencedUnsubscribe, type RpcContext, type RpcRequest } from './core' import { resolveRpcCallerIdentity } from './rpc-caller-identity' @@ -44,6 +45,10 @@ export function rpcContextFromTransport( subscriptionRegistrationVersion: isRegistrationFencedUnsubscribe(request.method) ? runtime.getSubscriptionRegistrationVersion() : undefined, + structuredAgentCliSourceEvidence: + request.method === 'agentSession.create' + ? captureStructuredAgentCliSource(() => runtime.getAgentProviderSessionRows()) + : undefined, clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, pairedDeviceParentId: options?.pairedDeviceParentId, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 4a82cb90475..ecbb599de7f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -59,20 +60,21 @@ function adapter(): StructuredAgentSessionAdapter { } } -function createParams(operationId = OPERATION) { +function createParams(operationId = OPERATION, sessionId = SESSION) { const fields = { worktree: `id:${WORKSPACE}`, agent: 'codex' as const, - resumeFrom: { providerSessionId: THREAD } + resumeFrom: { providerSessionId: THREAD, + cliSource: { workspaceId: WORKSPACE, paneKey: 'source-pane' } } } return { envelope: { - sessionId: SESSION, + sessionId, clientOperationId: operationId, expectedRuntimeFence: null, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.create', - sessionId: SESSION, + sessionId, fields }) }, @@ -80,7 +82,7 @@ function createParams(operationId = OPERATION) { } } -async function call(dispatcher: RpcDispatcher, params: unknown, client = CLIENT) { +async function call(dispatcher: RpcDispatcher, params: unknown, client = CLIENT, unary = false) { const replies: RpcResponse[] = [] const request: RpcRequest = { id: `request-${replies.length + 1}`, @@ -88,6 +90,9 @@ async function call(dispatcher: RpcDispatcher, params: unknown, client = CLIENT) method: 'agentSession.create', params } + if (unary) { + return dispatcher.dispatch(request, client) + } await dispatcher.dispatchStreaming( request, (raw) => replies.push(JSON.parse(raw) as RpcResponse), @@ -114,7 +119,7 @@ afterEach(async () => { }) describe('committed adopting create RPC replay', () => { - it('republishes from durable identity after the source disappears and account selection drifts', async () => { + it.each([true, false])('captures source before routing (unary=%s)', async (unary) => { const originalHome = join(root, 'account-original') const driftedHome = join(root, 'account-drifted') const transcriptPath = join( @@ -150,6 +155,13 @@ describe('committed adopting create RPC replay', () => { undefined, { prepareCodexStructuredLaunch: selectAccountHome } ) + const sourceRow = { + paneKey: 'source-pane', worktreeId: WORKSPACE, agentType: 'codex', + connectionId: null, receivedAt: 1, stateStartedAt: 1, state: 'waiting' as const, prompt: '', + providerSession: { key: 'session_id' as const, id: THREAD, transcriptPath } + } + let rows: AgentStatusIpcPayload[] = [sourceRow] + const sourceRows = vi.spyOn(runtime, 'getAgentProviderSessionRows').mockImplementation(() => rows) // The structured surface is settings-gated for every caller, not just mobile; this test // probes durable-identity replay, which only runs once the gate admits the call. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The RPC gate reads only this setting from the test double. @@ -202,16 +214,52 @@ describe('committed adopting create RPC replay', () => { }) const params = createParams() - expect(await call(dispatcher, params)).toMatchObject({ + rows = unary ? [] : [{ ...sourceRow, providerSession: { ...sourceRow.providerSession, id: 'replaced' } }] + const route = vi.spyOn(runtime, 'routeClientHostedBrowserRpc').mockImplementationOnce(async () => { + rows = [sourceRow] + return { handled: false } + }) + expect(await call(dispatcher, params, CLIENT, unary)).toMatchObject({ + ok: true, + result: { ok: false, refusal: { details: { reason: 'transcriptUnreadable' } } } + }) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() + expect(internal.publishStructuredAgentSessionTab).not.toHaveBeenCalled() + selectAccountHome.mockClear() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + route.mockImplementationOnce(async () => { + entered.resolve() + await release.promise + return { handled: false } + }) + const pending = call(dispatcher, params, CLIENT, unary) + await entered.promise + if (unary) { + rows = [] + } else { + sourceRow.worktreeId = 'replacement-workspace' + sourceRow.providerSession.id = 'replacement-thread' + sourceRow.providerSession.transcriptPath = join(root, 'replacement.jsonl') + } + release.resolve() + expect(await pending).toMatchObject({ ok: true, result: { ok: false, refusal: { code: 'agent_session_operation_unknown' } } }) + expect(store.getRecord(SESSION)?.accountHome).toMatchObject({ path: originalHome }) await rm(transcriptPath) selectedHome = driftedHome setStructuredAgentSessionHost(null) internal.ensureStructuredAgentSessionHost = vi.fn(async () => { setStructuredAgentSessionHost(host) }) + sourceRows.mockImplementation(() => { + if (!unary) { + throw new Error('source snapshot unavailable') + } + return [] + }) expect(await call(dispatcher, params)).toMatchObject({ ok: true, @@ -236,6 +284,12 @@ describe('committed adopting create RPC replay', () => { expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) expect(selectAccountHome).toHaveBeenCalledTimes(1) + expect(await call(dispatcher, createParams(OPERATION, 'another-chat'))).toMatchObject({ + ok: true, + result: { ok: false, refusal: { details: { + reason: 'conversationHeldElsewhere', owner: { sessionId: SESSION, workspaceId: WORKSPACE } + } } } + }) const otherOperation = createParams(`${Date.now()}-00000000000000000000000000000002`) expect(await call(dispatcher, otherOperation)).toMatchObject({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts index c015eb82981..351feef5d32 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -90,6 +90,7 @@ export async function handleStructuredAgentSessionCreate( worktree: params.worktree, agent: params.agent, caller: callerFor(ctx), + structuredAgentCliSourceEvidence: ctx.structuredAgentCliSourceEvidence, ...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}), ...(params.tabId ? { tabId: params.tabId } : {}), ...(params.options ? { options: params.options } : {}), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 0f0ca0d752c..98ad00f3e7e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -20,6 +20,10 @@ import type { } from '../../../../shared/structured-agent-session-create' import { structuredAgentSessionOptionOverridesRefusal } from '../../../native-chat/agent-session-wire/structured-agent-session-options-read' import type { OrcaRuntimeService } from '../../orca-runtime' +import { + captureStructuredAgentCliSource, + type StructuredAgentCliSourceEvidence +} from '../../../native-chat/structured-agent-cli-source' import type { StructuredAgentId } from '../../../../shared/agent-session-provider-handle' import { resolveUncommittedStructuredCreate, @@ -73,6 +77,7 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { agent: StructuredAgentId caller: StructuredAgentSessionCaller resumeFrom?: StructuredAgentSessionResumeSource + structuredAgentCliSourceEvidence?: StructuredAgentCliSourceEvidence /** Explicit picks override seed keys at rest; acquired in-process launches replace the seed. */ options?: Readonly> /** The tab id the caller reserved for this chat, taken when its tab is published; absent, the tab @@ -81,6 +86,10 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { firstMessage?: StructuredAgentSessionFirstMessage atRest?: boolean }): Promise { + const structuredAgentCliSourceEvidence = args.structuredAgentCliSourceEvidence ?? + (args.resumeFrom?.cliSource + ? captureStructuredAgentCliSource(() => args.runtime.getAgentProviderSessionRows()) + : undefined) // Adoption replay may need the record loaded from disk before source discovery can be skipped. let host = args.resumeFrom ? await args.ensureHost() : null const resolved = await args.runtime.resolveStructuredAgentSessionCreateIntent({ @@ -88,6 +97,7 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { worktree: args.worktree, agent: args.agent, callerKey: args.caller.callerKey, + structuredAgentCliSourceEvidence, ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}) }) const attachFingerprint = computeAgentSessionPayloadFingerprint({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 7d839fcd19e..ae089150db3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -383,10 +383,12 @@ describe('method routing', () => { } const created = await call('agentSession.create', params, STRUCTURED_CLIENT) expect(created).toMatchObject({ ok: true, result: { ok: true } }) - expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith({ - ...params, - callerKey: 'trusted-local:runtime' - }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith( + expect.objectContaining({ + ...params, + callerKey: 'trusted-local:runtime' + }) + ) expect(hostCalls.attach).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -536,10 +538,12 @@ describe('method routing', () => { ok: true, result: { ok: true } }) - expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith({ - ...params, - callerKey: 'trusted-local:runtime' - }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith( + expect.objectContaining({ + ...params, + callerKey: 'trusted-local:runtime' + }) + ) } ) @@ -570,10 +574,12 @@ describe('method routing', () => { } const created = await call('agentSession.create', params, STRUCTURED_CLIENT) expect(created).toMatchObject({ ok: true, result: { ok: true } }) - expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith({ - ...params, - callerKey: 'trusted-local:runtime' - }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith( + expect.objectContaining({ + ...params, + callerKey: 'trusted-local:runtime' + }) + ) expect(hostCalls.attach).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ diff --git a/src/main/runtime/session-tabs/structured-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts index b5e63b30f72..4d60e8fc208 100644 --- a/src/main/runtime/session-tabs/structured-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -1,3 +1,7 @@ +import { + captureStructuredAgentCliSource, + structuredAgentCliSourcePathFromEvidence +} from '../../native-chat/structured-agent-cli-source' import type { OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce as RestoreStructuredAgentSessionTabsOnceMethods } from './structured-session-tab-restoration' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' @@ -5,7 +9,8 @@ import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-w import { resolveCommittedStructuredAgentSessionAdoptionIntent, requireUnownedStructuredAgentSessionAdoption, - resolveStructuredAgentSessionAdoptionForCreate + resolveStructuredAgentSessionAdoptionForCreate, + type StructuredAgentSessionCreateIntentInput } from '../structured-agent-session-create-adoption' import { isNativeLocalLocation, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' @@ -27,7 +32,6 @@ import { import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' -import { readPiSessionHeader, samePiDirectory } from '../../pi/rpc-launch-resolution' export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { protected abstract restoreStructuredAgentSessionTabsOnce( @@ -145,13 +149,9 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte return (await this.resolveRuntimeFileTarget(worktreeSelector)).worktree.path } - async resolveStructuredAgentSessionCreateIntent(input: { - envelope: { sessionId: string; clientOperationId: string } - worktree: string - agent: StructuredAgentId - callerKey?: string - resumeFrom?: { providerSessionId: string } - }): Promise { + async resolveStructuredAgentSessionCreateIntent( + input: StructuredAgentSessionCreateIntentInput + ): Promise { if (input.resumeFrom) { requireUnownedStructuredAgentSessionAdoption({ host: getStructuredAgentSessionHost(), @@ -160,6 +160,10 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte selfSessionId: input.envelope.sessionId }) } + const structuredAgentCliSourceEvidence = input.structuredAgentCliSourceEvidence ?? + (input.resumeFrom?.cliSource + ? captureStructuredAgentCliSource(() => this.getAgentProviderSessionRows()) + : undefined) const hostLaunchDirectory = isFloatingWorkspaceSelector(input.worktree) ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path : undefined @@ -174,7 +178,10 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte reason: 'hostUnsupported' }) } - const resolved = await this.resolveStructuredAgentSessionIntent(input, resolveAccountHome) + const resolved = await this.resolveStructuredAgentSessionIntent( + { ...input, structuredAgentCliSourceEvidence }, + resolveAccountHome + ) return hostLaunchDirectory ? { ...resolved, hostLaunchDirectory } : resolved } @@ -197,13 +204,7 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte } protected async resolveStructuredAgentSessionIntent( - input: { - envelope: { sessionId: string; clientOperationId: string } - worktree: string - agent: StructuredAgentId - callerKey?: string - resumeFrom?: { providerSessionId: string } - }, + input: StructuredAgentSessionCreateIntentInput, resolveAccountHome: (context: { launchEnv: NodeJS.ProcessEnv location: { @@ -238,32 +239,30 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte if (committedReplay) { return committedReplay } + const resumeFrom = input.resumeFrom + const workspaceCwd = resumeFrom ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path : '' const selectedAccountHome = await resolveAccountHome({ launchEnv, location }) // Adopting pins the account home to wherever the conversation actually lives, which is not // necessarily the one a fresh create would pick: Codex resolves its rollout under // `accountHome.path`, and Claude reads its transcript under `/projects`. Resuming under // the wrong home finds nothing and lands the user in a blank chat wearing the old chat's name. const adoption = - input.resumeFrom && transcriptImport + resumeFrom && transcriptImport ? await resolveStructuredAgentSessionAdoptionForCreate({ settings, agent: input.agent, transcriptImport, - providerSessionId: input.resumeFrom.providerSessionId, + providerSessionId: resumeFrom.providerSessionId, + resolveTranscriptPath: () => structuredAgentCliSourcePathFromEvidence({ + agent: input.agent, resumeFrom, workspaceId: location.workspaceId, + evidence: input.structuredAgentCliSourceEvidence + }), + prepareCodexResume: this.prepareAiVaultSessionResumeFn ?? undefined, selectedAccountHome, launchEnv: await resolveHostStructuredAgentLaunchEnvironment(input.agent, settings), - workspaceCwd: (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + workspaceCwd: workspaceCwd }) : null - if (input.agent === 'pi' && adoption) { - const header = await readPiSessionHeader(adoption.transcriptPath) - const targetDirectory = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path - if (!header || !(await samePiDirectory(header.cwd, targetDirectory))) { - throw agentSessionRefusalError('agent_session_identity_required', { - reason: 'transcriptNotFound' - }) - } - } return { envelope: { sessionId: input.envelope.sessionId, @@ -289,7 +288,11 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte ? adoption.transcriptPath : input.resumeFrom.providerSessionId }, - transcriptPath: adoption.transcriptPath + transcriptPath: adoption.transcriptPath, + source: { + providerSessionId: input.resumeFrom.providerSessionId, + workspaceCwd: workspaceCwd + } } } : {}), diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index 1285dc2f33c..0288fdeb8d5 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -1,3 +1,9 @@ +import { stat } from 'node:fs/promises' +import { agentSessionRefusalError, isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { prepareCodexAdoption } from '../native-chat/codex-adoption-preparation' +import type { NativeCodexResumePreparation } from '../codex/codex-ai-vault-session-resume' +import type { StructuredAgentSessionResumeSource } from '../../shared/structured-agent-session-create' +import type { StructuredAgentCliSourceEvidence } from '../native-chat/structured-agent-cli-source' import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' @@ -17,6 +23,15 @@ import type { StructuredAgentTranscriptImportSettings } from '../native-chat/structured-agent-cli-conversations' +export type StructuredAgentSessionCreateIntentInput = { + envelope: { sessionId: string; clientOperationId: string } + worktree: string + agent: StructuredAgentId + callerKey?: string + resumeFrom?: StructuredAgentSessionResumeSource + structuredAgentCliSourceEvidence?: StructuredAgentCliSourceEvidence +} + export function resolveCommittedStructuredAgentSessionAdoptionIntent(input: { host: StructuredAgentSessionHost | null envelope: { sessionId: string; clientOperationId: string } @@ -86,12 +101,87 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { selectedAccountHome: AgentSessionAccountHome launchEnv: NodeJS.ProcessEnv workspaceCwd: string + resolveTranscriptPath?: () => string | undefined + prepareCodexResume?: NativeCodexResumePreparation }) { - return resolveStructuredAgentSessionAdoption({ - providerSessionId: input.providerSessionId, - candidateAccountHomes: input.transcriptImport.accountHomeCandidates(input), - resolveTranscript: input.transcriptImport.findTranscript, - launchEnv: input.launchEnv, - workspaceCwd: input.workspaceCwd - }) + try { + const transcriptPath = input.resolveTranscriptPath?.() + const candidates = input.transcriptImport.accountHomeCandidates(input) + // Each account's store is resolved once, so the hook path, the lookup and its re-admission + // all judge the same place even if the environment changes mid-create. + const stores = new Map() + const storeOf = (accountHome: AgentSessionAccountHome): string | null => { + if (!stores.has(accountHome)) { + stores.set(accountHome, input.transcriptImport.conversationStore({ + accountHome, launchEnv: input.launchEnv + })) + } + return stores.get(accountHome) ?? null + } + let admitted: { accountHome: AgentSessionAccountHome; path: string } | null = null + if (transcriptPath) { + for (const accountHome of candidates) { + const store = storeOf(accountHome) + const path = store + ? await input.transcriptImport.admitTranscriptPath({ store, transcriptPath }) + : null + if (path) { + admitted = { accountHome, path } + break + } + } + } + if (transcriptPath && !admitted) { + throw new Error('The conversation account is not available on this host.') + } + const adoption = await resolveStructuredAgentSessionAdoption({ + providerSessionId: input.providerSessionId, + candidateAccountHomes: admitted ? [admitted.accountHome] : candidates, + resolveTranscript: async (args) => { + const exact = admitted?.path + if (exact) { + try { + if ((await stat(exact)).isFile()) { + return exact + } + } catch (error) { + if (!(error instanceof Error && 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR'))) { + throw error + } + } + } + const store = storeOf(args.accountHome) + if (!store) { + return null + } + const found = await input.transcriptImport.findTranscript({ + providerSessionId: args.providerSessionId, + store, + launchEnv: args.launchEnv, + workspaceCwd: args.workspaceCwd, + strictErrors: true + }) + if (!found) { + return null + } + const path = await input.transcriptImport.admitTranscriptPath({ store, transcriptPath: found }) + if (!path) { + throw new Error('The conversation path is outside its account storage.') + } + return path + }, + launchEnv: input.launchEnv, + workspaceCwd: input.workspaceCwd + }) + return input.agent === 'codex' + ? await prepareCodexAdoption(adoption, input.providerSessionId, input.prepareCodexResume, candidates) + : adoption + } catch (error) { + if (isAgentSessionRefusalError(error)) { + throw error + } + throw agentSessionRefusalError('agent_session_identity_required', + { reason: 'transcriptUnreadable' }, error instanceof Error ? error.message : String(error)) + } } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 6fa6ddb109e..2c9e9453cc0 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -13,7 +13,7 @@ import { agentHookServer } from '../agent-hooks/server' import { browserManager } from '../browser/browser-manager' import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' -import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { codexAiVaultSessionResumePreparation } from '../codex/codex-ai-vault-session-resume' import { prepareCodexPinnedLaunchHome } from './codex-session-resume-launch' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { agentHookRuntimeDeps } from '../agent-hooks/agent-hook-runtime-deps' @@ -114,12 +114,11 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [], - prepareAiVaultSessionResume: (args) => - prepareCodexAiVaultSessionResume(args, { - runtimeHome: state.codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()), - preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home) - }), + prepareAiVaultSessionResume: codexAiVaultSessionResumePreparation(() => ({ + runtimeHome: state.codexRuntimeHome, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()), + preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home) + })), ...codexStructuredLaunchHomeResolvers, prepareCodexCatalogProbeHome: (homePath) => state.codexRuntimeHome?.prepareHostCodexHomeForReadOnlyAppServer( diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index e220e5540fe..0688fd0147a 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -25,7 +25,8 @@ export { STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY } from './structured-agent-session-surface-capabilities' import { AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES } from './agent-session-attention-capabilities' export { diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 1c46bf6cc9a..da29e7ab640 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -116,7 +116,14 @@ export const AttachParams = z * the provider child launches against; both are derived host-side from this id instead. */ export const ResumeSource = z .object({ - providerSessionId: Identifier('Invalid provider session id') + providerSessionId: Identifier('Invalid provider session id'), + cliSource: z + .object({ + workspaceId: Identifier('Invalid workspace id'), + paneKey: Identifier('Invalid pane key') + }) + .strict() + .optional() }) .strict() diff --git a/src/shared/structured-agent-session-create.test.ts b/src/shared/structured-agent-session-create.test.ts index f83658659a1..b4aa2ca3896 100644 --- a/src/shared/structured-agent-session-create.test.ts +++ b/src/shared/structured-agent-session-create.test.ts @@ -1,3 +1,5 @@ +import { ResumeSource } from './rpc-contract/structured-agent-session-params' +import { STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY } from './structured-agent-session-surface-capabilities' import { describe, expect, it } from 'vitest' import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, RUNTIME_CAPABILITIES } from './protocol-version' import { @@ -7,7 +9,8 @@ import { import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from './electron-remote-runtime-client-capabilities' import { structuredAgentSessionCreateParams, - type StructuredAgentSessionFirstMessage + type StructuredAgentSessionFirstMessage, + type StructuredAgentSessionResumeSource } from './structured-agent-session-create' import { structuredAgentSessionCreateFingerprint, @@ -26,7 +29,7 @@ function nextUuid(): string { function createParams( overrides: { - resumeFrom?: { providerSessionId: string } + resumeFrom?: StructuredAgentSessionResumeSource tabId?: string firstMessage?: StructuredAgentSessionFirstMessage options?: Record @@ -158,3 +161,17 @@ describe('the tab id a create reserves', () => { expect(AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY).toBe('agentSession.create.tab-id.v1') }) }) + +it('negotiates a strict pane identity and includes it in the create fingerprint without accepting paths', () => { + const source = { providerSessionId: 'thread-abc', cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } } + expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY) + expect(ResumeSource.parse(source)).toEqual(source) + expect(ResumeSource.safeParse({ ...source, transcriptPath: '/client/path' }).success).toBe(false) + expect(ResumeSource.safeParse({ ...source, cliSource: { ...source.cliSource, accountHome: '/client/home' } }).success).toBe(false) + const first = createParams({ resumeFrom: source }) + expect(first.envelope.payloadFingerprint).not.toBe(createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint) + expect(first.envelope.payloadFingerprint).toBe(createParams({ resumeFrom: source }).envelope.payloadFingerprint) + expect(first.envelope.payloadFingerprint).not.toBe(createParams({ resumeFrom: { + ...source, cliSource: { ...source.cliSource, paneKey: 'other:leaf' } + } }).envelope.payloadFingerprint) +}) diff --git a/src/shared/structured-agent-session-create.ts b/src/shared/structured-agent-session-create.ts index c4ef6fb40c5..d1e4e23e161 100644 --- a/src/shared/structured-agent-session-create.ts +++ b/src/shared/structured-agent-session-create.ts @@ -18,6 +18,8 @@ import { export type StructuredAgentSessionResumeSource = { /** claude: the session id. codex: the thread id. */ providerSessionId: string + /** Host-owned pane evidence; send only with the CLI-source create capability. */ + cliSource?: { workspaceId: string; paneKey: string } } export type StructuredAgentSessionCreateParams = { diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 9b20c9b170e..6a0237ce272 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,5 +1,5 @@ import { sha256 } from './sha256' -import type { StructuredAgentSessionFirstMessage } from './structured-agent-session-create' +import type { StructuredAgentSessionFirstMessage, StructuredAgentSessionResumeSource } from './structured-agent-session-create' import type { StructuredAgentId } from './agent-session-provider-handle' function canonicalize(value: unknown): string { @@ -44,7 +44,7 @@ export function structuredAgentSessionCreateFingerprint(input: { sessionId: string worktree: string agent: StructuredAgentId - resumeFrom?: { providerSessionId: string } + resumeFrom?: StructuredAgentSessionResumeSource tabId?: string firstMessage?: StructuredAgentSessionFirstMessage options?: Readonly> diff --git a/src/shared/structured-agent-session-surface-capabilities.ts b/src/shared/structured-agent-session-surface-capabilities.ts index 4aba07206f7..d8bdf74c27d 100644 --- a/src/shared/structured-agent-session-surface-capabilities.ts +++ b/src/shared/structured-agent-session-surface-capabilities.ts @@ -32,11 +32,16 @@ export const STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY = export const AGENT_SESSION_MODEL_CATALOG_SAVED_ONLY_RUNTIME_CAPABILITY = 'agent-session.model-catalog.saved-only.v1' as const +/** Strict create source identity is sent only after host negotiation. */ +export const STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY = + 'agent-session.structured.cli-source.v1' as const + export const STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES = [ STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY, AGENT_SESSION_MODEL_CATALOG_SAVED_ONLY_RUNTIME_CAPABILITY ] as const