fix(runtime): route runtime filesystem commands by resolved execution host (#18325)

`ResolvedRuntimeFileTarget` carried `connectionId?: string` and no host id, so
`undefined` spelled three different answers at once — "runtime: host", "unresolved"
and "genuinely local". Its sole resolver read `store.getRepo(worktree.repoId)?.connectionId`
and never looked at `worktree.hostId`, which outranks every repo row, so one
arbitrarily chosen row decided the execution host for ~30 filesystem dispatches.
This is #18307's defect in the same file family; it was deliberately left out of
that PR rather than doubling an already-36-site diff.

The target now carries `executionHostId: ExecutionHostId` (never null, never
optional), resolved through `resolveWorktreeHostRouting` — the same adapter #18307
added — and dispatched through #18296's `resolveFilesystemRouteForHost`. Dispatch
sites call `requireRuntimeFileProvider`, where `null` means exactly one thing: the
host is `local` and the read happens here.

Four answers that used to collapse into one:

- `ssh:x` with a rival row on `ssh:y` — routes to x. Previously the first row won.
- `local` with a surviving `connectionId` — a row contradicting itself; no SSH
  connection is handed out.
- `runtime:<env>` — throws `ExecutionHostNotDispatchableError`. Its repo row's
  connection names a target in the *server's* namespace; reading it here reaches a
  same-named target on this client.
- rival rows disagreeing with no worktree host — `worktree_execution_host_unresolved`,
  matching the launch and Git paths rather than guessing a row.

Two further reads stop degrading. `assertRuntimeFileMutationExpectation` recomputed
the host from `connectionId`, so a client's host expectation could pass against a
host the workspace never named; it now compares the resolved host. And the
cross-workspace terminal tap coalesced `knownWorkspaceTarget?.connectionId ??
connectionId`, so a sibling workspace resolved as `local` inherited the origin
worktree's SSH target and statted a local path on the remote box; a non-optional
host id replaces rather than coalesces.

An unreachable SSH host still throws `SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE`;
loss of contact is never evidence of locality (docs/reference/ssh-execution-boundary.md).
Quick-open listing and path search keep degrading to empty for an unreachable host —
that is a false negative, not a local answer — and now do so only for a host that
really is remote.

The whole `runtime-file-commands-*` family carries `@ts-nocheck` from a mechanical
class split, so removing the field could not raise the compile errors that made
#18307 safe. `runtime-file-command-target.ts` is deliberately checked, and a ratchet
test stands in for the errors the family cannot produce.

No wire change: `ResolvedRuntimeFileTarget` is main-process internal, and the SSH
watcher-release and grant keys are byte-identical to before.
This commit is contained in:
Neil
2026-09-02 20:47:09 -07:00
committed by GitHub
parent 21210aad34
commit 946627f2ce
32 changed files with 655 additions and 282 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ Two consequences, both non-negotiable:
The vocabulary is fixed: **`live` / `unverifiable` / `exited`**, taken from the incumbent `UnstoppedPtyVerdict`. Do not introduce synonyms, and never collapse `unverifiable` into either neighbour. `exited` requires positive evidence of absence from the host that owns the process; a transport failure can only ever produce `unverifiable`.
Rule 1 is stated at `src/main/source-control/repo-default-branch.ts:76-78`, `src/main/repo-worktrees.ts:45-48`, `OrcaRuntimeService.probeWorktreeDrift` in `src/main/runtime/orca-runtime.ts`, and `src/renderer/src/lib/connection-context.ts:22-24`. It is enforced throughout `src/main/runtime/orca-runtime-git.ts` by `requireRuntimeGitProvider` in `src/main/runtime/runtime-git-command-target.ts`, which routes on the target's resolved `executionHostId` rather than on a repo row's `connectionId`: it throws `SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE` when an SSH host has no registered provider, throws `ExecutionHostNotDispatchableError` for a `runtime:` host this process does not execute, and returns `null` only for `local`. Grep those two names for the current call sites rather than trusting a count.
Rule 1 is stated at `src/main/source-control/repo-default-branch.ts:76-78`, `src/main/repo-worktrees.ts:45-48`, `OrcaRuntimeService.probeWorktreeDrift` in `src/main/runtime/orca-runtime.ts`, and `src/renderer/src/lib/connection-context.ts:22-24`. It is enforced throughout `src/main/runtime/orca-runtime-git.ts` by `requireRuntimeGitProvider` in `src/main/runtime/runtime-git-command-target.ts`, and throughout the runtime filesystem commands by `requireRuntimeFileProvider` in `src/main/runtime/runtime-file-command-target.ts`. Both route on the target's resolved `executionHostId` rather than on a repo row's `connectionId`: they throw the provider-unavailable message when an SSH host has no registered provider, throw `ExecutionHostNotDispatchableError` for a `runtime:` host this process does not execute, and return `null` only for `local`. Grep those names for the current call sites rather than trusting a count.
`src/main/runtime/unstopped-pty-verification.ts:12-16` is the reference implementation of rule 2: it keeps `live` / `unverifiable` / `exited` as three distinct verdicts, and treats "we could not ask" as its own answer.
@@ -50,7 +50,8 @@ function createRuntimeCommands(): RuntimeFileCommands {
return new RuntimeFileCommands({
requireStore: () => store,
resolveRuntimeFileTarget: async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH }
worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH },
executionHostId: 'local'
})
} as never)
}
@@ -29,8 +29,8 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC
requireStore: () => this.requireStore(),
resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector),
resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector),
resolveKnownWorkspaceFileTarget: (absolutePath, connectionId) =>
this.resolveKnownWorkspaceFileTarget(absolutePath, connectionId),
resolveKnownWorkspaceFileTarget: (absolutePath, executionHostId) =>
this.resolveKnownWorkspaceFileTarget(absolutePath, executionHostId),
resolveTerminalCwd: (terminalHandle) => this.resolveTerminalCwd(terminalHandle),
resolveTerminalContext: (terminalHandle) => this.resolveTerminalContext(terminalHandle),
resolveTerminalFileUriHostname: (terminalHandle) =>
@@ -154,7 +154,7 @@ describe('RuntimeFileCommands', () => {
repoId: 'repo-1',
path: '/remote/repo'
},
connectionId: 'ssh-1'
executionHostId: 'ssh:ssh-1'
}))
const { commands } = createRuntimeFileCommands({
openFile,
@@ -105,11 +105,20 @@ export const filesystemSearchGitMock = {
searchWithGitGrep: searchWithGitGrepMock
}
const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE =
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
export const sshFilesystemDispatchMock = {
getSshFilesystemProvider: getSshFilesystemProviderMock,
requireSshFilesystemProvider: (connectionId: string) => {
const provider = getSshFilesystemProviderMock(connectionId)
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider
},
onSshFilesystemProviderRegistered: () => () => undefined,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
}
export function resetRuntimeFileMocks(): void {
@@ -75,7 +75,7 @@ describe('RuntimeFileCommands', () => {
const { commands } = createRuntimeFileCommands({
resolveRuntimeFileTarget: vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
connectionId: 'ssh-1'
executionHostId: 'ssh:ssh-1'
}))
})
@@ -96,7 +96,7 @@ describe('RuntimeFileCommands', () => {
repoId: 'repo-1',
path: '/repo'
},
connectionId: null
executionHostId: 'local'
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
@@ -128,7 +128,7 @@ describe('RuntimeFileCommands', () => {
async (order) => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
connectionId: null
executionHostId: 'local'
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
@@ -163,7 +163,7 @@ describe('RuntimeFileCommands', () => {
it("falls back when a runtime native launcher exits outside ripgrep's contract", async () => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
connectionId: null
executionHostId: 'local'
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
@@ -191,7 +191,7 @@ describe('RuntimeFileCommands', () => {
repoId: 'repo-1',
path: 'C:\\repo'
},
connectionId: null
executionHostId: 'local'
}))
const { commands, store } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
@@ -231,7 +231,7 @@ describe('RuntimeFileCommands', () => {
it('keeps the runtime WSL preflight and falls back before starting real rg', async () => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo' },
connectionId: null
executionHostId: 'local'
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const fallback = { files: [], totalMatches: 0, truncated: false }
@@ -250,7 +250,7 @@ describe('RuntimeFileCommands', () => {
it('keeps legacy SSH Quick Open replies within the frame-sized result bound', async () => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
connectionId: 'ssh-1'
executionHostId: 'ssh:ssh-1'
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const listFiles = vi.fn(async () => ['src/target.ts'])
@@ -67,7 +67,7 @@ function sshCommands() {
path: '/remote/repo',
resolveRuntimeFileTarget: vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' },
connectionId: 'ssh-1'
executionHostId: 'ssh:ssh-1'
}))
}).commands
}
@@ -62,7 +62,7 @@ function createRuntimeFileCommands(): RuntimeFileCommands {
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH })),
resolveRuntimeFileTarget: vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH },
connectionId: CONNECTION_ID
executionHostId: `ssh:${CONNECTION_ID}`
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
@@ -103,6 +103,7 @@ describe('RuntimeFileCommands', () => {
}
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
worktree: sibling,
executionHostId: 'local',
relativePath: 'docs/readme.md'
}))
const { commands } = createRuntimeFileCommands({
@@ -154,6 +155,7 @@ describe('RuntimeFileCommands', () => {
}
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
worktree: sibling,
executionHostId: 'local',
relativePath: ''
}))
const hasRecentTerminalOutputPath = vi.fn(() => true)
@@ -195,7 +197,7 @@ describe('RuntimeFileCommands', () => {
}
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
worktree: sibling,
connectionId: 'ssh-1',
executionHostId: 'ssh:ssh-1',
relativePath: 'docs/readme.md'
}))
const { commands, store } = createRuntimeFileCommands({
@@ -245,7 +247,7 @@ describe('RuntimeFileCommands', () => {
}
const resolveKnownWorkspaceFileTarget = vi.fn(async () => ({
worktree: sibling,
connectionId: 'ssh-1',
executionHostId: 'ssh:ssh-1',
relativePath: ''
}))
const hasRecentTerminalOutputPath = vi.fn(() => true)
@@ -274,11 +276,14 @@ describe('RuntimeFileCommands', () => {
expect(hasRecentTerminalOutputPath).not.toHaveBeenCalled()
})
// The host was `runtime:env-a` until this process stopped dispatching runtime hosts at all
// (see runtime-file-target-execution-host.test.ts); an SSH host proves the same scoping on a
// host this process actually serves.
it('scopes sibling lookup to the selected worktree execution host', async () => {
const resolveKnownWorkspaceFileTarget = vi.fn(async () => null)
const { commands } = createRuntimeFileCommands({
path: '/repo-a',
hostId: 'runtime:env-a',
hostId: 'ssh:openclaw',
resolveKnownWorkspaceFileTarget
})
@@ -293,7 +298,7 @@ describe('RuntimeFileCommands', () => {
expect(resolveKnownWorkspaceFileTarget).toHaveBeenCalledWith(
'/repo-b/docs/readme.md',
'runtime:env-a'
'ssh:openclaw'
)
})
@@ -2,6 +2,12 @@ import { afterEach, beforeEach, vi } from 'vitest'
import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files'
import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation'
import { resetRuntimeFileMocks } from './orca-runtime-files-mock-registry'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
/** Restores the shared fs/auth/watcher mock state and fake timers around each test. */
export function useRuntimeFileCommandsLifecycle(): void {
@@ -51,6 +57,14 @@ export function createRuntimeFileCommands(options?: {
path,
...(options?.hostId ? { hostId: options.hostId } : {})
}
// Mirrors the real resolver: the worktree's own host outranks the repo row.
const runtimeFileTargetExecutionHostId = (): ExecutionHostId => {
const connectionId = store.getRepo(worktree.repoId)?.connectionId
return (
normalizeExecutionHostId(options?.hostId) ??
(connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID)
)
}
const commands = new RuntimeFileCommands({
getRuntimeId: () => 'runtime-1',
requireStore: () => store,
@@ -59,7 +73,7 @@ export function createRuntimeFileCommands(options?: {
options?.resolveRuntimeFileTarget ??
vi.fn(async () => ({
worktree,
connectionId: store.getRepo(worktree.repoId)?.connectionId
executionHostId: runtimeFileTargetExecutionHostId()
})),
...(options?.resolveKnownWorkspaceFileTarget
? { resolveKnownWorkspaceFileTarget: options.resolveKnownWorkspaceFileTarget }
@@ -87,7 +87,8 @@ function createRuntimeFileCommands(rootPath: string) {
id: 'wt-1',
repoId: 'repo-1',
path: rootPath
}
},
executionHostId: 'local'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
@@ -560,7 +561,7 @@ describe('RuntimeFileCommands file watching', () => {
repoId: 'repo-1',
path: '/remote/repo'
},
connectionId: 'ssh-1'
executionHostId: 'ssh:ssh-1'
})),
resolveRuntimeGitTarget: vi.fn(),
openFile: vi.fn()
+2 -3
View File
@@ -6,8 +6,7 @@ export { WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS } from './runtime-file-com
export { awaitRuntimeFileWatcherUnsubscribes } from './runtime-file-watcher-leases'
export { _getRuntimeFileWatcherReleaseCountForTests } from './runtime-file-watcher-leases'
export { _resetRuntimeFileWatcherLeasesForTests } from './runtime-file-watcher-leases'
export type { ResolvedRuntimeFileWorktree } from './runtime-file-watcher-leases'
export type { ResolvedRuntimeFileTarget } from './runtime-file-watcher-leases'
export { getRuntimeFileTargetExecutionHostId } from './runtime-file-watcher-leases'
export type { ResolvedRuntimeFileWorktree } from './runtime-file-command-target'
export type { ResolvedRuntimeFileTarget } from './runtime-file-command-target'
export type { RuntimeFileCommandHost } from './runtime-file-command-host'
export { isSafeMobileRelativePath } from './runtime-file-command-host'
@@ -8,7 +8,11 @@ import type {
} from '../../shared/runtime-types'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
import type { Repo } from '../../shared/repo-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import { resolveWorktreeHostRouting } from './worktree-launch-host-repo'
@@ -186,21 +190,34 @@ export class OrcaRuntimeWithPersistHeadlessTerminalTitle extends OrcaRuntimeWith
return { worktree, repo, executionHostId, localGitOptions }
}
// Why: same defect as `resolveRuntimeGitTarget` above, in ~30 filesystem dispatches. `getRepo(id)`
// is host-blind and never read `worktree.hostId`, and the `connectionId` it returned spelled
// "runtime host", "unresolved" and "genuinely local" all as `undefined` (#11163).
protected async resolveRuntimeFileTarget(worktreeSelector: string): Promise<{
worktree: ResolvedWorktree
connectionId?: string
executionHostId: ExecutionHostId
}> {
const folderScope = await this.resolveFolderWorkspaceLaunchScope(worktreeSelector)
if (folderScope?.folderWorkspace) {
// A folder workspace has no repo row to disagree with; its own inference already threw on an
// ambiguous one, and it is never hosted by a runtime environment.
return {
worktree: this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace),
connectionId: folderScope.connectionId ?? undefined
executionHostId: folderScope.connectionId
? toSshExecutionHostId(folderScope.connectionId)
: LOCAL_EXECUTION_HOST_ID
}
}
const store = this.requireStore()
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
const repo = store.getRepo(worktree.repoId)
return { worktree, connectionId: repo?.connectionId ?? undefined }
const routing = resolveWorktreeHostRouting(store.getRepos(), worktree)
if (routing.kind === 'ambiguous') {
throw new Error('worktree_execution_host_unresolved')
}
return {
worktree,
executionHostId: routing.kind === 'resolved' ? routing.hostId : LOCAL_EXECUTION_HOST_ID
}
}
}
@@ -1,8 +1,12 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithPersistHeadlessTerminalTitle } from './orca-runtime-persist-headless-terminal-title'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
import { getRuntimeFileTargetExecutionHostId } from './orca-runtime-files'
import { resolveWorktreeHostRouting } from './worktree-launch-host-repo'
import { findRuntimeWorkspaceFileOwner } from '../../shared/runtime-workspace-file-owner'
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
import { randomUUID } from 'node:crypto'
@@ -14,17 +18,14 @@ export class OrcaRuntimeWithResolveKnownWorkspaceFileTarget extends OrcaRuntimeW
executionHostId: ExecutionHostId
): Promise<{
worktree: ResolvedWorktree
connectionId?: string
executionHostId: ExecutionHostId
relativePath: string
} | null> {
const targets = new Map<
string,
{
worktree: ResolvedWorktree
connectionId?: string
executionHostId: ExecutionHostId
}
{ worktree: ResolvedWorktree; executionHostId: ExecutionHostId }
>()
const repos = this.store?.getRepos() ?? []
const resolvedWorktrees = await this.listResolvedWorktrees()
const visibilitySourceMatchersByRepoId =
this.buildRuntimeVisibilitySourceMatchersByRepoId(resolvedWorktrees)
@@ -37,29 +38,28 @@ export class OrcaRuntimeWithResolveKnownWorkspaceFileTarget extends OrcaRuntimeW
) {
continue
}
const candidateConnectionId = this.store?.getRepo(worktree.repoId)?.connectionId ?? undefined
// Why: `getRepo(id)` is host-blind, so a candidate on one SSH host could be filed under
// another's key and then answer for a path it does not hold. Rival rows that disagree with
// no worktree host name no single filesystem authority, so that candidate is dropped.
const routing = resolveWorktreeHostRouting(repos, worktree)
if (routing.kind === 'ambiguous') {
continue
}
const target = {
worktree,
executionHostId: getRuntimeFileTargetExecutionHostId({
worktree,
connectionId: candidateConnectionId
}),
...(candidateConnectionId ? { connectionId: candidateConnectionId } : {})
executionHostId: routing.kind === 'resolved' ? routing.hostId : LOCAL_EXECUTION_HOST_ID
}
targets.set(`${target.executionHostId}\0${worktree.id}`, target)
}
for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) {
try {
const candidateConnectionId =
this.resolveFolderWorkspaceConnectionId(folderWorkspace) ?? undefined
const candidateConnectionId = this.resolveFolderWorkspaceConnectionId(folderWorkspace)
const worktree = this.folderWorkspaceToResolvedWorktree(folderWorkspace)
const target = {
worktree,
executionHostId: getRuntimeFileTargetExecutionHostId({
worktree,
connectionId: candidateConnectionId
}),
...(candidateConnectionId ? { connectionId: candidateConnectionId } : {})
executionHostId: candidateConnectionId
? toSshExecutionHostId(candidateConnectionId)
: LOCAL_EXECUTION_HOST_ID
}
targets.set(`${target.executionHostId}\0${worktree.id}`, target)
} catch {
@@ -7,7 +7,6 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import { getRuntimeFileTargetExecutionHostId } from './orca-runtime-files'
import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach'
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults'
@@ -90,18 +89,16 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) {
const target = await this.resolveRuntimeFileTarget(worktreeSelector)
const repo = this.store?.getRepo(target.worktree.repoId)
// WSL routing describes *this* machine; no remote or runtime host may inherit it.
const wslDistro =
repo && !target.connectionId
repo && target.executionHostId === LOCAL_EXECUTION_HOST_ID
? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null)
: null
const folderWorkspace = this.store
?.getFolderWorkspaces?.()
.some((workspace) => workspace.id === target.worktree.id)
return {
executionHostId: getRuntimeFileTargetExecutionHostId({
worktree: target.worktree,
connectionId: target.connectionId
}),
executionHostId: target.executionHostId,
wslDistro,
workspaceId: target.worktree.id,
workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const)
@@ -3,7 +3,7 @@ import type { Store } from '../persistence'
import type {
ResolvedRuntimeFileTarget,
ResolvedRuntimeFileWorktree
} from './runtime-file-watcher-leases'
} from './runtime-file-command-target'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { RuntimeNativeChatFileContext } from '../../shared/runtime-types'
import type { FsChangeEvent } from '../../shared/filesystem-entry-types'
@@ -42,8 +42,9 @@ export type RuntimeFileCommandHost = {
pathText: string,
absolutePath: string
): boolean | Promise<boolean>
// `executionHostId`, not `connectionId`: a repo row's connection cannot tell `runtime:` from
// `local`, and this contract must not re-introduce that spelling. See runtime-git-command-target.
// `executionHostId`, not `connectionId`, on both target contracts: a repo row's connection cannot
// tell `runtime:` from `local`, and neither may re-introduce that spelling. See
// runtime-git-command-target and runtime-file-command-target.
resolveRuntimeGitTarget(
selector: string
): Promise<{ worktree: ResolvedRuntimeFileWorktree; executionHostId: ExecutionHostId }>
@@ -0,0 +1,90 @@
import type { ExecutionHostId } from '../../shared/execution-host'
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import {
ExecutionHostNotDispatchableError,
resolveFilesystemRouteForHost
} from '../providers/execution-host-provider-dispatch'
import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch'
import type { IFilesystemProvider } from '../providers/types'
export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo }
export type ResolvedRuntimeFileTarget = {
worktree: ResolvedRuntimeFileWorktree
/**
* The host whose filesystem holds this workspace. Never optional and never null: the field it
* replaced (`connectionId?: string`) spelled "runtime host", "unresolved" and "genuinely local"
* all as `undefined`, so every path that could not resolve answered "local" and read remote
* paths on the client (#11163). Unresolved now fails at resolution time instead of arriving here
* as a silently-local target. Mirrors `RuntimeGitTarget.executionHostId`.
*/
executionHostId: ExecutionHostId
}
/** A workspace-relative path already joined onto its host's root; `executionHostId` routes it. */
export type RuntimeFileExplorerPath = {
worktree: ResolvedRuntimeFileWorktree
path: string
executionHostId: ExecutionHostId
}
/**
* The two hosts this process can itself run a runtime filesystem command on, narrowed from the
* shared host-keyed route in `src/main/providers/execution-host-provider-dispatch.ts`.
*
* `runtime:<env>` is deliberately not a variant, for the same reason it is not one for Git: the
* files live on that environment's own server, which normalizes the call to its own `local`, and
* the SSH target on its repo row is that server's *nested* one — addressable only as the pair
* (environmentId, targetId). Handing that id to this client's SSH table reads a same-named target
* in the wrong namespace, so it throws rather than routing.
*/
export type RuntimeFileRoute =
| { kind: 'local' }
/** `provider: null` is "remote and currently unreachable" — never "read it here". */
| { kind: 'ssh'; connectionId: string; provider: IFilesystemProvider | null }
/** The remote half of the route, for leaf helpers that only ever run against an SSH host. */
export type RuntimeFileSshRoute = Extract<RuntimeFileRoute, { kind: 'ssh' }>
export function runtimeFileRouteForTarget(target: {
executionHostId: ExecutionHostId
}): RuntimeFileRoute {
const route = resolveFilesystemRouteForHost(target.executionHostId)
switch (route.kind) {
case 'local':
return { kind: 'local' }
case 'ssh':
return { kind: 'ssh', connectionId: route.connectionId, provider: route.provider }
case 'runtime':
throw new ExecutionHostNotDispatchableError(route.hostId)
}
}
/**
* `null` means exactly one thing: the host is `local`, and this command reads and writes here. An
* unreachable SSH host and a `runtime:` host both throw.
*/
export function requireRuntimeFileProvider(target: {
executionHostId: ExecutionHostId
}): IFilesystemProvider | null {
const route = runtimeFileRouteForTarget(target)
if (route.kind === 'local') {
return null
}
if (!route.provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
return route.provider
}
/**
* The SSH target id for the leaf helpers that still address a connection by name — watcher release
* keys, re-arm registration, remote path stats. `undefined` is `local`; a `runtime:` host throws
* rather than surrendering its nested target id to this client's namespace.
*/
export function runtimeFileSshTargetId(target: {
executionHostId: ExecutionHostId
}): string | undefined {
const route = runtimeFileRouteForTarget(target)
return route.kind === 'ssh' ? route.connectionId : undefined
}
@@ -10,6 +10,11 @@ import {
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
getSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import {
requireRuntimeFileProvider,
runtimeFileRouteForTarget,
runtimeFileSshTargetId
} from './runtime-file-command-target'
import { readdir, stat } from 'node:fs/promises'
import type { DirEntry, FsChangeEvent } from '../../shared/filesystem-entry-types'
import { sortDirEntries } from '../../shared/file-name-sort'
@@ -56,11 +61,8 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni
async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise<DirEntry[]> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(target)
if (provider) {
// Why: re-sort locally — the remote relay may be an older build with
// lexicographic ordering.
return sortDirEntries(await provider.readDir(target.path))
@@ -83,22 +85,29 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni
signal?: AbortSignal
): Promise<() => Promise<void>> {
const target = await this.resolveFileExplorerPath(worktreeSelector, '')
// Why: watcher keys must scope teardown to the owning host; a `runtime:` host throws here
// rather than registering a lease under this client's namespace.
const sshTargetId = runtimeFileSshTargetId(target)
const open = async (): Promise<{
unsubscribe: () => Promise<void>
rootPaths: string[]
}> => {
const finishInstall = beginWatcherInstall(target.path, target.connectionId)
const finishInstall = beginWatcherInstall(target.path, sshTargetId)
try {
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
// Re-resolved per open: a reconnect mints a fresh provider for the same target.
const route = runtimeFileRouteForTarget(target)
if (route.kind === 'ssh') {
if (!route.provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
// Why: the RPC layer already threads AbortSignal for local watches; SSH must cancel the remote fs.watch, not wait it out.
const close = await provider.watch(target.path, callback, { signal, onTerminalError })
const close = await route.provider.watch(target.path, callback, {
signal,
onTerminalError
})
const rearm = armSshFileExplorerWatchRearm({
runtimeId: this.host.getRuntimeId(),
connectionId: target.connectionId,
connectionId: route.connectionId,
rootPath: target.path,
callback,
onTerminalError,
@@ -132,7 +141,7 @@ export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanoni
const initial = await open()
return registerRuntimeFileWatcherRelease(
this.host.getRuntimeId(),
target.connectionId,
sshTargetId,
initial.rootPaths,
initial.unsubscribe,
async () => (await open()).unsubscribe,
@@ -24,6 +24,7 @@ import { stat } from 'node:fs/promises'
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
import { isENOENT } from '../ipc/filesystem-path-containment'
import { runtimeFileRouteForTarget, type RuntimeFileRoute } from './runtime-file-command-target'
export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithActiveRuntimeTextSearches {
constructor(private readonly host: RuntimeFileCommandHost) {
@@ -36,10 +37,12 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
): Promise<RuntimeFileListResult> {
const store = this.host.requireStore()
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree, connectionId } = target
const files = connectionId
? await this.listRemoteMobileFiles(worktree.path, connectionId, undefined, options.signal)
: await listQuickOpenFiles(worktree.path, store, undefined, options.signal)
const { worktree } = target
const route = runtimeFileRouteForTarget(target)
const files =
route.kind === 'ssh'
? await this.listRemoteMobileFiles(worktree.path, route.provider, undefined, options.signal)
: await listQuickOpenFiles(worktree.path, store, undefined, options.signal)
const entries = files
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
.sort((a, b) => a.localeCompare(b))
@@ -66,22 +69,26 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
): Promise<RuntimeFileListResult> {
const store = this.host.requireStore()
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree, connectionId } = target
const cacheKey = `${connectionId ?? 'local'}:${worktree.id}:${worktree.path}`
const { worktree } = target
const route = runtimeFileRouteForTarget(target)
// Why: identical paths exist on local and on several SSH hosts; the cache key must name the
// resolved host, which `connectionId` could not tell apart from "unresolved".
const cacheKey = `${target.executionHostId}:${worktree.id}:${worktree.path}`
const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => {
const listed = connectionId
? await this.listRemoteMobileFiles(
worktree.path,
connectionId,
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
)
: await listQuickOpenFiles(
worktree.path,
store,
undefined,
undefined,
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
)
const listed =
route.kind === 'ssh'
? await this.listRemoteMobileFiles(
worktree.path,
route.provider,
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
)
: await listQuickOpenFiles(
worktree.path,
store,
undefined,
undefined,
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
)
const safePaths = listed
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
.sort((a, b) => a.localeCompare(b))
@@ -113,14 +120,15 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
signal?: AbortSignal
): Promise<RuntimeFileListResult> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree, connectionId } = target
const { worktree } = target
const route = runtimeFileRouteForTarget(target)
const result =
!query.trim() || isQuickOpenQueryTooLarge(query)
? { paths: [], totalCount: 0, truncated: false }
: connectionId
: route.kind === 'ssh'
? await this.searchRemoteQuickOpenFilePaths(
worktree.path,
connectionId,
route.provider,
query,
limit,
excludePaths,
@@ -149,7 +157,8 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
worktreeSelector: string,
relativePath: string
): Promise<RuntimeFileOpenResult> {
const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree } = target
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
@@ -166,7 +175,7 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
}
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
// Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab.
await this.assertMobileOpenTargetExists(filePath, connectionId)
await this.assertMobileOpenTargetExists(filePath, runtimeFileRouteForTarget(target))
// Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId.
this.host.openFile(worktree.id, filePath, relativePath, undefined)
return { worktree: worktree.id, relativePath, kind, opened: true }
@@ -174,16 +183,16 @@ export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithA
protected async assertMobileOpenTargetExists(
filePath: string,
connectionId?: string
route: RuntimeFileRoute
): Promise<void> {
try {
await (connectionId
? this.statRemoteTerminalPath(filePath, connectionId)
await (route.kind === 'ssh'
? this.statRemoteTerminalPath(filePath, route.connectionId)
: stat(await resolveAuthorizedPath(filePath, this.host.requireStore())))
} catch (error) {
if (
isENOENT(error) ||
(connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
(route.kind === 'ssh' && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
) {
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`)
}
@@ -1,10 +1,7 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeFileCommandsWithWriteFileExplorerFile } from './runtime-file-commands-write-file-explorer-file'
import { assertRuntimeFileMutationExpectation } from './runtime-file-commands-mobile-file-list-limit'
import {
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
getSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { requireRuntimeFileProvider } from './runtime-file-command-target'
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
import { constants, copyFile, mkdir, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
@@ -20,16 +17,13 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
target.executionHostId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(target)
if (provider) {
await provider.createDirNoClobber(target.path)
return { ok: true }
}
@@ -52,18 +46,13 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
finalRelativePath
])
assertRuntimeFileMutationExpectation(
tempTarget.connectionId,
tempTarget.executionHostId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = tempTarget.connectionId
? getSshFilesystemProvider(tempTarget.connectionId)
: null
if (tempTarget.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(tempTarget)
if (provider) {
await provider.copy(tempTarget.path, finalTarget.path)
await provider.deletePath(tempTarget.path, false).catch(() => {})
return { ok: true }
@@ -91,18 +80,13 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
newRelativePath
])
assertRuntimeFileMutationExpectation(
oldTarget.connectionId,
oldTarget.executionHostId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = oldTarget.connectionId
? getSshFilesystemProvider(oldTarget.connectionId)
: null
if (oldTarget.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(oldTarget)
if (provider) {
await provider.renameNoClobber(oldTarget.path, newTarget.path)
return { ok: true }
}
@@ -127,18 +111,13 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
[sourceRelativePath, destinationRelativePath]
)
assertRuntimeFileMutationExpectation(
sourceTarget.connectionId,
sourceTarget.executionHostId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = sourceTarget.connectionId
? getSshFilesystemProvider(sourceTarget.connectionId)
: null
if (sourceTarget.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(sourceTarget)
if (provider) {
await provider.copy(sourceTarget.path, destinationTarget.path)
return { ok: true }
}
@@ -166,16 +145,13 @@ export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends Runti
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
target.executionHostId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(target)
if (provider) {
await provider.deletePath(target.path, recursive)
return { ok: true }
}
@@ -7,7 +7,7 @@ import {
remoteRpcResultExceedsContentBudget
} from '../../shared/remote-rpc-content-budget'
import { constants } from 'node:fs/promises'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { getSshTargetIdForExecutionHost, type ExecutionHostId } from '../../shared/execution-host'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
import { basenameFromRelativePath } from './runtime-file-paths'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
@@ -87,7 +87,10 @@ export const RUNTIME_FILE_MUTATION_UPDATE_REQUIRED =
'Remote file changes require a newer Orca client. Update the paired client and try again.'
export function assertRuntimeFileMutationExpectation(
connectionId: string | undefined,
// The resolved host, not a repo row's connection: recomputing it from `connectionId` here spelled
// `runtime:<env>` and "unresolved" as `local`, so a client's host expectation could pass against
// a host it never named (#11163).
executionHostId: ExecutionHostId,
expectedExecutionHostId: string | undefined,
expectedSshTargetId: string | undefined,
expectedSshConnectionGeneration: number | undefined
@@ -95,11 +98,14 @@ export function assertRuntimeFileMutationExpectation(
if (!expectedExecutionHostId) {
throw new Error(RUNTIME_FILE_MUTATION_UPDATE_REQUIRED)
}
const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local'
if (expectedExecutionHostId !== actualExecutionHostId) {
if (expectedExecutionHostId !== executionHostId) {
throw new Error('Workspace host changed; refresh and try again')
}
assertSshMutationExpectation(connectionId, expectedSshTargetId, expectedSshConnectionGeneration)
assertSshMutationExpectation(
getSshTargetIdForExecutionHost(executionHostId) ?? undefined,
expectedSshTargetId,
expectedSshConnectionGeneration
)
}
export const pendingRuntimeFileWatcherUnsubscribes = new Set<Promise<void>>()
@@ -12,10 +12,7 @@ import {
previewableBinaryByteLimit,
readPreviewFileWithinCap
} from './runtime-file-commands-mobile-file-list-limit'
import {
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
getSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { requireRuntimeFileProvider } from './runtime-file-command-target'
import { open, stat } from 'node:fs/promises'
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
import { extname } from 'node:path'
@@ -42,11 +39,8 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC
? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES
: previewableBinaryByteLimit(maxContentBytes)
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(target)
if (provider) {
const fileStats = await provider.stat(target.path)
if (fileStats.size > binaryMaxBytes) {
throw new Error('file_too_large')
@@ -140,11 +134,8 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC
maxTextBytes: MOBILE_FILE_READ_MAX_BYTES,
maxBinaryBytes: binaryMaxBytes
}
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId && !provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
if (target.connectionId && !provider?.readDocPreviewFile) {
const provider = requireRuntimeFileProvider(target)
if (provider && !provider.readDocPreviewFile) {
throw new Error('Secure document previews require a newer SSH relay')
}
const result = provider?.readDocPreviewFile
@@ -160,11 +151,8 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC
length: number
): Promise<RuntimeFileReadChunkResult> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(target)
if (provider) {
const fileStat = await provider.stat(target.path)
if (fileStat.type === 'directory') {
throw new Error('Cannot download a directory')
@@ -5,6 +5,7 @@ import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-com
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { readLocalMobileFile } from './runtime-file-commands-terminal-file-paths'
import { truncateMobileFilePreview } from './runtime-file-commands-terminal-artifact-access'
import { requireRuntimeFileProvider } from './runtime-file-command-target'
export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWithConstructor {
async readMobileFile(
@@ -13,7 +14,8 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWi
): Promise<RuntimeFileReadResult> {
const store = this.host.requireStore()
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree, connectionId } = target
const { worktree } = target
const provider = requireRuntimeFileProvider(target)
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
@@ -22,8 +24,8 @@ export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWi
}
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
const content = connectionId
? await this.readRemoteMobileFile(filePath, connectionId)
const content = provider
? await this.readRemoteMobileFile(filePath, provider)
: await readLocalMobileFile(filePath, store)
const truncated = truncateMobileFilePreview(content)
@@ -23,7 +23,10 @@ import { TERMINAL_FILE_GRANT_TTL_MS } from './runtime-file-commands-mobile-file-
import type { RuntimeTerminalPathResolution } from '../../shared/runtime-types'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { randomUUID } from 'node:crypto'
import type { ResolvedRuntimeFileTarget } from './runtime-file-watcher-leases'
import {
runtimeFileSshTargetId,
type ResolvedRuntimeFileTarget
} from './runtime-file-command-target'
export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends RuntimeFileCommandsWithResolveTerminalPath {
protected async resolveAllowedTerminalArtifactPath(args: {
@@ -188,7 +191,7 @@ export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends R
if (
grant.worktreeId !== target.worktree.id ||
grant.absolutePath !== absolutePath ||
grant.connectionId !== target.connectionId ||
grant.connectionId !== runtimeFileSshTargetId(target) ||
grant.clientId !== clientId
) {
throw new Error('terminal_file_grant_mismatch')
@@ -13,15 +13,12 @@ import {
resolveTerminalAbsolutePath
} from './runtime-file-commands-terminal-file-paths'
import { stat } from 'node:fs/promises'
import { getRuntimeFileTargetExecutionHostId } from './runtime-file-watcher-leases'
import { runtimeFileRouteForTarget } from './runtime-file-command-target'
import { isSafeMobileRelativePath } from './runtime-file-command-host'
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
import { isENOENT } from '../ipc/filesystem-path-containment'
import type { RuntimeFileStatLike } from './runtime-file-commands-mobile-file-list-limit'
import {
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
getSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileCommandsWithReadMobileFile {
// Resolves a mobile terminal tap to a worktree-relative path; relatives resolve against cwd, else the worktree root.
@@ -36,7 +33,9 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
): Promise<RuntimeTerminalPathResolution> {
const store = this.host.requireStore()
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree, connectionId } = target
const { worktree } = target
const route = runtimeFileRouteForTarget(target)
const connectionId = route.kind === 'ssh' ? route.connectionId : undefined
// Why: mobile may attach after OSC7 cwd was emitted; the runtime still owns the terminal's latest cwd to resolve the tap.
const normalizedTerminalHandle =
terminalHandle && terminalHandle.trim().length > 0 ? terminalHandle.trim() : null
@@ -74,13 +73,13 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
// follow-up files.open, so retargeting to a sibling workspace must be opt-in.
const knownWorkspaceTarget =
crossWorkspace && relativePath === null
? await this.host.resolveKnownWorkspaceFileTarget?.(
absolutePath,
getRuntimeFileTargetExecutionHostId(target)
)
? await this.host.resolveKnownWorkspaceFileTarget?.(absolutePath, target.executionHostId)
: null
const ownedWorktree = knownWorkspaceTarget?.worktree ?? worktree
const ownedConnectionId = knownWorkspaceTarget?.connectionId ?? connectionId
// Why: the owner's host replaces this target's outright. Coalescing an optional connection
// instead let a sibling workspace resolved as `local` inherit this worktree's SSH target and
// stat a local path on the remote host.
const ownedRoute = runtimeFileRouteForTarget(knownWorkspaceTarget ?? target)
const ownedRelativePath = knownWorkspaceTarget?.relativePath ?? relativePath
try {
@@ -88,9 +87,10 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
ownedRelativePath !== null &&
(ownedRelativePath === '' || isSafeMobileRelativePath(ownedRelativePath))
) {
const stats = ownedConnectionId
? await this.statRemoteTerminalPath(absolutePath, ownedConnectionId)
: await stat(await resolveAuthorizedPath(absolutePath, store))
const stats =
ownedRoute.kind === 'ssh'
? await this.statRemoteTerminalPath(absolutePath, ownedRoute.connectionId)
: await stat(await resolveAuthorizedPath(absolutePath, store))
return {
worktree: ownedWorktree.id,
relativePath: ownedRelativePath,
@@ -101,7 +101,7 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
? undefined
: {
kind: 'worktree-file',
provider: ownedConnectionId ? 'ssh' : 'local',
provider: ownedRoute.kind,
relativePath: ownedRelativePath,
absolutePath
}
@@ -168,7 +168,7 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
// Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing.
if (
isENOENT(error) ||
(ownedConnectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
(ownedRoute.kind === 'ssh' && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
) {
return {
...empty,
@@ -181,15 +181,13 @@ export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileComma
}
}
// Leaf helper: only ever reached from a route already resolved to `ssh`, so the id it takes is
// this client's dialable target rather than a repo row's raw `connectionId`.
protected async statRemoteTerminalPath(
absolutePath: string,
connectionId: string
): Promise<RuntimeFileStatLike & { isDirectory: () => boolean }> {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const stats = await provider.stat(absolutePath)
const stats = await requireSshFilesystemProvider(connectionId).stat(absolutePath)
return { ...stats, isDirectory: () => stats.type === 'directory' }
}
}
@@ -21,9 +21,9 @@ import {
} from '../../shared/ripgrep-process-availability'
import type { ChildProcessHandle } from '../../shared/child-process/process-spec'
import { wslAwareSpawn } from '../git/runner'
import type { ResolvedRuntimeFileWorktree } from './runtime-file-watcher-leases'
import type { RuntimeFileExplorerPath } from './runtime-file-command-target'
import type { IFilesystemProvider } from '../providers/types'
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileCommandsWithSearchRuntimeFiles {
protected async searchLocalRuntimeFiles(
@@ -176,7 +176,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC
protected async resolveFileExplorerPath(
worktreeSelector: string,
relativePath: string
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> {
): Promise<RuntimeFileExplorerPath> {
const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath])
return target
}
@@ -184,7 +184,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC
protected async resolveFileExplorerPaths(
worktreeSelector: string,
relativePaths: readonly string[]
): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }[]> {
): Promise<RuntimeFileExplorerPath[]> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
return relativePaths.map((relativePath) => ({
worktree: target.worktree,
@@ -192,17 +192,17 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC
target.worktree.path,
normalizeRuntimeRelativePath(relativePath)
),
connectionId: target.connectionId
executionHostId: target.executionHostId
}))
}
// `null` provider is the caller's "this host is unreachable" answer, not "list it here".
protected async listRemoteMobileFiles(
rootPath: string,
connectionId: string,
provider: IFilesystemProvider | null,
maxResults?: number,
signal?: AbortSignal
): Promise<string[]> {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
return []
}
@@ -1,9 +1,6 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeFileCommandsWithSearchLocalRuntimeFiles } from './runtime-file-commands-search-local-runtime-files'
import {
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
getSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import type { IFilesystemProvider } from '../providers/types'
import {
MOBILE_FILE_READ_MAX_BYTES,
QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT
@@ -13,13 +10,14 @@ import { QuickOpenPathRanker } from '../../shared/quick-open-path-search'
export class RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths extends RuntimeFileCommandsWithSearchLocalRuntimeFiles {
protected async searchRemoteQuickOpenFilePaths(
rootPath: string,
connectionId: string,
// `null` is "remote and currently unreachable": quick open reports no matches rather than
// failing the keystroke, but it never falls back to searching this machine.
provider: IFilesystemProvider | null,
query: string,
limit: number,
excludePaths?: string[],
signal?: AbortSignal
): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
return { paths: [], totalCount: 0, truncated: false }
}
@@ -55,11 +53,10 @@ export class RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths extends Runti
}
}
protected async readRemoteMobileFile(filePath: string, connectionId: string): Promise<string> {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
protected async readRemoteMobileFile(
filePath: string,
provider: IFilesystemProvider
): Promise<string> {
const fileStat = await provider.stat(filePath)
// Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it.
if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) {
@@ -2,9 +2,9 @@
import { RuntimeFileCommandsWithCreateFileExplorerDirNoClobber } from './runtime-file-commands-create-file-explorer-dir-no-clobber'
import type { SearchOptions, SearchResult } from '../../shared/code-search-types'
import {
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
getSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
requireRuntimeFileProvider,
runtimeFileRouteForTarget
} from './runtime-file-command-target'
import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits'
import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget'
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
@@ -22,13 +22,10 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman
options: Omit<SearchOptions, 'rootPath'>
): Promise<SearchResult> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
const provider = requireRuntimeFileProvider(target)
const rootPath = target.worktree.path
const searchOptions = { ...options, rootPath }
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
if (provider) {
return provider.search(searchOptions)
}
return this.searchLocalRuntimeFiles(rootPath, searchOptions)
@@ -44,8 +41,10 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman
} = {}
): Promise<string[]> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
const route = runtimeFileRouteForTarget(target)
if (route.kind === 'ssh') {
// Why: quick-open listings degrade to empty for an unreachable host rather than throwing.
const provider = route.provider
if (!provider) {
return []
}
@@ -73,11 +72,8 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman
async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise<MarkdownDocument[]> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(target)
if (provider) {
const relativePaths = await provider.listFiles(target.worktree.path)
return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths)
}
@@ -89,11 +85,8 @@ export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileComman
relativePath: string
): Promise<{ size: number; isDirectory: boolean; mtime: number }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(target)
if (provider) {
const fileStat = await provider.stat(target.path)
return {
size: fileStat.size,
@@ -1,10 +1,7 @@
// @ts-nocheck -- mechanically split class members.
import { RuntimeFileCommandsWithReadFileExplorerPreview } from './runtime-file-commands-read-file-explorer-preview'
import { assertRuntimeFileMutationExpectation } from './runtime-file-commands-mobile-file-list-limit'
import {
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE,
getSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { requireRuntimeFileProvider } from './runtime-file-command-target'
import { lstat, mkdir, writeFile } from 'node:fs/promises'
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
import { isENOENT } from '../ipc/filesystem-path-containment'
@@ -25,16 +22,13 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
target.executionHostId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(target)
if (provider) {
await provider.writeFile(target.path, content)
return { ok: true }
}
@@ -64,17 +58,14 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
target.executionHostId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
const provider = requireRuntimeFileProvider(target)
const content = Buffer.from(contentBase64, 'base64')
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
if (provider) {
await provider.writeFileBase64(target.path, contentBase64)
return { ok: true }
}
@@ -96,17 +87,14 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
target.executionHostId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
const provider = requireRuntimeFileProvider(target)
const content = Buffer.from(contentBase64, 'base64')
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
if (provider) {
await provider.writeFileBase64Chunk(target.path, contentBase64, append)
return { ok: true }
}
@@ -126,16 +114,13 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
target.executionHostId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(target)
if (provider) {
await provider.createFile(target.path)
return { ok: true }
}
@@ -159,16 +144,13 @@ export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCom
): Promise<{ ok: true }> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
assertRuntimeFileMutationExpectation(
target.connectionId,
target.executionHostId,
expectedExecutionHostId,
expectedSshTargetId,
expectedSshConnectionGeneration
)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const provider = requireRuntimeFileProvider(target)
if (provider) {
await provider.createDir(target.path)
return { ok: true }
}
@@ -0,0 +1,50 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
/**
* Guard the removal of `ResolvedRuntimeFileTarget.connectionId` at the tree level.
*
* Removing the field is what turned every reader into an error rather than letting old call sites
* silently inherit a changed meaning — the way this defect spread. But the whole
* `runtime-file-commands-*` family carries `// @ts-nocheck` from a mechanical class split, so the
* compiler reports nothing there: a re-introduced `target.connectionId` would read `undefined`,
* which is exactly the "unresolved means local" spelling the migration deleted (#11163).
*
* This test is the compile error those files cannot produce. Routing goes through
* `runtime-file-command-target.ts`, which is deliberately not `@ts-nocheck`.
*/
const RUNTIME_DIR = __dirname
const TARGET_MODULE = 'runtime-file-command-target.ts'
// Matches `target.connectionId`, `tempTarget.connectionId`, `knownWorkspaceTarget?.connectionId`.
// Not `grant.connectionId` or `args.connectionId`: a grant and a leaf argument legitimately carry
// an SSH target id, having already been resolved from a host.
const TARGET_CONNECTION_READ = /\b\w*[Tt]arget\??\.connectionId\b/
function familyFiles(): string[] {
return readdirSync(RUNTIME_DIR).filter(
(name) =>
(name.startsWith('runtime-file-') || name === 'orca-runtime-file-commands.ts') &&
name.endsWith('.ts') &&
!name.endsWith('.test.ts')
)
}
describe('runtime file target connection field', () => {
it('is read nowhere in the runtime file command family', () => {
const offenders = familyFiles().filter((name) =>
TARGET_CONNECTION_READ.test(readFileSync(join(RUNTIME_DIR, name), 'utf8'))
)
expect(offenders).toEqual([])
})
// The one module in the family the compiler still checks; it is where the routing rule lives.
it('routes through a module the compiler still checks', () => {
const source = readFileSync(join(RUNTIME_DIR, TARGET_MODULE), 'utf8')
expect(source).not.toMatch(/@ts-nocheck/)
expect(source).toMatch(/executionHostId: ExecutionHostId/)
})
})
@@ -0,0 +1,245 @@
// `resolveRuntimeFileTarget` read `store.getRepo(worktree.repoId)?.connectionId` and never looked
// at `worktree.hostId`, so one arbitrarily chosen row decided the execution host for ~30 downstream
// filesystem dispatches. `undefined` there meant "runtime host", "unresolved" and "genuinely local"
// at once (#11163). These cases pin all four answers end to end, through the real SSH filesystem
// provider table. Companion to runtime-git-target-execution-host.test.ts.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
}))
import type * as MarkdownDocumentsModule from '../ipc/markdown-documents'
const mocks = vi.hoisted(() => ({ listMarkdownDocuments: vi.fn() }))
vi.mock('../ipc/markdown-documents', async () => ({
...(await vi.importActual<typeof MarkdownDocumentsModule>('../ipc/markdown-documents')),
listMarkdownDocuments: mocks.listMarkdownDocuments
}))
import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { OrcaRuntimeService } from './orca-runtime'
const REMOTE_PATH = '/srv/app-feature'
const WORKTREE_ID = 'repo-shared::/srv/app-feature'
type RuntimeInternals = {
resolveWorktreeSelector: (selector: string) => Promise<unknown>
}
function makeRuntime(repos: readonly Record<string, unknown>[], hostId?: string) {
const store = {
getSettings: () => ({
disabledTuiAgents: [],
workspaceDir: '/tmp/workspaces'
}),
getProjectHostSetups: () => [],
getProjects: () => [],
getFolderWorkspaces: () => [],
getRepos: () => repos,
getRepo: (id: string) => repos.find((repo) => repo.id === id)
}
const runtime = new OrcaRuntimeService(store as never)
vi.spyOn(runtime as unknown as RuntimeInternals, 'resolveWorktreeSelector').mockResolvedValue({
id: WORKTREE_ID,
repoId: 'repo-shared',
path: REMOTE_PATH,
git: {
path: REMOTE_PATH,
branch: 'main',
isBare: false,
isMainWorktree: false
},
...(hostId ? { hostId } : {})
})
return runtime
}
function stubProvider() {
return { listFiles: vi.fn().mockResolvedValue(['README.md']) }
}
describe('runtime file target execution host', () => {
const registered: string[] = []
function register(connectionId: string) {
const provider = stubProvider()
registerSshFilesystemProvider(connectionId, provider as never)
registered.push(connectionId)
return provider
}
beforeEach(() => {
vi.restoreAllMocks()
mocks.listMarkdownDocuments.mockReset().mockResolvedValue([])
})
afterEach(() => {
for (const connectionId of registered.splice(0)) {
unregisterSshFilesystemProvider(connectionId)
}
})
// The case whose absence let the original cross-host leak through review: two SSH hosts
// registered at once, and the rival row is the one `getRepo` returns first.
it('lists an ssh workspace from the host it names, not from a rival row on another ssh host', async () => {
const openclaw = register('openclaw')
const m4air = register('m4air')
const runtime = makeRuntime(
[
{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' },
{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }
],
'ssh:m4air'
)
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
expect(m4air.listFiles).toHaveBeenCalledWith(REMOTE_PATH)
expect(openclaw.listFiles).not.toHaveBeenCalled()
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
})
it("routes to the workspace's host even when the only repo row names a different ssh host", async () => {
const openclaw = register('openclaw')
const m4air = register('m4air')
const runtime = makeRuntime(
[{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }],
'ssh:m4air'
)
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
expect(m4air.listFiles).toHaveBeenCalledWith(REMOTE_PATH)
expect(openclaw.listFiles).not.toHaveBeenCalled()
})
// `local` has no SSH namespace to nest in, so a surviving `connectionId` is a row contradicting
// itself. The old shape handed it out and read a local workspace off a remote host.
it('ignores a stale connection on a row that declares itself local', async () => {
const m4air = register('m4air')
const runtime = makeRuntime(
[
{
id: 'repo-shared',
path: '/home/me/app',
executionHostId: 'local',
connectionId: 'm4air'
}
],
'local'
)
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
expect(m4air.listFiles).not.toHaveBeenCalled()
expect(mocks.listMarkdownDocuments).toHaveBeenCalledWith(REMOTE_PATH)
})
// A `runtime:` row's `connectionId` names a target in the *server's* namespace. Reading it here
// reaches a same-named target on this client — a silent-wrong-host answer, worse than the
// silent-local one it replaced.
it('refuses a runtime host whose nested ssh target is also registered on this client', async () => {
const impostor = register('nested-1')
const runtime = makeRuntime(
[
{
id: 'repo-shared',
path: '/srv/app',
executionHostId: 'runtime:env-a',
connectionId: 'nested-1'
}
],
'runtime:env-a'
)
await expect(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)).rejects.toThrow(
ExecutionHostNotDispatchableError
)
expect(impostor.listFiles).not.toHaveBeenCalled()
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
})
it('refuses a runtime host with no nested ssh target rather than answering locally', async () => {
const runtime = makeRuntime(
[
{
id: 'repo-shared',
path: '/srv/app',
executionHostId: 'runtime:env-a'
}
],
'runtime:env-a'
)
await expect(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)).rejects.toThrow(
ExecutionHostNotDispatchableError
)
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
})
it('refuses rather than guessing when rival rows disagree and the workspace names no host', async () => {
register('m4air')
const runtime = makeRuntime([
{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' },
{ id: 'repo-shared', path: '/home/me/app' }
])
await expect(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)).rejects.toThrow(
'worktree_execution_host_unresolved'
)
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
})
it('still answers from the single row when the workspace names no host', async () => {
const m4air = register('m4air')
const runtime = makeRuntime([{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }])
await runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)
expect(m4air.listFiles).toHaveBeenCalledWith(REMOTE_PATH)
})
// Losing contact with a remote host is never evidence that its files are here
// (docs/reference/ssh-execution-boundary.md).
it('reports the dropped connection instead of reading a remote path locally', async () => {
const runtime = makeRuntime(
[{ id: 'repo-shared', path: '/srv/app', connectionId: 'm4air' }],
'ssh:m4air'
)
await expect(runtime.listRuntimeMarkdownDocuments(`id:${WORKTREE_ID}`)).rejects.toThrow(
/Remote connection dropped/
)
expect(mocks.listMarkdownDocuments).not.toHaveBeenCalled()
})
// The mutation guard recomputed the host from `connectionId`, so a client could satisfy a host
// expectation the workspace never named.
it('rejects a mutation whose expected host is the row rather than the resolved one', async () => {
register('m4air')
register('openclaw')
const runtime = makeRuntime(
[{ id: 'repo-shared', path: '/home/me/app', connectionId: 'openclaw' }],
'ssh:m4air'
)
await expect(
runtime.createFileExplorerDir(
`id:${WORKTREE_ID}`,
'docs',
undefined,
undefined,
'ssh:openclaw'
)
).rejects.toThrow('Workspace host changed; refresh and try again')
})
})
@@ -9,9 +9,6 @@ import {
} from './runtime-file-commands-mobile-file-list-limit'
import { isWatcherProcessFailure } from '../ipc/parcel-watcher-process-failure'
import { stopSshFileExplorerWatchRearms } from './runtime-file-commands-ssh-file-watcher-rearm'
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { toSshExecutionHostId } from '../../shared/execution-host'
export function registerRuntimeFileWatcherRelease(
runtimeId: string,
@@ -178,19 +175,3 @@ export function _resetRuntimeFileWatcherLeasesForTests(): void {
}
runtimeFileWatcherLeasesByOwnerAndRoot.clear()
}
export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo }
export type ResolvedRuntimeFileTarget = {
worktree: ResolvedRuntimeFileWorktree
connectionId?: string
}
export function getRuntimeFileTargetExecutionHostId(
target: ResolvedRuntimeFileTarget
): ExecutionHostId {
return (
target.worktree.hostId ??
(target.connectionId ? toSshExecutionHostId(target.connectionId) : 'local')
)
}