diff --git a/config/scripts/orcad-e2e-routing.test.mjs b/config/scripts/orcad-e2e-routing.test.mjs index d2ec99491dd..018eda14a75 100644 --- a/config/scripts/orcad-e2e-routing.test.mjs +++ b/config/scripts/orcad-e2e-routing.test.mjs @@ -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)), diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 7d7d0813fdb..c0204a432dc 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -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 )) }, diff --git a/src/renderer/src/components/editor/csv/csv-file-content.ts b/src/renderer/src/components/editor/csv/csv-file-content.ts index c78b0214414..a5d66664dc7 100644 --- a/src/renderer/src/components/editor/csv/csv-file-content.ts +++ b/src/renderer/src/components/editor/csv/csv-file-content.ts @@ -15,7 +15,8 @@ export async function readEditorCsvFileContent( args: RuntimeFileReadArgs, allowPagedPreview = true ): Promise { - 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 } } diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index a6df9203175..461d7e5a050 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -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 ) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts index a76f4f45763..4337adb3689 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts index a085acc594b..7715e80c8d8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts @@ -16,6 +16,7 @@ type FileLinkHitTestDeps = { worktreePath: string runtimeEnvironmentId?: string | null wslDistro?: string | null + terminalHandle?: string | null pathExistsCache?: Map openWithSystemDefault?: boolean } diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-target.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-target.ts index 9505829cf67..9820230fa8f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-target.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-target.ts @@ -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, - pathExists: FileLinkPathExistence + pathExists: FileLinkPathExistence, + terminalHandle?: string | null ): Promise { // 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)) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index 3596fd9eeea..ce6508a0cef 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -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 && diff --git a/src/renderer/src/components/terminal-pane/terminal-host-file-link-existence.ts b/src/renderer/src/components/terminal-pane/terminal-host-file-link-existence.ts new file mode 100644 index 00000000000..489a77a041d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-host-file-link-existence.ts @@ -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, + terminalHandle: string | null | undefined +): Promise { + // 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 +} diff --git a/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts b/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts index 9306f2b56b9..4cfba0b4f40 100644 --- a/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts +++ b/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts @@ -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 { + absolutePath: string, + terminalHandle?: string | null +): Promise { 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', diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts index 31b0479ee51..6f7cc8576d6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts @@ -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 | null | undefined +): string | null { + const ptyId = transport?.getPtyId() ?? null + return (ptyId ? parseRemoteRuntimePtyId(ptyId)?.handle : null) || null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts index b4e708a875f..e6d516d1491 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-client-local-file.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 1b677c8a2fe..6ab7104a692 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -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) } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-host-granted-file-link.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-host-granted-file-link.test.ts new file mode 100644 index 00000000000..6268313a270 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-host-granted-file-link.test.ts @@ -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 { + const { provider } = createProviderSetup([makeBufferLine(IMAGE)], new Map(), { + worktreePath: '/srv/repo', + startupCwd: '/srv/repo', + runtimeEnvironmentId: 'env-1', + getRuntimeTerminalHandleForPane: () => 'term-1' + }) + return new Promise((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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts index 22b654073fd..2433f20bb62 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts @@ -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 diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index 4d15a233938..6a3cb560eb8 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -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 }, diff --git a/src/renderer/src/lib/local-file-access.ts b/src/renderer/src/lib/local-file-access.ts index f6e2741a61e..8e982324235 100644 --- a/src/renderer/src/lib/local-file-access.ts +++ b/src/renderer/src/lib/local-file-access.ts @@ -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 + file: Pick & + Partial> ): 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) + ) } /** diff --git a/src/renderer/src/runtime/runtime-file-client-types.ts b/src/renderer/src/runtime/runtime-file-client-types.ts index e9df1c00d20..b1929218b8d 100644 --- a/src/renderer/src/runtime/runtime-file-client-types.ts +++ b/src/renderer/src/runtime/runtime-file-client-types.ts @@ -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 = { diff --git a/src/renderer/src/runtime/runtime-file-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts index ce99fd5bda2..37e99b6ec69 100644 --- a/src/renderer/src/runtime/runtime-file-read-client.ts +++ b/src/renderer/src/runtime/runtime-file-read-client.ts @@ -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 { 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') } diff --git a/src/renderer/src/runtime/runtime-terminal-artifact-read.test.ts b/src/renderer/src/runtime/runtime-terminal-artifact-read.test.ts new file mode 100644 index 00000000000..e35607c1385 --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-artifact-read.test.ts @@ -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' }) + ) + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-artifact-read.ts b/src/renderer/src/runtime/runtime-terminal-artifact-read.ts new file mode 100644 index 00000000000..bba09589a4d --- /dev/null +++ b/src/renderer/src/runtime/runtime-terminal-artifact-read.ts @@ -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 { + const params = { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + grantId: args.grantId, + absolutePath: args.absolutePath + } + let result: RuntimeFileReadResult + try { + result = await callRuntimeRpc( + 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( + 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 } +} diff --git a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts index f8c2f7b7fe9..80ebbabde55 100644 --- a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts +++ b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts @@ -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, diff --git a/src/renderer/src/store/slices/editor/types/open-file.ts b/src/renderer/src/store/slices/editor/types/open-file.ts index fdb924551fd..507bb53026f 100644 --- a/src/renderer/src/store/slices/editor/types/open-file.ts +++ b/src/renderer/src/store/slices/editor/types/open-file.ts @@ -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. */ diff --git a/tests/e2e/helpers/orcad-managed-workspace.ts b/tests/e2e/helpers/orcad-managed-workspace.ts index 70943839d31..b17112155dd 100644 --- a/tests/e2e/helpers/orcad-managed-workspace.ts +++ b/tests/e2e/helpers/orcad-managed-workspace.ts @@ -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 { 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 diff --git a/tests/e2e/ssh-orcad-workspace-tools.spec.ts b/tests/e2e/ssh-orcad-workspace-tools.spec.ts index 0c9a9fe7adf..8f9c6eddc80 100644 --- a/tests/e2e/ssh-orcad-workspace-tools.spec.ts +++ b/tests/e2e/ssh-orcad-workspace-tools.spec.ts @@ -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')