mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(files): a managed terminal links and opens a host file outside its workspace (#27364)
* fix(files): a managed terminal links and opens a host file outside its workspace A paired-server terminal checked out-of-workspace paths on this computer, so a host file such as /tmp/screenshot.png never became a link, and a click was refused. Link detection now asks the host the same question a click asks, with the printing terminal's handle, and a granted file opens read-only through the host's existing terminal-artifact grant. Co-Authored-By: Claude <noreply@anthropic.com> * fix(files): a restored server-granted tab is never this computer's file Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): keep the external-image journey's /tmp outside every workspace The default folder workspace is /tmp, so the image resolved into it instead of exercising a host file outside all workspaces. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -143,7 +143,15 @@ it('runs the managed-host journey specs in their own job when routed', () => {
|
||||
const job = jobs['orcad-journeys-docker']
|
||||
expect(job.needs).toEqual(['build', 'prepare-native-cache'])
|
||||
for (const spec of ORCAD_JOURNEY_E2E_SPECS) {
|
||||
expectRouted(['tests/e2e/helpers/orcad-managed-workspace.ts'], spec)
|
||||
expectRouted(
|
||||
[
|
||||
'tests/e2e/helpers/orcad-managed-workspace.ts',
|
||||
'src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts',
|
||||
'src/renderer/src/components/terminal-pane/terminal-host-file-link-existence.ts',
|
||||
'src/renderer/src/runtime/runtime-terminal-artifact-read.ts'
|
||||
],
|
||||
spec
|
||||
)
|
||||
expect(job.if, spec).toContain(`contains(inputs.test_files, '${spec}')`)
|
||||
expect(
|
||||
job.steps.some((step) => step.run?.includes(spec)),
|
||||
|
||||
@@ -199,7 +199,7 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
file
|
||||
) ||
|
||||
(isProductSource(file) &&
|
||||
/^src\/(?:main\/ssh\/orcad-managed-|main\/ipc\/ssh-port-forward|renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts$|renderer\/src\/runtime\/web-runtime-session|renderer\/src\/components\/terminal-pane\/remote-runtime-)/.test(
|
||||
/^src\/(?:main\/ssh\/orcad-managed-|main\/ipc\/ssh-port-forward|renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts$|renderer\/src\/runtime\/(?:web-runtime-session|runtime-terminal-artifact-read\.ts$)|renderer\/src\/components\/terminal-pane\/(?:remote-runtime-|terminal-host-(?:workspace-file|file-link-existence)\.ts$))/.test(
|
||||
file
|
||||
))
|
||||
},
|
||||
|
||||
@@ -15,7 +15,8 @@ export async function readEditorCsvFileContent(
|
||||
args: RuntimeFileReadArgs,
|
||||
allowPagedPreview = true
|
||||
): Promise<FileContent> {
|
||||
if (allowPagedPreview && /\.(csv|tsv)$/i.test(args.filePath)) {
|
||||
// Why: a granted host file has no stat route; it reads whole through its grant.
|
||||
if (allowPagedPreview && !args.terminalArtifactGrantId && /\.(csv|tsv)$/i.test(args.filePath)) {
|
||||
const snapshot = await statRuntimeReadTarget(args)
|
||||
if (!snapshot.isDirectory && snapshot.size >= CSV_PAGED_PREVIEW_BYTES) {
|
||||
return { content: '', isBinary: false, csvPreview: { readArgs: args, snapshot } }
|
||||
|
||||
@@ -185,7 +185,7 @@ export function useEditorPanelFileContentLoader({
|
||||
await reownRestoredFile(route, runtimeEnvironmentId ?? null)
|
||||
return
|
||||
}
|
||||
if (runtimeEnvironmentId && !route) {
|
||||
if (runtimeEnvironmentId && !route && !restoredOpenFile.terminalArtifactGrantId) {
|
||||
throw new Error('External local files are not available for remote workspaces.')
|
||||
}
|
||||
if (!externalSshOwnerId) {
|
||||
@@ -225,7 +225,8 @@ export function useEditorPanelFileContentLoader({
|
||||
connectionId: readConnectionId,
|
||||
expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId,
|
||||
includeLocalLogMetadata: isLiveTailLogTab,
|
||||
access
|
||||
access,
|
||||
terminalArtifactGrantId: restoredOpenFile?.terminalArtifactGrantId
|
||||
},
|
||||
allowPagedPreview
|
||||
)
|
||||
|
||||
@@ -24,6 +24,8 @@ export type TerminalFileLinkActionDeps = {
|
||||
worktreePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
wslDistro?: string | null
|
||||
/** The paired-server terminal that printed the link; lets its host grant a file outside workspaces. */
|
||||
terminalHandle?: string | null
|
||||
onOpenFailure?: (failure: FileOpenFailure) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ type FileLinkHitTestDeps = {
|
||||
worktreePath: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
wslDistro?: string | null
|
||||
terminalHandle?: string | null
|
||||
pathExistsCache?: Map<string, boolean>
|
||||
openWithSystemDefault?: boolean
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
writeTerminalPathExistsCache
|
||||
} from './terminal-path-exists-cache'
|
||||
import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link'
|
||||
import {
|
||||
hostFileLinkTargetExists,
|
||||
isHostPathOutsideWorkspace
|
||||
} from './terminal-host-file-link-existence'
|
||||
|
||||
/** Where detected path text resolves and is checked: a terminal pane's or a chat's folder. */
|
||||
export type FileLinkHost = {
|
||||
@@ -118,7 +122,8 @@ export function mayCheckFileLinkTargetUnprompted(
|
||||
export async function fileLinkTargetExists(
|
||||
target: FileLinkTarget,
|
||||
cache: Map<string, boolean>,
|
||||
pathExists: FileLinkPathExistence
|
||||
pathExists: FileLinkPathExistence,
|
||||
terminalHandle?: string | null
|
||||
): Promise<boolean> {
|
||||
// Why: exact known workspace roots must stay clickable for SSH or
|
||||
// stale local paths even when filesystem probing says "missing".
|
||||
@@ -129,6 +134,10 @@ export async function fileLinkTargetExists(
|
||||
// Why: no owner means no host to ask; a local stat would answer for the wrong machine.
|
||||
throw new Error('The terminal workspace host could not be determined')
|
||||
}
|
||||
if (isHostPathOutsideWorkspace(target)) {
|
||||
// Why: a local stat here would answer for this computer, not the server that printed the path.
|
||||
return hostFileLinkTargetExists(target, cache, terminalHandle)
|
||||
}
|
||||
const exists =
|
||||
readTerminalPathExistsCache(cache, target.cacheKey) ??
|
||||
(await pathExists(target.fileContext, target.absolutePath, target.isRemoteRuntimePath))
|
||||
|
||||
@@ -50,6 +50,8 @@ type TerminalFileOpenDeps = {
|
||||
runtimeEnvironmentId?: string | null
|
||||
wslDistro?: string | null
|
||||
openWithSystemDefault?: boolean
|
||||
/** The paired-server terminal that printed the link; lets its host grant a file outside workspaces. */
|
||||
terminalHandle?: string | null
|
||||
/** Reports a path that could not be verified before opening; skipped once a later open supersedes it. */
|
||||
onOpenFailure?: (failure: FileOpenFailure) => void
|
||||
}
|
||||
@@ -130,6 +132,7 @@ export function openDetectedFilePath(
|
||||
let statResult
|
||||
let fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
|
||||
let hostWorkspaceFile: HostWorkspaceFile | null = null
|
||||
let hostGrantId: string | null = null
|
||||
let clientLocal = false
|
||||
if (!fileContext.sourceHostResolved) {
|
||||
// Why: with no owner the host is unknown — refuse rather than touch this machine's files.
|
||||
@@ -160,8 +163,16 @@ export function openDetectedFilePath(
|
||||
}
|
||||
|
||||
try {
|
||||
const linkOwner = await resolveHostWorkspaceFile(fileContext, mappedFilePath)
|
||||
if (linkOwner?.kind === 'client') {
|
||||
const linkOwner = await resolveHostWorkspaceFile(
|
||||
fileContext,
|
||||
mappedFilePath,
|
||||
deps.terminalHandle
|
||||
)
|
||||
if (linkOwner?.kind === 'grant') {
|
||||
hostGrantId = linkOwner.grantId
|
||||
mappedFilePath = linkOwner.absolutePath
|
||||
statResult = { isDirectory: false, escapesWorktree: true }
|
||||
} else if (linkOwner?.kind === 'client') {
|
||||
clientLocal = true
|
||||
fileContext = {
|
||||
...fileContext,
|
||||
@@ -213,7 +224,8 @@ export function openDetectedFilePath(
|
||||
return
|
||||
}
|
||||
|
||||
if (openWithSystemDefault && !canOpenWithSystemDefault) {
|
||||
// Why: a granted file is readable only through its grant, so it opens in Orca, never as a download.
|
||||
if (openWithSystemDefault && !canOpenWithSystemDefault && hostGrantId === null) {
|
||||
// Why: the popover names Shift+Cmd/Ctrl "Download & open with default app", and the OS
|
||||
// cannot launch a remote path, so the direct gesture must reach the same download.
|
||||
await downloadAndOpenRemoteTerminalFile(fileContext, mappedFilePath)
|
||||
@@ -222,7 +234,7 @@ export function openDetectedFilePath(
|
||||
|
||||
// Why: local HTML files render in Orca's browser for ordinary Cmd/Ctrl-click,
|
||||
// and remain the fallback if Shift+Cmd/Ctrl cannot launch the OS default.
|
||||
if (isHtmlFilePath(mappedFilePath)) {
|
||||
if (isHtmlFilePath(mappedFilePath) && hostGrantId === null) {
|
||||
if (canOpenWithSystemDefault) {
|
||||
openHtmlFileInBrowser(mappedFilePath, worktreeId, clientLocal)
|
||||
return
|
||||
@@ -249,6 +261,7 @@ export function openDetectedFilePath(
|
||||
relativePath = maybeRelative
|
||||
}
|
||||
} else if (
|
||||
hostGrantId === null &&
|
||||
!isFloatingWorkspaceId(worktreeId) &&
|
||||
store.openFiles.some(
|
||||
(openFile) => openFile.filePath === mappedFilePath && openFile.worktreeId !== worktreeId
|
||||
@@ -287,6 +300,8 @@ export function openDetectedFilePath(
|
||||
runtimeEnvironmentId: clientLocal ? null : runtimeEnvironmentId,
|
||||
// Why: a tab's owner must match its workspace's to be editable; this one is this computer's.
|
||||
...(clientLocal && targetWorktreeId === worktreeId ? { readOnly: true } : {}),
|
||||
// Why: the grant is the only authority to read a host file outside its workspaces.
|
||||
...(hostGrantId !== null ? { readOnly: true, terminalArtifactGrantId: hostGrantId } : {}),
|
||||
// Why: absolute SSH paths outside the worktree otherwise look identical
|
||||
// to client-local external files when the editor reloads or restores.
|
||||
...(relativePath === filePath &&
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { hasRemoteRuntimeOwner } from '@/runtime/runtime-file-routing'
|
||||
import type { FileLinkTarget } from './terminal-file-link-target'
|
||||
import { HostFileRefusal, resolveHostWorkspaceFile } from './terminal-host-workspace-file'
|
||||
import {
|
||||
readTerminalPathExistsCache,
|
||||
writeTerminalPathExistsCache
|
||||
} from './terminal-path-exists-cache'
|
||||
|
||||
/** A paired-server path outside the terminal's workspace: only its host can say whether it opens. */
|
||||
export function isHostPathOutsideWorkspace(target: FileLinkTarget): boolean {
|
||||
return !target.isRemoteRuntimePath && hasRemoteRuntimeOwner(target.fileContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the host the same question a click asks, so a link shows exactly when a click can open it.
|
||||
* Rejects when the host cannot answer, so callers never mistake an outage for a missing file.
|
||||
*/
|
||||
export async function hostFileLinkTargetExists(
|
||||
target: FileLinkTarget,
|
||||
cache: Map<string, boolean>,
|
||||
terminalHandle: string | null | undefined
|
||||
): Promise<boolean> {
|
||||
// Why: an unprompted stat of a network share can send the user's credentials to its server.
|
||||
if (/^[\\/]{2}/.test(target.absolutePath)) {
|
||||
return false
|
||||
}
|
||||
const cached = readTerminalPathExistsCache(cache, target.cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
let exists: boolean
|
||||
try {
|
||||
exists =
|
||||
(await resolveHostWorkspaceFile(target.fileContext, target.absolutePath, terminalHandle)) !==
|
||||
null
|
||||
} catch (error) {
|
||||
if (!(error instanceof HostFileRefusal)) {
|
||||
throw error
|
||||
}
|
||||
exists = false
|
||||
}
|
||||
writeTerminalPathExistsCache(cache, target.cacheKey, exists)
|
||||
return exists
|
||||
}
|
||||
@@ -30,6 +30,18 @@ export type HostWorkspaceFile = {
|
||||
/** A path the host disowned that this computer has; the user's click here is the only authority to read it. */
|
||||
export type ClientLocalFile = { kind: 'client'; isDirectory: boolean }
|
||||
|
||||
/** A host file outside every workspace that the host granted because this terminal printed it. */
|
||||
export type HostGrantedFile = {
|
||||
kind: 'grant'
|
||||
/** The host's canonical path; the grant is bound to it. */
|
||||
absolutePath: string
|
||||
grantId: string
|
||||
isDirectory: false
|
||||
}
|
||||
|
||||
/** The host answered: it has no file there this client may open. Transport failures stay other errors. */
|
||||
export class HostFileRefusal extends Error {}
|
||||
|
||||
export const CLIENT_LOCAL_FILE_TARGET: RuntimeClientTarget = { kind: 'local' }
|
||||
|
||||
/** Strips the host's relative suffix; separators are swapped 1:1, so the slice stays byte-exact. */
|
||||
@@ -73,14 +85,15 @@ async function statClientLocalFile(
|
||||
|
||||
/**
|
||||
* A clicked paired-server link outside its own workspace: the server, not this client's catalog
|
||||
* copy, says which of its workspaces holds the path. A path it disowns without a grant falls to
|
||||
* this computer. Null when the link is not a paired-server path outside the source workspace;
|
||||
* throws when neither the host nor this computer can open it.
|
||||
* copy, says which of its workspaces holds the path, or grants it when the terminal printed it.
|
||||
* A path it disowns without a grant falls to this computer. Null when the link is not a
|
||||
* paired-server path outside the source workspace; throws when neither can open it.
|
||||
*/
|
||||
export async function resolveHostWorkspaceFile(
|
||||
context: RuntimeFileOperationArgs,
|
||||
absolutePath: string
|
||||
): Promise<HostWorkspaceFile | ClientLocalFile | null> {
|
||||
absolutePath: string,
|
||||
terminalHandle?: string | null
|
||||
): Promise<HostWorkspaceFile | HostGrantedFile | ClientLocalFile | null> {
|
||||
const environmentId = runtimeTargetEnvironmentId(context.target)
|
||||
if (
|
||||
!environmentId ||
|
||||
@@ -95,26 +108,38 @@ export async function resolveHostWorkspaceFile(
|
||||
{
|
||||
worktree: toRuntimeWorktreeSelector(context.worktreeId),
|
||||
pathText: absolutePath,
|
||||
crossWorkspace: true
|
||||
crossWorkspace: true,
|
||||
// Why: the host grants a file outside its workspaces only to the terminal that printed it.
|
||||
...(terminalHandle ? { terminal: terminalHandle } : {})
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
const relativePath = resolved.relativePath
|
||||
if (relativePath === null || !resolved.absolutePath) {
|
||||
// Why: hosts grant reads only inside their workspaces; servers without crossWorkspace land here too.
|
||||
const refusal = new Error(`${absolutePath} is outside every workspace on its host`)
|
||||
if (resolved.openTarget?.kind === 'absolute-file') {
|
||||
const refusal = new HostFileRefusal(`${absolutePath} is outside every workspace on its host`)
|
||||
const openTarget = resolved.openTarget
|
||||
if (openTarget?.kind === 'absolute-file') {
|
||||
// Why: a host grant means the host owns the path; it never becomes a read of this computer.
|
||||
return {
|
||||
kind: 'grant',
|
||||
absolutePath: openTarget.absolutePath,
|
||||
grantId: openTarget.grantId,
|
||||
isDirectory: false
|
||||
}
|
||||
}
|
||||
if (resolved.exists) {
|
||||
// Why: an ungranted host path (a directory, or one no terminal printed) is still the host's.
|
||||
throw refusal
|
||||
}
|
||||
return statClientLocalFile(environmentId, absolutePath, refusal)
|
||||
}
|
||||
if (!resolved.exists) {
|
||||
throw new Error(`File not found on its host: ${absolutePath}`)
|
||||
throw new HostFileRefusal(`File not found on its host: ${absolutePath}`)
|
||||
}
|
||||
const worktreePath = workspaceRootOf(resolved.absolutePath, relativePath)
|
||||
if (worktreePath === null) {
|
||||
throw new Error(`The host answered an unexpected path for ${absolutePath}`)
|
||||
throw new HostFileRefusal(`The host answered an unexpected path for ${absolutePath}`)
|
||||
}
|
||||
return {
|
||||
kind: 'host',
|
||||
|
||||
@@ -44,3 +44,11 @@ export function resolveTerminalHttpLinkSourceOwner(
|
||||
}
|
||||
return { kind: 'local' }
|
||||
}
|
||||
|
||||
/** The pane's terminal handle on its paired server; null for a pane no server runs. */
|
||||
export function resolveTerminalRuntimeHandle(
|
||||
transport: Pick<PtyTransport, 'getPtyId'> | null | undefined
|
||||
): string | null {
|
||||
const ptyId = transport?.getPtyId() ?? null
|
||||
return (ptyId ? parseRemoteRuntimePtyId(ptyId)?.handle : null) || null
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('a link to this computer printed in a paired-server terminal', () => {
|
||||
expect(onOpenFailure).toHaveBeenCalledWith(expect.objectContaining({ verdict: 'unverifiable' }))
|
||||
})
|
||||
|
||||
it('never reads this computer for a path the host granted as its own', async () => {
|
||||
it('opens a path the host granted read-only from the host, never from this computer', async () => {
|
||||
runtimeEnvironmentCallMock.mockResolvedValueOnce({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
@@ -212,10 +212,58 @@ describe('a link to this computer printed in a paired-server terminal', () => {
|
||||
|
||||
openDetectedFilePath('/tmp/artifact.txt', null, null, {
|
||||
...serverWorkspace,
|
||||
terminalHandle: 'term-1',
|
||||
openWithSystemDefault: true,
|
||||
onOpenFailure
|
||||
})
|
||||
await flushDoubleRaf()
|
||||
|
||||
expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'files.resolveTerminalPath',
|
||||
params: {
|
||||
worktree: 'id:wt-feat',
|
||||
pathText: '/tmp/artifact.txt',
|
||||
crossWorkspace: true,
|
||||
terminal: 'term-1'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
expect(openFilePathMock).not.toHaveBeenCalled()
|
||||
expect(downloadAndOpenRemoteTerminalFile).not.toHaveBeenCalled()
|
||||
expect(onOpenFailure).not.toHaveBeenCalled()
|
||||
expect(openFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/tmp/artifact.txt',
|
||||
relativePath: '/tmp/artifact.txt',
|
||||
worktreeId: 'wt-feat',
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
readOnly: true,
|
||||
terminalArtifactGrantId: 'g-1'
|
||||
}),
|
||||
{ forceContentReload: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('never reads this computer for a host directory outside its workspaces', async () => {
|
||||
runtimeEnvironmentCallMock.mockResolvedValueOnce({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: 'wt-feat',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/out',
|
||||
exists: true,
|
||||
isDirectory: true
|
||||
},
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
const onOpenFailure = vi.fn()
|
||||
|
||||
openDetectedFilePath('/tmp/out', null, null, { ...serverWorkspace, onOpenFailure })
|
||||
await flushDoubleRaf()
|
||||
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
expect(openFileMock).not.toHaveBeenCalled()
|
||||
expect(onOpenFailure).toHaveBeenCalled()
|
||||
|
||||
@@ -49,6 +49,8 @@ export type LinkHandlerDeps = {
|
||||
terminalHomePath?: string | null
|
||||
wslDistro?: string | null
|
||||
getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null
|
||||
/** The pane's terminal on its paired server, which may grant files it printed. */
|
||||
getRuntimeTerminalHandleForPane?: (paneId: number) => string | null
|
||||
getLinkActionContext?: (paneId: number) => TerminalLinkActionContext | null
|
||||
}
|
||||
|
||||
@@ -126,7 +128,10 @@ export function createFilePathLinkProvider(
|
||||
if (!target || !range) {
|
||||
return null
|
||||
}
|
||||
if (!(await fileLinkTargetExists(target, pathExistsCache, pathExists))) {
|
||||
const terminalHandle = deps.getRuntimeTerminalHandleForPane?.(paneId) ?? null
|
||||
if (
|
||||
!(await fileLinkTargetExists(target, pathExistsCache, pathExists, terminalHandle))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const { absolutePath: mappedPath, fileContext } = target
|
||||
@@ -147,7 +152,8 @@ export function createFilePathLinkProvider(
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro
|
||||
wslDistro: deps.wslDistro,
|
||||
terminalHandle
|
||||
},
|
||||
deps.getLinkActionContext?.(paneId)
|
||||
)
|
||||
@@ -247,6 +253,7 @@ export function installFilePathLinkClickFallback(
|
||||
worktreePath: deps.worktreePath,
|
||||
runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro,
|
||||
terminalHandle: deps.getRuntimeTerminalHandleForPane?.(paneId) ?? null,
|
||||
pathExistsCache: deps.pathExistsCache,
|
||||
openWithSystemDefault: Boolean(event.shiftKey)
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import type { ILink } from '@xterm/xterm'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures'
|
||||
import { createProviderSetup, makeBufferLine } from './terminal-link-provider-buffer-fixtures'
|
||||
import {
|
||||
flushDoubleRaf,
|
||||
installTerminalLinkTestEnvironment
|
||||
} from './terminal-link-handlers-test-harness'
|
||||
|
||||
const doubles = createTerminalLinkTestDoubles()
|
||||
const { storeState, statMock, openFileMock, runtimeEnvironmentCallMock } = doubles
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorkspace: vi.fn(),
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionId: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
installTerminalLinkTestEnvironment(doubles)
|
||||
|
||||
const IMAGE = '/tmp/orca-preview.png'
|
||||
|
||||
function hostGrants(): void {
|
||||
runtimeEnvironmentCallMock.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: IMAGE,
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: { kind: 'absolute-file', provider: 'local', absolutePath: IMAGE, grantId: 'g-1' }
|
||||
},
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
}
|
||||
|
||||
function provideLinks(): Promise<ILink[]> {
|
||||
const { provider } = createProviderSetup([makeBufferLine(IMAGE)], new Map(), {
|
||||
worktreePath: '/srv/repo',
|
||||
startupCwd: '/srv/repo',
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
getRuntimeTerminalHandleForPane: () => 'term-1'
|
||||
})
|
||||
return new Promise<ILink[]>((resolve) => {
|
||||
provider.provideLinks(1, (links) => resolve(links ?? []))
|
||||
})
|
||||
}
|
||||
|
||||
describe('a paired-server terminal path outside its workspace', () => {
|
||||
it('links a file the host grants to the terminal that printed it', async () => {
|
||||
hostGrants()
|
||||
|
||||
const links = await provideLinks()
|
||||
|
||||
expect(links.map((link) => link.text)).toEqual([IMAGE])
|
||||
expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'env-1',
|
||||
method: 'files.resolveTerminalPath',
|
||||
params: { worktree: 'id:wt-1', pathText: IMAGE, crossWorkspace: true, terminal: 'term-1' }
|
||||
})
|
||||
)
|
||||
// Why: this computer's copy of the path says nothing about the server's.
|
||||
expect(window.api.shell.pathExists).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the linked file through its grant', async () => {
|
||||
hostGrants()
|
||||
|
||||
const links = await provideLinks()
|
||||
// Why a prototype-less object: node has no MouseEvent; activation reads only these fields.
|
||||
const click: MouseEvent = Object.assign(Object.create(null), {
|
||||
metaKey: true,
|
||||
ctrlKey: true,
|
||||
button: 0,
|
||||
preventDefault: vi.fn()
|
||||
})
|
||||
links[0]!.activate(click, IMAGE)
|
||||
await flushDoubleRaf()
|
||||
|
||||
expect(statMock).not.toHaveBeenCalled()
|
||||
expect(openFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: IMAGE,
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
readOnly: true,
|
||||
terminalArtifactGrantId: 'g-1'
|
||||
}),
|
||||
{ forceContentReload: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('shows no link for a path the host refuses, even when this computer has it', async () => {
|
||||
runtimeEnvironmentCallMock.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: IMAGE,
|
||||
exists: true,
|
||||
isDirectory: true
|
||||
},
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
expect(await provideLinks()).toEqual([])
|
||||
expect(window.api.shell.pathExists).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
canOpenWorkspaceBrowserTabOnSsh
|
||||
} from '@/lib/workspace-browser-tab-open'
|
||||
import { resolvePaneWslDistro } from './terminal-pane-wsl-distro'
|
||||
import { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-source-owner'
|
||||
import {
|
||||
resolveTerminalHttpLinkSourceOwner,
|
||||
resolveTerminalRuntimeHandle
|
||||
} from './terminal-http-link-source-owner'
|
||||
import {
|
||||
getTerminalFileOpenHint,
|
||||
getTerminalUrlOpenHint,
|
||||
@@ -169,6 +172,7 @@ export function prepareTerminalPaneMount(
|
||||
const sourceOwner = getHttpLinkSourceOwnerForPane(paneId)
|
||||
return sourceOwner.kind === 'runtime' ? sourceOwner.runtimeEnvironmentId : null
|
||||
},
|
||||
getRuntimeTerminalHandleForPane: (id) => resolveTerminalRuntimeHandle(paneTransports.get(id)),
|
||||
getLinkActionContext
|
||||
}
|
||||
let resizeRaf: number | null = null
|
||||
|
||||
@@ -104,6 +104,17 @@ describe('editorTabFileAccess', () => {
|
||||
},
|
||||
'user-file'
|
||||
],
|
||||
[
|
||||
'a read-only server file outside every workspace, restored without its grant',
|
||||
{
|
||||
filePath: '/tmp/out.png',
|
||||
relativePath: '/tmp/out.png',
|
||||
worktreeId: localWorktreeId,
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
readOnly: true
|
||||
},
|
||||
undefined
|
||||
],
|
||||
[
|
||||
'a project tab, which stays inside its root',
|
||||
{ filePath: '/Users/me/project/a.ts', relativePath: 'a.ts', worktreeId: localWorktreeId },
|
||||
|
||||
@@ -47,9 +47,15 @@ type EditorTabAccessFile = Pick<
|
||||
|
||||
/** A read-only tab named by absolute path is this computer's file: an AI Vault log or a client-local link. */
|
||||
export function isClientLocalReadOnlyTab(
|
||||
file: Pick<OpenFile, 'filePath' | 'relativePath' | 'readOnly' | 'liveTail'>
|
||||
file: Pick<OpenFile, 'filePath' | 'relativePath' | 'readOnly' | 'liveTail'> &
|
||||
Partial<Pick<OpenFile, 'runtimeEnvironmentId'>>
|
||||
): boolean {
|
||||
return file.readOnly === true && (file.liveTail === true || file.relativePath === file.filePath)
|
||||
return (
|
||||
file.readOnly === true &&
|
||||
// Why: a server-granted file (live or restored without its grant) names a server path.
|
||||
!file.runtimeEnvironmentId?.trim() &&
|
||||
(file.liveTail === true || file.relativePath === file.filePath)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,8 @@ export type RuntimeFileReadArgs = {
|
||||
includeLocalLogMetadata?: boolean
|
||||
/** File access of the local fallback read; remote reads stay root-relative. */
|
||||
access?: LocalFileAccess
|
||||
/** Host grant for a runtime file outside every workspace (see OpenFile.terminalArtifactGrantId). */
|
||||
terminalArtifactGrantId?: string
|
||||
}
|
||||
|
||||
export type RuntimeFileOperationArgs = {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
||||
import { isAgentSessionAttachmentStorePath } from '../../../shared/agent-session-attachments'
|
||||
import type { LocalFileAccess } from '../../../shared/local-file-access'
|
||||
import { readRuntimeTerminalArtifactContent } from './runtime-terminal-artifact-read'
|
||||
|
||||
const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024
|
||||
const REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE =
|
||||
@@ -42,7 +43,8 @@ export async function readRuntimeFileContent({
|
||||
connectionId,
|
||||
expectedExternalSshTargetId,
|
||||
includeLocalLogMetadata,
|
||||
access
|
||||
access,
|
||||
terminalArtifactGrantId
|
||||
}: RuntimeFileReadArgs): Promise<RuntimeReadableFileContent> {
|
||||
assertExternalSshReadOwnership(target, connectionId, expectedExternalSshTargetId)
|
||||
if (target.kind !== 'environment' || !worktreeId) {
|
||||
@@ -53,6 +55,14 @@ export async function readRuntimeFileContent({
|
||||
...localAccess(connectionId, access)
|
||||
})
|
||||
}
|
||||
if (terminalArtifactGrantId) {
|
||||
return readRuntimeTerminalArtifactContent({
|
||||
target,
|
||||
worktreeId,
|
||||
absolutePath: filePath,
|
||||
grantId: terminalArtifactGrantId
|
||||
})
|
||||
}
|
||||
if (!canReadRelativeRuntimeFile(relativePath)) {
|
||||
throw new Error('Remote file is outside the owning runtime worktree')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readRuntimeFileContent } from './runtime-file-client'
|
||||
import {
|
||||
runtimeEnvironmentCall,
|
||||
installRuntimeFileClientEnvironment
|
||||
} from './runtime-file-client-test-harness'
|
||||
|
||||
installRuntimeFileClientEnvironment()
|
||||
|
||||
const granted = {
|
||||
target: { kind: 'environment' as const, environmentId: 'env-1' },
|
||||
filePath: '/tmp/out.png',
|
||||
relativePath: '/tmp/out.png',
|
||||
worktreeId: 'wt-1',
|
||||
terminalArtifactGrantId: 'g-1'
|
||||
}
|
||||
const grantParams = { worktree: 'id:wt-1', grantId: 'g-1', absolutePath: '/tmp/out.png' }
|
||||
|
||||
describe('a granted host file outside every workspace', () => {
|
||||
it('reads text through its grant', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: 'wt-1',
|
||||
relativePath: '/tmp/out.png',
|
||||
content: 'log',
|
||||
truncated: false,
|
||||
byteLength: 3
|
||||
},
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
await expect(readRuntimeFileContent(granted)).resolves.toEqual({
|
||||
content: 'log',
|
||||
isBinary: false
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'files.readTerminalArtifact', params: grantParams })
|
||||
)
|
||||
})
|
||||
|
||||
it('previews a binary file through its grant', async () => {
|
||||
const preview = { content: 'iVBO', isBinary: true, isImage: true, mimeType: 'image/png' }
|
||||
runtimeEnvironmentCall.mockImplementation((args: { method: string }) =>
|
||||
Promise.resolve(
|
||||
args.method === 'files.readTerminalArtifact'
|
||||
? {
|
||||
id: 'rpc-1',
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
}
|
||||
: { id: 'rpc-2', ok: true, result: preview, _meta: { runtimeId: 'remote-runtime' } }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(readRuntimeFileContent(granted)).resolves.toEqual(preview)
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'files.readTerminalArtifactPreview', params: grantParams })
|
||||
)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'files.read' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../../shared/runtime-types'
|
||||
import type { RuntimeClientTarget } from './runtime-client-target'
|
||||
import type { RuntimeReadableFileContent } from './runtime-file-client-types'
|
||||
import { callRuntimeRpc, RuntimeRpcCallError } from './runtime-rpc-client'
|
||||
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
||||
|
||||
/** Reads a host file outside every workspace through the grant its host issued for it. */
|
||||
export async function readRuntimeTerminalArtifactContent(args: {
|
||||
target: RuntimeClientTarget & { kind: 'environment' }
|
||||
worktreeId: string
|
||||
absolutePath: string
|
||||
grantId: string
|
||||
}): Promise<RuntimeReadableFileContent> {
|
||||
const params = {
|
||||
worktree: toRuntimeWorktreeSelector(args.worktreeId),
|
||||
grantId: args.grantId,
|
||||
absolutePath: args.absolutePath
|
||||
}
|
||||
let result: RuntimeFileReadResult
|
||||
try {
|
||||
result = await callRuntimeRpc<RuntimeFileReadResult>(
|
||||
args.target,
|
||||
'files.readTerminalArtifact',
|
||||
params,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
} catch (err) {
|
||||
// Why: the host refuses binary artifacts with this exact typed error; images use the preview.
|
||||
if (err instanceof RuntimeRpcCallError && err.message === 'binary_file') {
|
||||
return callRuntimeRpc<RuntimeFilePreviewResult>(
|
||||
args.target,
|
||||
'files.readTerminalArtifactPreview',
|
||||
params,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
if (result.truncated) {
|
||||
throw new Error(`Remote file is too large to open in the editor (${result.byteLength} bytes)`)
|
||||
}
|
||||
return { content: result.content, isBinary: false }
|
||||
}
|
||||
@@ -108,6 +108,8 @@ export function applyOpenFileToState(
|
||||
const updatedPreview = isPreview ? existing.isPreview : false
|
||||
const nextExternalSshTargetId = file.externalSshTargetId ?? existing.externalSshTargetId
|
||||
const refreshExternalSshProvenance = file.externalSshTargetId !== undefined
|
||||
// Why: a re-click mints a fresh grant; the old one may have expired.
|
||||
const nextGrantId = file.terminalArtifactGrantId ?? existing.terminalArtifactGrantId
|
||||
const fileContentReloadNonce = shouldRequestExistingFileContentReload(
|
||||
existing,
|
||||
file.mode,
|
||||
@@ -135,6 +137,7 @@ export function applyOpenFileToState(
|
||||
existing.worktreeId !== file.worktreeId ||
|
||||
existing.runtimeEnvironmentId !== runtimeEnvironmentId ||
|
||||
existing.externalSshTargetId !== nextExternalSshTargetId ||
|
||||
existing.terminalArtifactGrantId !== nextGrantId ||
|
||||
refreshExternalSshProvenance ||
|
||||
existing.fileContentReloadNonce !== fileContentReloadNonce ||
|
||||
existing.readOnly !== nextReadOnly ||
|
||||
@@ -152,6 +155,7 @@ export function applyOpenFileToState(
|
||||
language: file.language,
|
||||
runtimeEnvironmentId,
|
||||
externalSshTargetId: nextExternalSshTargetId,
|
||||
terminalArtifactGrantId: nextGrantId,
|
||||
operationProvenance: refreshExternalSshProvenance
|
||||
? operationProvenance
|
||||
: f.operationProvenance,
|
||||
|
||||
@@ -98,6 +98,8 @@ export type OpenFile = {
|
||||
runtimeEnvironmentId?: string | null
|
||||
/** SSH target that owns an absolute path outside the worktree. */
|
||||
externalSshTargetId?: string
|
||||
/** Host grant for a read-only file outside every workspace on `runtimeEnvironmentId`; never persisted. */
|
||||
terminalArtifactGrantId?: string
|
||||
/** Host provenance captured when the tab opened; mutations reject replacement owners. */
|
||||
operationProvenance?: EditorFileOperationProvenance
|
||||
/** Why: preview tabs mirror a source file's live draft; storing its ID lets the preview follow unsaved edits without becoming editable. */
|
||||
|
||||
@@ -55,7 +55,9 @@ export type ManagedWorkspace = {
|
||||
|
||||
/** Leaves the managed repository active; each launch is recorded on `fixture` for teardown. */
|
||||
export async function launchManagedWorkspace(
|
||||
fixture: ManagedHostFixture
|
||||
fixture: ManagedHostFixture,
|
||||
/** Overrides the host's folder workspace, e.g. to keep /tmp outside every workspace. */
|
||||
options: { folderPath?: string } = {}
|
||||
): Promise<ManagedWorkspace> {
|
||||
const { host, session } = fixture
|
||||
// The first launch only creates the profile the saved SSH host is written into.
|
||||
@@ -66,7 +68,7 @@ export async function launchManagedWorkspace(
|
||||
fixture.app = null
|
||||
const seeded = seedRelayEraProfile(session.userDataDir, host.input, {
|
||||
repoPath: host.remoteRepoPath,
|
||||
folderPath: host.remoteFolderPath
|
||||
folderPath: options.folderPath ?? host.remoteFolderPath
|
||||
})
|
||||
const { app, page } = await session.launch()
|
||||
fixture.app = app
|
||||
|
||||
@@ -114,8 +114,6 @@ for (const acrossReconnect of [false, true]) {
|
||||
|
||||
test('a terminal link opens an image outside the managed workspace', async (// oxlint-disable-next-line no-empty-pattern -- Owns its app launch through a restart session.
|
||||
{}, testInfo) => {
|
||||
// Why fixme: in-workspace links work (ssh-orcad-terminal-root-owner); this path gets no link at all.
|
||||
test.fixme(true, 'A managed terminal does not linkify a path outside its workspace')
|
||||
test.setTimeout(10 * 60_000)
|
||||
const imagePath = '/tmp/orca-ssh-external-preview.png'
|
||||
const imageBase64 =
|
||||
@@ -124,7 +122,8 @@ test('a terminal link opens an image outside the managed workspace', async (// o
|
||||
fixture.host.exec!(
|
||||
`printf '%s' ${shellQuote(imageBase64)} | base64 -d > ${shellQuote(imagePath)}`
|
||||
)
|
||||
const { page, seeded } = await launchManagedWorkspace(fixture)
|
||||
// Why /root: the default folder workspace is /tmp, which would make the image a workspace file.
|
||||
const { page, seeded } = await launchManagedWorkspace(fixture, { folderPath: '/root' })
|
||||
// A link is only clickable once the shell has printed it, so wait for one that answers.
|
||||
await expectTerminalAnswers(page, await openTerminalTab(page, seeded.worktreeId))
|
||||
await openTerminalWorkspaceRootLink(page, testInfo, imagePath, 'open')
|
||||
|
||||
Reference in New Issue
Block a user