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

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

* fix(mobile): keep cited external files read-only

* refactor(mobile): derive cited-file mode from provenance

* fix(mobile): accept sentence-final cited paths

* fix(mobile): preserve cited SSH grant scope

* refactor(file-links): share location suffix parsing
This commit is contained in:
Brennan Benson
2026-08-13 01:12:19 -07:00
committed by GitHub
parent af7dcdc196
commit 0ed6db77cf
29 changed files with 948 additions and 107 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[]
@@ -3105,6 +3105,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),
@@ -3,6 +3,8 @@
// viewer). We deliberately favor precision over recall: a missed path is a minor
// annoyance, but a false positive on prose or a version number is a broken tap.
import { parseFileLinkLocation } from '../../../src/shared/file-link-location'
export type FilePathSegment =
| { type: 'text'; value: string }
| { type: 'file'; value: string; path: string }
@@ -101,8 +103,6 @@ function hasMidTokenAt(candidate: string): boolean {
return /[^\\/]@/.test(candidate)
}
const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/
/**
* Split an agent-style `path:line(:col)` citation into its parts. Windows drive
* colons are safe: only a trailing all-digit suffix is treated as a line ref.
@@ -112,14 +112,14 @@ export function splitFilePathLineSuffix(pathText: string): {
line: number | null
column: number | null
} {
const match = LINE_SUFFIX_PATTERN.exec(pathText)
if (!match) {
const parsed = parseFileLinkLocation(pathText)
if (!parsed || (parsed.line === null && parsed.column === null)) {
return { path: pathText, line: null, column: null }
}
return {
path: match[1]!,
line: Number.parseInt(match[2]!, 10),
column: match[3] ? Number.parseInt(match[3], 10) : null
path: parsed.pathText,
line: parsed.line,
column: parsed.column
}
}
@@ -39,6 +39,21 @@ describe('MobileFilePreviewScreen', () => {
).toBe(false)
})
it('treats native-chat artifact grants as read-only', () => {
expect(
isEditableMobileTerminalArtifactPreview(
{
status: 'ready',
kind: 'text',
content: '<h1>Result</h1>',
truncated: false,
byteLength: 15
},
true
)
).toBe(false)
})
it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => {
const sourceKey = sourceKeyForPreview({
source: 'terminalArtifact',
+2 -1
View File
@@ -181,7 +181,8 @@ export function MobileFilePreviewScreen({ route }: Props) {
)
const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
const isEditableTerminalArtifact =
previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview)
previewSource?.source === 'terminalArtifact' &&
isEditableMobileTerminalArtifactPreview(preview, previewSource.readOnly === true)
const canSaveArtifact =
isEditableTerminalArtifact &&
draftSourceKeyRef.current === previewSourceKey &&
@@ -3,7 +3,13 @@ import type {
MobileFilePreviewSource
} from './mobile-file-preview-request'
export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean {
export function isEditableMobileTerminalArtifactPreview(
preview: MobileFilePreviewResult,
readOnly = false
): boolean {
if (readOnly) {
return false
}
return (
(preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) ||
preview.status === 'empty'
@@ -179,6 +179,47 @@ 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',
readOnly: true
}
}),
ok({ content: '<h1>Result</h1>', truncated: false, byteLength: 15 })
])
const onTerminalArtifactSourceRefreshed = vi.fn()
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' }
},
undefined,
{ onTerminalArtifactSourceRefreshed }
)
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
worktree: 'id:wt-1',
pathText: '~/orca-plans/result.html',
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
})
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith(
expect.objectContaining({ grantId: 'grant-2', readOnly: true })
)
})
it.each([
['terminal_file_grant_expired'],
['terminal_file_grant_mismatch'],
@@ -242,6 +283,25 @@ describe('mobile-file-preview-request', () => {
})
})
it('does not send writes for read-only native-chat artifacts', async () => {
const client = clientWith(ok({ ok: true }))
await expect(
saveMobileTerminalArtifactPreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-1',
readOnly: true
},
'<h1>Changed</h1>'
)
).resolves.toMatchObject({ status: 'error' })
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('does not refresh and retry a failed terminal artifact save without a base content check', async () => {
const client = clientWithResponses([fail('terminal_file_grant_mismatch')])
@@ -116,6 +116,9 @@ export async function saveMobileTerminalArtifactPreview(
content: string,
options: TerminalArtifactSaveOptions = {}
): Promise<MobileFilePreviewResult | { status: 'saved' }> {
if (source.readOnly) {
return previewError('This file is read-only')
}
let writeSource = source
if (typeof options.baseContent === 'string') {
const verified = await verifyTerminalArtifactBaseContent(
@@ -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),
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { sourceKeyForPreview } from './mobile-file-preview-source'
import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
describe('mobile-file-preview-source', () => {
it('uses structured preview keys so colons in paths cannot collide', () => {
@@ -25,4 +25,21 @@ describe('mobile-file-preview-source', () => {
sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' })
).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts']))
})
it('makes native-chat artifact previews read-only', () => {
expect(
previewSourceFromRoute({
hostId: 'host-1',
worktreeId: 'wt-1',
source: 'terminalArtifact',
absolutePath: '/tmp/result.html',
grantId: 'grant-1',
nativeChatTab: 'tab-1',
nativeChatSession: 'session-1'
})
).toMatchObject({
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' },
readOnly: true
})
})
})
+18 -9
View File
@@ -15,7 +15,16 @@ 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
},
readOnly: true as const
}
: {})
}
}
if (!params.relativePath) {
@@ -28,12 +37,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,8 @@ export type MobileTerminalArtifactPreviewSource = {
terminalHandle?: string
pathText?: string
cwd?: string
nativeChatContext?: RuntimeNativeChatFileContext
readOnly?: true
}
export type TerminalArtifactRetryOptions = {
@@ -32,7 +35,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 +55,9 @@ 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 } : {}),
...(source.readOnly || result.openTarget.readOnly === true ? { readOnly: true as const } : {})
}
}
@@ -68,7 +74,7 @@ function isTerminalArtifactGrantFailure(
function isTerminalArtifactResolution(result: unknown): result is {
exists: true
isDirectory: false
openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string }
openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string; readOnly?: true }
} {
if (!result || typeof result !== 'object') {
return false
+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,54 @@ 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',
readOnly: true
}
})
)
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,
+10 -14
View File
@@ -3,11 +3,12 @@
// one path span containing the tapped column — mobile opens a tapped path, it
// does not render hover links over the whole line.
export type TappedFilePath = {
pathText: string
line: number | null
column: number | null
}
import {
parseFileLinkLocation,
type ParsedFileLinkLocation
} from '../../../src/shared/file-link-location'
export type TappedFilePath = ParsedFileLinkLocation
// Separator-anchored path tokens (absolute, relative, ~/, drive-letter, UNC) OR
// a bare filename with an extension (README.md, index.ts), optionally suffixed
@@ -47,18 +48,13 @@ function trimBoundaryPunctuation(
}
export function parsePathWithOptionalLineColumn(value: string): TappedFilePath | null {
const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value)
if (!match) {
const parsed = parseFileLinkLocation(value)
if (!parsed) {
return null
}
const pathText = match[1]
const { pathText, line, column } = parsed
// Reject a directory-only token (trailing separator) for either slash style.
if (!pathText || pathText.endsWith('/') || pathText.endsWith('\\')) {
return null
}
const line = match[2] ? Number.parseInt(match[2], 10) : null
const column = match[3] ? Number.parseInt(match[3], 10) : null
if ((line !== null && line < 1) || (column !== null && column < 1)) {
if (pathText.endsWith('/') || pathText.endsWith('\\')) {
return null
}
return { pathText, line, column }
@@ -0,0 +1,129 @@
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('accepts a path followed by a sentence-final period', async () => {
const readTranscript = vi.fn(async () => ({
messages: [message('assistant', 'Open /tmp/orca-pr14166-external.txt.')]
}))
await expect(
nativeChatTranscriptIncludesPath({
tabs: [terminalTab()],
context: { tabId: 'tab-1', sessionId: 'session-1' },
pathText: '/tmp/orca-pr14166-external.txt',
absolutePath: '/tmp/orca-pr14166-external.txt',
readTranscript
})
).resolves.toBe(true)
})
it('does not accept a longer filename sharing the requested path prefix', async () => {
const readTranscript = vi.fn(async () => ({
messages: [message('assistant', 'Open /tmp/orca-pr14166-external.txt.backup')]
}))
await expect(
nativeChatTranscriptIncludesPath({
tabs: [terminalTab()],
context: { tabId: 'tab-1', sessionId: 'session-1' },
pathText: '/tmp/orca-pr14166-external.txt',
absolutePath: '/tmp/orca-pr14166-external.txt',
readTranscript
})
).resolves.toBe(false)
})
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,74 @@
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 }>
function transcriptTextIncludesPath(text: string, pathText: string, absolutePath: string): boolean {
if (recentTerminalOutputIncludesPath(text, pathText, absolutePath)) {
return true
}
// Mobile excludes a sentence-final period from the tappable path span.
return recentTerminalOutputIncludesPath(text, `${pathText}.`, `${absolutePath}.`)
}
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' &&
transcriptTextIncludesPath(block.text, args.pathText, args.absolutePath)
)
)
} catch {
return false
}
}
+270 -5
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 } : {})
@@ -945,11 +949,18 @@ describe('RuntimeFileCommands', () => {
)
}
function createRemoteTerminalArtifactGrantFixture(artifactPath = '/tmp/result.json') {
const { commands, store } = createRuntimeFileCommands({ path: '/repo' })
function createRemoteTerminalArtifactGrantFixture(
artifactPath = '/tmp/result.json',
nativeChat = false
) {
const { commands, store } = createRuntimeFileCommands({
path: '/repo',
...(nativeChat ? { hasRecentNativeChatOutputPath: vi.fn(() => true) } : {})
})
store.getRepo.mockReturnValue({ connectionId: 'ssh-1' })
let realArtifactPath = artifactPath
const stat = vi.fn().mockResolvedValue({ type: 'file', size: 11, mtime: 3 })
let artifactStat = { type: 'file', size: 11, mtime: 3 }
const stat = vi.fn(async () => artifactStat)
const readTerminalArtifact = vi
.fn()
.mockResolvedValue({ content: '{"ok":true}', isBinary: false })
@@ -967,10 +978,20 @@ describe('RuntimeFileCommands', () => {
writeTerminalArtifact,
moveArtifactTarget: (nextPath: string) => {
realArtifactPath = nextPath
},
replaceArtifact: () => {
artifactStat = { type: 'file', size: 12, mtime: 4 }
}
}
}
function resolveRemoteNativeChatArtifact(commands: RuntimeFileCommands, artifactPath: string) {
return commands.resolveTerminalPath('id:wt-1', artifactPath, null, 'client-a', null, true, {
tabId: 'tab-1',
sessionId: 'session-1'
})
}
it('resolves an absolute path inside the worktree to a relative path', async () => {
const { commands } = createRuntimeFileCommands({ path: '/repo' })
statAsFile()
@@ -992,6 +1013,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 +1410,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 +1427,139 @@ 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),
readOnly: true
}
})
const target = absoluteFileTarget(result)
await expect(
commands.writeTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'<h1>Changed</h1>',
'client-a'
)
).rejects.toThrow('terminal_file_grant_read_only')
await expect(readFile(artifactPath, 'utf8')).resolves.toBe('<h1>Result</h1>')
})
it('binds a cited symlink alias to its canonical read-only target', async () => {
const artifactPath = await tempFile('chat-target.html', '<h1>Result</h1>')
const citedPath = join(artifactPath, '..', 'chat-citation.html')
await symlink(artifactPath, citedPath)
const hasRecentNativeChatOutputPath = vi.fn(() => true)
const { commands } = createRuntimeFileCommands({
path: '/repo',
hasRecentNativeChatOutputPath
})
const result = await commands.resolveTerminalPath(
'id:wt-1',
citedPath,
null,
'client-a',
null,
true,
{ tabId: 'tab-1', sessionId: 'session-1' }
)
expect(hasRecentNativeChatOutputPath).toHaveBeenCalledWith(
'wt-1',
{ tabId: 'tab-1', sessionId: 'session-1' },
citedPath,
citedPath
)
expect(result).toMatchObject({
absolutePath: await realpath(artifactPath),
exists: true,
openTarget: {
kind: 'absolute-file',
absolutePath: await realpath(artifactPath),
readOnly: true
}
})
})
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('refuses native-chat tilde paths when the workspace runs in WSL', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const hasRecentNativeChatOutputPath = vi.fn(() => true)
const { commands } = createRuntimeFileCommands({
path: String.raw`\\wsl.localhost\Ubuntu\work\repo`,
hasRecentNativeChatOutputPath
})
const result = await commands.resolveTerminalPath(
'id:wt-1',
'~/.ssh/config',
null,
'client-a',
null,
true,
{ tabId: 'tab-1', sessionId: 'session-1' }
)
expect(result).toMatchObject({ relativePath: null, absolutePath: null, exists: false })
expect(hasRecentNativeChatOutputPath).not.toHaveBeenCalled()
})
it('does not mint an absolute terminal artifact grant for an unobserved path', async () => {
@@ -2213,6 +2393,69 @@ describe('RuntimeFileCommands', () => {
expect(writeTerminalArtifact).toHaveBeenCalled()
})
it('reads a remote non-temp artifact cited by native chat', async () => {
const artifactPath = '/home/me/report.json'
const { commands, readTerminalArtifact } = createRemoteTerminalArtifactGrantFixture(
artifactPath,
true
)
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
const target = absoluteFileTarget(result)
await expect(
commands.readTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'client-a'
)
).resolves.toMatchObject({ content: '{"ok":true}' })
expect(readTerminalArtifact).toHaveBeenCalledWith(
artifactPath,
expect.objectContaining({ expectedRealPath: artifactPath })
)
})
it('rejects a retargeted remote native-chat artifact grant', async () => {
const artifactPath = '/home/me/report.json'
const { commands, readTerminalArtifact, moveArtifactTarget } =
createRemoteTerminalArtifactGrantFixture(artifactPath, true)
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
const target = absoluteFileTarget(result)
moveArtifactTarget('/home/me/private.json')
await expect(
commands.readTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'client-a'
)
).rejects.toThrow('terminal_file_grant_stale')
expect(readTerminalArtifact).not.toHaveBeenCalled()
})
it('rejects a replaced remote native-chat artifact grant', async () => {
const artifactPath = '/home/me/report.json'
const { commands, readTerminalArtifact, replaceArtifact } =
createRemoteTerminalArtifactGrantFixture(artifactPath, true)
const result = await resolveRemoteNativeChatArtifact(commands, artifactPath)
const target = absoluteFileTarget(result)
replaceArtifact()
await expect(
commands.readTerminalArtifactFile(
'id:wt-1',
target.grantId,
target.absolutePath,
'client-a'
)
).rejects.toThrow('terminal_file_grant_stale')
expect(readTerminalArtifact).not.toHaveBeenCalled()
})
it('rejects remote terminal artifact reads when a grant no longer resolves to the granted path', async () => {
const { commands, readTerminalArtifact, moveArtifactTarget } =
createRemoteTerminalArtifactGrantFixture()
@@ -2294,6 +2537,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' })
+123 -42
View File
@@ -45,6 +45,7 @@ import type {
RuntimeFileReadChunkResult,
RuntimeFilePreviewResult,
RuntimeFileReadResult,
RuntimeNativeChatFileContext,
RuntimeTerminalPathResolution
} from '../../shared/runtime-types'
import {
@@ -183,6 +184,8 @@ type TerminalFileGrant = {
clientId?: string
expiresAt: number
statIdentity: string | null
readOnly: boolean
provenance: 'terminal-output' | 'native-chat'
expiryTimer?: ReturnType<typeof setTimeout>
}
@@ -524,6 +527,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 +735,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)
@@ -750,9 +760,9 @@ export class RuntimeFileCommands {
isDirectory: false
}
// Why: remote home is unknown (only local os.homedir), so a tapped ~/… on a remote worktree is not-openable, not guessed.
// Why: SSH/WSL homes are unknown here; native-chat grants must not expand their ~/… paths against the local host home.
const isTilde = pathText.startsWith('~/') || pathText.startsWith('~\\')
if (isTilde && connectionId) {
if (isTilde && (connectionId || (nativeChatContext && parseWslPath(worktree.path)))) {
return empty
}
const expanded = isTilde ? resolveRuntimePath(homedir(), pathText.slice(2)) : pathText
@@ -802,6 +812,26 @@ 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,
readOnly: true,
provenance: 'native-chat'
})
}
// 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 +861,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 +914,72 @@ 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
readOnly?: boolean
provenance?: TerminalFileGrant['provenance']
}): 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,
readOnly: args.readOnly === true,
provenance: args.provenance ?? 'terminal-output',
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,
...(grant.readOnly ? { readOnly: true } : {})
}
: undefined
}
}
private async resolveAllowedRemoteTerminalArtifactPath(
absolutePath: string,
connectionId: string
@@ -953,6 +1024,8 @@ export class RuntimeFileCommands {
provider: 'local' | 'ssh'
connectionId?: string
clientId?: string
readOnly?: boolean
provenance: TerminalFileGrant['provenance']
stats: RuntimeFileStatLike
}): TerminalFileGrant {
assertTerminalArtifactNotHardLinked(args.stats)
@@ -964,7 +1037,9 @@ export class RuntimeFileCommands {
...(args.connectionId ? { connectionId: args.connectionId } : {}),
...(args.clientId ? { clientId: args.clientId } : {}),
expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS,
statIdentity: terminalFileStatIdentity(args.stats)
statIdentity: terminalFileStatIdentity(args.stats),
readOnly: args.readOnly === true,
provenance: args.provenance
}
this.terminalFileGrants.set(grant.id, grant)
this.scheduleTerminalFileGrantExpiry(grant)
@@ -1129,6 +1204,9 @@ export class RuntimeFileCommands {
absolutePath,
clientId
)
if (grant.readOnly) {
throw new Error('terminal_file_grant_read_only')
}
if (isMobileBinaryPath(grant.absolutePath)) {
throw new Error('binary_file')
}
@@ -1280,12 +1358,15 @@ export class RuntimeFileCommands {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const allowedPath = await this.resolveAllowedRemoteTerminalArtifactPath(
grant.absolutePath,
grant.connectionId
)
// Why: relay I/O follows symlinks, so re-canonicalize a remote temp-artifact grant after the process can mutate it.
if (allowedPath !== grant.absolutePath) {
const canonicalPath =
grant.provenance === 'native-chat'
? await provider.realpath(grant.absolutePath)
: await this.resolveAllowedRemoteTerminalArtifactPath(
grant.absolutePath,
grant.connectionId
)
// Why: relay I/O follows symlinks, so re-canonicalize after the remote process can mutate the path.
if (canonicalPath !== grant.absolutePath) {
throw new Error('terminal_file_grant_stale')
}
return provider
+8
View File
@@ -1013,6 +1013,7 @@ import {
recentTerminalOutputIncludesPath,
recentTerminalPathCandidatesIncludePath
} from './terminal-output-path-candidates'
import { nativeChatTranscriptIncludesPath } from '../native-chat/native-chat-file-provenance'
import {
getSelectedReviewBranch,
getSelectedReviewLookupHints,
@@ -8782,6 +8783,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({
@@ -1,14 +1,14 @@
import {
parseFileLinkLocation,
type ParsedFileLinkLocation
} from '../../../shared/file-link-location'
import {
joinAbsolutePath,
normalizeAbsolutePath,
resolveTildePath
} from './terminal-path-normalization'
export type ParsedExplicitFileLinkTarget = {
pathText: string
line: number | null
column: number | null
}
export type ParsedExplicitFileLinkTarget = ParsedFileLinkLocation
export type ResolvedExplicitFileLinkTarget = Pick<
ParsedExplicitFileLinkTarget,
@@ -34,15 +34,12 @@ export function parseExplicitFileLinkTarget(
value: string,
options: ParseExplicitFileLinkTargetOptions = {}
): ParsedExplicitFileLinkTarget | null {
const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value)
if (!match) {
return null
}
const pathText = match[1]
const hasLineOrColumn = Boolean(match[2] || match[3])
if (!pathText) {
const parsed = parseFileLinkLocation(value)
if (!parsed) {
return null
}
const { pathText, line, column } = parsed
const hasLineOrColumn = line !== null || column !== null
if (/^[\\/]\s/.test(pathText)) {
return null
}
@@ -53,12 +50,6 @@ export function parseExplicitFileLinkTarget(
}
}
const line = match[2] ? Number.parseInt(match[2], 10) : null
const column = match[3] ? Number.parseInt(match[3], 10) : null
if ((line !== null && line < 1) || (column !== null && column < 1)) {
return null
}
return { pathText, line, column }
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { parseFileLinkLocation } from './file-link-location'
describe('parseFileLinkLocation', () => {
it('parses agent-style line and column suffixes', () => {
expect(parseFileLinkLocation('src/main.ts:12:4')).toEqual({
pathText: 'src/main.ts',
line: 12,
column: 4
})
expect(parseFileLinkLocation('src/main.ts')).toEqual({
pathText: 'src/main.ts',
line: null,
column: null
})
})
it('preserves Windows drive colons', () => {
expect(parseFileLinkLocation(String.raw`C:\repo\src\main.ts:12`)).toEqual({
pathText: String.raw`C:\repo\src\main.ts`,
line: 12,
column: null
})
})
it('rejects empty paths and zero locations', () => {
expect(parseFileLinkLocation('')).toBeNull()
expect(parseFileLinkLocation('src/main.ts:0')).toBeNull()
expect(parseFileLinkLocation('src/main.ts:12:0')).toBeNull()
})
})
+19
View File
@@ -0,0 +1,19 @@
export type ParsedFileLinkLocation = {
pathText: string
line: number | null
column: number | null
}
export function parseFileLinkLocation(value: string): ParsedFileLinkLocation | null {
const match = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value)
const pathText = match?.[1]
if (!pathText) {
return null
}
const line = match[2] ? Number.parseInt(match[2], 10) : null
const column = match[3] ? Number.parseInt(match[3], 10) : null
if ((line !== null && line < 1) || (column !== null && column < 1)) {
return null
}
return { pathText, line, column }
}
+7
View File
@@ -404,12 +404,19 @@ export type RuntimeTerminalPathOpenTarget =
provider: 'local' | 'ssh'
absolutePath: string
grantId: string
/** Present when the exact-path grant permits preview/read but not mutation. */
readOnly?: true
}
| {
kind: 'unsupported'
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. */