feat(terminal): report execution host and listing scope in terminal list (#14973)

* feat(terminal): report execution host and listing scope in terminal list

`orca terminal list` returned rows with no host identity and no statement
of what the listing covered, so a scoped listing that saw nothing read as
"nothing exists anywhere" — an agent reported a live remote worker dead.

Each row now carries an optional `executionHostId` derived from the PTY id
(SSH and paired-runtime ids embed their owner), and the result carries an
optional `hostScope` naming the hosts covered and the known hosts skipped.
Both are surfaced in `--json` and in the human-readable CLI output, where
an absent field renders as `unknown` rather than `local`.

Both row builders route through one resolver, so the rule lives in one place.

* fix(terminal): preserve unverifiable host scope

* fix(terminal): fail closed on unverifiable hosts

* test(terminal): name unverifiable scope explicitly

* perf(terminal): keep graph hydration host scans narrow

* fix(terminal): reject blank foreign host owners

* fix(terminal): validate inferred inventory hosts

* fix(terminal): preserve paired folder host scope

* fix(terminal): keep inventory host inference typed

* fix(terminal): disclose paired folder hosts
This commit is contained in:
Brennan Benson
2026-08-16 22:13:03 -07:00
committed by GitHub
parent b36456007e
commit 8ca4ed945e
12 changed files with 1000 additions and 51 deletions
@@ -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')
})
})
+19 -4
View File
@@ -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: <empty>'}`
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
)
.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(
@@ -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> = {}): 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> = {}): 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')
})
})
+24 -6
View File
@@ -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)
+155 -7
View File
@@ -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<PtyProcessInfo[]>
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<string>
terminalIdentityByPtyId: ReadonlyMap<string, PtyControllerTerminalIdentity>
queriedHostIds: ReadonlySet<ExecutionHostId>
}>
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<ExecutionHostId> = [],
includeConfiguredHosts = true
): Set<ExecutionHostId> {
const hostIds = new Set<ExecutionHostId>([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<string, WorkspaceSessionState> {
@@ -5832,7 +5884,7 @@ export class OrcaRuntimeService {
] as const
})
)
const hostIds = new Set<ExecutionHostId>(['local'])
const hostIds = new Set<ExecutionHostId>([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<ResolvedWorktree>,
queriedHostIds: ReadonlySet<ExecutionHostId>
): 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<ExecutionHostId>()
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<string, PtyControllerTerminalIdentity>()
const ptyIdByControllerHandle = new Map<string, string>()
const ambiguousControllerPtyIds = new Set<string>()
@@ -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)
}
}
@@ -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'] })
})
})
@@ -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'])
})
})
@@ -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']
})
})
})
@@ -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
}
+36
View File
@@ -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
}
}
+15
View File
@@ -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<string, number>
totalCount: number
truncated: boolean
/** Absent from hosts that predate the field — treat that scope as unverifiable. */
hostScope?: RuntimeTerminalListHostScope
}
export type RuntimeTerminalOrphanAdoptionClaim = {
+37
View File
@@ -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
}