diff --git a/docs/reference/ssh-execution-boundary.md b/docs/reference/ssh-execution-boundary.md index 87495960b76..cc88cf39a17 100644 --- a/docs/reference/ssh-execution-boundary.md +++ b/docs/reference/ssh-execution-boundary.md @@ -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. diff --git a/src/main/destination-serialized-local-rename.test.ts b/src/main/destination-serialized-local-rename.test.ts index 49fd190e469..7bdab6a0914 100644 --- a/src/main/destination-serialized-local-rename.test.ts +++ b/src/main/destination-serialized-local-rename.test.ts @@ -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) } diff --git a/src/main/runtime/orca-runtime-file-commands.ts b/src/main/runtime/orca-runtime-file-commands.ts index c195ee5dbd8..46a25083bef 100644 --- a/src/main/runtime/orca-runtime-file-commands.ts +++ b/src/main/runtime/orca-runtime-file-commands.ts @@ -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) => diff --git a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts index 7f73e77c2fa..f64e9a37f83 100644 --- a/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts +++ b/src/main/runtime/orca-runtime-files-mobile-explorer-reads.test.ts @@ -154,7 +154,7 @@ describe('RuntimeFileCommands', () => { repoId: 'repo-1', path: '/remote/repo' }, - connectionId: 'ssh-1' + executionHostId: 'ssh:ssh-1' })) const { commands } = createRuntimeFileCommands({ openFile, diff --git a/src/main/runtime/orca-runtime-files-mock-registry.ts b/src/main/runtime/orca-runtime-files-mock-registry.ts index 35d419754a1..62c1acef4b3 100644 --- a/src/main/runtime/orca-runtime-files-mock-registry.ts +++ b/src/main/runtime/orca-runtime-files-mock-registry.ts @@ -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 { diff --git a/src/main/runtime/orca-runtime-files-search.test.ts b/src/main/runtime/orca-runtime-files-search.test.ts index f71aee54c4a..09cb185ea28 100644 --- a/src/main/runtime/orca-runtime-files-search.test.ts +++ b/src/main/runtime/orca-runtime-files-search.test.ts @@ -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']) diff --git a/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts b/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts index 2396c2705b6..8f09e269f7f 100644 --- a/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts +++ b/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.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 } diff --git a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts b/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts index 28d734cab63..4403d61b63d 100644 --- a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts +++ b/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts @@ -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() diff --git a/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts b/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts index 1d9ecea725b..23c599801b8 100644 --- a/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-path-resolution.test.ts @@ -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' ) }) diff --git a/src/main/runtime/orca-runtime-files-test-harness.ts b/src/main/runtime/orca-runtime-files-test-harness.ts index edc4904b244..c7f42008cf6 100644 --- a/src/main/runtime/orca-runtime-files-test-harness.ts +++ b/src/main/runtime/orca-runtime-files-test-harness.ts @@ -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 } diff --git a/src/main/runtime/orca-runtime-files-watch.test.ts b/src/main/runtime/orca-runtime-files-watch.test.ts index e89bc26958b..7de40e51df5 100644 --- a/src/main/runtime/orca-runtime-files-watch.test.ts +++ b/src/main/runtime/orca-runtime-files-watch.test.ts @@ -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() diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 78cee692a65..75a59f606c5 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -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' diff --git a/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts b/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts index b9a24685be4..1869ac70819 100644 --- a/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts +++ b/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts @@ -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 + } } } diff --git a/src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts b/src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts index 6b9f1d3c67e..69b58c0632a 100644 --- a/src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts +++ b/src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts @@ -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 { diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index ab724b469dc..5700b71d9a5 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -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) diff --git a/src/main/runtime/runtime-file-command-host.ts b/src/main/runtime/runtime-file-command-host.ts index b757ab29b14..8f6763f09e5 100644 --- a/src/main/runtime/runtime-file-command-host.ts +++ b/src/main/runtime/runtime-file-command-host.ts @@ -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 - // `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 }> diff --git a/src/main/runtime/runtime-file-command-target.ts b/src/main/runtime/runtime-file-command-target.ts new file mode 100644 index 00000000000..0b7c732f73b --- /dev/null +++ b/src/main/runtime/runtime-file-command-target.ts @@ -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:` 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 + +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 +} diff --git a/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts b/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts index bfa75f708d9..9a6af2a82b4 100644 --- a/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts +++ b/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts @@ -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 { 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> { 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 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, diff --git a/src/main/runtime/runtime-file-commands-constructor.ts b/src/main/runtime/runtime-file-commands-constructor.ts index 79c0cdbbeda..1af54400c42 100644 --- a/src/main/runtime/runtime-file-commands-constructor.ts +++ b/src/main/runtime/runtime-file-commands-constructor.ts @@ -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 { 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 { 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 { 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 { - 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 { 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}'`) } diff --git a/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts b/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts index 8702d0370ed..eddafdcdabc 100644 --- a/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts +++ b/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts @@ -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 } } diff --git a/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts b/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts index 918a3264a2d..0f497715993 100644 --- a/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts +++ b/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts @@ -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:` 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>() diff --git a/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts b/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts index e31a5e31b28..394e6940112 100644 --- a/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts +++ b/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts @@ -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 { 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') diff --git a/src/main/runtime/runtime-file-commands-read-mobile-file.ts b/src/main/runtime/runtime-file-commands-read-mobile-file.ts index c6582ca7880..34c5db8591d 100644 --- a/src/main/runtime/runtime-file-commands-read-mobile-file.ts +++ b/src/main/runtime/runtime-file-commands-read-mobile-file.ts @@ -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 { 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) diff --git a/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts b/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts index a5b3e662b4f..8b80da0e69d 100644 --- a/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts +++ b/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts @@ -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') diff --git a/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts b/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts index 1b84ea13fe0..976f0b532c7 100644 --- a/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts +++ b/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts @@ -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 { 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 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' } } } diff --git a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts index 17373ab6050..6e0ec4896c8 100644 --- a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts @@ -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 { 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 { 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 { - const provider = getSshFilesystemProvider(connectionId) if (!provider) { return [] } diff --git a/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts b/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts index f70c28de430..d3b703591e7 100644 --- a/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts +++ b/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts @@ -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 { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } + protected async readRemoteMobileFile( + filePath: string, + provider: IFilesystemProvider + ): Promise { 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) { diff --git a/src/main/runtime/runtime-file-commands-search-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-runtime-files.ts index f31b65c0cdf..5cd1f6246a8 100644 --- a/src/main/runtime/runtime-file-commands-search-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-runtime-files.ts @@ -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 ): Promise { 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 { 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 { 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, diff --git a/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts b/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts index 2ebc30bec74..3336a86e7d7 100644 --- a/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts +++ b/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts @@ -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 } } diff --git a/src/main/runtime/runtime-file-target-connection-field-ratchet.test.ts b/src/main/runtime/runtime-file-target-connection-field-ratchet.test.ts new file mode 100644 index 00000000000..da7c583e60b --- /dev/null +++ b/src/main/runtime/runtime-file-target-connection-field-ratchet.test.ts @@ -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/) + }) +}) diff --git a/src/main/runtime/runtime-file-target-execution-host.test.ts b/src/main/runtime/runtime-file-target-execution-host.test.ts new file mode 100644 index 00000000000..d9770b182df --- /dev/null +++ b/src/main/runtime/runtime-file-target-execution-host.test.ts @@ -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('../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 +} + +function makeRuntime(repos: readonly Record[], 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') + }) +}) diff --git a/src/main/runtime/runtime-file-watcher-leases.ts b/src/main/runtime/runtime-file-watcher-leases.ts index f1d8a3594af..6b4a221528d 100644 --- a/src/main/runtime/runtime-file-watcher-leases.ts +++ b/src/main/runtime/runtime-file-watcher-leases.ts @@ -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') - ) -}