diff --git a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx index 65412d51958..df60f8fc117 100644 --- a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx @@ -10,6 +10,11 @@ export default function MobileFilePreviewRoute() { source?: string | string[] absolutePath?: string | string[] grantId?: string | string[] + terminal?: string | string[] + pathText?: string | string[] + cwd?: string | string[] + nativeChatTab?: string | string[] + nativeChatSession?: string | string[] line?: string | string[] column?: string | string[] name?: string | string[] diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index cf0f1dd7edb..aa59f6df7cb 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -3085,6 +3085,10 @@ export default function SessionScreen() { hostId, worktreeId, worktreeName: routeWorktreeName, + nativeChatSessionId: + activeSessionTab?.type === 'terminal' + ? (activeSessionTab.agentStatus?.providerSession?.id ?? null) + : null, activeHandleRef, terminalCwdRef, openBrowser: (url) => void handleCreateBrowserRef.current?.(url), diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/mobile-file-preview-request.test.ts index f7f77fabd19..48bd26a71db 100644 --- a/mobile/src/files/mobile-file-preview-request.test.ts +++ b/mobile/src/files/mobile-file-preview-request.test.ts @@ -179,6 +179,37 @@ describe('mobile-file-preview-request', () => { }) }) + it('refreshes a native-chat artifact grant with the same transcript provenance', async () => { + const client = clientWithResponses([ + fail('terminal_file_grant_expired'), + ok({ + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-2' + } + }), + ok({ content: '

Result

', truncated: false, byteLength: 15 }) + ]) + + await loadMobileFilePreview(client, { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1', + pathText: '~/orca-plans/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }) + + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', { + worktree: 'id:wt-1', + pathText: '~/orca-plans/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }) + }) + it.each([ ['terminal_file_grant_expired'], ['terminal_file_grant_mismatch'], diff --git a/mobile/src/files/mobile-file-preview-route.test.ts b/mobile/src/files/mobile-file-preview-route.test.ts index ac1888ed46f..784313464ee 100644 --- a/mobile/src/files/mobile-file-preview-route.test.ts +++ b/mobile/src/files/mobile-file-preview-route.test.ts @@ -65,6 +65,8 @@ describe('mobile-file-preview-route', () => { terminal: 'term-1', pathText: 'result.json', cwd: '/tmp/run', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1', line: '12', column: '3' }) @@ -80,6 +82,8 @@ describe('mobile-file-preview-route', () => { terminal: 'term-1', pathText: 'result.json', cwd: '/tmp/run', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1', line: '12', column: '3' } diff --git a/mobile/src/files/mobile-file-preview-route.ts b/mobile/src/files/mobile-file-preview-route.ts index 1b277de7faa..f0e7dfbbf49 100644 --- a/mobile/src/files/mobile-file-preview-route.ts +++ b/mobile/src/files/mobile-file-preview-route.ts @@ -10,6 +10,8 @@ export type MobileFilePreviewRouteParams = { terminal?: string pathText?: string cwd?: string + nativeChatTab?: string + nativeChatSession?: string line?: string column?: string name?: string @@ -35,6 +37,8 @@ type RawPreviewRouteParams = { terminal?: MobileFilePreviewParamValue pathText?: MobileFilePreviewParamValue cwd?: MobileFilePreviewParamValue + nativeChatTab?: MobileFilePreviewParamValue + nativeChatSession?: MobileFilePreviewParamValue line?: MobileFilePreviewParamValue column?: MobileFilePreviewParamValue name?: MobileFilePreviewParamValue @@ -76,6 +80,8 @@ export function normalizeMobileFilePreviewRouteParams( terminal: optionalSingleParam(params.terminal), pathText: optionalSingleParam(params.pathText), cwd: optionalSingleParam(params.cwd), + nativeChatTab: optionalSingleParam(params.nativeChatTab), + nativeChatSession: optionalSingleParam(params.nativeChatSession), line: optionalSingleParam(params.line), column: optionalSingleParam(params.column), name: optionalSingleParam(params.name), diff --git a/mobile/src/files/mobile-file-preview-source.ts b/mobile/src/files/mobile-file-preview-source.ts index b1b23757a6e..ae1c846ccfc 100644 --- a/mobile/src/files/mobile-file-preview-source.ts +++ b/mobile/src/files/mobile-file-preview-source.ts @@ -15,7 +15,15 @@ export function previewSourceFromRoute( grantId: params.grantId, ...(params.terminal ? { terminalHandle: params.terminal } : {}), ...(params.pathText ? { pathText: params.pathText } : {}), - ...(params.cwd ? { cwd: params.cwd } : {}) + ...(params.cwd ? { cwd: params.cwd } : {}), + ...(params.nativeChatTab && params.nativeChatSession + ? { + nativeChatContext: { + tabId: params.nativeChatTab, + sessionId: params.nativeChatSession + } + } + : {}) } } if (!params.relativePath) { @@ -28,12 +36,12 @@ export function sourceKeyForPreview(source: MobileFilePreviewSource | null): str if (!source) { return null } - return source.source === 'terminalArtifact' - ? JSON.stringify([ - 'terminal', - source.worktreeId, - source.absolutePath, - source.terminalHandle ?? '' - ]) - : JSON.stringify(['worktree', source.worktreeId, source.relativePath]) + if (source.source !== 'terminalArtifact') { + return JSON.stringify(['worktree', source.worktreeId, source.relativePath]) + } + const key = ['terminal', source.worktreeId, source.absolutePath, source.terminalHandle ?? ''] + if (source.nativeChatContext) { + key.push(source.nativeChatContext.tabId, source.nativeChatContext.sessionId) + } + return JSON.stringify(key) } diff --git a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts index a8864119be4..6e7d4329413 100644 --- a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts +++ b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts @@ -1,3 +1,4 @@ +import type { RuntimeNativeChatFileContext } from '../../../src/shared/runtime-types' import type { RpcClient } from '../transport/rpc-client' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' @@ -12,6 +13,7 @@ export type MobileTerminalArtifactPreviewSource = { terminalHandle?: string pathText?: string cwd?: string + nativeChatContext?: RuntimeNativeChatFileContext } export type TerminalArtifactRetryOptions = { @@ -32,7 +34,8 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure( worktree: `id:${source.worktreeId}`, pathText: source.pathText ?? source.absolutePath, ...(source.cwd ? { cwd: source.cwd } : {}), - ...(source.terminalHandle ? { terminal: source.terminalHandle } : {}) + ...(source.terminalHandle ? { terminal: source.terminalHandle } : {}), + ...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {}) }) if (!refreshed.ok) { return null @@ -51,7 +54,8 @@ export async function refreshTerminalArtifactSourceAfterGrantFailure( grantId: result.openTarget.grantId, ...(source.terminalHandle ? { terminalHandle: source.terminalHandle } : {}), ...(source.pathText ? { pathText: source.pathText } : {}), - ...(source.cwd ? { cwd: source.cwd } : {}) + ...(source.cwd ? { cwd: source.cwd } : {}), + ...(source.nativeChatContext ? { nativeChatContext: source.nativeChatContext } : {}) } } diff --git a/mobile/src/session/mobile-file-tap-open.ts b/mobile/src/session/mobile-file-tap-open.ts index 627d9a5ff39..13c306af649 100644 --- a/mobile/src/session/mobile-file-tap-open.ts +++ b/mobile/src/session/mobile-file-tap-open.ts @@ -1,5 +1,6 @@ import type { RuntimeFileOpenResult, + RuntimeNativeChatFileContext, RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path' @@ -22,6 +23,7 @@ export type OpenMobileFileTapOptions = { terminalHandle?: string | null pathText: string cwd?: string | null + nativeChatContext?: RuntimeNativeChatFileContext | null line: number | null column: number | null pushPreviewRoute: (href: ReturnType) => void @@ -80,7 +82,8 @@ async function openMobileFileTapAsync( ...(options.terminalHandle && options.terminalHandle.trim().length > 0 ? { terminal: options.terminalHandle } : {}), - ...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}) + ...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}), + ...(options.nativeChatContext ? { nativeChatContext: options.nativeChatContext } : {}) }, { timeoutMs: 10_000 } ) @@ -116,6 +119,12 @@ async function openMobileFileTapAsync( ...(options.terminalHandle && options.terminalHandle.trim().length > 0 ? { terminal: options.terminalHandle } : {}), + ...(options.nativeChatContext + ? { + nativeChatTab: options.nativeChatContext.tabId, + nativeChatSession: options.nativeChatContext.sessionId + } + : {}), name: displayNameFromPath(resolved.openTarget.absolutePath), ...(options.line !== null ? { line: String(options.line) } : {}), ...(options.column !== null ? { column: String(options.column) } : {}), diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts index d44530e5cd0..363edb90218 100644 --- a/mobile/src/session/mobile-native-chat-open-file.test.ts +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -65,6 +65,53 @@ describe('openMobileNativeChatFileTap', () => { ) }) + it('carries the native-chat session identity into resolution and grant refresh', async () => { + const sendRequest = vi.fn(async () => + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/Users/ada/orca-plans/result.html', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1' + } + }) + ) + const options = baseOptions({ sendRequest }) + + openMobileNativeChatFileTap({ + ...options, + pathText: '~/orca-plans/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }) + await Promise.resolve() + + expect(sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { + worktree: 'id:wt-1', + pathText: '~/orca-plans/result.html', + crossWorkspace: true, + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }, + { timeoutMs: 10_000 } + ) + expect(options.pushPreviewRoute).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/files/preview/[worktreeId]', + params: expect.objectContaining({ + source: 'terminalArtifact', + absolutePath: '/Users/ada/orca-plans/result.html', + grantId: 'grant-1', + nativeChatTab: 'tab-1', + nativeChatSession: 'session-1' + }) + }) + }) + it('parses a :line:col citation and opens the mobile preview route', async () => { const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts')) const options = baseOptions({ sendRequest }) diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts index 0cfa617f269..21f07562459 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.test.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts @@ -34,12 +34,13 @@ describe('useMobileFileTapHandlers', () => { hostId: 'host-1', worktreeId: 'wt-1', worktreeName: 'Orca', + nativeChatSessionId: 'session-1', activeHandleRef: { current: 'terminal-1' as string | null }, terminalCwdRef: { current: new Map([['terminal-1', '/repo/sub']]) }, openBrowser: vi.fn(), fetchSessionTabs: vi.fn(async () => {}), getSessionTabs: () => [], - getActiveSessionTabId: () => null, + getActiveSessionTabId: () => 'terminal-tab', getActiveSessionTabType: () => 'terminal', switchSessionTab: vi.fn(), scheduleDelayedAction: vi.fn(), @@ -131,7 +132,12 @@ describe('useMobileFileTapHandlers', () => { expect(sendRequest).toHaveBeenCalledWith( 'files.resolveTerminalPath', - { worktree: 'id:wt-1', pathText: 'mobile/src/x.ts', crossWorkspace: true }, + { + worktree: 'id:wt-1', + pathText: 'mobile/src/x.ts', + crossWorkspace: true, + nativeChatContext: { tabId: 'terminal-tab', sessionId: 'session-1' } + }, { timeoutMs: 10_000 } ) expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12") diff --git a/mobile/src/session/use-mobile-file-tap-handlers.ts b/mobile/src/session/use-mobile-file-tap-handlers.ts index 276be5b566f..67995115f45 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.ts @@ -10,6 +10,7 @@ type MobileFileTapHandlerOptions = { hostId: string worktreeId: string worktreeName?: string + nativeChatSessionId: string | null activeHandleRef: MutableRefObject terminalCwdRef: MutableRefObject> openBrowser: (url: string) => void @@ -48,6 +49,7 @@ export function useMobileFileTapHandlers( getActiveSessionTabType, getSessionTabs, hostId, + nativeChatSessionId, openBrowser, scheduleDelayedAction, reportChatTapFailure, @@ -71,6 +73,7 @@ export function useMobileFileTapHandlers( getActiveSessionTabType, getSessionTabs, hostId, + nativeChatSessionId, openBrowser, scheduleDelayedAction, reportChatTapFailure, @@ -87,6 +90,7 @@ export function useMobileFileTapHandlers( getActiveSessionTabType, getSessionTabs, hostId, + nativeChatSessionId, openBrowser, router, scheduleDelayedAction, @@ -144,12 +148,18 @@ export function useMobileFileTapHandlers( return } const activationSeq = ++activationSeqRef.current + const nativeChatSessionId = current.nativeChatSessionId + const nativeChatTabId = current.getActiveSessionTabId() openMobileNativeChatFileTap({ client: current.client, hostId: current.hostId, worktreeId: current.worktreeId, worktreeName: current.worktreeName, pathText, + nativeChatContext: + nativeChatSessionId && nativeChatTabId + ? { tabId: nativeChatTabId, sessionId: nativeChatSessionId } + : null, pushPreviewRoute: (href) => routerRef.current.push(href), openBrowser: current.openBrowser, triggerOpenFeedback: triggerSelection, diff --git a/src/main/native-chat/native-chat-file-provenance.test.ts b/src/main/native-chat/native-chat-file-provenance.test.ts new file mode 100644 index 00000000000..2592a257a8e --- /dev/null +++ b/src/main/native-chat/native-chat-file-provenance.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import type { RuntimeMobileSessionClientTab } from '../../shared/runtime-types' +import { nativeChatTranscriptIncludesPath } from './native-chat-file-provenance' + +function terminalTab(): RuntimeMobileSessionClientTab { + return { + type: 'terminal', + id: 'tab-1', + title: 'Codex', + parentTabId: 'parent-1', + leafId: 'leaf-1', + launchAgent: 'codex', + agentStatus: { + state: 'done', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey: 'parent-1:leaf-1', + stateHistory: [], + agentType: 'codex', + providerSession: { + key: 'session_id', + id: 'session-1', + transcriptPath: '/host/transcripts/session-1.jsonl' + } + }, + isActive: true, + status: 'ready', + terminal: 'term-1' + } +} + +function message(role: NativeChatMessage['role'], text: string): NativeChatMessage { + return { + id: `${role}-1`, + role, + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'transcript' + } +} + +describe('nativeChatTranscriptIncludesPath', () => { + it('accepts a path in recent assistant output from the host-bound session', async () => { + const readTranscript = vi.fn(async () => ({ + messages: [message('assistant', 'Open ~/orca-plans/result.html to review it.')] + })) + + await expect( + nativeChatTranscriptIncludesPath({ + tabs: [terminalTab()], + context: { tabId: 'tab-1', sessionId: 'session-1' }, + pathText: '~/orca-plans/result.html', + absolutePath: '/Users/ada/orca-plans/result.html', + readTranscript + }) + ).resolves.toBe(true) + expect(readTranscript).toHaveBeenCalledWith({ + agent: 'codex', + sessionId: 'session-1', + transcriptPath: '/host/transcripts/session-1.jsonl', + limit: 2000 + }) + }) + + it('rejects a client session identity that does not match the host tab', async () => { + const readTranscript = vi.fn() + + await expect( + nativeChatTranscriptIncludesPath({ + tabs: [terminalTab()], + context: { tabId: 'tab-1', sessionId: 'forged-session' }, + pathText: '/etc/passwd', + absolutePath: '/etc/passwd', + readTranscript + }) + ).resolves.toBe(false) + expect(readTranscript).not.toHaveBeenCalled() + }) + + it('does not treat user-authored transcript text as agent provenance', async () => { + const readTranscript = vi.fn(async () => ({ + messages: [message('user', 'Please open /etc/passwd')] + })) + + await expect( + nativeChatTranscriptIncludesPath({ + tabs: [terminalTab()], + context: { tabId: 'tab-1', sessionId: 'session-1' }, + pathText: '/etc/passwd', + absolutePath: '/etc/passwd', + readTranscript + }) + ).resolves.toBe(false) + }) +}) diff --git a/src/main/native-chat/native-chat-file-provenance.ts b/src/main/native-chat/native-chat-file-provenance.ts new file mode 100644 index 00000000000..746e7735078 --- /dev/null +++ b/src/main/native-chat/native-chat-file-provenance.ts @@ -0,0 +1,66 @@ +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' +import type { + RuntimeMobileSessionClientTab, + RuntimeMobileSessionTerminalClientTab, + RuntimeNativeChatFileContext +} from '../../shared/runtime-types' +import { recentTerminalOutputIncludesPath } from '../runtime/terminal-output-path-candidates' +import { readNativeChatTranscriptTail } from './transcript-tail-reader' + +// Match the largest transcript window mobile can render so any visible citation remains provable. +const NATIVE_CHAT_FILE_PROVENANCE_WINDOW = 2000 + +type TranscriptRead = (args: { + agent: AgentType + sessionId: string + transcriptPath?: string + limit: number +}) => Promise<{ messages: NativeChatMessage[] } | { error: string }> + +export async function nativeChatTranscriptIncludesPath(args: { + tabs: readonly RuntimeMobileSessionClientTab[] + context: RuntimeNativeChatFileContext + pathText: string + absolutePath: string + readTranscript?: TranscriptRead +}): Promise { + const tab = args.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalClientTab => + candidate.type === 'terminal' && candidate.id === args.context.tabId + ) + const providerSession = tab?.agentStatus?.providerSession + const agent = tab?.agentStatus?.agentType ?? tab?.launchAgent + if ( + !tab || + !providerSession || + providerSession.id !== args.context.sessionId || + !agent || + !resolveNativeChatTranscriptAgent(agent) + ) { + return false + } + + try { + const result = await (args.readTranscript ?? readNativeChatTranscriptTail)({ + agent, + sessionId: providerSession.id, + ...(providerSession.transcriptPath ? { transcriptPath: providerSession.transcriptPath } : {}), + limit: NATIVE_CHAT_FILE_PROVENANCE_WINDOW + }) + if (!('messages' in result)) { + return false + } + return result.messages.some( + (message) => + message.role === 'assistant' && + message.blocks.some( + (block) => + block.type === 'text' && + recentTerminalOutputIncludesPath(block.text, args.pathText, args.absolutePath) + ) + ) + } catch { + return false + } +} diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index ce96eddb5e0..e57ab8c1f67 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -157,6 +157,7 @@ function createRuntimeFileCommands(options?: { resolveTerminalContext?: ReturnType resolveTerminalFileUriHostname?: ReturnType hasRecentTerminalOutputPath?: ReturnType + hasRecentNativeChatOutputPath?: ReturnType }) { const store = { getRepo: vi.fn((_repoId?: string) => undefined as { connectionId?: string } | undefined) @@ -192,6 +193,9 @@ function createRuntimeFileCommands(options?: { ? { resolveTerminalFileUriHostname: options.resolveTerminalFileUriHostname } : {}), hasRecentTerminalOutputPath: options?.hasRecentTerminalOutputPath ?? vi.fn(() => true), + ...(options?.hasRecentNativeChatOutputPath + ? { hasRecentNativeChatOutputPath: options.hasRecentNativeChatOutputPath } + : {}), resolveRuntimeGitTarget: options?.resolveRuntimeGitTarget ?? vi.fn(), openFile: options?.openFile ?? vi.fn(), ...(options?.openDiff ? { openDiff: options.openDiff } : {}) @@ -992,6 +996,28 @@ describe('RuntimeFileCommands', () => { }) }) + it('keeps in-worktree resolution unchanged when chat provenance is present', async () => { + const hasRecentNativeChatOutputPath = vi.fn(() => true) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) + statAsFile() + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '/repo/src/index.ts', + null, + 'client-a', + null, + true, + { tabId: 'tab-1', sessionId: 'session-1' } + ) + + expect(result).toMatchObject({ relativePath: 'src/index.ts', exists: true }) + expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled() + }) + it('resolves an absolute path through a known sibling workspace', async () => { const sibling = { id: 'wt-2', @@ -1367,9 +1393,13 @@ describe('RuntimeFileCommands', () => { expect(statMock).not.toHaveBeenCalled() }) - it('does not mint an absolute terminal artifact grant without a source terminal', async () => { + it('keeps old-client behavior without native-chat provenance', async () => { const artifactPath = await tempFile('result.json', '{}') - const { commands } = createRuntimeFileCommands({ path: '/repo' }) + const hasRecentNativeChatOutputPath = vi.fn(() => true) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) const result = await commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a') @@ -1380,6 +1410,68 @@ describe('RuntimeFileCommands', () => { isDirectory: false }) expect(result.openTarget).toBeUndefined() + expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled() + }) + + it('mints an exact-path grant for an out-of-worktree path cited by native chat', async () => { + const artifactPath = await tempFile('chat-result.html', '

Result

') + const hasRecentNativeChatOutputPath = vi.fn(() => true) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + artifactPath, + null, + 'client-a', + null, + true, + { tabId: 'tab-1', sessionId: 'session-1' } + ) + + expect(hasRecentNativeChatOutputPath).toHaveBeenCalledWith( + 'wt-1', + { tabId: 'tab-1', sessionId: 'session-1' }, + artifactPath, + artifactPath + ) + expect(result).toMatchObject({ + worktree: 'wt-1', + relativePath: null, + absolutePath: await realpath(artifactPath), + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: await realpath(artifactPath) + } + }) + }) + + it('refuses an out-of-worktree chat path without transcript provenance', async () => { + const artifactPath = await tempFile('uncited-result.html', '

Secret

') + const hasRecentNativeChatOutputPath = vi.fn(() => false) + const { commands } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + artifactPath, + null, + 'client-a', + null, + true, + { tabId: 'tab-1', sessionId: 'session-1' } + ) + + expect(result).toMatchObject({ relativePath: null, exists: false }) + expect(result.openTarget).toBeUndefined() + expect(hasRecentNativeChatOutputPath).toHaveBeenCalledTimes(1) }) it('does not mint an absolute terminal artifact grant for an unobserved path', async () => { @@ -2294,6 +2386,28 @@ describe('RuntimeFileCommands', () => { expect(stat).not.toHaveBeenCalled() }) + it('still refuses a native-chat ~/ path on a remote worktree', async () => { + const hasRecentNativeChatOutputPath = vi.fn(() => true) + const { commands, store } = createRuntimeFileCommands({ + path: '/repo', + hasRecentNativeChatOutputPath + }) + store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) + + const result = await commands.resolveTerminalPath( + 'id:wt-1', + '~/notes.md', + null, + 'client-a', + null, + true, + { tabId: 'tab-1', sessionId: 'session-1' } + ) + + expect(result).toMatchObject({ relativePath: null, exists: false }) + expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled() + }) + it('reports a missing remote file as not existing', async () => { const { commands, store } = createRuntimeFileCommands({ path: '/repo' }) store.getRepo.mockReturnValue({ connectionId: 'ssh-1' }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index f85bff8abde..aaebfb24713 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -45,6 +45,7 @@ import type { RuntimeFileReadChunkResult, RuntimeFilePreviewResult, RuntimeFileReadResult, + RuntimeNativeChatFileContext, RuntimeTerminalPathResolution } from '../../shared/runtime-types' import { @@ -524,6 +525,12 @@ export type RuntimeFileCommandHost = { pathText: string, absolutePath: string ): boolean | Promise + hasRecentNativeChatOutputPath?( + worktreeId: string, + context: RuntimeNativeChatFileContext, + pathText: string, + absolutePath: string + ): boolean | Promise resolveRuntimeGitTarget( selector: string ): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }> @@ -726,7 +733,8 @@ export class RuntimeFileCommands { cwd?: string | null, clientId?: string, terminalHandle?: string | null, - crossWorkspace?: boolean + crossWorkspace?: boolean, + nativeChatContext?: RuntimeNativeChatFileContext | null ): Promise { const store = this.host.requireStore() const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) @@ -802,6 +810,24 @@ export class RuntimeFileCommands { } } + if ( + nativeChatContext && + (await this.host.hasRecentNativeChatOutputPath?.( + worktree.id, + nativeChatContext, + pathText, + absolutePath + )) + ) { + const artifactPath = await this.resolveNativeChatArtifactPath(absolutePath, connectionId) + return await this.resolveAbsoluteFileGrant({ + worktreeId: worktree.id, + artifactPath, + connectionId, + clientId + }) + } + // Why: mobile taps may hit agent artifacts outside the worktree; grant the exact path, not arbitrary absolute paths. if (!normalizedTerminalHandle || !terminalCwd) { return { ...empty, relativePath, absolutePath } @@ -831,38 +857,13 @@ export class RuntimeFileCommands { ) { return { ...empty, relativePath, absolutePath } } - const stats = connectionId - ? await this.statRemoteTerminalPath(artifactPath, connectionId) - : await this.statLocalTerminalPath(artifactPath) - const isDirectory = stats.isDirectory() - if (!isDirectory && isTerminalArtifactHardLinked(stats)) { - return { ...empty, relativePath, absolutePath } - } - const grant = isDirectory - ? null - : this.createTerminalFileGrant({ - worktreeId: worktree.id, - absolutePath: artifactPath, - provider: connectionId ? 'ssh' : 'local', - connectionId, - clientId, - stats - }) - return { - worktree: worktree.id, - relativePath: null, - absolutePath: artifactPath, - exists: true, - isDirectory, - openTarget: grant - ? { - kind: 'absolute-file', - provider: grant.provider, - absolutePath: artifactPath, - grantId: grant.id - } - : undefined - } + return await this.resolveAbsoluteFileGrant({ + worktreeId: worktree.id, + artifactPath, + rejectedAbsolutePath: absolutePath, + connectionId, + clientId + }) } catch (error) { // Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing. if ( @@ -909,6 +910,67 @@ export class RuntimeFileCommands { return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath) } + private async resolveNativeChatArtifactPath( + absolutePath: string, + connectionId?: string + ): Promise { + if (!connectionId) { + return canonicalPathForArtifactComparison(absolutePath) + } + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.realpath(absolutePath) + } + + private async resolveAbsoluteFileGrant(args: { + worktreeId: string + artifactPath: string + rejectedAbsolutePath?: string + connectionId?: string + clientId?: string + }): Promise { + const stats = args.connectionId + ? await this.statRemoteTerminalPath(args.artifactPath, args.connectionId) + : await this.statLocalTerminalPath(args.artifactPath) + const isDirectory = stats.isDirectory() + if (!isDirectory && isTerminalArtifactHardLinked(stats)) { + return { + worktree: args.worktreeId, + relativePath: null, + absolutePath: args.rejectedAbsolutePath ?? args.artifactPath, + exists: false, + isDirectory: false + } + } + const grant = isDirectory + ? null + : this.createTerminalFileGrant({ + worktreeId: args.worktreeId, + absolutePath: args.artifactPath, + provider: args.connectionId ? 'ssh' : 'local', + connectionId: args.connectionId, + clientId: args.clientId, + stats + }) + return { + worktree: args.worktreeId, + relativePath: null, + absolutePath: args.artifactPath, + exists: true, + isDirectory, + openTarget: grant + ? { + kind: 'absolute-file', + provider: grant.provider, + absolutePath: args.artifactPath, + grantId: grant.id + } + : undefined + } + } + private async resolveAllowedRemoteTerminalArtifactPath( absolutePath: string, connectionId: string diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7cad4ad94bc..e54134eeade 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1012,6 +1012,7 @@ import { recentTerminalOutputIncludesPath, recentTerminalPathCandidatesIncludePath } from './terminal-output-path-candidates' +import { nativeChatTranscriptIncludesPath } from '../native-chat/native-chat-file-provenance' import { getSelectedReviewBranch, getSelectedReviewLookupHints, @@ -8764,6 +8765,13 @@ export class OrcaRuntimeService { this.resolveTerminalFileUriHostname(terminalHandle), hasRecentTerminalOutputPath: (terminalHandle, pathText, absolutePath) => this.hasRecentTerminalOutputPath(terminalHandle, pathText, absolutePath), + hasRecentNativeChatOutputPath: (worktreeId, context, pathText, absolutePath) => + nativeChatTranscriptIncludesPath({ + tabs: this.getMobileSessionTabsForWorktree(worktreeId).tabs, + context, + pathText, + absolutePath + }), resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => { if (!this.notifier?.openFile) { diff --git a/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts b/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts index 2055222ff7f..d589b74116c 100644 --- a/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts +++ b/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts @@ -40,7 +40,8 @@ describe('files.resolveTerminalPath RPC', () => { '/repo', undefined, 'term-1', - false + false, + null ) expect(response).toMatchObject({ ok: true, @@ -67,7 +68,30 @@ describe('files.resolveTerminalPath RPC', () => { null, undefined, null, - true + true, + null + ) + }) + + it('forwards optional native-chat provenance', async () => { + const { runtime, dispatcher } = createDispatcher() + + await dispatcher.dispatch( + makeRequest('files.resolveTerminalPath', { + worktree: 'id:wt-1', + pathText: '/outside/result.html', + nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' } + }) + ) + + expect(runtime.resolveTerminalPath).toHaveBeenLastCalledWith( + 'id:wt-1', + '/outside/result.html', + null, + undefined, + null, + false, + { tabId: 'tab-1', sessionId: 'session-1' } ) }) }) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index ffaa48b510e..741fc65635f 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -78,6 +78,12 @@ const ResolveTerminalPath = WorktreeSelector.extend({ crossWorkspace: z .unknown() .transform((v) => v === true) + .optional(), + nativeChatContext: z + .object({ + tabId: z.string().min(1), + sessionId: z.string().min(1) + }) .optional() }) @@ -257,7 +263,8 @@ export const FILE_METHODS: RpcAnyMethod[] = [ params.cwd ?? null, clientId, params.terminal ?? null, - params.crossWorkspace === true + params.crossWorkspace === true, + params.nativeChatContext ?? null ) }), defineMethod({ diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index af02b02e484..901af13fdcc 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -410,6 +410,11 @@ export type RuntimeTerminalPathOpenTarget = reason: string } +export type RuntimeNativeChatFileContext = { + tabId: string + sessionId: string +} + /** Result of resolving a file path tapped in the mobile terminal against the * selected or sibling workspace root (+ optional cwd). relativePath is null * when no workspace on the same execution host owns the path. */