mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 12:07:58 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user