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:
OrcaWin
2026-10-10 20:36:10 -07:00
committed by GitHub
co-authored by Claude m4air
parent 5f350a3786
commit e0625521ac
25 changed files with 471 additions and 32 deletions
+9 -1
View File
@@ -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)),
+1 -1
View File
@@ -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)
}
@@ -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 },
+8 -2
View File
@@ -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. */
+4 -2
View File
@@ -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
+2 -3
View File
@@ -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')