fix: resolve terminal authority from workspace owner evidence (#29565)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-16 02:10:23 -07:00
committed by GitHub
co-authored by OrcaWin
parent 172a5d2015
commit 2e97d9aa84
5 changed files with 439 additions and 24 deletions
-7
View File
@@ -225,7 +225,6 @@
1 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx
1 src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts
1 src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts
1 src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts
1 src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts
1 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts
1 src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts
@@ -264,9 +263,7 @@
2 src/renderer/src/components/use-task-page-store-bindings.ts
2 src/renderer/src/components/use-terminal-activation-actions.ts
2 src/renderer/src/components/use-terminal-bulk-close-actions.ts
1 src/renderer/src/components/use-terminal-controller.ts
8 src/renderer/src/components/use-terminal-create-actions.ts
1 src/renderer/src/components/use-terminal-watcher-effects.ts
2 src/renderer/src/components/use-worktree-jump-palette-controller.ts
1 src/renderer/src/components/use-worktree-jump-palette-create-action.ts
2 src/renderer/src/components/use-worktree-jump-palette-filter.ts
@@ -339,13 +336,11 @@
2 src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts
1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts
2 src/renderer/src/lib/repo-runtime-owner.ts
2 src/renderer/src/lib/resume-sleeping-agent-session.ts
1 src/renderer/src/lib/runtime-session-mirror-owners.ts
1 src/renderer/src/lib/runtime-session-mirror-targets.ts
1 src/renderer/src/lib/runtime-workspace-file-route.ts
1 src/renderer/src/lib/single-runtime-legacy-owner.ts
1 src/renderer/src/lib/sleeping-agent-session-launch.ts
1 src/renderer/src/lib/sleeping-record-execution-host-scope.ts
1 src/renderer/src/lib/structured-agent-session-launch.ts
1 src/renderer/src/lib/structured-agent-session-provisional-tab.ts
4 src/renderer/src/lib/structured-agent-session-tab-activation.ts
@@ -360,11 +355,9 @@
1 src/renderer/src/lib/workspace-file-drag-source.ts
1 src/renderer/src/lib/workspace-tab-commands.ts
1 src/renderer/src/lib/workspace-tab-palette-activation.ts
3 src/renderer/src/lib/workspace-terminal-host-authority.ts
1 src/renderer/src/lib/worktree-activation-pty-inventory.ts
3 src/renderer/src/lib/worktree-activation.ts
1 src/renderer/src/lib/worktree-creation-agent-seeds.ts
1 src/renderer/src/lib/worktree-initial-terminal-seeding.ts
1 src/renderer/src/lib/worktree-jump-navigation.ts
2 src/renderer/src/lib/worktree-operation-route.ts
1 src/renderer/src/lib/worktree-row-open-in-owner.ts
@@ -119,7 +119,12 @@ describe('sleepingRecordNamesAnotherExecutionHost', () => {
// yours is the inverse of the defect and worse.
const record = makeRecord({ connectionId })
expect(
sleepingRecordNamesAnotherExecutionHost(record, { repos: [], worktreesByRepo: {} })
sleepingRecordNamesAnotherExecutionHost(record, {
repos: [],
worktreesByRepo: {},
settings: { activeRuntimeEnvironmentId: 'unrelated' },
runtimeEnvironments: [{ id: 'unrelated' }]
})
).toBe(false)
}
)
@@ -129,7 +134,9 @@ describe('sleepingRecordNamesAnotherExecutionHost', () => {
expect(
sleepingRecordNamesAnotherExecutionHost(record, {
repos: [{ id: 'repo-1' }],
worktreesByRepo: {}
worktreesByRepo: {},
settings: { activeRuntimeEnvironmentId: 'unrelated' },
runtimeEnvironments: [{ id: 'unrelated' }]
})
).toBe(true)
})
@@ -242,3 +249,131 @@ describe('the resume sweep under execution-host scope', () => {
expect(state.sleepingAgentSessionsByPaneKey[native.paneKey]).toBeUndefined()
})
})
const ownerEvidenceCases: {
name: string
state: WorktreeRuntimeOwnerState
connectionId: string | null
worktreeId?: string
foreign: boolean
}[] = [
{
name: 'the active local host over an SSH catalog',
state: {
...catalogOwnedBy('ssh:openclaw'),
activeWorktreeId: WORKTREE_ID,
activeWorkspaceExecutionHostId: 'local'
},
connectionId: TARGET_ID,
foreign: true
},
{
name: 'the matching active SSH host over a local catalog',
state: {
...catalogOwnedBy('local'),
activeWorktreeId: WORKTREE_ID,
activeWorkspaceExecutionHostId: 'ssh:openclaw'
},
connectionId: TARGET_ID,
foreign: false
},
{
name: 'a foreign detected-only SSH owner',
state: {
detectedWorktreesByRepo: {
remote: { worktrees: [{ id: WORKTREE_ID, repoId: 'remote', hostId: 'ssh:other' }] }
}
},
connectionId: TARGET_ID,
foreign: true
},
{
name: 'conflicting detected owners',
state: {
detectedWorktreesByRepo: {
a: { worktrees: [{ id: WORKTREE_ID, repoId: 'a', hostId: 'ssh:other' }] },
b: { worktrees: [{ id: WORKTREE_ID, repoId: 'b', hostId: 'local' }] }
}
},
connectionId: TARGET_ID,
foreign: false
},
...[TARGET_ID, 'other'].map((connectionId) => ({
name: `physical SSH ownership through a runtime for ${connectionId}`,
state: {
repos: [{ id: 'repo-1', connectionId: TARGET_ID, executionHostId: 'runtime:hub' as const }],
worktreesByRepo: {
'repo-1': [{ id: WORKTREE_ID, repoId: 'repo-1', hostId: 'ssh:openclaw' as const }]
}
},
connectionId,
foreign: connectionId !== TARGET_ID
})),
{
name: 'a direct SSH folder for a locally captured session',
worktreeId: 'folder:f',
state: { folderWorkspaces: [{ id: 'f', projectGroupId: 'g', connectionId: TARGET_ID }] },
connectionId: null,
foreign: true
},
{
name: 'a known unstamped local folder',
worktreeId: 'folder:f',
state: { folderWorkspaces: [{ id: 'f', projectGroupId: 'g' }] },
connectionId: TARGET_ID,
foreign: true
},
{
name: 'a restored runtime folder before its catalog',
worktreeId: 'folder:f',
state: { restoredRuntimeHostIdByWorkspaceSessionKey: { 'folder:f': 'runtime:restored' } },
connectionId: TARGET_ID,
foreign: false
},
{
name: 'a missing folder',
worktreeId: 'folder:f',
state: {},
connectionId: TARGET_ID,
foreign: false
},
{
name: 'conflicting folder owners',
worktreeId: 'folder:f',
state: {
folderWorkspaces: [
{ id: 'f', projectGroupId: 'g', executionHostId: 'local' },
{ id: 'f', projectGroupId: 'g', executionHostId: 'ssh:other' }
]
},
connectionId: TARGET_ID,
foreign: false
},
{
name: 'conflicting inherited folder group owners',
worktreeId: 'folder:f',
state: {
folderWorkspaces: [{ id: 'f', projectGroupId: 'g' }],
projectGroups: [
{ id: 'g', executionHostId: 'local' },
{ id: 'g', executionHostId: 'ssh:other' }
]
},
connectionId: TARGET_ID,
foreign: false
}
]
it.each(ownerEvidenceCases)(
'compares capture against $name',
({ state, connectionId, worktreeId, foreign }) => {
const record = makeRecord({ connectionId, ...(worktreeId ? { worktreeId } : {}) })
expect(
sleepingRecordNamesAnotherExecutionHost(record, {
settings: { activeRuntimeEnvironmentId: 'unrelated' },
runtimeEnvironments: [{ id: 'unrelated' }],
...state
})
).toBe(foreign)
}
)
@@ -3,16 +3,24 @@ import {
getRepoExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
findFolderWorkspaceOwner,
getExplicitRuntimeEnvironmentIdForFolderWorkspace,
type FolderWorkspaceRuntimeOwnerState
} from './folder-workspace-runtime-owner'
import { findIndexedRepoOwner, findIndexedWorktreeOwner } from './worktree-runtime-owner-index'
import { getKnownExecutionHostIdForWorktree } from './worktree-runtime-owner'
import {
findIndexedRepoOwner,
findIndexedWorktreeOwner,
findIndexedProjectGroupOwner,
hasIndexedDetectedWorktree
} from './worktree-runtime-owner-index'
import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route'
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
/**
@@ -79,12 +87,74 @@ export function sleepingRecordNamesAnotherExecutionHost(
): boolean {
return agentResumeOriginNamesAnotherExecutionHost(
record.connectionId,
getKnownExecutionHostIdForWorktree(state, record.worktreeId)
currentExecutionHostIdForRecord(record, state)
)
}
type SleepingRecordOwnerState = WorktreeRuntimeOwnerState & FolderWorkspaceRuntimeOwnerState
// Current routing selection is identity evidence; a focused runtime is not.
function currentExecutionHostIdForRecord(
record: SleepingAgentSessionRecord,
state: SleepingRecordOwnerState
): ExecutionHostId | null {
const worktreeId = record.worktreeId
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return 'local'
}
const activeHost =
state.activeWorktreeId === worktreeId
? parseExecutionHostId(state.activeWorkspaceExecutionHostId)
: null
if (activeHost) {
return activeHost.id
}
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const environmentId = getExplicitRuntimeEnvironmentIdForFolderWorkspace(
state,
scope.folderWorkspaceId
)
if (environmentId) {
return toRuntimeExecutionHostId(environmentId)
}
const folder = findFolderWorkspaceOwner(state, scope.folderWorkspaceId)
if (!folder) {
return null
}
if (
!folder.executionHostId &&
!folder.connectionId &&
state.projectGroups?.some((group) => group.id === folder.projectGroupId) &&
!findIndexedProjectGroupOwner(state.projectGroups, folder.projectGroupId)
) {
return null
}
return ownerExecutionHostIdForRecord(record, state) ?? 'local'
}
const resolution = resolveExplicitWorktreeOperationRouteResult(state, worktreeId)
if (resolution.kind === 'resolved') {
return (
resolution.route.executionHostId ??
(resolution.route.runtimeEnvironmentId
? toRuntimeExecutionHostId(resolution.route.runtimeEnvironmentId)
: null)
)
}
if (
resolution.kind === 'ambiguous' ||
hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId)
) {
return null
}
const worktree = findIndexedWorktreeOwner(state.worktreesByRepo, worktreeId)
const repo = findIndexedRepoOwner(
state.repos,
worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
)
return repo ? getRepoExecutionHostId(repo) : null
}
// Why owner rows only: two records' machines are a property of their own workspaces, never of the
// focused server, so this reads no routing default and an unknown owner stays unknown.
function ownerExecutionHostIdForRecord(
@@ -2,11 +2,15 @@
* The seeding predicate answers one question for both remote flavors: does some other party own
* terminal creation for this workspace right now? Three answers, and only `none` seeds.
*/
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import { createTestStore, makeWorktree } from '@/store/slices/store-test-helpers'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import { resolveWorkspaceTerminalHostAuthority } from '@/lib/workspace-terminal-host-authority'
import {
resolveWorkspaceTerminalHostAuthority,
type WorkspaceTerminalHostAuthorityState
} from '@/lib/workspace-terminal-host-authority'
import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
@@ -95,6 +99,38 @@ describe('workspace terminal seeding authority', () => {
expect(terminalTabCount(store, LOCAL_WORKTREE_ID)).toBe(1)
})
it.each(['local', 'unstamped', 'missing', 'folder'] as const)(
'seeds a %s workspace while an unrelated runtime is selected',
(ownership) => {
const store = createTestStore()
seedLocal(store)
if (ownership === 'unstamped') {
store.setState({
worktreesByRepo: {
repoLocal: [makeWorktree({ id: LOCAL_WORKTREE_ID, repoId: 'repoLocal' })]
}
})
} else if (ownership === 'missing') {
store.setState({ repos: [], worktreesByRepo: {} })
}
if (ownership === 'folder') {
store.setState({ folderWorkspaces: [makeFolderWorkspace()] })
}
const worktreeId = ownership === 'folder' ? 'folder:folder-1' : LOCAL_WORKTREE_ID
const state = {
...store.getState(),
settings: {
...store.getState().settings,
activeRuntimeEnvironmentId: ENVIRONMENT_ID
},
runtimeEnvironments: [{ id: ENVIRONMENT_ID }]
}
expect(ensureWorktreeHasInitialTerminal(state, worktreeId)).toBeTruthy()
expect(terminalTabCount(store, worktreeId)).toBe(1)
}
)
it('seeds once the SSH target connects, with no workspace snapshot to wait for', () => {
// The relay that served host workspace snapshots is gone (#27214), so nothing will ever answer
// one; waiting for it left a reconnected target unable to seed or resume for good.
@@ -220,3 +256,139 @@ describe('workspace terminal seeding authority', () => {
expect(terminalTabCount(store, PAIRED_WORKTREE_ID)).toBe(0)
})
})
const WT = 'repo::/workspace'
const FOLDER = 'folder:folder-1'
const TARGET = 'ssh:target' as const
const base = {
settings: { activeRuntimeEnvironmentId: 'unrelated' },
runtimeEnvironments: [{ id: 'unrelated' }],
runtimeEnvironmentCatalogHydrated: false
} satisfies WorkspaceTerminalHostAuthorityState
const sshRow = { id: WT, repoId: 'repo', hostId: TARGET }
const hubA = { id: 'repo', connectionId: 'target', executionHostId: 'runtime:hub-a' as const }
const hubB = { id: 'repo', connectionId: 'target', executionHostId: 'runtime:hub-b' as const }
const cases: {
name: string
id: string
state: WorkspaceTerminalHostAuthorityState
expected: 'live' | 'unverifiable' | 'none'
}[] = [
{
name: 'repo-owned runtime before worktree hydration',
id: WT,
expected: 'live',
state: { repos: [{ id: 'repo', executionHostId: 'runtime:owner' }] }
},
{
name: 'runtime HUB behind an SSH worktree',
id: WT,
expected: 'live',
state: { repos: [hubA], worktreesByRepo: { repo: [sshRow] } }
},
{
name: 'runtime project group owns a folder',
id: FOLDER,
expected: 'live',
state: {
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group' }],
projectGroups: [{ id: 'group', executionHostId: 'runtime:owner' }]
}
},
{
name: 'restored runtime folder before catalog hydration',
id: FOLDER,
expected: 'live',
state: {
folderWorkspaces: [],
projectGroups: [],
restoredRuntimeHostIdByWorkspaceSessionKey: { [FOLDER]: 'runtime:restored' }
}
},
{
name: 'disconnected direct SSH folder is outside replacement scope',
id: FOLDER,
expected: 'none',
state: {
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group', connectionId: 'target' }]
}
},
{
name: 'conflicting folder owners stay unverifiable',
id: FOLDER,
expected: 'unverifiable',
state: {
folderWorkspaces: [
{ id: 'folder-1', projectGroupId: 'group', executionHostId: 'local' },
{ id: 'folder-1', projectGroupId: 'group', executionHostId: 'runtime:owner' }
]
}
},
{
name: 'conflicting folder project-group owners stay unverifiable',
id: FOLDER,
expected: 'unverifiable',
state: {
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group' }],
projectGroups: [
{ id: 'group', executionHostId: 'local' },
{ id: 'group', executionHostId: 'runtime:owner' }
]
}
},
{
name: 'unroutable detected publication stays unverifiable',
id: WT,
expected: 'unverifiable',
state: { detectedWorktreesByRepo: { repo: { worktrees: [{ id: WT, repoId: 'repo' }] } } }
}
]
it.each(cases)('$name', ({ id, state, expected }) => {
expect(resolveWorkspaceTerminalHostAuthority({ ...base, ...state }, id)).toBe(expected)
})
it('lets the explicit same-workspace host disambiguate rival catalog owners', () => {
const state: WorkspaceTerminalHostAuthorityState = {
...base,
activeWorktreeId: WT,
repos: [
{ id: 'repo', executionHostId: 'local' },
{ id: 'repo', executionHostId: 'runtime:owner' }
]
}
expect(
resolveWorkspaceTerminalHostAuthority({ ...state, activeWorkspaceExecutionHostId: 'local' }, WT)
).toBe('none')
expect(
resolveWorkspaceTerminalHostAuthority(
{ ...state, activeWorkspaceExecutionHostId: 'runtime:owner' },
WT
)
).toBe('live')
})
it('waits for rival SSH HUB owners, then accepts the surviving runtime owner', () => {
const state: WorkspaceTerminalHostAuthorityState = {
...base,
activeWorktreeId: WT,
activeWorkspaceExecutionHostId: TARGET,
worktreesByRepo: { repo: [sshRow] }
}
expect(resolveWorkspaceTerminalHostAuthority({ ...state, repos: [hubA, hubB] }, WT)).toBe(
'unverifiable'
)
expect(resolveWorkspaceTerminalHostAuthority({ ...state, repos: [hubA] }, WT)).toBe('live')
})
afterEach(() => vi.unstubAllGlobals())
it.each([WT, FOLDER])('leaves local terminal ownership with the real paired web host: %s', (id) => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
const state: WorkspaceTerminalHostAuthorityState = {
settings: { activeRuntimeEnvironmentId: null },
runtimeEnvironments: [{ id: 'paired-host' }],
worktreesByRepo: { repo: [{ id: WT, repoId: 'repo', hostId: 'local' }] },
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group', executionHostId: 'local' }]
}
expect(resolveWorkspaceTerminalHostAuthority(state, id)).toBe('live')
})
@@ -4,10 +4,17 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { HostLiveTerminalProbeVerdict } from '@/runtime/host-live-terminal-probe'
import type { SshConnectionState } from '../../../shared/ssh-types'
import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment'
import { resolveExplicitWorktreeOperationRouteResult } from '@/lib/worktree-operation-catalog-route'
import { resolveStrictWorktreeOperationRouteResultForHost } from '@/lib/worktree-operation-route'
import { getPairedWebClientEnvironmentId } from '@/lib/paired-web-client-host'
import {
getExecutionHostIdForWorktree,
getRuntimeEnvironmentIdForWorktree
} from '@/lib/worktree-runtime-owner'
findFolderWorkspaceOwner,
getExplicitRuntimeEnvironmentIdForFolderWorkspace
} from '@/lib/folder-workspace-runtime-owner'
import {
findIndexedProjectGroupOwner,
hasIndexedDetectedWorktree
} from '@/lib/worktree-runtime-owner-index'
import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state'
/**
@@ -64,16 +71,54 @@ export function resolveWorkspaceTerminalHostAuthority(
if (!worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return 'none'
}
if (isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(state, worktreeId))) {
return 'live'
const selectedHost =
state.activeWorktreeId === worktreeId
? parseExecutionHostId(state.activeWorkspaceExecutionHostId)
: null
const scope = parseWorkspaceKey(worktreeId)
if (!selectedHost && scope?.type === 'folder') {
const folderId = scope.folderWorkspaceId
const owner = findFolderWorkspaceOwner(state, folderId)
if (!owner && state.folderWorkspaces?.some((folder) => folder.id === folderId)) {
return 'unverifiable'
}
if (
owner &&
!owner.executionHostId &&
!owner.connectionId &&
state.projectGroups?.some((group) => group.id === owner.projectGroupId) &&
!findIndexedProjectGroupOwner(state.projectGroups, owner.projectGroupId)
) {
return 'unverifiable'
}
const environmentId =
getExplicitRuntimeEnvironmentIdForFolderWorkspace(state, folderId) ??
getPairedWebClientEnvironmentId(state)
// Folder workspaces were never host-replaced, so direct SSH keeps `none`.
return isWebRuntimeSessionActive(environmentId) ? 'live' : 'none'
}
const host = parseExecutionHostId(getExecutionHostIdForWorktree(state, worktreeId))
if (host?.kind === 'runtime') {
// A runtime host we could not name (rival detected publications, unhydrated catalog) is unasked.
const resolution = selectedHost
? resolveStrictWorktreeOperationRouteResultForHost(state, worktreeId, selectedHost.id)
: resolveExplicitWorktreeOperationRouteResult(state, worktreeId)
if (
resolution.kind === 'ambiguous' ||
(resolution.kind === 'missing' &&
hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId))
) {
return 'unverifiable'
}
if (host?.kind === 'ssh' && parseWorkspaceKey(worktreeId)?.type !== 'folder') {
// Folder workspaces were never host-replaced, so they keep `none`.
const route = resolution.kind === 'resolved' ? resolution.route : null
// A focused runtime routes legacy operations; only owner evidence grants terminal authority.
if (
isWebRuntimeSessionActive(route?.runtimeEnvironmentId ?? getPairedWebClientEnvironmentId(state))
) {
return 'live'
}
const host = parseExecutionHostId(route?.executionHostId)
if (host?.kind === 'runtime') {
return 'unverifiable'
}
if (host?.kind === 'ssh' && scope?.type !== 'folder') {
return resolveDirectSshAuthority(state, host.targetId)
}
// Local, or outside the host's replace scope: this client is the execution host and its own rows are