diff --git a/src/cli/index-terminal-list-host-scope.test.ts b/src/cli/index-terminal-list-host-scope.test.ts new file mode 100644 index 00000000000..b38cd71451f --- /dev/null +++ b/src/cli/index-terminal-list-host-scope.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' + +const { + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) + +vi.mock('child_process', async () => { + const { createChildProcessModuleMock } = await import('./index-test-harness.js') + return createChildProcessModuleMock(spawnMock) +}) + +import { main } from './index' +import { okFixture, queueFixtures } from './test-fixtures' +import { useWorktreeAwarenessEnvironment } from './index-test-harness' + +const REMOTE_ROW = { + handle: 'term_remote', + ptyId: 'ssh:box-1@@pty-7', + worktreeId: 'repo-ssh::/remote/wt', + worktreePath: '/remote/wt', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'worker', + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + executionHostId: 'ssh:box-1' +} + +describe('orca terminal list host scope', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + it('keeps the execution host and scope in --json output', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [REMOTE_ROW], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.result.terminals[0].executionHostId).toBe('ssh:box-1') + expect(printed.result.hostScope).toEqual({ + hostIds: ['ssh:box-1'], + omittedHostIds: ['local'] + }) + }) + + it('prints the execution host and scope in human output', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [REMOTE_ROW], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('host=ssh:box-1') + expect(printed).toContain('scope: ssh:box-1') + expect(printed).toContain('not covered: local') + }) + + it('does not claim a local scope when the host never reported one', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_list', { terminals: [], totalCount: 0, truncated: false }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('scope: unverifiable') + expect(printed).not.toContain('scope: local') + }) +}) diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index ae126fa6001..a4df4ba6951 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -2,6 +2,7 @@ import type { RuntimeTerminalClose, RuntimeTerminalCreate, RuntimeTerminalFocus, + RuntimeTerminalListHostScope, RuntimeTerminalListResult, RuntimeTerminalVisualLayout, RuntimeTerminalVisualLayoutNode, @@ -16,20 +17,34 @@ import type { } from '../shared/runtime-types' export function formatTerminalList(result: RuntimeTerminalListResult): string { + const scope = formatTerminalListHostScope(result.hostScope) if (result.terminals.length === 0) { - return 'No live terminals.' + return `No terminals listed.\n${scope}` } const body = result.terminals .map( (terminal) => - `${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: '}` + `${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: '}` ) .join('\n\n') const visualLayout = formatTerminalVisualLayouts(result.visualLayouts) const bodyWithLayout = visualLayout ? `${body}\n\nvisual layout:\n${visualLayout}` : body + const bodyWithScope = `${bodyWithLayout}\n\n${scope}` return result.truncated - ? `${bodyWithLayout}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}` - : bodyWithLayout + ? `${bodyWithScope}\ntruncated: showing ${result.terminals.length} of ${result.totalCount}` + : bodyWithScope +} + +// Why: a listing that does not say what it covers reads as absolute, and an +// absent scope means the host is too old to know — not that it covered everything. +function formatTerminalListHostScope(scope: RuntimeTerminalListHostScope | undefined): string { + if (!scope) { + return 'scope: unverifiable — this host does not report which hosts it lists' + } + const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none' + const omitted = + scope.omittedHostIds.length > 0 ? ` — not covered: ${scope.omittedHostIds.join(', ')}` : '' + return `scope: ${covered}${omitted}` } function formatTerminalVisualLayouts( diff --git a/src/cli/terminal-list-host-scope-format.test.ts b/src/cli/terminal-list-host-scope-format.test.ts new file mode 100644 index 00000000000..45b3230d898 --- /dev/null +++ b/src/cli/terminal-list-host-scope-format.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { formatTerminalList } from './format' +import type { RuntimeTerminalListResult, RuntimeTerminalSummary } from '../shared/runtime-types' + +function terminal(overrides: Partial = {}): RuntimeTerminalSummary { + return { + handle: 'term_1', + ptyId: 'pty-1', + worktreeId: 'repo::/repo', + worktreePath: '/repo', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'worker', + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + ...overrides + } +} + +function listResult(overrides: Partial = {}): RuntimeTerminalListResult { + return { terminals: [terminal()], totalCount: 1, truncated: false, ...overrides } +} + +describe('formatTerminalList host identity', () => { + it('prints the execution host each terminal runs on', () => { + const output = formatTerminalList( + listResult({ terminals: [terminal({ executionHostId: 'ssh:box-1' })] }) + ) + + expect(output).toContain('host=ssh:box-1') + }) + + it('prints unverifiable, not local, for a row whose host the runtime could not name', () => { + const output = formatTerminalList(listResult({ terminals: [terminal()] })) + + expect(output).toContain('host=unverifiable') + expect(output).not.toContain('host=local') + }) +}) + +describe('formatTerminalList scope declaration', () => { + it('states the covered and omitted hosts', () => { + const output = formatTerminalList( + listResult({ + hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] } + }) + ) + + expect(output).toContain('scope: local') + expect(output).toContain('not covered: ssh:box-1') + }) + + it('keeps an empty listing self-describing instead of reading as absolute', () => { + const output = formatTerminalList( + listResult({ + terminals: [], + totalCount: 0, + hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] } + }) + ) + + expect(output).toContain('No terminals listed') + expect(output).toContain('scope: local') + expect(output).toContain('not covered: ssh:box-1') + }) + + it('says the scope is unverifiable when the host predates the field', () => { + const output = formatTerminalList(listResult()) + + expect(output).toContain('scope: unverifiable') + expect(output).not.toContain('scope: local') + }) +}) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 2a6b3ebed84..ba1255c058e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -17,7 +17,11 @@ import type { PtyBindingSourceExpectation, Store } from '../persistence' import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement' import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgent } from '../../shared/tui-agent' -import { toSshExecutionHostId } from '../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' import type { @@ -72,6 +76,7 @@ import { } from '../../shared/pi-agent-kind' import { isPwshAvailableAsync } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' +import type { PtyProcessInfo } from '../providers/pty-process-info' import { normalizeWindowsTerminalCwd } from '../providers/windows-shell-args' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' @@ -259,6 +264,22 @@ function registeredPtyProviders(): RegisteredPtyProvider[] { ] } +async function listRegisteredPtyProcessesWithHostScope(): Promise<{ + processes: PtyProcessInfo[] + hostIds: ExecutionHostId[] +}> { + const providers = registeredPtyProviders() + const providerSessions = await Promise.all( + providers.map(({ provider }) => provider.listProcesses()) + ) + return { + processes: providerSessions.flat(), + hostIds: providers.map(({ connectionId }) => + connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID + ) + } +} + const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 // Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. const PRODUCER_FLOW_CONTROL_ENABLED = true @@ -5747,12 +5768,9 @@ export function registerPtyHandlers( if (connectionId !== undefined) { return getProvider(connectionId).listProcesses() } - const providerSessions = await Promise.all([ - localProvider.listProcesses(), - ...Array.from(sshProviders.values(), (provider) => provider.listProcesses()) - ]) - return providerSessions.flat() + return (await listRegisteredPtyProcessesWithHostScope()).processes }, + listProcessesWithHostScope: listRegisteredPtyProcessesWithHostScope, serializeBuffer: (ptyId, opts) => { // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. return requestSerializedBuffer(ptyId, opts) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 179a05e2f2b..fc6a0fcc715 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -421,6 +421,7 @@ import { type RuntimeTerminalSplit, type RuntimeTerminalFocus, type RuntimeTerminalClose, + type RuntimeTerminalListHostScope, type RuntimeTerminalListResult, type RuntimeTerminalOrphanAdoptionRequest, type RuntimeTerminalOrphanAdoptionResult, @@ -516,6 +517,7 @@ import { parsePaneKey } from '../../shared/stable-pane-id' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' +import { getPtyExecutionHost } from '../../shared/terminal-execution-host' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id' import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import { @@ -1867,6 +1869,10 @@ type RuntimePtyController = { // Why: exact-id mobile polls should not enumerate every local and SSH PTY. hasPty?(ptyId: string): boolean | null listProcesses?(connectionId?: string | null): Promise + listProcessesWithHostScope?(): Promise<{ + processes: PtyProcessInfo[] + hostIds: ExecutionHostId[] + }> serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } @@ -1911,6 +1917,7 @@ type PtyControllerInventory = Readonly<{ // must consult the unscoped inventory or a misattributed live PTY reads as dead. allLivePtyIds: ReadonlySet terminalIdentityByPtyId: ReadonlyMap + queriedHostIds: ReadonlySet }> type WorktreeStartupDraftPaste = { @@ -5769,6 +5776,9 @@ export class OrcaRuntimeService { if (!workspace) { return null } + if (workspace.executionHostId != null) { + return parseExecutionHostId(workspace.executionHostId)?.id ?? null + } const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID } @@ -5816,6 +5826,48 @@ export class OrcaRuntimeService { return worktreeIds } + // Every execution host known to this runtime; knowledge is not coverage. + private listKnownExecutionHostIds( + additionalHostIds: Iterable = [], + includeConfiguredHosts = true + ): Set { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) { + hostIds.add(hostId) + } + for (const hostId of additionalHostIds) { + hostIds.add(hostId) + } + if (!includeConfiguredHosts) { + return hostIds + } + const repos = this.store?.getRepos?.() ?? [] + for (const repo of repos) { + hostIds.add(getRepoExecutionHostId(repo)) + } + const projectGroups = this.store?.getProjectGroups?.() ?? [] + for (const workspace of this.store?.getFolderWorkspaces?.() ?? []) { + if (workspace.executionHostId != null) { + const explicitHostId = parseExecutionHostId(workspace.executionHostId)?.id + if (explicitHostId) { + hostIds.add(explicitHostId) + } + continue + } + const connection = inferFolderWorkspacePathConnection({ + folderPath: workspace.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: workspace.connectionId ?? null, + projectGroups, + repos + }) + if (connection.kind === 'ssh') { + hostIds.add(toSshExecutionHostId(connection.connectionId)) + } + } + return hostIds + } + private getWorkspaceSessionHydrationTargets( includeAllPersistedWorktrees: boolean ): Map { @@ -5832,7 +5884,7 @@ export class OrcaRuntimeService { ] as const }) ) - const hostIds = new Set(['local']) + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) for (const repo of repos) { hostIds.add(getRepoExecutionHostId(repo)) } @@ -16646,6 +16698,12 @@ export class OrcaRuntimeService { return { terminals: listedTerminals, + hostScope: this.buildTerminalListHostScope( + targetWorktreeId, + matchingTerminals, + worktreesById.values(), + controllerInventory?.queriedHostIds ?? new Set() + ), ...(visualLayouts.length > 0 ? { visualLayouts } : {}), topologyRevisions: Object.fromEntries( [...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [ @@ -16658,6 +16716,52 @@ export class OrcaRuntimeService { } } + // A worktree-scoped request answers for one host only, so name the hosts it + // skipped: absence from a scoped listing is not evidence a worker exited. + private buildTerminalListHostScope( + targetWorktreeId: string | null, + terminals: readonly RuntimeTerminalSummary[], + worktrees: Iterable, + queriedHostIds: ReadonlySet + ): RuntimeTerminalListHostScope { + const knownHostIds = this.listKnownExecutionHostIds( + queriedHostIds, + targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID + ) + let resolvedTargetHostId: ExecutionHostId | null = null + for (const worktree of worktrees) { + if (worktree.hostId) { + knownHostIds.add(worktree.hostId) + if (worktree.id === targetWorktreeId) { + resolvedTargetHostId = worktree.hostId + } + } + } + for (const terminal of terminals) { + if (terminal.executionHostId) { + knownHostIds.add(terminal.executionHostId) + } + } + const scopedHostId = targetWorktreeId + ? (resolvedTargetHostId ?? this.tryGetWorkspaceSessionHostIdForWorktree(targetWorktreeId)) + : null + if (scopedHostId) { + knownHostIds.add(scopedHostId) + } + const candidates = targetWorktreeId ? (scopedHostId ? [scopedHostId] : []) : knownHostIds + // Paired runtimes own a separate control plane. Mirrored rows are evidence + // for those rows only; this runtime cannot claim their complete inventory. + const coveredHostIds = new Set( + [...candidates].filter( + (hostId) => queriedHostIds.has(hostId) && parseExecutionHostId(hostId)?.kind !== 'runtime' + ) + ) + return { + hostIds: [...coveredHostIds].sort(), + omittedHostIds: [...knownHostIds].filter((hostId) => !coveredHostIds.has(hostId)).sort() + } + } + async inspectTerminalProcessIncarnationLiveness( processIncarnation: string, serializedHostScope: string | null @@ -31215,7 +31319,8 @@ export class OrcaRuntimeService { return { livePtyIds: targetedLiveness, allLivePtyIds: targetedLiveness, - terminalIdentityByPtyId: new Map() + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set([LOCAL_EXECUTION_HOST_ID]) } } } @@ -31230,8 +31335,32 @@ export class OrcaRuntimeService { } else { this.ptyControllerInventoryGenerationByProvider.set(providerKey, inventoryGeneration) } + const processInventory = + connectionId === undefined && this.ptyController.listProcessesWithHostScope + ? this.ptyController.listProcessesWithHostScope() + : this.ptyController.listProcesses(connectionId).then((processes) => { + const hostIds = new Set() + if (connectionId === undefined || connectionId === null) { + hostIds.add(LOCAL_EXECUTION_HOST_ID) + } else { + hostIds.add(toSshExecutionHostId(connectionId)) + } + if (connectionId === undefined) { + for (const process of processes) { + const hostId = getPtyExecutionHost(process.id) + if ( + hostId && + hostId !== 'foreign' && + parseExecutionHostId(hostId)?.kind === 'ssh' + ) { + hostIds.add(hostId) + } + } + } + return { processes, hostIds: [...hostIds] } + }) const sessionsResult = await withTimeoutResult( - this.ptyController.listProcesses(connectionId), + processInventory, deadline === undefined ? PTY_CONTROLLER_LIST_TIMEOUT_MS : Math.max(1, Math.min(PTY_CONTROLLER_LIST_TIMEOUT_MS, deadline - Date.now())) @@ -31252,7 +31381,8 @@ export class OrcaRuntimeService { if (!isCurrentInventory) { return null } - const sessions = sessionsResult.value + const sessions = sessionsResult.value.processes + const queriedHostIds = new Set(sessionsResult.value.hostIds) const controllerIdentityByPtyId = new Map() const ptyIdByControllerHandle = new Map() const ambiguousControllerPtyIds = new Set() @@ -31431,7 +31561,8 @@ export class OrcaRuntimeService { return { livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds, allLivePtyIds, - terminalIdentityByPtyId: controllerIdentityByPtyId + terminalIdentityByPtyId: controllerIdentityByPtyId, + queriedHostIds } } @@ -31680,10 +31811,26 @@ export class OrcaRuntimeService { connected: provenAbsent ? false : leaf.connected, writable: provenAbsent ? false : leaf.writable, lastOutputAt: leaf.lastOutputAt, - preview: leaf.preview + preview: leaf.preview, + ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId) } } + // Why: the PTY id names its own host when it has one; only a host-less id may + // fall back to the worktree's. A foreign id with no owner stays unset rather + // than inheriting a local worktree's host and reading as local. + private terminalExecutionHostField( + ptyId: string | null, + worktreeId: string + ): { executionHostId?: ExecutionHostId } { + const fromPtyId = getPtyExecutionHost(ptyId) + if (fromPtyId === 'foreign') { + return {} + } + const hostId = fromPtyId ?? this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) + return hostId ? { executionHostId: hostId } : {} + } + // Returns the worktrees whose stored snapshot object changed during this // sync, so the caller can fan out only actually-changed worktrees. private syncMobileSessionTabs( @@ -33622,7 +33769,8 @@ export class OrcaRuntimeService { connected: pty.connected, writable: pty.connected, lastOutputAt: pty.lastOutputAt, - preview: pty.preview + preview: pty.preview, + ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId) } } diff --git a/src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts b/src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts new file mode 100644 index 00000000000..fc4fab66147 --- /dev/null +++ b/src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalListResult } from '../../../shared/runtime-types' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +// Request options in this codebase have been dropped by a transport that +// forgot to forward them. The host-scope answer must survive the RPC boundary +// the same way, so assert it on the far side of the dispatcher. +const LIST_RESULT: RuntimeTerminalListResult = { + terminals: [ + { + handle: 'term_remote', + ptyId: 'ssh:box-1@@pty-7', + worktreeId: 'repo-ssh::/remote/wt', + worktreePath: '/remote/wt', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'worker', + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + executionHostId: 'ssh:box-1' + } + ], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] } +} + +describe('terminal.list RPC boundary', () => { + it('forwards the execution host and scope the runtime reported', async () => { + const runtime = { + listTerminals: vi.fn(async () => LIST_RESULT), + getRuntimeId: () => 'runtime-a' + } + const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch({ + id: 'request-1', + authToken: 'test-token', + method: 'terminal.list', + params: {} + }) + + expect(response.ok).toBe(true) + const result = (response as { result: RuntimeTerminalListResult }).result + expect(result.terminals[0]?.executionHostId).toBe('ssh:box-1') + expect(result.hostScope).toEqual({ hostIds: ['ssh:box-1'], omittedHostIds: ['local'] }) + }) +}) diff --git a/src/main/runtime/terminal-list-execution-host-scope.test.ts b/src/main/runtime/terminal-list-execution-host-scope.test.ts new file mode 100644 index 00000000000..c0460e0bd08 --- /dev/null +++ b/src/main/runtime/terminal-list-execution-host-scope.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { folderWorkspaceKey } from '../../shared/workspace-scope' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' + +// An agent once declared a task exited because `terminal list` came back without +// its worker: the worker was live on an SSH host, and nothing in the response +// said the listing was scoped or which host each row ran on. + +const LOCAL_WORKTREE_ID = 'repo-local::/tmp/local-worktree' +const SSH_WORKTREE_ID = 'repo-ssh::/remote/ssh-worktree' +const LOCAL_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SSH_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const REMOTE_LEAF_ID = '33333333-3333-4333-8333-333333333333' + +const REPOS = [ + { + id: 'repo-local', + path: '/tmp/local-worktree', + displayName: 'local', + badgeColor: '#000000', + addedAt: 0 + }, + { + id: 'repo-ssh', + path: '/remote/ssh-worktree', + displayName: 'ssh', + badgeColor: '#000000', + addedAt: 0, + connectionId: 'box-1' + } +] + +function makeStore() { + const session: WorkspaceSessionState = getDefaultWorkspaceSession() + return { + getWorkspaceSession: vi.fn(() => session), + getWorkspaceSessionHostIds: vi.fn(() => ['local', 'ssh:box-1']), + getFolderWorkspaces: vi.fn((): FolderWorkspace[] => []), + getProjectGroups: vi.fn(() => []), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => REPOS), + getRepo: vi.fn((id: string) => REPOS.find((repo) => repo.id === id)), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +function makeSshFolderWorkspace() { + return { + id: 'folder-ssh', + projectGroupId: 'group-1', + name: 'SSH folder', + folderPath: '/remote/folder', + connectionId: 'box-2', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } +} + +function makeRuntimeFolderWorkspace() { + return { + ...makeSshFolderWorkspace(), + id: 'folder-runtime', + name: 'Runtime folder', + connectionId: 'stale-box', + executionHostId: 'runtime:env-9' as const + } +} + +type GraphLeaf = { worktreeId: string; leafId: string; ptyId: string } + +function makeRuntime(leaves: GraphLeaf[], store = makeStore()): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(store as never) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => leaves.map((leaf) => ({ id: leaf.ptyId, cwd: '/tmp' }))) + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: leaves.map((leaf, index) => ({ + tabId: `tab-${index + 1}`, + worktreeId: leaf.worktreeId, + title: '', + activeLeafId: leaf.leafId, + layout: null + })), + leaves: leaves.map((leaf, index) => ({ + tabId: `tab-${index + 1}`, + worktreeId: leaf.worktreeId, + leafId: leaf.leafId, + paneRuntimeId: index + 1, + ptyId: leaf.ptyId, + paneTitle: null, + title: '' + })) + }) + return runtime +} + +describe('listTerminals execution-host identity', () => { + it('names the SSH connection a remote terminal runs on instead of local', async () => { + const runtime = makeRuntime([ + { worktreeId: LOCAL_WORKTREE_ID, leafId: LOCAL_LEAF_ID, ptyId: 'pty-local-1' }, + { worktreeId: SSH_WORKTREE_ID, leafId: SSH_LEAF_ID, ptyId: 'ssh:box-1@@pty-7' } + ]) + + const { terminals } = await runtime.listTerminals() + + const sshRow = terminals.find((terminal) => terminal.ptyId === 'ssh:box-1@@pty-7') + const localRow = terminals.find((terminal) => terminal.ptyId === 'pty-local-1') + expect(sshRow?.executionHostId).toBe('ssh:box-1') + expect(localRow?.executionHostId).toBe('local') + }) + + it('names the paired runtime environment a mirrored terminal belongs to', async () => { + const runtime = makeRuntime([ + { + worktreeId: LOCAL_WORKTREE_ID, + leafId: REMOTE_LEAF_ID, + ptyId: 'remote:env-7@@handle-1' + } + ]) + + const { terminals } = await runtime.listTerminals() + + expect(terminals[0]?.executionHostId).toBe('runtime:env-7') + }) + + it('leaves the host unset — never local — for a paired PTY that names no environment', async () => { + const runtime = makeRuntime([ + { worktreeId: LOCAL_WORKTREE_ID, leafId: REMOTE_LEAF_ID, ptyId: 'remote:handle-1' } + ]) + + const { hostScope, terminals } = await runtime.listTerminals() + + expect(terminals[0]?.executionHostId).toBeUndefined() + expect(hostScope?.hostIds).toEqual(['local']) + }) + + it.each([ + 'remote:env@@%E0%A4%A', + 'remote:%20@@terminal%3Aone', + 'ssh:%E0%A4%A@@pty-7', + 'ssh:%20@@pty-7' + ])('leaves the host unset for a malformed foreign PTY id: %s', async (ptyId) => { + const runtime = makeRuntime([{ worktreeId: LOCAL_WORKTREE_ID, leafId: REMOTE_LEAF_ID, ptyId }]) + + const { hostScope, terminals } = await runtime.listTerminals() + + expect(terminals[0]?.executionHostId).toBeUndefined() + expect(hostScope?.hostIds).toEqual(['local']) + }) +}) + +describe('listTerminals scope declaration', () => { + it('declares every host an unscoped listing covers', async () => { + const runtime = makeRuntime([ + { worktreeId: LOCAL_WORKTREE_ID, leafId: LOCAL_LEAF_ID, ptyId: 'pty-local-1' }, + { worktreeId: SSH_WORKTREE_ID, leafId: SSH_LEAF_ID, ptyId: 'ssh:box-1@@pty-7' } + ]) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual(['local', 'ssh:box-1']) + expect(result.hostScope?.omittedHostIds).toEqual([]) + }) + + it('does not claim a paired runtime was covered from a mirrored row', async () => { + const runtime = makeRuntime([ + { + worktreeId: LOCAL_WORKTREE_ID, + leafId: REMOTE_LEAF_ID, + ptyId: 'remote:env-7@@handle-1' + } + ]) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-7', 'ssh:box-1']) + }) + + it('keeps a repo-known paired runtime omitted without a mirrored row', async () => { + const baseStore = makeStore() + const repos = [ + REPOS[0]!, + { + id: 'repo-runtime', + path: '/remote/runtime-worktree', + displayName: 'runtime', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'runtime:env-9' as const + } + ] + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => repos), + getRepo: vi.fn((id: string) => repos.find((repo) => repo.id === id)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']) + }) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9']) + }) + + it('keeps a repo-known paired runtime omitted in a worktree-scoped listing', async () => { + const baseStore = makeStore() + const repos = [ + REPOS[0]!, + { + id: 'repo-runtime', + path: '/remote/runtime-worktree', + displayName: 'runtime', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'runtime:env-9' as const + } + ] + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => repos), + getRepo: vi.fn((id: string) => repos.find((repo) => repo.id === id)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']) + }) + + const result = await runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`) + + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9']) + }) + + it('covers a queried SSH host used only by a folder workspace', async () => { + const baseStore = makeStore() + const folderWorkspace = makeSshFolderWorkspace() + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => [REPOS[0]!]), + getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']), + getFolderWorkspaces: vi.fn(() => [folderWorkspace]) + }) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => []), + listProcessesWithHostScope: vi.fn(async () => ({ + processes: [], + hostIds: ['local', 'ssh:box-2'] + })) + } as never) + + const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`) + + expect(result.hostScope?.hostIds).toEqual(['ssh:box-2']) + expect(result.hostScope?.omittedHostIds).toEqual(['local']) + }) + + it('keeps a disconnected folder-workspace SSH host omitted', async () => { + const baseStore = makeStore() + const folderWorkspace = makeSshFolderWorkspace() + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => [REPOS[0]!]), + getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']), + getFolderWorkspaces: vi.fn(() => [folderWorkspace]) + }) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => { + throw new Error('relay unavailable') + }) + } as never) + + const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`) + + expect(result.hostScope?.hostIds).toEqual([]) + expect(result.hostScope?.omittedHostIds).toEqual(['local', 'ssh:box-2']) + }) + + it('does not claim local coverage for a paired-runtime folder workspace', async () => { + const baseStore = makeStore() + const folderWorkspace = makeRuntimeFolderWorkspace() + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => [REPOS[0]!]), + getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']), + getFolderWorkspaces: vi.fn(() => [folderWorkspace]) + }) + + const result = await runtime.listTerminals(`id:${folderWorkspaceKey(folderWorkspace.id)}`) + + expect(result.hostScope?.hostIds).toEqual([]) + expect(result.hostScope?.omittedHostIds).toEqual(['local', 'runtime:env-9']) + }) + + it('keeps a paired-runtime folder owner omitted in an unscoped listing', async () => { + const baseStore = makeStore() + const folderWorkspace = makeRuntimeFolderWorkspace() + const runtime = makeRuntime([], { + ...baseStore, + getRepos: vi.fn(() => [REPOS[0]!]), + getRepo: vi.fn((id: string) => (id === REPOS[0]!.id ? REPOS[0] : undefined)), + getWorkspaceSessionHostIds: vi.fn(() => ['local']), + getFolderWorkspaces: vi.fn(() => [folderWorkspace]) + }) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-9']) + }) + + it('keeps a disconnected SSH host omitted when only local inventory answered', async () => { + const runtime = makeRuntime([]) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => []) + } as never) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['ssh:box-1']) + }) + + it('marks every known host omitted when process inventory is unverifiable', async () => { + const runtime = makeRuntime([]) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write: () => true, + kill: () => true, + listProcesses: vi.fn(async () => { + throw new Error('relay unavailable') + }) + } as never) + + const result = await runtime.listTerminals() + + expect(result.hostScope?.hostIds).toEqual([]) + expect(result.hostScope?.omittedHostIds).toEqual(['local', 'ssh:box-1']) + }) + + it('reports the hosts a worktree-scoped listing skipped, so an empty result is not absolute', async () => { + const runtime = makeRuntime([ + { worktreeId: SSH_WORKTREE_ID, leafId: SSH_LEAF_ID, ptyId: 'ssh:box-1@@pty-7' } + ]) + + const result = await runtime.listTerminals(`id:${LOCAL_WORKTREE_ID}`) + + expect(result.terminals).toEqual([]) + expect(result.hostScope?.hostIds).toEqual(['local']) + expect(result.hostScope?.omittedHostIds).toEqual(['ssh:box-1']) + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts b/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts new file mode 100644 index 00000000000..1e3a9718239 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-terminal-host-scope.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/host/app' + } +})) +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/host/user-data' +})) + +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' +import { runRemoteOrcaCli } from './ssh-remote-orca-cli' + +// Why: a missing CLI entry forces the legacy in-process bridge, the transport +// an SSH-hosted agent actually reaches `terminal list` through. +const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => false +} + +describe('remote CLI bridge terminal list', () => { + it('relays the execution host and scope to an SSH-hosted caller', async () => { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [ + { + handle: 'term_remote', + ptyId: 'ssh:box-1@@pty-7', + worktreeId: 'repo-ssh::/remote/wt', + worktreePath: '/remote/wt', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'worker', + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + executionHostId: 'ssh:box-1' + } + ], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['local'] } + }) + + const result = await runRemoteOrcaCli( + runtime, + { argv: ['terminal', 'list', '--json'], cwd: '/home/alice/repo', env: {} }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + const payload = JSON.parse(result.stdout) as { + result: { + terminals: { executionHostId?: string }[] + hostScope?: { hostIds: string[]; omittedHostIds: string[] } + } + } + expect(payload.result.terminals[0]?.executionHostId).toBe('ssh:box-1') + expect(payload.result.hostScope).toEqual({ + hostIds: ['ssh:box-1'], + omittedHostIds: ['local'] + }) + }) +}) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.ts b/src/renderer/src/runtime/runtime-terminal-stream.ts index 327e8d3e904..0c9604b71d1 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.ts @@ -1,16 +1,16 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import { RuntimeRpcCallError, getActiveRuntimeTarget } from './runtime-rpc-client' import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-multiplexer' +import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' + +export { + parseRemoteRuntimePtyId, + toRemoteRuntimePtyId, + type RemoteRuntimePtyIdParts +} from '../../../shared/remote-runtime-pty-id' -const REMOTE_PTY_ID_PREFIX = 'remote:' -const REMOTE_PTY_OWNER_SEPARATOR = '@@' const LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS = 10_000 -export type RemoteRuntimePtyIdParts = { - environmentId: string | null - handle: string -} - export type RuntimeTerminalDataSubscriptionOptions = { startAtLiveTail?: boolean onSnapshot?: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void @@ -19,33 +19,6 @@ export type RuntimeTerminalDataSubscriptionOptions = { onTransportClose?: (event: { recoverable: boolean; retryWithBackoff?: boolean }) => void } -export function toRemoteRuntimePtyId(handle: string, environmentId?: string | null): string { - const owner = environmentId?.trim() - if (!owner) { - return `${REMOTE_PTY_ID_PREFIX}${handle}` - } - return `${REMOTE_PTY_ID_PREFIX}${encodeURIComponent(owner)}${REMOTE_PTY_OWNER_SEPARATOR}${encodeURIComponent(handle)}` -} - -export function parseRemoteRuntimePtyId(ptyId: string): RemoteRuntimePtyIdParts | null { - if (!ptyId.startsWith(REMOTE_PTY_ID_PREFIX)) { - return null - } - const rest = ptyId.slice(REMOTE_PTY_ID_PREFIX.length) - const separatorIndex = rest.indexOf(REMOTE_PTY_OWNER_SEPARATOR) - if (separatorIndex === -1) { - return { environmentId: null, handle: rest } - } - try { - return { - environmentId: decodeURIComponent(rest.slice(0, separatorIndex)), - handle: decodeURIComponent(rest.slice(separatorIndex + REMOTE_PTY_OWNER_SEPARATOR.length)) - } - } catch { - return null - } -} - export function getRemoteRuntimeTerminalHandle(ptyId: string): string | null { return parseRemoteRuntimePtyId(ptyId)?.handle ?? null } diff --git a/src/shared/remote-runtime-pty-id.ts b/src/shared/remote-runtime-pty-id.ts new file mode 100644 index 00000000000..66c32612a35 --- /dev/null +++ b/src/shared/remote-runtime-pty-id.ts @@ -0,0 +1,36 @@ +// Why: shared so main can name the paired runtime a mirrored PTY belongs to; +// the encoding lives here rather than in the renderer transport that mints it. +const REMOTE_PTY_ID_PREFIX = 'remote:' +const REMOTE_PTY_OWNER_SEPARATOR = '@@' + +export type RemoteRuntimePtyIdParts = { + environmentId: string | null + handle: string +} + +export function toRemoteRuntimePtyId(handle: string, environmentId?: string | null): string { + const owner = environmentId?.trim() + if (!owner) { + return `${REMOTE_PTY_ID_PREFIX}${handle}` + } + return `${REMOTE_PTY_ID_PREFIX}${encodeURIComponent(owner)}${REMOTE_PTY_OWNER_SEPARATOR}${encodeURIComponent(handle)}` +} + +export function parseRemoteRuntimePtyId(ptyId: string): RemoteRuntimePtyIdParts | null { + if (!ptyId.startsWith(REMOTE_PTY_ID_PREFIX)) { + return null + } + const rest = ptyId.slice(REMOTE_PTY_ID_PREFIX.length) + const separatorIndex = rest.indexOf(REMOTE_PTY_OWNER_SEPARATOR) + if (separatorIndex === -1) { + return { environmentId: null, handle: rest } + } + try { + return { + environmentId: decodeURIComponent(rest.slice(0, separatorIndex)), + handle: decodeURIComponent(rest.slice(separatorIndex + REMOTE_PTY_OWNER_SEPARATOR.length)) + } + } catch { + return null + } +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 69c8c972e36..fa2e28bc5bc 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -465,6 +465,9 @@ export type RuntimeTerminalSummary = { writable: boolean lastOutputAt: number | null preview: string + /** Where this terminal actually runs. Absent when the host predates the field + * or could not name the host — never read an absent value as local. */ + executionHostId?: ExecutionHostId } export type RuntimeTerminalVisualTerminalNode = { @@ -515,12 +518,24 @@ export type RuntimeTerminalVisualLayout = { root: RuntimeTerminalVisualLayoutNode } +/** Which execution hosts a listing answered for, so an empty or partial result + * reads as "none here" instead of "none anywhere". Host ids are as the + * answering runtime names them — `_meta.runtimeId` says which runtime that is. */ +export type RuntimeTerminalListHostScope = { + hostIds: ExecutionHostId[] + /** Known hosts this listing skipped; a live terminal on one of them can be + * absent from `terminals` without having exited. */ + omittedHostIds: ExecutionHostId[] +} + export type RuntimeTerminalListResult = { terminals: RuntimeTerminalSummary[] visualLayouts?: RuntimeTerminalVisualLayout[] topologyRevisions?: Record totalCount: number truncated: boolean + /** Absent from hosts that predate the field — treat that scope as unverifiable. */ + hostScope?: RuntimeTerminalListHostScope } export type RuntimeTerminalOrphanAdoptionClaim = { diff --git a/src/shared/terminal-execution-host.ts b/src/shared/terminal-execution-host.ts new file mode 100644 index 00000000000..908c8e9fe98 --- /dev/null +++ b/src/shared/terminal-execution-host.ts @@ -0,0 +1,37 @@ +import { + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from './execution-host' +import { parseRemoteRuntimePtyId } from './remote-runtime-pty-id' +import { parseAppSshPtyId } from './ssh-pty-id' + +/** 'foreign' = the id proves the PTY runs off this host but cannot name where. */ +export type PtyExecutionHost = ExecutionHostId | 'foreign' | null + +// Why: a PTY id, not a path, is the authority on where a terminal runs — SSH and +// paired-runtime ids embed their owner. `null` means the id carries no host at +// all, so the caller may fall back to the worktree's host. +export function getPtyExecutionHost(ptyId: string | null | undefined): PtyExecutionHost { + if (!ptyId) { + return null + } + const ssh = parseAppSshPtyId(ptyId) + if (ssh) { + const connectionId = ssh.connectionId.trim() + return connectionId && connectionId === ssh.connectionId + ? toSshExecutionHostId(connectionId) + : 'foreign' + } + const remote = parseRemoteRuntimePtyId(ptyId) + if (remote) { + const environmentId = remote.environmentId?.trim() + return environmentId && environmentId === remote.environmentId + ? toRuntimeExecutionHostId(environmentId) + : 'foreign' + } + if (ptyId.startsWith('ssh:') || ptyId.startsWith('remote:')) { + return 'foreign' + } + return null +}