fix(mobile): open agent-cited external chat files

This commit is contained in:
Brennan Benson
2026-08-12 19:30:48 -07:00
parent f1ee3088a5
commit e2afd92280
19 changed files with 569 additions and 52 deletions
@@ -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[]
@@ -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),
@@ -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: '<h1>Result</h1>', 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'],
@@ -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'
}
@@ -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),
+17 -9
View File
@@ -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)
}
@@ -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 } : {})
}
}
+10 -1
View File
@@ -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<T extends FileTapSessionTab> = {
terminalHandle?: string | null
pathText: string
cwd?: string | null
nativeChatContext?: RuntimeNativeChatFileContext | null
line: number | null
column: number | null
pushPreviewRoute: (href: ReturnType<typeof createMobileFilePreviewHref>) => void
@@ -80,7 +82,8 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
...(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<T extends FileTapSessionTab>(
...(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) } : {}),
@@ -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 })
@@ -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")
@@ -10,6 +10,7 @@ type MobileFileTapHandlerOptions<T extends FileTapSessionTab> = {
hostId: string
worktreeId: string
worktreeName?: string
nativeChatSessionId: string | null
activeHandleRef: MutableRefObject<string | null>
terminalCwdRef: MutableRefObject<Map<string, string>>
openBrowser: (url: string) => void
@@ -48,6 +49,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
getActiveSessionTabType,
getSessionTabs,
hostId,
nativeChatSessionId,
openBrowser,
scheduleDelayedAction,
reportChatTapFailure,
@@ -71,6 +73,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
getActiveSessionTabType,
getSessionTabs,
hostId,
nativeChatSessionId,
openBrowser,
scheduleDelayedAction,
reportChatTapFailure,
@@ -87,6 +90,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
getActiveSessionTabType,
getSessionTabs,
hostId,
nativeChatSessionId,
openBrowser,
router,
scheduleDelayedAction,
@@ -144,12 +148,18 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
return
}
const activationSeq = ++activationSeqRef.current
const nativeChatSessionId = current.nativeChatSessionId
const nativeChatTabId = current.getActiveSessionTabId()
openMobileNativeChatFileTap<T>({
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,
@@ -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)
})
})
@@ -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<boolean> {
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
}
}
+116 -2
View File
@@ -157,6 +157,7 @@ function createRuntimeFileCommands(options?: {
resolveTerminalContext?: ReturnType<typeof vi.fn>
resolveTerminalFileUriHostname?: ReturnType<typeof vi.fn>
hasRecentTerminalOutputPath?: ReturnType<typeof vi.fn>
hasRecentNativeChatOutputPath?: ReturnType<typeof vi.fn>
}) {
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', '<h1>Result</h1>')
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', '<h1>Secret</h1>')
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' })
+95 -33
View File
@@ -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<boolean>
hasRecentNativeChatOutputPath?(
worktreeId: string,
context: RuntimeNativeChatFileContext,
pathText: string,
absolutePath: string
): boolean | Promise<boolean>
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<RuntimeTerminalPathResolution> {
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<string> {
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<RuntimeTerminalPathResolution> {
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
+8
View File
@@ -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) {
@@ -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' }
)
})
})
+8 -1
View File
@@ -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({
+5
View File
@@ -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. */