diff --git a/config/deprecated-runtime-owner-importers-baseline.txt b/config/deprecated-runtime-owner-importers-baseline.txt index a8b94e02abf..158913c4f2d 100644 --- a/config/deprecated-runtime-owner-importers-baseline.txt +++ b/config/deprecated-runtime-owner-importers-baseline.txt @@ -34,9 +34,7 @@ src/renderer/src/lib/active-agent-note-send.ts src/renderer/src/lib/active-agent-note-target.ts src/renderer/src/lib/agent-hibernation-coordinator.ts src/renderer/src/lib/client-creation-action-policy.ts -src/renderer/src/lib/doc-preview-grants.ts src/renderer/src/lib/execute-open-editor-path-move.ts -src/renderer/src/lib/execution-host-facts.ts src/renderer/src/lib/file-preview.ts src/renderer/src/lib/launch-agent-background-session.ts src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -47,10 +45,8 @@ src/renderer/src/lib/terminal-tab-owner-activation.ts src/renderer/src/lib/terminal-worktree-route.ts src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts src/renderer/src/lib/workspace-tab-palette-activation.ts -src/renderer/src/lib/worktree-activation-pty-inventory.ts src/renderer/src/lib/worktree-activation.ts src/renderer/src/lib/worktree-creation-agent-seeds.ts -src/renderer/src/lib/worktree-row-open-in-owner.ts src/renderer/src/lib/worktree-sort-order-host-split.ts src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts src/renderer/src/store/slices/browser/browser-tab-actions.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 37e8cafbcb7..8d91c9a8e36 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -164,7 +164,6 @@ 1 src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx 1 src/renderer/src/components/sidebar/WorktreeCardPorts.tsx 2 src/renderer/src/components/sidebar/WorktreeList.tsx -1 src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx 1 src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx 2 src/renderer/src/components/sidebar/delete-worktree-flow.ts 1 src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts @@ -312,11 +311,10 @@ 1 src/renderer/src/lib/client-creation-action-error.ts 3 src/renderer/src/lib/client-creation-action-policy.ts 1 src/renderer/src/lib/default-creation-host.ts # the sanctioned reader of the default host -1 src/renderer/src/lib/doc-preview-grants.ts 1 src/renderer/src/lib/empty-workspace-default-agent-chat.ts 3 src/renderer/src/lib/execute-open-editor-path-move.ts 1 src/renderer/src/lib/execution-host-display-label.ts -2 src/renderer/src/lib/execution-host-facts.ts +1 src/renderer/src/lib/execution-host-facts.ts 6 src/renderer/src/lib/file-preview.ts 2 src/renderer/src/lib/folder-workspace-runtime-owner.ts 6 src/renderer/src/lib/http-link-destinations.ts @@ -351,12 +349,10 @@ 5 src/renderer/src/lib/workspace-browser-tab-open.ts 1 src/renderer/src/lib/workspace-file-drag-source.ts 1 src/renderer/src/lib/workspace-tab-palette-activation.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-jump-navigation.ts 2 src/renderer/src/lib/worktree-operation-route.ts -1 src/renderer/src/lib/worktree-row-open-in-owner.ts 7 src/renderer/src/lib/worktree-runtime-owner.ts # typed owner resolver, its null-collapsing wrapper, and the legacy-fallback adapter the folds move to (round 3 F0); deleting the deprecated pair (F8) nets it down 1 src/renderer/src/lib/worktree-sort-order-host-split.ts 1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts diff --git a/src/renderer/src/components/native-chat/NativeChatInlineVisual.open-as-file.test.tsx b/src/renderer/src/components/native-chat/NativeChatInlineVisual.open-as-file.test.tsx index e0b020788d9..9f610b24bd9 100644 --- a/src/renderer/src/components/native-chat/NativeChatInlineVisual.open-as-file.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInlineVisual.open-as-file.test.tsx @@ -22,7 +22,8 @@ vi.mock('@/lib/connection-owner-resolution', async (importOriginal) => ({ })) vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => ({ ...(await importOriginal()), - getRuntimeEnvironmentIdForWorktree: () => 'env-1' + getRuntimeEnvironmentIdForWorktree: () => 'env-1', + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin: () => 'env-1' })) import { NativeChatInlineVisual } from './NativeChatInlineVisual' diff --git a/src/renderer/src/lib/doc-preview-grants.test.ts b/src/renderer/src/lib/doc-preview-grants.test.ts index 6953b1938f0..308760c184d 100644 --- a/src/renderer/src/lib/doc-preview-grants.test.ts +++ b/src/renderer/src/lib/doc-preview-grants.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ connectionId: null as string | null | undefined, environmentId: null as string | null, + ambiguous: false, mintGrant: vi.fn(), revokeGrant: vi.fn() })) @@ -11,7 +12,8 @@ vi.mock('@/lib/connection-owner-resolution', () => ({ getConnectionIdForFileFromState: () => mocks.connectionId })) vi.mock('@/lib/worktree-runtime-owner', () => ({ - getRuntimeEnvironmentIdForWorktree: () => mocks.environmentId + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin: () => + mocks.ambiguous ? null : mocks.environmentId })) import { @@ -26,6 +28,7 @@ beforeEach(() => { vi.clearAllMocks() mocks.connectionId = null mocks.environmentId = null + mocks.ambiguous = false mocks.mintGrant.mockResolvedValue({ grantId: 'grant-1', url: 'orca-preview://grant-1/a.html' }) vi.stubGlobal('window', { api: { docPreview: { mintGrant: mocks.mintGrant, revokeGrant: mocks.revokeGrant } } @@ -33,6 +36,11 @@ beforeEach(() => { }) describe('buildDocPreviewGrantRequest', () => { + it('mints no grant for a twin, which names no server to mint against', () => { + mocks.ambiguous = true + expect(buildDocPreviewGrantRequest(state, 'wt-1', '/srv/repo/docs/a.html')).toBeNull() + }) + // A document outside every workspace resolves and authorizes from its own directory. it('roots an SSH grant outside the workspace at the document directory', () => { mocks.connectionId = 'ssh-1' diff --git a/src/renderer/src/lib/doc-preview-grants.ts b/src/renderer/src/lib/doc-preview-grants.ts index 4a3a2bfd6d6..5129830f3fd 100644 --- a/src/renderer/src/lib/doc-preview-grants.ts +++ b/src/renderer/src/lib/doc-preview-grants.ts @@ -1,7 +1,7 @@ import type { DocPreviewGrantRequest } from '../../../preload/api/doc-preview-api' import { basename, dirname, getRelativePathInsideRoot } from '@/lib/path' import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resolution' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from '@/lib/worktree-runtime-owner' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { chatScratchFileAddress } from '@/runtime/runtime-chat-scratch-file-address' import type { AppState } from '@/store/types' @@ -43,7 +43,8 @@ export function buildDocPreviewGrantRequest( // directory is often a home directory, which is where secrets live. return { owner: { kind: 'ssh', connectionId }, requestBase, root, entryRelativePath } } - const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + // Why: a twin names no server to mint against, so it gets no grant. + const environmentId = runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, worktreeId) // Outside the workspace, a chat's scratch folder stands in for it: its host serves it as a root. const base = worktreeRoot && worktreeRelativePath diff --git a/src/renderer/src/lib/execution-host-facts.ts b/src/renderer/src/lib/execution-host-facts.ts index 5ef43967c7f..7836e9db404 100644 --- a/src/renderer/src/lib/execution-host-facts.ts +++ b/src/renderer/src/lib/execution-host-facts.ts @@ -14,7 +14,7 @@ import { getLocalProjectExecutionRuntimeContext } from './local-preflight-contex import { CLIENT_PLATFORM } from './new-workspace' import { getExecutionHostIdForWorktree, - getRuntimeEnvironmentIdForWorktree + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from './worktree-runtime-owner' /** @@ -148,7 +148,9 @@ export function resolveWorktreeExecutionHostPlatform( nestedSshTargetId: host?.kind === 'runtime' ? (getConnectionIdFromState(state, worktreeId) ?? null) : null, sshOwnerEnvironmentId: - host?.kind === 'ssh' ? getRuntimeEnvironmentIdForWorktree(state, worktreeId) : null, + host?.kind === 'ssh' + ? runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, worktreeId) + : null, workspacePath, projectRuntime: () => getLocalProjectExecutionRuntimeContext(state, worktreeId) }) diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 34fe80efc42..6b46b1e5cad 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -178,7 +178,8 @@ vi.mock('@/runtime/local-runtime-capabilities', () => ({ vi.mock('@/lib/worktree-runtime-owner', () => ({ getExecutionHostIdForWorktree: () => store.repos[0]?.connectionId ? `ssh:${store.repos[0].connectionId}` : 'local', - getRuntimeEnvironmentIdForWorktree: () => null + getRuntimeEnvironmentIdForWorktree: () => null, + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin: () => null })) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: (target: unknown, method: string, params: unknown) => diff --git a/src/renderer/src/lib/worktree-activation-pty-inventory.ts b/src/renderer/src/lib/worktree-activation-pty-inventory.ts index ce211a5b63c..a91a78fa24f 100644 --- a/src/renderer/src/lib/worktree-activation-pty-inventory.ts +++ b/src/renderer/src/lib/worktree-activation-pty-inventory.ts @@ -2,7 +2,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseExecutionHostId } from '../../../shared/execution-host' import type { PtyListedSession, PtySessionListScope } from '../../../shared/pty-listed-session' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' -import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from './worktree-runtime-owner' import { resolveIndexedRepoOwner, resolveIndexedWorktreeOwner @@ -21,7 +21,10 @@ export function resolveActivationPtyListScope( return { connectionId: null } } const resolution = resolveWorktreeOperationRouteResult(state, worktreeId) - if (resolution.kind === 'missing' && !getRuntimeEnvironmentIdForWorktree(state, worktreeId)) { + if ( + resolution.kind === 'missing' && + !runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, worktreeId) + ) { const repo = resolveIndexedRepoOwner(state.repos, getRepoIdFromWorktreeId(worktreeId)) const worktree = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId) // A known native repo remains usable while the unrelated runtime catalog hydrates. diff --git a/src/renderer/src/lib/worktree-row-open-in-owner.test.ts b/src/renderer/src/lib/worktree-row-open-in-owner.test.ts index e5fcba72891..8bed4f2f131 100644 --- a/src/renderer/src/lib/worktree-row-open-in-owner.test.ts +++ b/src/renderer/src/lib/worktree-row-open-in-owner.test.ts @@ -3,6 +3,30 @@ import type { Worktree } from '../../../shared/worktree/types' import { resolveWorktreeRowOpenInRuntimeOwner } from './worktree-row-open-in-owner' import { getExternalEditorOpenCapability } from './external-editor-open-capability' import { getLocalPathOpenOwnerForRoute, isLocalPathOpenBlocked } from './local-path-open-guard' +import { OWNER_RESOLUTION_CASES } from './runtime-owner-alias-matrix.test-fixture' + +describe('an unhosted row resolves through the fold adapter', () => { + const caseState = (name: string) => OWNER_RESOLUTION_CASES.find((c) => c.name === name)!.state + const row = (id: string, repoId: string) => ({ id, repoId, hostId: undefined }) + + it('leaves a git twin unresolved instead of launching it as this desktop checkout', () => { + expect( + resolveWorktreeRowOpenInRuntimeOwner( + caseState('focused | projected-twin::wt'), + row('projected-twin::wt', 'projected-twin') + ).ownerUnresolved + ).toBe(true) + }) + + it('routes a row whose only repo row is a server proxying its target through that server', () => { + expect( + resolveWorktreeRowOpenInRuntimeOwner( + caseState('focused | stale-hub-repo::wt'), + row('stale-hub-repo::wt', 'stale-hub-repo') + ) + ).toEqual({ runtimeEnvironmentId: 'hub-env', connectionId: null, ownerUnresolved: false }) + }) +}) const WORKTREE_ID = 'repo-1::/srv/worktree' diff --git a/src/renderer/src/lib/worktree-row-open-in-owner.ts b/src/renderer/src/lib/worktree-row-open-in-owner.ts index e87a9b24967..6ba0ea159e2 100644 --- a/src/renderer/src/lib/worktree-row-open-in-owner.ts +++ b/src/renderer/src/lib/worktree-row-open-in-owner.ts @@ -1,7 +1,6 @@ import { parseExecutionHostId } from '../../../shared/execution-host' import type { Worktree } from '../../../shared/worktree/types' -import { getResolvedExecutionHostIdForWorktree } from './resolved-worktree-execution-host' -import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { resolveOwnerWithLegacyFallback } from './worktree-runtime-owner' import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' import { resolveStrictWorktreeOperationRouteResultForHost, @@ -46,10 +45,17 @@ export function resolveWorktreeRowOpenInRuntimeOwner( return runtimeEnvironmentId ? resolved(runtimeEnvironmentId) : UNRESOLVED } if (!worktree.hostId) { - return resolved( - getRuntimeEnvironmentIdForWorktree(state, worktree.id), - getResolvedExecutionHostIdForWorktree(state, worktree.id) - ) + // Why: a twin names no owner, and Open in must not launch it as this desktop's checkout. + const owner = resolveOwnerWithLegacyFallback(state, { workspaceId: worktree.id }) + if (owner.kind === 'ambiguous') { + return UNRESOLVED + } + // Why: an owner no row places was read as this desktop before the fold, and still is. + if (owner.kind === 'missing') { + return resolved(null) + } + const { endpoint, at } = owner.owner + return resolved(endpoint.kind === 'environment' ? endpoint.environmentId : null, at) } // Why: an inactive row can name its host before the catalog has it, so the explicit host wins. const resolution = resolveStrictWorktreeOperationRouteResultForHost( diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 6e3fff9cd70..7e9077a37bc 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -288,3 +288,17 @@ export function resolveOwnerWithLegacyFallback( } return match.kind === 'resolved' ? match : { kind: 'resolved', owner: SELF_LOCAL_AUTHORITY } } + +/** + * The paired server behind {@link resolveOwnerWithLegacyFallback}, or `null` for this app, a + * directly dialed SSH target or a twin: for readers whose twin answer is "no server", as before. + */ +export function runtimeEnvironmentIdWithLegacyFallbackUnlessTwin( + state: WorktreeRuntimeOwnerState, + workspaceId: string +): string | null { + const match = resolveOwnerWithLegacyFallback(state, { workspaceId }) + return match.kind === 'resolved' && match.owner.endpoint.kind === 'environment' + ? match.owner.endpoint.environmentId + : null +}