From 002320c17d0fbd6b38c4341823d6871622c8e681 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:14:03 -0700 Subject: [PATCH] fix(native-chat): verify the actual CLI conversation during resume (#29124) * fix(native-chat): identify the owner when resume create is refused * test(native-chat): consolidate transcript refusal cases * test(native-chat): keep ownership coverage at the launch boundary * test(native-chat): update adoption fixture for early ownership admission * test(native-chat): align resume admission assertions with host ownership * test(native-chat): keep RPC coverage focused on ownership proof * test(native-chat): assert blank request presence before optional source * fix(native-chat): admit the actual CLI source during chat creation * fix(native-chat): satisfy source admission lint checks * test(native-chat): provide the waiting hook prompt * test(native-chat): isolate source admission fixtures from host homes * test(native-chat): canonicalize adoption fixture directories * test(native-chat): preserve early ownership admission in source stack * fix(native-chat): preserve resume refusal behavior and honest transcript feedback * fix(native-chat): retain import read evidence in typed result * fix(native-chat): preserve admitted source and prepared execution home * fix(chat): retain source evidence for dispatched adoption * fix(chat): keep source capture within module limits * test(chat): allow host evidence in create routing * fix(native-chat): admit an unsaved transcript under a linked account folder A missing transcript stayed unresolved while its account root resolved through symlinks, so containment failed and a not-yet-saved conversation showed a load error instead of the unsaved notice. * refactor(native-chat): move the newest-item import window into its own module Keeps journal-legacy-import.ts under the line limit after the source identity observer landed on top of the streaming import. * fix(native-chat): restore the refusal error import lost in the main merge * fix(native-chat): admit Pi transcripts against the root Session History walks A PI_CODING_AGENT_DIR that already ends in sessions, or any custom folder, made admission expect /sessions while the lookup searched , so a found conversation was refused. * refactor(native-chat): admit and look up CLI transcripts in one store per account Each agent's import described where an account keeps its conversations twice, once for the path check and once for the lookup, and the two could disagree (the linked-folder and Pi custom-root refusals). The import now names one conversation store per account; the create path resolves it once and both the hook-path check and the fallback lookup judge that same store. OpenCode's store is its exact database. --- ...session-scanner-discovery-wsl-gate.test.ts | 16 ++ .../ai-vault/session-scanner-discovery.ts | 3 +- src/main/ai-vault/structured-chat-cli-fork.ts | 10 +- src/main/claude/claude-cli-conversations.ts | 7 +- .../codex/codex-ai-vault-session-resume.ts | 18 ++ src/main/codex/codex-cli-conversations.ts | 7 +- src/main/grok/grok-transcript-import.ts | 14 +- .../adopted-transcript-source.ts | 96 +++++++++ .../journal-legacy-import.ts | 51 ++--- .../journal-newest-item-window.ts | 29 +++ ...structured-agent-session-adopted-import.ts | 4 +- .../structured-agent-session-attach.ts | 1 + .../native-chat/codex-adoption-preparation.ts | 64 ++++++ src/main/native-chat/session-file-resolver.ts | 38 ++-- ...ed-agent-cli-conversation-adoption.test.ts | 31 ++- .../structured-agent-cli-conversations.ts | 46 ++-- .../structured-agent-cli-source.ts | 92 ++++++++ ...red-agent-session-source-admission.test.ts | 201 ++++++++++++++++++ .../transcript-opencode-sqlite-query.test.ts | 2 + .../transcript-opencode-sqlite-query.ts | 28 ++- .../transcript-opencode2-sqlite.test.ts | 5 +- .../transcript-opencode2-sqlite.ts | 6 +- .../native-chat/transcript-path-admission.ts | 61 ++++++ src/main/omp/omp-session-cwd-lookup.test.ts | 24 ++- src/main/omp/omp-session-cwd-lookup.ts | 13 +- src/main/omp/omp-transcript-import.ts | 32 +-- .../opencode/opencode-transcript-import.ts | 66 ++++-- src/main/pi/pi-transcript-import.ts | 6 +- ...ctured-agent-session-create-intent.test.ts | 18 +- src/main/runtime/rpc/core.ts | 2 + .../runtime/rpc/dispatcher-stream-options.ts | 5 + ...ured-agent-session-adoption-replay.test.ts | 68 +++++- ...structured-agent-session-create-handler.ts | 1 + .../structured-agent-session-create.ts | 10 + .../methods/structured-agent-session.test.ts | 30 +-- .../structured-session-create-support.ts | 63 +++--- ...tructured-agent-session-create-adoption.ts | 104 ++++++++- .../startup/main-process-runtime-service.ts | 13 +- src/shared/protocol-version.ts | 3 +- .../structured-agent-session-params.ts | 9 +- .../structured-agent-session-create.test.ts | 21 +- src/shared/structured-agent-session-create.ts | 2 + .../structured-agent-session-mutation.ts | 4 +- ...ured-agent-session-surface-capabilities.ts | 5 + 44 files changed, 1099 insertions(+), 230 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/adopted-transcript-source.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-newest-item-window.ts create mode 100644 src/main/native-chat/codex-adoption-preparation.ts create mode 100644 src/main/native-chat/structured-agent-cli-source.ts create mode 100644 src/main/native-chat/structured-agent-session-source-admission.test.ts create mode 100644 src/main/native-chat/transcript-path-admission.ts 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