mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(mobile): open agent-cited external chat files
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user