From 9f4ea42493883ca2c86145bd6bd630761a151226 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Mon, 17 Aug 2026 16:37:07 -0700
Subject: [PATCH 01/43] fix(agent-status): say what an OpenCode permission
request is waiting on (STA-3160) (#14614)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(agent-status): say what an OpenCode permission request is waiting on (STA-3160)
A permission.asked arrives as hook_event_name PermissionRequest, but
extractOpenCodeToolFields had no branch for it, so the pane reported a bare
{state:'waiting'} with no tool or command. The user could see that OpenCode was
blocked but not on what.
Read the fields @opencode-ai/sdk fixes for EventPermissionAsked: 'permission'
names the request, and metadata/patterns carry the command or paths it covers.
The normalizer is shared with mimo-code, so both are covered.
* fix(agent-status): show the OpenCode permission on the row, and retire it after (STA-3160)
Live validation against opencode 1.18.18 showed the original change populated
toolName/toolInput on a `waiting` entry that no surface rendered, while leaving
the answered permission cached for the rest of the pane's session.
Retire the tool fields on every OpenCode-family event except PermissionRequest.
isNewTurnEvent is false for this family, so resolveToolState otherwise inherits
one answered permission onto every later frame and the row reads a resolved
command as the live tool. Reproduced end to end: after approving `rm -rf build/`,
an unrelated later turn still reported it.
Read `filepath` from permission metadata. The SDK types metadata as
Record, so its keys come from each tool; a live opencode 1.18.18
sends `filepath` (one word) for `edit`, which the previous key list missed. The
fallback to `patterns` covered it by accident, and the test that claimed to cover
it used `metadata: {}` — a shape OpenCode never emits. Tests now use captured
payloads for bash, edit and webfetch.
Show tool fields on `waiting` as well as `working`. All three consumers gated on
`working`, so a permission request rendered nothing at all; before/after of the
sidebar was pixel-identical. The rule now lives in one place (showsAgentToolPreview)
because a gate duplicated across three surfaces is a gate that drifts.
---
.../dashboard/DashboardAgentRow.test.tsx | 39 +++
.../dashboard/DashboardAgentRow.tsx | 12 +-
.../dashboard/DashboardAgentRowToolStep.tsx | 9 +-
.../worktree-card-compact-agent-row.tsx | 13 +-
.../src/lib/activity-thread-display.test.ts | 38 +++
.../src/lib/activity-thread-display.ts | 13 +-
.../src/lib/agent-row-tool-preview.test.ts | 51 ++++
.../src/lib/agent-row-tool-preview.ts | 32 +++
src/shared/agent-hook-listener.ts | 49 +++-
src/shared/opencode-permission-status.test.ts | 255 ++++++++++++++++++
10 files changed, 483 insertions(+), 28 deletions(-)
create mode 100644 src/renderer/src/lib/agent-row-tool-preview.test.ts
create mode 100644 src/renderer/src/lib/agent-row-tool-preview.ts
create mode 100644 src/shared/opencode-permission-status.test.ts
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
index 5be1c78d574..4ce41472d49 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx
@@ -348,6 +348,45 @@ describe('DashboardAgentRow', () => {
expect(activeToolMarkup).toContain('ListDir')
})
+ it('names what a blocked approval is waiting on', () => {
+ const markup = renderRow(
+ makeAgent(
+ { state: 'waiting' },
+ { state: 'waiting', toolName: 'bash', toolInput: 'rm -rf build/' }
+ )
+ )
+
+ // Why: a permission request parks the row on 'waiting'; without the tool line the row
+ // is a bare amber dot that cannot say what the user is being asked to approve.
+ expect(markup).toContain('lucide-wrench')
+ expect(markup).toContain('bash')
+ expect(markup).toContain('rm -rf build/')
+ })
+
+ it('leaves a wait without tool metadata unchanged', () => {
+ const markup = renderRow(makeAgent({ state: 'waiting' }, { state: 'waiting' }))
+
+ // Why: the height placeholder exists because a working row's tool line is imminent.
+ // A question-style wait has nothing coming, so reserving the row would just add a gap.
+ expect(markup).not.toContain('data-agent-row-tool-slot=""')
+ expect(markup).not.toContain('lucide-wrench')
+ })
+
+ it('keeps a resolved tool off a finished row', () => {
+ for (const state of ['done', 'blocked', 'idle'] as const) {
+ const markup = renderRow(
+ makeAgent(
+ { state },
+ // Why: 'idle' is a row-only state; the entry it is derived from still reports 'done'.
+ { state: state === 'idle' ? 'done' : state, toolName: 'bash', toolInput: 'rm -rf build/' }
+ )
+ )
+
+ expect(markup).not.toContain('lucide-wrench')
+ expect(markup).not.toContain('rm -rf build/')
+ }
+ })
+
it('renders orchestration child rows with a connector and tree level', () => {
const markup = renderRow(
makeAgent({
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
index 460c251b98b..96c52f12d22 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
@@ -8,6 +8,7 @@ import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure'
import { DashboardAgentRowMessage } from './DashboardAgentRowMessage'
import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingControls'
import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep'
+import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
@@ -144,10 +145,12 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
// Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled.
const displayLabel = prompt || agentStateLabel(asDotState(agent.state))
const model = agent.entry.model?.trim() ?? ''
- // Why: gate tool fields on 'working' — a stale tool line on a done row reads as still-running.
const isWorking = agent.state === 'working'
- const toolName = isWorking ? (agent.entry.toolName?.trim() ?? '') : ''
- const toolInput = isWorking ? (agent.entry.toolInput?.trim() ?? '') : ''
+ // Why: 'working' names the running tool and 'waiting' names what an approval is blocked on;
+ // anywhere else a leftover tool line reads as still-running. See showsAgentToolPreview.
+ const showsTool = showsAgentToolPreview(agent.state)
+ const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : ''
+ const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : ''
const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() ?? ''
const isInterrupted = agent.entry.interrupted === true
const lineage = agent.lineage
@@ -300,7 +303,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRowToolStep.tsx b/src/renderer/src/components/dashboard/DashboardAgentRowToolStep.tsx
index 1be38acde0e..5362212bb20 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRowToolStep.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRowToolStep.tsx
@@ -3,18 +3,21 @@ import { cn } from '@/lib/utils'
type DashboardAgentRowToolStepProps = {
expanded: boolean
- isWorking: boolean
+ showsTool: boolean
+ /** Hold the slot open while tool metadata is still in flight — see the empty branch below. */
+ reservesHeight: boolean
toolName: string
toolInput: string
}
export function DashboardAgentRowToolStep({
expanded,
- isWorking,
+ showsTool,
+ reservesHeight,
toolName,
toolInput
}: DashboardAgentRowToolStepProps): React.JSX.Element | null {
- if (!isWorking) {
+ if (!showsTool || (!toolName && !reservesHeight)) {
return null
}
diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
index ac60427fea6..c285d4769af 100644
--- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
+++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
import { getAgentDotState } from './worktree-card-agent-summary'
import { translate } from '@/i18n/i18n'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
+import { formatAgentToolPreview } from '@/lib/agent-row-tool-preview'
import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name'
import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp'
import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer'
@@ -40,15 +41,9 @@ function getCompactAgentSecondary(agent: DashboardAgentRowData): string {
if (agent.entry.interrupted === true) {
return 'Interrupted by user'
}
- if (agent.state === 'working') {
- const toolName = agent.entry.toolName?.trim() ?? ''
- const toolInput = agent.entry.toolInput?.trim() ?? ''
- if (toolName && toolInput) {
- return `${toolName}: ${toolInput}`
- }
- if (toolName) {
- return toolName
- }
+ const toolPreview = formatAgentToolPreview(agent.entry, agent.state)
+ if (toolPreview) {
+ return toolPreview
}
const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim()
if (lastAssistantMessage) {
diff --git a/src/renderer/src/lib/activity-thread-display.test.ts b/src/renderer/src/lib/activity-thread-display.test.ts
index 1728c8ab5ca..6beb4387c13 100644
--- a/src/renderer/src/lib/activity-thread-display.test.ts
+++ b/src/renderer/src/lib/activity-thread-display.test.ts
@@ -200,6 +200,44 @@ describe('getActivityThreadStatusPreview', () => {
})
).toBe('Interrupted by user')
})
+
+ it('names what a blocked approval is waiting on', () => {
+ // Why: a permission request parks the row on 'waiting', and the tool fields are the
+ // only thing that says what the user is being asked to approve (STA-3160).
+ expect(
+ getActivityThreadStatusPreview({
+ state: 'waiting',
+ toolName: 'bash',
+ toolInput: 'rm -rf build/',
+ prompt: 'Clean the build'
+ })
+ ).toBe('bash: rm -rf build/')
+ })
+
+ it('keeps a resolved tool off a finished row', () => {
+ // Why: tool fields outlive the turn that set them; showing them once the agent is done
+ // or idle reads as work still in flight.
+ for (const state of ['done', 'blocked'] as const) {
+ expect(
+ getActivityThreadStatusPreview({
+ state,
+ toolName: 'bash',
+ toolInput: 'rm -rf build/',
+ prompt: 'Clean the build'
+ })
+ ).toBe('')
+ }
+ })
+
+ it('shows nothing on a wait that carries no tool', () => {
+ // Why: not every wait is an approval — a question-style wait must not borrow tool text.
+ expect(
+ getActivityThreadStatusPreview({
+ state: 'waiting',
+ prompt: 'Pick a branch'
+ })
+ ).toBe('')
+ })
})
describe('resolveActivityThreadStatusPreview', () => {
diff --git a/src/renderer/src/lib/activity-thread-display.ts b/src/renderer/src/lib/activity-thread-display.ts
index ea6603edeac..7dd5f663370 100644
--- a/src/renderer/src/lib/activity-thread-display.ts
+++ b/src/renderer/src/lib/activity-thread-display.ts
@@ -10,6 +10,7 @@ import {
isOrcaDispatchPrompt,
orchestrationLabelsMatchLiveDispatch
} from './agent-row-primary-text'
+import { formatAgentToolPreview } from './agent-row-tool-preview'
// Why: follow-up replies ("yes", "ok proceed") are valid hook prompts but are
// terrible scan labels for a cross-worktree agent list — treat them as non-titles.
@@ -164,15 +165,9 @@ export function getActivityThreadStatusPreview(
return 'Interrupted by user'
}
const state = agentState ?? entry.state
- if (state === 'working') {
- const toolName = entry.toolName?.trim() ?? ''
- const toolInput = entry.toolInput?.trim() ?? ''
- if (toolName && toolInput) {
- return `${toolName}: ${toolInput}`
- }
- if (toolName) {
- return toolName
- }
+ const toolPreview = formatAgentToolPreview(entry, state)
+ if (toolPreview) {
+ return toolPreview
}
const assistant = entry.lastAssistantMessage?.trim() ?? ''
if (assistant && !isMislabeledUserPrompt(assistant, entry)) {
diff --git a/src/renderer/src/lib/agent-row-tool-preview.test.ts b/src/renderer/src/lib/agent-row-tool-preview.test.ts
new file mode 100644
index 00000000000..b9267afe5e6
--- /dev/null
+++ b/src/renderer/src/lib/agent-row-tool-preview.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { AGENT_STATUS_STATES } from '../../../shared/agent-status-types'
+import type { AgentRowState } from './agent-row-tool-preview'
+import { formatAgentToolPreview, showsAgentToolPreview } from './agent-row-tool-preview'
+
+const TOOL = { toolName: 'bash', toolInput: 'rm -rf build/' }
+const ROW_STATES: readonly AgentRowState[] = [...AGENT_STATUS_STATES, 'idle']
+
+describe('showsAgentToolPreview', () => {
+ it('covers exactly the two states whose tool fields describe live work', () => {
+ // Why: enumerate every row state so a new one has to make this decision explicitly
+ // rather than defaulting into showing a stale tool.
+ const showing = ROW_STATES.filter((state) => showsAgentToolPreview(state))
+
+ expect(showing).toEqual(['working', 'waiting'])
+ })
+
+ it('shows nothing when the state is unknown', () => {
+ expect(showsAgentToolPreview(null)).toBe(false)
+ expect(showsAgentToolPreview(undefined)).toBe(false)
+ })
+})
+
+describe('formatAgentToolPreview', () => {
+ it('names what a blocked approval is waiting on', () => {
+ expect(formatAgentToolPreview(TOOL, 'waiting')).toBe('bash: rm -rf build/')
+ })
+
+ it('names the running tool while working', () => {
+ expect(formatAgentToolPreview(TOOL, 'working')).toBe('bash: rm -rf build/')
+ })
+
+ it('keeps a resolved tool off every other state', () => {
+ for (const state of ROW_STATES.filter((candidate) => !showsAgentToolPreview(candidate))) {
+ expect(formatAgentToolPreview(TOOL, state)).toBe('')
+ }
+ })
+
+ it('shows nothing on a wait that carries no tool', () => {
+ // Why: a question-style wait sets no tool fields; it must not borrow an earlier one.
+ expect(formatAgentToolPreview({}, 'waiting')).toBe('')
+ })
+
+ it('falls back to the bare tool name when the input is not previewable', () => {
+ expect(formatAgentToolPreview({ toolName: 'edit' }, 'waiting')).toBe('edit')
+ })
+
+ it('ignores whitespace-only fields', () => {
+ expect(formatAgentToolPreview({ toolName: ' ', toolInput: ' ' }, 'working')).toBe('')
+ })
+})
diff --git a/src/renderer/src/lib/agent-row-tool-preview.ts b/src/renderer/src/lib/agent-row-tool-preview.ts
new file mode 100644
index 00000000000..1ac4c478af9
--- /dev/null
+++ b/src/renderer/src/lib/agent-row-tool-preview.ts
@@ -0,0 +1,32 @@
+import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types'
+
+/** Row states, which add the renderer-only 'idle' to the hook-reported statuses. */
+export type AgentRowState = AgentStatusState | 'idle'
+
+/**
+ * States whose cached tool fields describe live work: 'working' names the tool the agent is
+ * running, 'waiting' names the tool a permission request is blocked on. Tool fields outlive
+ * the turn that set them, so on any other state they read as work still in flight.
+ *
+ * Shared because three surfaces render this line — the sidebar's compact row, the dashboard
+ * row, and the activity thread — and a rule that only two of them relax is a rule that lies.
+ */
+export function showsAgentToolPreview(state: AgentRowState | null | undefined): boolean {
+ return state === 'working' || state === 'waiting'
+}
+
+/** One-line `tool: input` preview, or '' when the state or the entry has nothing to show. */
+export function formatAgentToolPreview(
+ entry: Pick,
+ state: AgentRowState | null | undefined
+): string {
+ if (!showsAgentToolPreview(state)) {
+ return ''
+ }
+ const toolName = entry.toolName?.trim() ?? ''
+ const toolInput = entry.toolInput?.trim() ?? ''
+ if (toolName && toolInput) {
+ return `${toolName}: ${toolInput}`
+ }
+ return toolName
+}
diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts
index e471c55d25e..61202afec0e 100644
--- a/src/shared/agent-hook-listener.ts
+++ b/src/shared/agent-hook-listener.ts
@@ -1787,6 +1787,17 @@ function extractAmpToolFields(
return {}
}
+/**
+ * Retires any cached tool fields. PermissionRequest is the only OpenCode-family event that
+ * carries them, and isNewTurnEvent is false for this family, so nothing else ever resets the
+ * cache — without an explicit retire, resolveToolState inherits one answered permission onto
+ * every later frame in the pane and the row reads a resolved command as the live tool.
+ */
+const OPENCODE_TOOL_FIELDS_RETIRED: ToolSnapshot = {
+ hasToolUpdate: true,
+ hasToolInputField: true
+}
+
function extractOpenCodeToolFields(
eventName: unknown,
hookPayload: Record
@@ -1794,7 +1805,7 @@ function extractOpenCodeToolFields(
if (eventName === 'MessagePart' && hookPayload.role === 'assistant') {
const text = readString(hookPayload, 'text')
if (text) {
- return { lastAssistantMessage: capOpenCodeHookText(text) }
+ return { ...OPENCODE_TOOL_FIELDS_RETIRED, lastAssistantMessage: capOpenCodeHookText(text) }
}
}
if (eventName === 'AskUserQuestion') {
@@ -1803,11 +1814,43 @@ function extractOpenCodeToolFields(
? hookPayload.tool_input
: stripHookEnvelopeKeys(hookPayload)
return {
- hasToolUpdate: true,
+ ...OPENCODE_TOOL_FIELDS_RETIRED,
interactivePrompt: deriveInteractivePrompt('AskUserQuestion', toolInputSource)
}
}
- return {}
+ if (eventName === 'PermissionRequest') {
+ // Why: the payload is permission.asked's event.properties — `permission` names what is
+ // being requested and `metadata`/`patterns` say which command or path it covers. Without
+ // them the row is a bare 'waiting' that cannot tell the user what to approve.
+ // The SDK types `metadata` as Record, so these keys come from the tools
+ // themselves: bash sends `command`, edit sends `filepath`, webfetch sends `url` (verified
+ // against opencode 1.18.18). `file_path`/`path` cover tools that spell it the common way.
+ // `diff` is deliberately absent — edit ships the whole patch and it would swamp the row.
+ const metadata = hookPayload.metadata
+ const metadataInput =
+ metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata)
+ ? readFirstString(metadata as Record, [
+ 'command',
+ 'filepath',
+ 'file_path',
+ 'path',
+ 'url'
+ ])
+ : undefined
+ const patterns = Array.isArray(hookPayload.patterns)
+ ? hookPayload.patterns.filter(
+ (pattern): pattern is string => typeof pattern === 'string' && pattern.length > 0
+ )
+ : []
+ return toolUpdate(
+ {
+ toolName: readString(hookPayload, 'permission'),
+ toolInput: metadataInput ?? (patterns.length > 0 ? patterns.join(', ') : undefined)
+ },
+ { hasToolInputField: hasAnyOwnField(hookPayload, ['metadata', 'patterns']) }
+ )
+ }
+ return OPENCODE_TOOL_FIELDS_RETIRED
}
function extractCursorToolFields(
diff --git a/src/shared/opencode-permission-status.test.ts b/src/shared/opencode-permission-status.test.ts
new file mode 100644
index 00000000000..6ed57e71fac
--- /dev/null
+++ b/src/shared/opencode-permission-status.test.ts
@@ -0,0 +1,255 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { createHookListenerState, normalizeHookPayload } from './agent-hook-listener'
+import { makePaneKey } from './stable-pane-id'
+
+const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
+
+/**
+ * OpenCode reports an approval prompt as `permission.asked`; the status plugin forwards
+ * that event's `properties` under `hook_event_name: 'PermissionRequest'`. The shape is
+ * fixed by @opencode-ai/sdk 1.18.18 (`EventPermissionAsked`): `permission` names what is
+ * being requested, `patterns` carries the concrete commands/paths it applies to, and
+ * `metadata` holds tool-specific detail.
+ *
+ * Only those outer names are typed — `metadata` is `Record`, so the keys
+ * inside it come from each tool. The payloads below were captured from a live opencode
+ * 1.18.18 rather than invented, because the first version of this suite guessed a shape
+ * (`metadata: {}`) that OpenCode never emits and so never exercised the real `edit` path.
+ *
+ * `normalizeOpenCodeFamilyEvent` serves opencode and mimo-code from one path, so every
+ * case here runs for both.
+ */
+describe('OpenCode-family permission request status', () => {
+ let state: ReturnType
+
+ beforeEach(() => {
+ state = createHookListenerState()
+ })
+
+ const SOURCES = ['opencode', 'mimo-code'] as const
+
+ function permissionEvent(
+ source: (typeof SOURCES)[number],
+ properties: Record
+ ): ReturnType {
+ return normalizeHookPayload(
+ state,
+ source,
+ {
+ paneKey: PANE_KEY,
+ payload: { hook_event_name: 'PermissionRequest', ...properties }
+ },
+ 'production'
+ )
+ }
+
+ function lifecycleEvent(
+ source: (typeof SOURCES)[number],
+ eventName: string,
+ properties: Record = {}
+ ): ReturnType {
+ return normalizeHookPayload(
+ state,
+ source,
+ { paneKey: PANE_KEY, payload: { hook_event_name: eventName, ...properties } },
+ 'production'
+ )
+ }
+
+ const BASH_PERMISSION = {
+ id: 'per_01',
+ sessionID: 'ses_root',
+ permission: 'bash',
+ patterns: ['rm -rf build/'],
+ metadata: { command: 'rm -rf build/' },
+ always: []
+ }
+
+ it.each(SOURCES)('reports a permission request as waiting for %s', (source) => {
+ const event = permissionEvent(source, BASH_PERMISSION)
+
+ expect(event?.payload.state).toBe('waiting')
+ expect(event?.payload.agentType).toBe(source)
+ })
+
+ it.each(SOURCES)('names the requested permission as the tool for %s', (source) => {
+ const event = permissionEvent(source, BASH_PERMISSION)
+
+ // Why: a bare `waiting` row cannot tell the user what is blocked; the permission
+ // name is the only field that always identifies it (STA-3160).
+ expect(event?.payload.toolName).toBe('bash')
+ })
+
+ it.each(SOURCES)('surfaces the blocked command as tool input for %s', (source) => {
+ const event = permissionEvent(source, BASH_PERMISSION)
+
+ expect(event?.payload.toolInput).toBe('rm -rf build/')
+ })
+
+ it.each(SOURCES)('names the edited file for %s', (source) => {
+ // Captured verbatim from opencode 1.18.18 — an `edit` request keys its path as
+ // `filepath` (one word) and ships the whole patch alongside it.
+ const event = permissionEvent(source, {
+ id: 'per_02',
+ sessionID: 'ses_root',
+ permission: 'edit',
+ patterns: ['private/tmp/oc-perm-probe/notes.md'],
+ metadata: {
+ filepath: '/private/tmp/oc-perm-probe/notes.md',
+ diff: '@@ -0,0 +1,1 @@\n+hello world\n\\ No newline at end of file\n'
+ },
+ always: ['*']
+ })
+
+ expect(event?.payload.toolName).toBe('edit')
+ expect(event?.payload.toolInput).toBe('/private/tmp/oc-perm-probe/notes.md')
+ })
+
+ it.each(SOURCES)('never previews a diff as the blocked target for %s', (source) => {
+ const event = permissionEvent(source, {
+ id: 'per_03',
+ sessionID: 'ses_root',
+ permission: 'edit',
+ patterns: ['src/a.ts'],
+ metadata: { diff: '@@ -1 +1 @@\n-before\n+after\n' },
+ always: []
+ })
+
+ // Why: `diff` is the largest field OpenCode sends and would swamp a one-line row.
+ expect(event?.payload.toolInput).not.toContain('@@')
+ })
+
+ it.each(SOURCES)('names the fetched url for %s', (source) => {
+ // Captured verbatim from opencode 1.18.18.
+ const event = permissionEvent(source, {
+ id: 'per_04',
+ sessionID: 'ses_root',
+ permission: 'webfetch',
+ patterns: ['https://example.com'],
+ metadata: { url: 'https://example.com', format: 'markdown' },
+ always: ['*']
+ })
+
+ expect(event?.payload.toolName).toBe('webfetch')
+ expect(event?.payload.toolInput).toBe('https://example.com')
+ })
+
+ it.each(SOURCES)('falls back to patterns when metadata is unrecognized for %s', (source) => {
+ // Why: `metadata` is typed `Record` by the SDK, so a tool Orca has
+ // never seen (an MCP server's, say) can key its detail anything — patterns is the one
+ // field every permission carries.
+ const event = permissionEvent(source, {
+ id: 'per_05',
+ sessionID: 'ses_root',
+ permission: 'some_mcp_tool',
+ patterns: ['src/a.ts', 'src/b.ts'],
+ metadata: { unrecognizedKey: 'ignored' },
+ always: []
+ })
+
+ expect(event?.payload.toolName).toBe('some_mcp_tool')
+ expect(event?.payload.toolInput).toBe('src/a.ts, src/b.ts')
+ })
+
+ it.each(SOURCES)(
+ 'still reports waiting when the payload names no permission for %s',
+ (source) => {
+ // Why: an unrecognized/absent permission must not downgrade the blocked state — the
+ // user still has to answer, they just get less detail.
+ const event = permissionEvent(source, { id: 'per_06', sessionID: 'ses_root' })
+
+ expect(event?.payload.state).toBe('waiting')
+ expect(event?.payload.toolName).toBeUndefined()
+ }
+ )
+
+ it.each(SOURCES)('leaves the AskUserQuestion route untouched for %s', (source) => {
+ const properties = { questions: [{ question: 'Choose', options: ['x', 'y'] }] }
+ const event = normalizeHookPayload(
+ state,
+ source,
+ {
+ paneKey: PANE_KEY,
+ payload: { hook_event_name: 'AskUserQuestion', ...properties }
+ },
+ 'production'
+ )
+
+ expect(event?.payload.state).toBe('waiting')
+ expect(event?.payload.interactivePrompt).toBe(JSON.stringify(properties))
+ })
+
+ it.each(SOURCES)('clears permission metadata once the turn resumes for %s', (source) => {
+ permissionEvent(source, BASH_PERMISSION)
+ const resumed = lifecycleEvent(source, 'SessionBusy')
+
+ // Why: a stale approval card must not outlive the approval — once work resumes the
+ // row is working again and the blocked command is no longer what the pane is on.
+ expect(resumed?.payload.state).toBe('working')
+ expect(resumed?.payload.toolName).toBeUndefined()
+ expect(resumed?.payload.toolInput).toBeUndefined()
+ })
+
+ it.each(SOURCES)('retires the permission when the session goes idle for %s', (source) => {
+ permissionEvent(source, BASH_PERMISSION)
+ const idle = lifecycleEvent(source, 'SessionIdle')
+
+ expect(idle?.payload.state).toBe('done')
+ expect(idle?.payload.toolName).toBeUndefined()
+ expect(idle?.payload.toolInput).toBeUndefined()
+ })
+
+ it.each(SOURCES)(
+ 'does not label a later question with the answered permission for %s',
+ (source) => {
+ permissionEvent(source, BASH_PERMISSION)
+ const question = lifecycleEvent(source, 'AskUserQuestion', {
+ questions: [{ question: 'Choose', options: ['x', 'y'] }]
+ })
+
+ expect(question?.payload.state).toBe('waiting')
+ expect(question?.payload.toolName).toBeUndefined()
+ expect(question?.payload.toolInput).toBeUndefined()
+ }
+ )
+
+ it.each(SOURCES)('does not attribute the answered permission to a reply for %s', (source) => {
+ permissionEvent(source, BASH_PERMISSION)
+ const part = lifecycleEvent(source, 'MessagePart', { role: 'assistant', text: 'Ran it.' })
+
+ expect(part?.payload.lastAssistantMessage).toBe('Ran it.')
+ expect(part?.payload.toolName).toBeUndefined()
+ expect(part?.payload.toolInput).toBeUndefined()
+ })
+
+ it.each(SOURCES)('does not carry an answered permission into a later wait for %s', (source) => {
+ // Why: the row now shows tool fields on 'waiting' as well as 'working' (that is the whole
+ // point of STA-3160), so a later non-permission wait must arrive with none — otherwise
+ // relaxing the row gate turns an answered command into the question's caption.
+ permissionEvent(source, BASH_PERMISSION)
+ lifecycleEvent(source, 'SessionBusy')
+ lifecycleEvent(source, 'SessionIdle')
+ const laterQuestion = lifecycleEvent(source, 'AskUserQuestion', {
+ questions: [{ question: 'Which branch?', options: ['main', 'dev'] }]
+ })
+
+ expect(laterQuestion?.payload.state).toBe('waiting')
+ expect(laterQuestion?.payload.toolInput).toBeUndefined()
+ expect(laterQuestion?.payload.toolName).toBeUndefined()
+ })
+
+ it.each(SOURCES)('does not carry an answered permission into a later turn for %s', (source) => {
+ // Why: isNewTurnEvent is false for this family, so nothing else ever resets the cached
+ // tool. Without an explicit retire, one permission pins its command to every later
+ // working frame in the pane — the exact stale-tool-line the row gate guards against.
+ permissionEvent(source, BASH_PERMISSION)
+ lifecycleEvent(source, 'SessionBusy')
+ lifecycleEvent(source, 'SessionIdle')
+ const laterTurn = lifecycleEvent(source, 'SessionBusy')
+
+ expect(laterTurn?.payload.state).toBe('working')
+ // Why: assert the command first — it is the string a user would misread as live work.
+ expect(laterTurn?.payload.toolInput).toBeUndefined()
+ expect(laterTurn?.payload.toolName).toBeUndefined()
+ })
+})
From 15efc87e35c5e00b3ba3922ef9e9bc55769599a0 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Mon, 17 Aug 2026 16:38:59 -0700
Subject: [PATCH 02/43] fix(agent-hooks): bind agent status to the pane its
session was spawned into (STA-2069) (#14615)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(agent-hooks): bind agent status to the pane the session was spawned into (STA-2069)
Claude Code >= 2.1.206 hosts TUI sessions as workers under a shared daemon,
and the daemon forwards only its own allowlisted env — so hook posts carry
whichever pane first started the daemon, not the pane the user is in.
Pin a minted --session-id at spawn where Orca still knows the pane, record
sessionId -> pane, and correct the posted key at both hook ingest seams.
Co-authored-by: Brian Dai <43929761+BrianDai22@users.noreply.github.com>
* fix(agent-hooks): pin the session id in root-option position, not appended
Appending `--session-id ` broke every `claude ` launch:
`--session-id` is a ROOT option, so `claude mcp list --session-id `
exits with "error: unknown option '--session-id'". Splice it immediately
after the executable token instead, which is valid for both a bare session
and a subcommand, and is already before claude's own `--` terminator.
Also write the binding-key separator as an escape rather than a raw
NUL byte, which made the file a binary blob in git.
Close three hunks that no test could fail on: the pty.ts spawn call site
that records the binding, the relay seam's worktreeId override, and the
already-correct-pane early-return that suppresses a worktree restamp.
---------
Co-authored-by: Brian Dai <43929761+BrianDai22@users.noreply.github.com>
---
.../agent-session-pane-bindings.test.ts | 66 +++++
.../agent-session-pane-bindings.ts | 84 ++++++
...ver-agent-session-pane-attribution.test.ts | 247 ++++++++++++++++++
src/main/agent-hooks/server.ts | 94 ++++++-
src/main/ipc/pty-ipc-mock-registry.ts | 6 +-
src/main/ipc/pty-ipc-suite-environment.ts | 4 +
.../pty-login-shell-startup-commands.test.ts | 58 +++-
src/main/ipc/pty.ts | 71 ++++-
src/main/pty/spawn-startup-shell.test.ts | 57 ++++
src/main/pty/spawn-startup-shell.ts | 25 ++
src/shared/agent-hook-listener.ts | 38 +++
src/shared/agent-resume-launch-command.ts | 66 +----
src/shared/claude-launch-executable-token.ts | 38 +++
.../claude-session-pin-launch-command.test.ts | 104 ++++++++
.../claude-session-pin-launch-command.ts | 86 ++++++
src/shared/tui-agent-startup-shell.ts | 39 +++
16 files changed, 1011 insertions(+), 72 deletions(-)
create mode 100644 src/main/agent-hooks/agent-session-pane-bindings.test.ts
create mode 100644 src/main/agent-hooks/agent-session-pane-bindings.ts
create mode 100644 src/main/agent-hooks/server-agent-session-pane-attribution.test.ts
create mode 100644 src/main/pty/spawn-startup-shell.test.ts
create mode 100644 src/main/pty/spawn-startup-shell.ts
create mode 100644 src/shared/claude-launch-executable-token.ts
create mode 100644 src/shared/claude-session-pin-launch-command.test.ts
create mode 100644 src/shared/claude-session-pin-launch-command.ts
diff --git a/src/main/agent-hooks/agent-session-pane-bindings.test.ts b/src/main/agent-hooks/agent-session-pane-bindings.test.ts
new file mode 100644
index 00000000000..fa70d892325
--- /dev/null
+++ b/src/main/agent-hooks/agent-session-pane-bindings.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest'
+
+import { AgentSessionPaneBindings } from './agent-session-pane-bindings'
+import { makePaneKey } from '../../shared/stable-pane-id'
+
+const PANE_A = makePaneKey('tab-1', 'aaaaaaaa-1111-4111-8111-111111111111')
+const PANE_B = makePaneKey('tab-1', 'bbbbbbbb-2222-4222-8222-222222222222')
+
+describe('AgentSessionPaneBindings', () => {
+ it('resolves only the source it was bound under', () => {
+ const bindings = new AgentSessionPaneBindings()
+ bindings.bind('claude', 'sess-1', { paneKey: PANE_A, ptyId: 'pty-1' })
+
+ expect(bindings.resolve('claude', 'sess-1')?.paneKey).toBe(PANE_A)
+ expect(bindings.resolve('codex', 'sess-1')).toBeNull()
+ expect(bindings.resolve('claude', 'sess-2')).toBeNull()
+ expect(bindings.resolve('claude', null)).toBeNull()
+ })
+
+ it('follows a session that re-binds to another pane', () => {
+ const bindings = new AgentSessionPaneBindings()
+ bindings.bind('claude', 'sess-1', { paneKey: PANE_A, ptyId: 'pty-1' })
+ bindings.bind('claude', 'sess-1', { paneKey: PANE_B, ptyId: 'pty-2' })
+
+ expect(bindings.resolve('claude', 'sess-1')?.paneKey).toBe(PANE_B)
+ expect(bindings.size()).toBe(1)
+ })
+
+ it('drops every binding a dying PTY established, and only those', () => {
+ const bindings = new AgentSessionPaneBindings()
+ bindings.bind('claude', 'sess-1', { paneKey: PANE_A, ptyId: 'pty-1' })
+ bindings.bind('claude', 'sess-2', { paneKey: PANE_A, ptyId: 'pty-1' })
+ bindings.bind('claude', 'sess-3', { paneKey: PANE_B, ptyId: 'pty-2' })
+
+ bindings.clearForPty('pty-1')
+
+ expect(bindings.resolve('claude', 'sess-1')).toBeNull()
+ expect(bindings.resolve('claude', 'sess-2')).toBeNull()
+ expect(bindings.resolve('claude', 'sess-3')?.paneKey).toBe(PANE_B)
+ })
+
+ it('evicts least-recently-bound sessions past the cap, keeping a re-bound one alive', () => {
+ const bindings = new AgentSessionPaneBindings()
+ bindings.bind('claude', 'sess-0', { paneKey: PANE_A, ptyId: 'pty-0' })
+ for (let i = 1; i < 512; i += 1) {
+ bindings.bind('claude', `sess-${i}`, { paneKey: PANE_B, ptyId: `pty-${i}` })
+ }
+ // Touch the oldest so it is no longer the eviction candidate.
+ bindings.bind('claude', 'sess-0', { paneKey: PANE_A, ptyId: 'pty-0' })
+ bindings.bind('claude', 'sess-512', { paneKey: PANE_B, ptyId: 'pty-512' })
+
+ expect(bindings.size()).toBe(512)
+ expect(bindings.resolve('claude', 'sess-0')?.paneKey).toBe(PANE_A)
+ expect(bindings.resolve('claude', 'sess-1')).toBeNull()
+ expect(bindings.resolve('claude', 'sess-512')?.paneKey).toBe(PANE_B)
+ })
+
+ it('ignores incomplete bindings rather than storing an unroutable row', () => {
+ const bindings = new AgentSessionPaneBindings()
+ bindings.bind('claude', ' ', { paneKey: PANE_A, ptyId: 'pty-1' })
+ bindings.bind('claude', 'sess-1', { paneKey: '', ptyId: 'pty-1' })
+ bindings.bind('claude', 'sess-2', { paneKey: PANE_A, ptyId: '' })
+
+ expect(bindings.size()).toBe(0)
+ })
+})
diff --git a/src/main/agent-hooks/agent-session-pane-bindings.ts b/src/main/agent-hooks/agent-session-pane-bindings.ts
new file mode 100644
index 00000000000..df26b0a1a11
--- /dev/null
+++ b/src/main/agent-hooks/agent-session-pane-bindings.ts
@@ -0,0 +1,84 @@
+import type { AgentHookSource } from '../../shared/agent-hook-relay'
+
+export type AgentSessionPaneBinding = {
+ paneKey: string
+ /** The PTY whose spawn established this binding; the binding dies with it. */
+ ptyId: string
+ /** Why carried: the inherited env names the DAEMON's workspace too, so a
+ * corrected event would otherwise keep filing itself under the wrong one. */
+ worktreeId?: string
+}
+
+// Why: one entry per pinned session. Generous headroom over any realistic live
+// count while still bounding a caller that leaks registrations.
+const MAX_BINDINGS = 512
+
+/**
+ * Maps a provider session id to the pane Orca spawned it into.
+ *
+ * Why this layer exists: a pane's identity reaches an agent's hooks only
+ * through `ORCA_PANE_KEY` in the process environment, and a CLI that hosts
+ * sessions inside a shared prewarmed daemon runs its hooks in a worker that
+ * inherited the DAEMON's env — whichever pane happened to start the daemon,
+ * possibly days ago in another workspace. The posted pane key is then simply
+ * wrong, and the hook payload carries no other pane coordinate, so no
+ * listener-side heuristic can recover the right one. The binding is therefore
+ * established where Orca still knows both halves — at spawn, where it mints the
+ * session id and owns the pane — and consulted at ingest.
+ *
+ * Session-scoped rather than process-scoped on purpose: the daemon's worker is
+ * not the pane's PTY and can outlive it, so process ancestry proves nothing.
+ * Transport-agnostic for the same reason — a remote host's daemon inherits a
+ * stale key exactly like a local one, and both ingest seams resolve here.
+ */
+export class AgentSessionPaneBindings {
+ private bindings = new Map()
+
+ private static key(source: AgentHookSource, sessionId: string): string {
+ // Why: source-scoped so two CLIs cannot collide on a shared id namespace.
+ return `${source}\u0000${sessionId}`
+ }
+
+ bind(source: AgentHookSource, sessionId: string, binding: AgentSessionPaneBinding): void {
+ const sessionKey = sessionId.trim()
+ if (!sessionKey || !binding.paneKey || !binding.ptyId) {
+ return
+ }
+ const key = AgentSessionPaneBindings.key(source, sessionKey)
+ // Why: delete first so re-binding an existing session moves it to the end
+ // of the insertion order — eviction stays least-recently-bound.
+ this.bindings.delete(key)
+ this.bindings.set(key, binding)
+ while (this.bindings.size > MAX_BINDINGS) {
+ const oldest = this.bindings.keys().next().value
+ if (oldest === undefined) {
+ break
+ }
+ this.bindings.delete(oldest)
+ }
+ }
+
+ resolve(
+ source: AgentHookSource,
+ sessionId: string | null | undefined
+ ): AgentSessionPaneBinding | null {
+ if (!sessionId) {
+ return null
+ }
+ return this.bindings.get(AgentSessionPaneBindings.key(source, sessionId.trim())) ?? null
+ }
+
+ /** Drops every binding a dying PTY established, so a reused id space cannot
+ * re-route a later session onto a pane that no longer runs it. */
+ clearForPty(ptyId: string): void {
+ for (const [key, binding] of this.bindings) {
+ if (binding.ptyId === ptyId) {
+ this.bindings.delete(key)
+ }
+ }
+ }
+
+ size(): number {
+ return this.bindings.size
+ }
+}
diff --git a/src/main/agent-hooks/server-agent-session-pane-attribution.test.ts b/src/main/agent-hooks/server-agent-session-pane-attribution.test.ts
new file mode 100644
index 00000000000..b567ad22e59
--- /dev/null
+++ b/src/main/agent-hooks/server-agent-session-pane-attribution.test.ts
@@ -0,0 +1,247 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { AgentHookServer, isValidPaneKey, _internals } from './server'
+import {
+ createHookListenerState,
+ normalizeHookPayload,
+ readHookBodyProviderSessionId
+} from '../../shared/agent-hook-listener'
+import { makePaneKey } from '../../shared/stable-pane-id'
+
+vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
+vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
+
+// Why: split panes live in ONE tab — same tabId, different leaf. The daemon's
+// inherited key names a pane in a different workspace entirely.
+const SPLIT_LEAF_A = 'aaaaaaaa-1111-4111-8111-111111111111'
+const SPLIT_LEAF_B = 'bbbbbbbb-2222-4222-8222-222222222222'
+const DAEMON_LEAF = 'cccccccc-3333-4333-8333-333333333333'
+
+const SPLIT_PANE_A = makePaneKey('tab-split', SPLIT_LEAF_A)
+const SPLIT_PANE_B = makePaneKey('tab-split', SPLIT_LEAF_B)
+/** The pane that first spawned the shared daemon; every later worker inherits its env. */
+const DAEMON_PANE = makePaneKey('tab-daemon', DAEMON_LEAF)
+
+const SESSION_A = '0192a4b1-1111-4111-8111-aaaaaaaaaaaa'
+const SESSION_B = '0192a4b1-2222-4222-8222-bbbbbbbbbbbb'
+const UNPINNED_SESSION = '0192a4b1-9999-4999-8999-999999999999'
+
+function buildDaemonHostedBody(sessionId: string, prompt: string): Record {
+ return {
+ // The daemon worker's env, NOT the pane the user is typing in.
+ paneKey: DAEMON_PANE,
+ tabId: 'tab-daemon',
+ worktreeId: 'wt-daemon',
+ env: 'production',
+ payload: {
+ hook_event_name: 'UserPromptSubmit',
+ prompt,
+ session_id: sessionId
+ }
+ }
+}
+
+async function withServer(
+ run: (server: AgentHookServer, post: (body: unknown) => Promise) => Promise
+): Promise {
+ _internals.resetCachesForTests()
+ const server = new AgentHookServer()
+ await server.start({ env: 'production' })
+ try {
+ const env = server.buildPtyEnv()
+ const post = async (body: unknown): Promise => {
+ const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
+ },
+ body: JSON.stringify(body)
+ })
+ return response.status
+ }
+ await run(server, post)
+ } finally {
+ server.stop()
+ }
+}
+
+function panesInSnapshot(server: AgentHookServer): { paneKey: string; prompt?: string }[] {
+ return server
+ .getStatusSnapshot()
+ .map((entry) => ({ paneKey: entry.paneKey, prompt: entry.prompt }))
+}
+
+describe('shared-daemon pane attribution (local hook path)', () => {
+ it('routes a daemon-hosted turn to the pane the session was spawned into, not the inherited pane key', async () => {
+ await withServer(async (server, post) => {
+ server.bindAgentSessionPane('claude', SESSION_A, { paneKey: SPLIT_PANE_A, ptyId: 'pty-a' })
+
+ expect(await post(buildDaemonHostedBody(SESSION_A, 'ship the fix'))).toBe(204)
+
+ expect(panesInSnapshot(server)).toEqual([{ paneKey: SPLIT_PANE_A, prompt: 'ship the fix' }])
+ })
+ })
+
+ it('re-files a corrected event under the workspace it was spawned in, not the daemon’s', async () => {
+ await withServer(async (server, post) => {
+ server.bindAgentSessionPane('claude', SESSION_A, {
+ paneKey: SPLIT_PANE_A,
+ ptyId: 'pty-a',
+ worktreeId: 'wt-real'
+ })
+
+ expect(await post(buildDaemonHostedBody(SESSION_A, 'cross-workspace turn'))).toBe(204)
+
+ // 'wt-daemon' is the daemon's inherited workspace and must not survive.
+ expect(server.getStatusSnapshot()).toEqual([
+ expect.objectContaining({ paneKey: SPLIT_PANE_A, worktreeId: 'wt-real' })
+ ])
+ })
+ })
+
+ it('keeps two split panes in one tab on their own rows when both post the same inherited key', async () => {
+ await withServer(async (server, post) => {
+ server.bindAgentSessionPane('claude', SESSION_A, { paneKey: SPLIT_PANE_A, ptyId: 'pty-a' })
+ server.bindAgentSessionPane('claude', SESSION_B, { paneKey: SPLIT_PANE_B, ptyId: 'pty-b' })
+
+ expect(await post(buildDaemonHostedBody(SESSION_A, 'left pane work'))).toBe(204)
+ expect(await post(buildDaemonHostedBody(SESSION_B, 'right pane work'))).toBe(204)
+
+ // Without per-session binding both turns collapse onto DAEMON_PANE and the
+ // second prompt clobbers the first.
+ expect(panesInSnapshot(server).sort((a, b) => a.paneKey.localeCompare(b.paneKey))).toEqual([
+ { paneKey: SPLIT_PANE_A, prompt: 'left pane work' },
+ { paneKey: SPLIT_PANE_B, prompt: 'right pane work' }
+ ])
+ })
+ })
+
+ it('leaves an unpinned session on the pane it posted', async () => {
+ await withServer(async (server, post) => {
+ server.bindAgentSessionPane('claude', SESSION_A, { paneKey: SPLIT_PANE_A, ptyId: 'pty-a' })
+
+ expect(await post(buildDaemonHostedBody(UNPINNED_SESSION, 'typed claude'))).toBe(204)
+
+ // A hand-typed `claude` in a plain shell has no pin; today's behavior stands.
+ expect(panesInSnapshot(server)).toEqual([{ paneKey: DAEMON_PANE, prompt: 'typed claude' }])
+ })
+ })
+
+ it('stops re-routing once the pinning PTY exits', async () => {
+ await withServer(async (server, post) => {
+ server.bindAgentSessionPane('claude', SESSION_A, { paneKey: SPLIT_PANE_A, ptyId: 'pty-a' })
+ server.clearAgentSessionPaneBindingsForPty('pty-a')
+
+ expect(await post(buildDaemonHostedBody(SESSION_A, 'after pane closed'))).toBe(204)
+
+ expect(panesInSnapshot(server)).toEqual([
+ { paneKey: DAEMON_PANE, prompt: 'after pane closed' }
+ ])
+ })
+ })
+
+ it('does not let one agent binding capture another agent that reuses the session id', async () => {
+ await withServer(async (server, post) => {
+ // Same id, different hook source: bindings are source-scoped.
+ server.bindAgentSessionPane('codex', SESSION_A, { paneKey: SPLIT_PANE_A, ptyId: 'pty-a' })
+
+ expect(await post(buildDaemonHostedBody(SESSION_A, 'claude turn'))).toBe(204)
+
+ expect(panesInSnapshot(server)).toEqual([{ paneKey: DAEMON_PANE, prompt: 'claude turn' }])
+ })
+ })
+
+ it('refuses to bind a pane key the status pipeline cannot route', async () => {
+ // Why #14018 reported an unroutable "$$:L$$" key: any key that is not
+ // `:` is rejected at the hook boundary, so a binding must
+ // never be able to introduce one.
+ const remintedShape = '$$q7v2m9c4:L$$'
+ expect(isValidPaneKey(remintedShape)).toBe(false)
+
+ await withServer(async (server, post) => {
+ server.bindAgentSessionPane('claude', SESSION_A, { paneKey: remintedShape, ptyId: 'pty-a' })
+
+ expect(await post(buildDaemonHostedBody(SESSION_A, 'unroutable target'))).toBe(204)
+
+ expect(panesInSnapshot(server)).toEqual([
+ { paneKey: DAEMON_PANE, prompt: 'unroutable target' }
+ ])
+ })
+ })
+})
+
+describe('shared-daemon pane attribution (relay path)', () => {
+ it('routes a remote daemon-hosted turn to the spawn-pinned pane', () => {
+ _internals.resetCachesForTests()
+ const server = new AgentHookServer()
+ server.bindAgentSessionPane('claude', SESSION_A, {
+ paneKey: SPLIT_PANE_A,
+ ptyId: 'pty-remote',
+ worktreeId: 'wt-real'
+ })
+
+ const event = normalizeHookPayload(
+ createHookListenerState(),
+ 'claude',
+ buildDaemonHostedBody(SESSION_A, 'remote turn'),
+ 'production'
+ )
+ if (!event) {
+ throw new Error('normalizeHookPayload rejected a known-good relay fixture')
+ }
+ expect(event.providerSession?.id).toBe(SESSION_A)
+ server.ingestRemote({ ...event, source: 'claude' }, 'conn-1')
+
+ expect(panesInSnapshot(server)).toEqual([{ paneKey: SPLIT_PANE_A, prompt: 'remote turn' }])
+ // Why asserted here too: the relay seam has its own worktree override, and
+ // 'wt-daemon' is the workspace the stale env named.
+ expect(server.getStatusSnapshot()[0]?.worktreeId).toBe('wt-real')
+ })
+})
+
+describe('an already-correct pane key', () => {
+ it('is left alone rather than overwritten from the binding', async () => {
+ // Why this matters beyond an early-out: the binding also carries a
+ // worktreeId. A session that posts its own (correct) pane must not have its
+ // workspace restamped from a binding recorded at spawn time.
+ await withServer(async (server, post) => {
+ server.bindAgentSessionPane('claude', SESSION_A, {
+ paneKey: DAEMON_PANE,
+ ptyId: 'pty-a',
+ worktreeId: 'wt-at-spawn'
+ })
+
+ expect(await post(buildDaemonHostedBody(SESSION_A, 'posted its own pane'))).toBe(204)
+
+ expect(server.getStatusSnapshot()).toEqual([
+ expect.objectContaining({ paneKey: DAEMON_PANE, worktreeId: 'wt-daemon' })
+ ])
+ })
+ })
+})
+
+describe('readHookBodyProviderSessionId', () => {
+ it('reads the id from both the object and JSON-string payload forms', () => {
+ const body = buildDaemonHostedBody(SESSION_A, 'x')
+ expect(readHookBodyProviderSessionId('claude', body)).toBe(SESSION_A)
+ expect(
+ readHookBodyProviderSessionId('claude', { ...body, payload: JSON.stringify(body.payload) })
+ ).toBe(SESSION_A)
+ })
+
+ it('ignores a Codex child hook, whose session id belongs to the child not the pane', () => {
+ expect(
+ readHookBodyProviderSessionId('codex', {
+ paneKey: DAEMON_PANE,
+ payload: { session_id: SESSION_A, agent_id: 'child-1' }
+ })
+ ).toBeNull()
+ })
+
+ it('returns null for malformed payloads instead of throwing', () => {
+ expect(readHookBodyProviderSessionId('claude', null)).toBeNull()
+ expect(readHookBodyProviderSessionId('claude', { payload: '{not json' })).toBeNull()
+ expect(readHookBodyProviderSessionId('claude', { payload: 7 })).toBeNull()
+ })
+})
diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts
index b301e99d6df..7bb86a34142 100644
--- a/src/main/agent-hooks/server.ts
+++ b/src/main/agent-hooks/server.ts
@@ -32,6 +32,7 @@ import {
resolveCachedClaudeCompactOwnership,
resolveHookSource,
preparePendingGrokResultDiscovery,
+ readHookBodyProviderSessionId,
seedClaudeLeadTurnFromPersistedStatus,
seedClaudeSubagentRosterFromSnapshots,
seedCodexStateFromSnapshot,
@@ -91,6 +92,10 @@ import {
type AgentProviderSessionMetadata
} from '../../shared/agent-session-resume'
import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary'
+import {
+ AgentSessionPaneBindings,
+ type AgentSessionPaneBinding
+} from './agent-session-pane-bindings'
export type { AgentHookSource }
@@ -703,6 +708,9 @@ export class AgentHookServer {
})
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
private runtimeObservedStatusPaneKeys = new Set()
+ // Why: a daemon-hosted session's hooks post the daemon's inherited pane key.
+ // Spawn-time session pins are the only evidence of the true pane.
+ private agentSessionPaneBindings = new AgentSessionPaneBindings()
private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([])
private hydratedLaunchTokenHashByPaneKey = new Map()
private persistedAuthorityCommitmentsByPaneKey = new Map()
@@ -1959,6 +1967,66 @@ export class AgentHookServer {
return changed
}
+ /** Records the pane a freshly spawned agent session belongs to. Called from
+ * the spawn path, which is the last place both halves are known: the session
+ * id Orca minted into the launch command, and the pane it launched into. */
+ bindAgentSessionPane(
+ source: AgentHookSource,
+ sessionId: string,
+ binding: AgentSessionPaneBinding
+ ): void {
+ if (!isValidPaneKey(binding.paneKey)) {
+ return
+ }
+ this.agentSessionPaneBindings.bind(source, sessionId, binding)
+ }
+
+ clearAgentSessionPaneBindingsForPty(ptyId: string): void {
+ this.agentSessionPaneBindings.clearForPty(ptyId)
+ }
+
+ /** The pane a session was spawned into, when it differs from the key the
+ * event posted. Null means "no correction" — an unpinned session (a
+ * hand-typed `claude`, an agent Orca never launched) keeps today's behavior,
+ * so this can only ever move status ONTO a pane Orca itself started. */
+ private resolveBoundPaneOverride(
+ source: AgentHookSource,
+ sessionId: string | null,
+ postedPaneKey: unknown
+ ): AgentSessionPaneBinding | null {
+ const bound = this.agentSessionPaneBindings.resolve(source, sessionId)
+ if (!bound || bound.paneKey === postedPaneKey) {
+ return null
+ }
+ return bound
+ }
+
+ private normalizeHookBodyAgentSessionPane(source: AgentHookSource, body: unknown): unknown {
+ if (typeof body !== 'object' || body === null) {
+ return body
+ }
+ const record = body as Record
+ const bound = this.resolveBoundPaneOverride(
+ source,
+ readHookBodyProviderSessionId(source, body),
+ typeof record.paneKey === 'string' ? record.paneKey.trim() : ''
+ )
+ if (!bound) {
+ return body
+ }
+ // Why: the posted key came from the worker's inherited env; the spawn-time
+ // binding names the pane that actually owns this session. Rewrite tabId
+ // with it too, or normalizeHookPayload's tabId/paneKey agreement check
+ // rejects the event outright — and the worktree, since the same inherited
+ // env named the daemon's workspace.
+ return {
+ ...record,
+ paneKey: bound.paneKey,
+ tabId: parsePaneKey(bound.paneKey)?.tabId,
+ ...(bound.worktreeId ? { worktreeId: bound.worktreeId } : {})
+ }
+ }
+
private normalizeHookBodyPaneKeyAlias(body: unknown): unknown {
if (typeof body !== 'object' || body === null) {
return body
@@ -2147,7 +2215,18 @@ export class AgentHookServer {
}
// Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge.
const physicalPaneKey = envelope.paneKey.trim()
- const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
+ const source = isAgentHookSource(envelope.source) ? envelope.source : undefined
+ // Why: a remote host's shared agent daemon inherits a stale ORCA_PANE_KEY
+ // exactly like a local one, so SSH/WSL panes need the same spawn-time
+ // session binding, resolved before pane-migration aliasing applies on top.
+ const boundPane = source
+ ? this.resolveBoundPaneOverride(
+ source,
+ normalizeAgentProviderSession(envelope.providerSession)?.id ?? null,
+ physicalPaneKey
+ )
+ : null
+ const paneKey = this.resolvePaneKeyAlias(boundPane?.paneKey ?? physicalPaneKey)
const parsedPaneKey = parsePaneKey(paneKey)
if (paneKey.length === 0) {
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
@@ -2182,7 +2261,6 @@ export class AgentHookServer {
typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0
? envelope.hookEventName.trim()
: undefined
- const source = isAgentHookSource(envelope.source) ? envelope.source : undefined
const providerPromptId =
source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined
const compactTrigger =
@@ -2197,10 +2275,13 @@ export class AgentHookServer {
if (statusDisposition === 'suppress') {
return
}
+ // Why: a corrected pane brings its own workspace — the envelope's came from
+ // the same inherited env that named the wrong pane.
const worktreeId =
- envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
+ boundPane?.worktreeId ??
+ (envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
? envelope.worktreeId.trim()
- : undefined
+ : undefined)
const promptInteractionKey =
typeof envelope.promptInteractionKey === 'string' &&
envelope.promptInteractionKey.trim().length > 0
@@ -2399,7 +2480,10 @@ export class AgentHookServer {
}
trackEmptyPaneKeyHook(body)
- const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
+ // Why: session attribution runs first — it corrects an inherited key to
+ // the real pane, and pane-migration aliasing then applies on top of it.
+ const attributedBody = this.normalizeHookBodyAgentSessionPane(source, body)
+ const aliasedBody = this.normalizeHookBodyPaneKeyAlias(attributedBody)
const normalized = this.normalizeLocalHookPayload(source, aliasedBody)
const statusDisposition = normalized.event
? this.getAgentStatusDisposition(normalized.event.paneKey, {
diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts
index 4a1b48972be..c99c0e6f6a6 100644
--- a/src/main/ipc/pty-ipc-mock-registry.ts
+++ b/src/main/ipc/pty-ipc-mock-registry.ts
@@ -37,6 +37,8 @@ export const unregisterPtyMock: Mock = vi.fn()
export const setMigrationUnsupportedPtyMock: Mock = vi.fn()
export const clearMigrationUnsupportedPtyMock: Mock = vi.fn()
export const clearMigrationUnsupportedPtysForPaneKeyMock: Mock = vi.fn()
+export const bindAgentSessionPaneMock: Mock = vi.fn()
+export const clearAgentSessionPaneBindingsForPtyMock: Mock = vi.fn()
export const clearPaneKeyAliasesForPtyMock: Mock = vi.fn()
export const recordCodexPaneAccountMock: Mock = vi.fn()
export const forgetCodexPaneAccountMock: Mock = vi.fn()
@@ -117,7 +119,9 @@ export const agentHookServerModuleMock = () => ({
buildPtyEnv: buildAgentHookEnvMock,
clearPaneState: clearAgentHookPaneStateMock,
registerPaneKeyAlias: registerPaneKeyAliasMock,
- clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock
+ clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock,
+ bindAgentSessionPane: bindAgentSessionPaneMock,
+ clearAgentSessionPaneBindingsForPty: clearAgentSessionPaneBindingsForPtyMock
}
})
diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts
index d1be8000551..ddf9e1a4787 100644
--- a/src/main/ipc/pty-ipc-suite-environment.ts
+++ b/src/main/ipc/pty-ipc-suite-environment.ts
@@ -32,6 +32,8 @@ import {
setMigrationUnsupportedPtyMock,
clearMigrationUnsupportedPtyMock,
clearMigrationUnsupportedPtysForPaneKeyMock,
+ bindAgentSessionPaneMock,
+ clearAgentSessionPaneBindingsForPtyMock,
clearPaneKeyAliasesForPtyMock,
recordCodexPaneAccountMock,
forgetCodexPaneAccountMock,
@@ -123,6 +125,8 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment {
setMigrationUnsupportedPtyMock.mockReset()
clearMigrationUnsupportedPtyMock.mockReset()
clearMigrationUnsupportedPtysForPaneKeyMock.mockReset()
+ bindAgentSessionPaneMock.mockReset()
+ clearAgentSessionPaneBindingsForPtyMock.mockReset()
clearPaneKeyAliasesForPtyMock.mockReset()
recordCodexPaneAccountMock.mockReset()
forgetCodexPaneAccountMock.mockReset()
diff --git a/src/main/ipc/pty-login-shell-startup-commands.test.ts b/src/main/ipc/pty-login-shell-startup-commands.test.ts
index bd74321fe89..0c548093788 100644
--- a/src/main/ipc/pty-login-shell-startup-commands.test.ts
+++ b/src/main/ipc/pty-login-shell-startup-commands.test.ts
@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import {
loginPreflightExecFileMock,
spawnMock,
- openCodeBuildPtyEnvMock
+ openCodeBuildPtyEnvMock,
+ bindAgentSessionPaneMock
} from './pty-ipc-mock-registry'
import { posixOnlyIt } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
@@ -229,7 +230,60 @@ describe('registerPtyHandlers', () => {
mockProc.emitData('\x1b]133;A\x07% ')
await Promise.resolve()
vi.runAllTimers()
- expect(mockProc.proc.write).toHaveBeenCalledWith('claude\n')
+ // Why the pin: a Claude launch carries a minted --session-id so a
+ // daemon-hosted session's hooks can still be traced to this pane (#9236).
+ expect(mockProc.proc.write).toHaveBeenCalledTimes(1)
+ expect(mockProc.proc.write.mock.calls[0][0]).toMatch(
+ /^claude --session-id [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\n$/
+ )
+ } finally {
+ vi.useRealTimers()
+ }
+ }
+ )
+ posixOnlyIt(
+ 'binds the minted session id to the spawning pane, so hooks can be traced back to it',
+ async () => {
+ // Why this asserts the SPAWN side: the hook server's own tests bind by
+ // hand, so without this the wiring that actually records the pane —
+ // the only thing that makes the correction fire in production — is
+ // deletable with every suite still green.
+ vi.useFakeTimers()
+ const mockProc = createMockProc()
+ spawnMock.mockReturnValue(mockProc.proc)
+ const tabId = 'tab-1'
+ const leafId = 'aaaaaaaa-1111-4111-8111-111111111111'
+ const paneKey = `${tabId}:${leafId}`
+
+ try {
+ registerPtyHandlers(mainWindow as never)
+ await handlers.get('pty:spawn')!(null, {
+ cols: 80,
+ rows: 24,
+ cwd: '/tmp',
+ command: 'claude',
+ tabId,
+ leafId,
+ worktreeId: 'wt-real',
+ env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: 'wt-real' }
+ })
+
+ mockProc.emitData('last login: today\r\n')
+ vi.runOnlyPendingTimers()
+ mockProc.emitData('\x1b]133;A\x07% ')
+ await Promise.resolve()
+ vi.runAllTimers()
+
+ const written = String(mockProc.proc.write.mock.calls[0][0])
+ const mintedId = /--session-id ([0-9a-f-]{36})/.exec(written)?.[1]
+ expect(mintedId).toBeDefined()
+ // The id on the command line and the id recorded against the pane must
+ // be the same one, or the correction can never resolve.
+ expect(bindAgentSessionPaneMock).toHaveBeenCalledWith(
+ 'claude',
+ mintedId,
+ expect.objectContaining({ paneKey, worktreeId: 'wt-real' })
+ )
} finally {
vi.useRealTimers()
}
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index 3c3e148ffb2..0414cd514fa 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -118,6 +118,9 @@ import {
markClaudePtyExited,
markClaudePtySpawned
} from '../claude-accounts/live-pty-gate'
+import { pinClaudeLaunchSessionId } from '../../shared/claude-session-pin-launch-command'
+import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
+import { resolveSpawnStartupShell } from '../pty/spawn-startup-shell'
import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim'
import {
isLegacyTerminalShimPathEntry,
@@ -1996,6 +1999,31 @@ function isClaudeLaunchCommand(command: string | undefined): boolean {
)
}
+type ClaudeSessionPinPlan = { command: string | undefined; sessionId: string | null }
+
+/** Mints a session id into a Claude launch so hook events can be traced back to
+ * the pane Orca spawned them into.
+ *
+ * Why: Claude Code >= 2.1.206 hosts TUI sessions as workers under a shared
+ * daemon, and the daemon forwards only its own allowlisted env — so a worker's
+ * `ORCA_PANE_KEY` names whichever pane first started the daemon, not the pane
+ * the user is looking at (#9236). The session id is the only pane-independent
+ * identity a hook payload carries, so binding it here is what lets the hook
+ * server route status to the true pane. Pinning is best-effort by design: a
+ * command it cannot splice safely is left untouched and keeps today's behavior.
+ */
+function planClaudeSessionPin(
+ command: string | undefined,
+ shell: AgentStartupShell
+): ClaudeSessionPinPlan {
+ if (command === undefined || !isClaudeLaunchCommand(command)) {
+ return { command, sessionId: null }
+ }
+ const sessionId = randomUUID()
+ const pinned = pinClaudeLaunchSessionId(command, sessionId, shell)
+ return pinned ? { command: pinned, sessionId } : { command, sessionId: null }
+}
+
function routesFreshSpawnsToLocalProvider(provider: IPtyProvider): boolean {
return provider.routesFreshSpawnsToLocalProvider === true
}
@@ -2123,6 +2151,9 @@ export function clearProviderPtyState(
piTitlebarExtensionService.clearPty(id)
// Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards.
markClaudePtyExited(id)
+ // Why: a dead PTY's session pin must not re-route a later session onto a pane
+ // it no longer runs.
+ agentHookServer.clearAgentSessionPaneBindingsForPty(id)
ptySizes.delete(id)
ptyIncarnationById.delete(id)
lastInputAtByPty.delete(id)
@@ -4653,7 +4684,18 @@ export function registerPtyHandlers(
const codexResumeHome = codexResumeLaunch.codexResumeHome
// Why: the drop still applies here, but this controller's result has no field for
// notifyResumeUnavailable — runtime/relay panes start fresh without the notice.
- const launchCommand = codexResumeLaunch.command
+ // Why not gated on isClaudeLaunch: that flag is local-only (it guards
+ // account switching); a remote host's daemon inherits a stale pane key too.
+ const claudeSessionPin = planClaudeSessionPin(
+ codexResumeLaunch.command,
+ resolveSpawnStartupShell({
+ connectionId: args.connectionId,
+ windowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro,
+ shellOverride: daemonShellOverride,
+ platform: process.platform
+ })
+ )
+ const launchCommand = claudeSessionPin.command
const claudeAuth =
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
@@ -5423,6 +5465,13 @@ export function registerPtyHandlers(
}
// Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup.
const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY)
+ if (claudeSessionPin.sessionId && paneKey) {
+ agentHookServer.bindAgentSessionPane('claude', claudeSessionPin.sessionId, {
+ paneKey,
+ ptyId: result.id,
+ worktreeId: args.worktreeId
+ })
+ }
const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined
const inheritRendererReadiness =
result.isReattach === true &&
@@ -6438,7 +6487,18 @@ export function registerPtyHandlers(
? await resolveCodexResumeLaunch(args.command, codexResumePreparation)
: noCodexResumeLaunch(preAdoptedStablePane ? undefined : args.command)
const codexResumeHome = codexResumeLaunch.codexResumeHome
- const launchCommand = codexResumeLaunch.command
+ // Why not gated on isClaudeLaunch: that flag is local-only (it guards
+ // account switching); a remote host's daemon inherits a stale pane key too.
+ const claudeSessionPin = planClaudeSessionPin(
+ codexResumeLaunch.command,
+ resolveSpawnStartupShell({
+ connectionId: args.connectionId,
+ windowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro,
+ shellOverride: initialShellOverride,
+ platform: process.platform
+ })
+ )
+ const launchCommand = claudeSessionPin.command
baseEnv = stripSequencedStartupResumeArgv(baseEnv, codexResumeLaunch)
// Why: declared after the strip so a local-provider spawn cannot capture the
// pre-strip env — only the daemon branch below re-derives this from baseEnv.
@@ -7090,6 +7150,13 @@ export function registerPtyHandlers(
const rememberedPaneKey = validatedPaneKey
? rememberPaneKeyForPty(result.id, validatedPaneKey)
: null
+ if (claudeSessionPin.sessionId && rememberedPaneKey) {
+ agentHookServer.bindAgentSessionPane('claude', claudeSessionPin.sessionId, {
+ paneKey: rememberedPaneKey,
+ ptyId: result.id,
+ worktreeId: args.worktreeId
+ })
+ }
if (legacySpawnPaneKey && migrationUnsupportedPaneKey) {
agentHookServer.registerPaneKeyAlias(
legacySpawnPaneKey.paneKey,
diff --git a/src/main/pty/spawn-startup-shell.test.ts b/src/main/pty/spawn-startup-shell.test.ts
new file mode 100644
index 00000000000..a819dceab17
--- /dev/null
+++ b/src/main/pty/spawn-startup-shell.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+
+import { resolveSpawnStartupShell } from './spawn-startup-shell'
+
+const base = {
+ connectionId: null,
+ windowsWslDistro: null,
+ shellOverride: undefined,
+ platform: 'darwin' as NodeJS.Platform
+}
+
+describe('resolveSpawnStartupShell', () => {
+ it('uses posix on macOS and Linux', () => {
+ expect(resolveSpawnStartupShell(base)).toBe('posix')
+ expect(resolveSpawnStartupShell({ ...base, platform: 'linux' })).toBe('posix')
+ })
+
+ it('uses posix for an SSH pane even when the client is on Windows', () => {
+ expect(resolveSpawnStartupShell({ ...base, platform: 'win32', connectionId: 'conn-1' })).toBe(
+ 'posix'
+ )
+ })
+
+ it('uses posix for a WSL pane on a Windows host', () => {
+ expect(
+ resolveSpawnStartupShell({ ...base, platform: 'win32', windowsWslDistro: 'Ubuntu' })
+ ).toBe('posix')
+ })
+
+ it('defaults a native Windows pane to powershell', () => {
+ expect(resolveSpawnStartupShell({ ...base, platform: 'win32' })).toBe('powershell')
+ expect(
+ resolveSpawnStartupShell({
+ ...base,
+ platform: 'win32',
+ shellOverride: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
+ })
+ ).toBe('powershell')
+ })
+
+ it('recognizes cmd and Git Bash overrides on Windows', () => {
+ expect(
+ resolveSpawnStartupShell({
+ ...base,
+ platform: 'win32',
+ shellOverride: 'C:\\Windows\\System32\\cmd.exe'
+ })
+ ).toBe('cmd')
+ expect(
+ resolveSpawnStartupShell({
+ ...base,
+ platform: 'win32',
+ shellOverride: 'C:\\Program Files\\Git\\bin\\bash.exe'
+ })
+ ).toBe('posix')
+ })
+})
diff --git a/src/main/pty/spawn-startup-shell.ts b/src/main/pty/spawn-startup-shell.ts
new file mode 100644
index 00000000000..9c9d49a5dc9
--- /dev/null
+++ b/src/main/pty/spawn-startup-shell.ts
@@ -0,0 +1,25 @@
+import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
+
+/** The dialect that parses a spawn's launch command.
+ *
+ * Why not just `process.platform`: a WSL pane and an SSH pane both run a POSIX
+ * shell while Orca itself is on Windows, and a Windows pane can be pointed at
+ * cmd or Git Bash instead of PowerShell. Callers that splice into the command
+ * text must model the shell that will actually parse it.
+ */
+export function resolveSpawnStartupShell(opts: {
+ connectionId: string | null | undefined
+ windowsWslDistro: string | null | undefined
+ shellOverride: string | undefined
+ platform: NodeJS.Platform
+}): AgentStartupShell {
+ if (opts.connectionId || opts.windowsWslDistro || opts.platform !== 'win32') {
+ return 'posix'
+ }
+ const base =
+ opts.shellOverride?.trim().replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? ''
+ if (/^(?:ba|z|k|da|)sh(?:\.exe)?$/.test(base)) {
+ return 'posix'
+ }
+ return base.startsWith('cmd') ? 'cmd' : 'powershell'
+}
diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts
index 61202afec0e..933e3de10d8 100644
--- a/src/shared/agent-hook-listener.ts
+++ b/src/shared/agent-hook-listener.ts
@@ -4349,6 +4349,44 @@ function readStringField(record: Record, key: string): string |
return trimmed.length > 0 ? trimmed : undefined
}
+/**
+ * The provider session id a raw hook body reports, read exactly as
+ * `normalizeHookPayload` reads it (same JSON guard, same per-source extractor,
+ * same Codex child carve-out).
+ *
+ * Why it is exposed: pane attribution has to run BEFORE normalization.
+ * `normalizeHookPayload` keys its prompt/compaction state machine on the posted
+ * pane key, so correcting the key afterwards would leave that state on the
+ * wrong pane. Callers that re-derive the session id themselves drift from this
+ * parser the moment a source changes shape.
+ */
+export function readHookBodyProviderSessionId(
+ source: AgentHookSource,
+ body: unknown
+): string | null {
+ if (typeof body !== 'object' || body === null) {
+ return null
+ }
+ const rawPayload = (body as Record).payload
+ let hookPayload: unknown = rawPayload
+ if (typeof rawPayload === 'string') {
+ try {
+ hookPayload = parseAgentHookJson(rawPayload)
+ } catch {
+ return null
+ }
+ }
+ if (typeof hookPayload !== 'object' || hookPayload === null) {
+ return null
+ }
+ const record = hookPayload as Record
+ // Why: Codex child hooks expose the child's session_id on the parent's pane.
+ if (source === 'codex' && readString(record, 'agent_id')) {
+ return null
+ }
+ return extractAgentProviderSession(source, record)?.id ?? null
+}
+
export function normalizeHookPayload(
state: HookListenerState,
source: AgentHookSource,
diff --git a/src/shared/agent-resume-launch-command.ts b/src/shared/agent-resume-launch-command.ts
index cda40878c28..a9a7ac5d5b0 100644
--- a/src/shared/agent-resume-launch-command.ts
+++ b/src/shared/agent-resume-launch-command.ts
@@ -1,6 +1,7 @@
import type { ResumableTuiAgent } from './agent-session-resume'
+import { findClaudeExecutableIndex } from './claude-launch-executable-token'
import {
- isPosixStartupShell,
+ isFullyModelableStartupCommand,
quoteStartupArg,
tokenizeStartupCommand,
type AgentStartupShell
@@ -20,40 +21,6 @@ function isClaudeResumeSelector(token: string): boolean {
return token === '-r' || token.startsWith('-r=') || token === '-c' || token.startsWith('-c=')
}
-function isClaudeExecutableToken(token: string): boolean {
- const base = token.split(/[\\/]/).pop() ?? ''
- return /^claude(\.(exe|cmd|bat|ps1))?$/i.test(base)
-}
-
-/** Accepts a claude token only in command position — index 0, right after a
- * wrapper's `--`, behind PowerShell's `&` call operator, or preceded solely by
- * NAME=value assignments — so an argument that merely ends in /claude (an ssh
- * key, a project dir) can never be mistaken for the executable. */
-function findClaudeExecutableIndex(tokens: readonly string[], shell: AgentStartupShell): number {
- let commandPosition = true
- for (let i = 0; i < tokens.length; i += 1) {
- const token = tokens[i]
- if (commandPosition) {
- if (isClaudeExecutableToken(token)) {
- return i
- }
- if (
- // Why: `NAME=value cmd` is sh-family syntax (fish included, 3.1+); on
- // cmd/PowerShell such a token is a bogus executable name, not a prefix.
- (isPosixStartupShell(shell) && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) ||
- (shell === 'powershell' && token === '&' && i === 0)
- ) {
- continue
- }
- commandPosition = false
- }
- if (token === '--') {
- commandPosition = true
- }
- }
- return -1
-}
-
/** Joins the resolved base command with the agent's resume argv. Claude goes
* through the selector guard below; other agents keep plain appending. */
export function buildAgentResumeLaunchCommand(
@@ -100,33 +67,8 @@ export function buildClaudeResumeLaunchCommand(
if (claudeIndex === -1) {
return appended
}
- // Why: any token the tokenizer cannot model for this shell — an operator,
- // comment, expansion, or cmd single-quoted region — means the splice could
- // cut live syntax or misread a literal as a selector. The whole base must
- // be modelable, including the executable itself; only PowerShell's leading
- // call operator is a known-safe divergent token.
- for (let i = 0; i <= tokens.length; i += 1) {
- const gapStart = i === 0 ? 0 : spans[i - 1].end
- const gapEnd = i === tokens.length ? baseCommand.length : spans[i].start
- if (!/^[ \t]*$/.test(baseCommand.slice(gapStart, gapEnd))) {
- return appended
- }
- if (i === tokens.length) {
- break
- }
- // Why: a bare `--%` makes PowerShell pass the rest of the line to the
- // child literally, so appended quoting would arrive as literal bytes. A
- // quoted `--%` can also stop parsing, but only before a parameter token,
- // where the base is already mangled with or without the guard.
- if (shell === 'powershell' && baseCommand.slice(spans[i].start, spans[i].end) === '--%') {
- return appended
- }
- if (spans[i].divergesFromShell) {
- const isCallOperator = shell === 'powershell' && i === 0 && tokens[i] === '&'
- if (!isCallOperator) {
- return appended
- }
- }
+ if (!isFullyModelableStartupCommand(baseCommand, tokens, spans, shell)) {
+ return appended
}
const cuts: { start: number; end: number }[] = []
let terminatorStart: number | null = null
diff --git a/src/shared/claude-launch-executable-token.ts b/src/shared/claude-launch-executable-token.ts
new file mode 100644
index 00000000000..664efd0d53f
--- /dev/null
+++ b/src/shared/claude-launch-executable-token.ts
@@ -0,0 +1,38 @@
+import { isPosixStartupShell, type AgentStartupShell } from './tui-agent-startup-shell'
+
+export function isClaudeExecutableToken(token: string): boolean {
+ const base = token.split(/[\\/]/).pop() ?? ''
+ return /^claude(\.(exe|cmd|bat|ps1))?$/i.test(base)
+}
+
+/** Accepts a claude token only in command position — index 0, right after a
+ * wrapper's `--`, behind PowerShell's `&` call operator, or preceded solely by
+ * NAME=value assignments — so an argument that merely ends in /claude (an ssh
+ * key, a project dir) can never be mistaken for the executable. */
+export function findClaudeExecutableIndex(
+ tokens: readonly string[],
+ shell: AgentStartupShell
+): number {
+ let commandPosition = true
+ for (let i = 0; i < tokens.length; i += 1) {
+ const token = tokens[i]
+ if (commandPosition) {
+ if (isClaudeExecutableToken(token)) {
+ return i
+ }
+ if (
+ // Why: `NAME=value cmd` is sh-family syntax (fish included, 3.1+); on
+ // cmd/PowerShell such a token is a bogus executable name, not a prefix.
+ (isPosixStartupShell(shell) && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) ||
+ (shell === 'powershell' && token === '&' && i === 0)
+ ) {
+ continue
+ }
+ commandPosition = false
+ }
+ if (token === '--') {
+ commandPosition = true
+ }
+ }
+ return -1
+}
diff --git a/src/shared/claude-session-pin-launch-command.test.ts b/src/shared/claude-session-pin-launch-command.test.ts
new file mode 100644
index 00000000000..16e310c608d
--- /dev/null
+++ b/src/shared/claude-session-pin-launch-command.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from 'vitest'
+
+import { pinClaudeLaunchSessionId } from './claude-session-pin-launch-command'
+
+const SESSION = '0192a4b1-1111-4111-8111-aaaaaaaaaaaa'
+
+describe('pinClaudeLaunchSessionId', () => {
+ it('pins a bare launch', () => {
+ expect(pinClaudeLaunchSessionId('claude', SESSION, 'posix')).toBe(
+ `claude --session-id ${SESSION}`
+ )
+ })
+
+ it('pins path-qualified and Windows executables', () => {
+ expect(pinClaudeLaunchSessionId('/usr/local/bin/claude --model opus', SESSION, 'posix')).toBe(
+ `/usr/local/bin/claude --session-id ${SESSION} --model opus`
+ )
+ expect(pinClaudeLaunchSessionId('claude.cmd', SESSION, 'cmd')).toBe(
+ `claude.cmd --session-id ${SESSION}`
+ )
+ expect(pinClaudeLaunchSessionId('& claude', SESSION, 'powershell')).toBe(
+ `& claude --session-id ${SESSION}`
+ )
+ })
+
+ it('pins behind posix env assignments, which are still command position', () => {
+ expect(pinClaudeLaunchSessionId('FOO=1 claude', SESSION, 'posix')).toBe(
+ `FOO=1 claude --session-id ${SESSION}`
+ )
+ })
+
+ it('keeps the pin in option position, before claude’s own -- terminator', () => {
+ expect(pinClaudeLaunchSessionId('claude -- --weird', SESSION, 'posix')).toBe(
+ `claude --session-id ${SESSION} -- --weird`
+ )
+ })
+
+ // Why: `--session-id` is a ROOT option. `claude mcp list --session-id `
+ // exits with "error: unknown option '--session-id'", so a trailing pin would
+ // break a launch that works today — the pin must precede the subcommand.
+ it.each([
+ ['mcp list', 'claude mcp list'],
+ ['doctor', 'claude doctor'],
+ ['update', 'claude update'],
+ ['setup-token', 'claude setup-token'],
+ ['install stable', 'claude install stable']
+ ])('pins ahead of the %s subcommand, never after it', (_label, command) => {
+ const rest = command.slice('claude'.length)
+ expect(pinClaudeLaunchSessionId(command, SESSION, 'posix')).toBe(
+ `claude --session-id ${SESSION}${rest}`
+ )
+ })
+
+ it('preserves the base bytes verbatim, including quoting', () => {
+ expect(pinClaudeLaunchSessionId('claude "fix the bug"', SESSION, 'posix')).toBe(
+ `claude --session-id ${SESSION} "fix the bug"`
+ )
+ })
+
+ it('leaves a selector that follows claude’s own -- terminator alone', () => {
+ // Why: past `--` the tokens are the child's argv, not claude flags, so they
+ // are not a competing selector and must not suppress the pin.
+ expect(pinClaudeLaunchSessionId('claude -- --resume abc', SESSION, 'posix')).toBe(
+ `claude --session-id ${SESSION} -- --resume abc`
+ )
+ })
+
+ it.each([
+ ['--session-id', `claude --session-id ${SESSION}`],
+ ['--session-id=', `claude --session-id=${SESSION}`],
+ ['--resume', 'claude --resume abc'],
+ ['--resume=', 'claude --resume=abc'],
+ ['--continue', 'claude --continue'],
+ ['--continue=', 'claude --continue=abc'],
+ ['--fork-session', 'claude --fork-session'],
+ ['-r', 'claude -r abc'],
+ ['-r=', 'claude -r=abc'],
+ ['-c', 'claude -c'],
+ ['-c=', 'claude -c=abc']
+ ])('refuses to compete with an existing %s selector', (_label, command) => {
+ expect(pinClaudeLaunchSessionId(command, SESSION, 'posix')).toBeNull()
+ })
+
+ it.each([
+ ['compound', 'claude && echo done'],
+ ['sequenced', 'claude; echo done'],
+ ['piped', 'claude | tee log'],
+ ['redirected', 'claude > out.txt'],
+ ['wrapper-launched', 'nvm exec claude'],
+ ['not claude at all', 'codex'],
+ ['a path that merely ends in claude', 'ssh -i ~/.ssh/claude host']
+ ])('refuses to pin a %s command', (_label, command) => {
+ expect(pinClaudeLaunchSessionId(command, SESSION, 'posix')).toBeNull()
+ })
+
+ it('refuses a PowerShell --% stop-parsing command', () => {
+ expect(pinClaudeLaunchSessionId('claude --% --raw', SESSION, 'powershell')).toBeNull()
+ })
+
+ it('refuses a session id that is not a UUID', () => {
+ expect(pinClaudeLaunchSessionId('claude', 'not-a-uuid', 'posix')).toBeNull()
+ expect(pinClaudeLaunchSessionId('claude', '', 'posix')).toBeNull()
+ })
+})
diff --git a/src/shared/claude-session-pin-launch-command.ts b/src/shared/claude-session-pin-launch-command.ts
new file mode 100644
index 00000000000..1d53d979d63
--- /dev/null
+++ b/src/shared/claude-session-pin-launch-command.ts
@@ -0,0 +1,86 @@
+import { findClaudeExecutableIndex } from './claude-launch-executable-token'
+import {
+ isFullyModelableStartupCommand,
+ tokenizeStartupCommand,
+ type AgentStartupShell
+} from './tui-agent-startup-shell'
+
+/** Every flag that makes the CLI choose its own session identity. If the user's
+ * command already carries one, Orca must not add a competing `--session-id`. */
+function isClaudeSessionSelector(token: string): boolean {
+ return (
+ token === '--session-id' ||
+ token.startsWith('--session-id=') ||
+ token === '--resume' ||
+ token.startsWith('--resume=') ||
+ token === '--continue' ||
+ token.startsWith('--continue=') ||
+ token === '--fork-session' ||
+ token === '-r' ||
+ token.startsWith('-r=') ||
+ token === '-c' ||
+ token.startsWith('-c=')
+ )
+}
+
+const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
+
+/**
+ * Splices `--session-id ` into a Claude launch so the host keeps a
+ * process-independent handle on the session it is about to start.
+ *
+ * Why this exists: Claude Code >= 2.1.206 hosts TUI sessions as workers under a
+ * shared daemon, and the daemon forwards only its own allowlisted env — so a
+ * pane's `ORCA_PANE_KEY` never reaches the worker that runs the hooks. The
+ * session id is the one identity that does survive into the hook payload, so
+ * pinning it at spawn is what lets the host recover the true pane later.
+ *
+ * Fails CLOSED — returns null rather than a best-effort command. Pinning is an
+ * attribution improvement, never a launch requirement, so any doubt about the
+ * command's shape (untokenizable, wrapper-launched, compound, redirected,
+ * already session-selecting) leaves the user's command byte-for-byte alone.
+ * Bytes outside the insertion point are always preserved verbatim; the command
+ * is spliced by source span and never re-quoted.
+ */
+export function pinClaudeLaunchSessionId(
+ baseCommand: string,
+ sessionId: string,
+ shell: AgentStartupShell
+): string | null {
+ if (!SESSION_ID_RE.test(sessionId)) {
+ return null
+ }
+ const tokenized = tokenizeStartupCommand(baseCommand, shell)
+ if (!tokenized.ok) {
+ return null
+ }
+ const { tokens, spans } = tokenized
+ const claudeIndex = findClaudeExecutableIndex(tokens, shell)
+ if (claudeIndex === -1) {
+ return null
+ }
+ // Why: modelability is what rules out compounds, pipelines and redirects —
+ // their operators tokenize as shell-divergent, so `claude && deploy` can
+ // never take a pin that would land on the wrong word.
+ if (!isFullyModelableStartupCommand(baseCommand, tokens, spans, shell)) {
+ return null
+ }
+ for (let i = claudeIndex + 1; i < tokens.length; i += 1) {
+ const token = tokens[i]
+ // Why: `--` is claude's own terminator, so anything after it is the child's
+ // argv, not a claude flag — a selector there is not ours to compete with.
+ if (token === '--') {
+ break
+ }
+ if (isClaudeSessionSelector(token)) {
+ return null
+ }
+ }
+ // Why immediately after the executable rather than appended: `--session-id` is
+ // a ROOT option, and `claude ` (`mcp list`, `doctor`, `update`)
+ // rejects unknown options, so a trailing pin makes those launches fail
+ // outright. Root position is also before claude's own `--` terminator, which
+ // a trailing pin would fall past.
+ const insertAt = spans[claudeIndex].end
+ return `${baseCommand.slice(0, insertAt)} --session-id ${sessionId}${baseCommand.slice(insertAt)}`
+}
diff --git a/src/shared/tui-agent-startup-shell.ts b/src/shared/tui-agent-startup-shell.ts
index 94df0f80ac5..d6dcd32ff33 100644
--- a/src/shared/tui-agent-startup-shell.ts
+++ b/src/shared/tui-agent-startup-shell.ts
@@ -162,6 +162,45 @@ export function tokenizeStartupCommand(
: tokenizeCustomCommandTemplate(value)
}
+/** True when every byte of `value` is accounted for by `tokens`/`spans` under
+ * `shell` — the precondition for splicing an argument in by source span.
+ *
+ * Why: any token the tokenizer cannot model for this shell — an operator,
+ * comment, expansion, or cmd single-quoted region — means a splice could cut
+ * live syntax or misread a literal as a flag. The whole command must be
+ * modelable, including the executable itself; only PowerShell's leading call
+ * operator is a known-safe divergent token. A bare `--%` makes PowerShell pass
+ * the rest of the line to the child literally, so spliced quoting would arrive
+ * as literal bytes.
+ */
+export function isFullyModelableStartupCommand(
+ value: string,
+ tokens: readonly string[],
+ spans: readonly CommandTokenSpan[],
+ shell: AgentStartupShell
+): boolean {
+ for (let i = 0; i <= tokens.length; i += 1) {
+ const gapStart = i === 0 ? 0 : spans[i - 1].end
+ const gapEnd = i === tokens.length ? value.length : spans[i].start
+ if (!/^[ \t]*$/.test(value.slice(gapStart, gapEnd))) {
+ return false
+ }
+ if (i === tokens.length) {
+ break
+ }
+ if (shell === 'powershell' && value.slice(spans[i].start, spans[i].end) === '--%') {
+ return false
+ }
+ if (spans[i].divergesFromShell) {
+ const isCallOperator = shell === 'powershell' && i === 0 && tokens[i] === '&'
+ if (!isCallOperator) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
export function resolveStartupShell(
platform: NodeJS.Platform,
shell?: AgentStartupShell
From 24e662adc1180abf23d8882733a170cfc8224894 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 17 Aug 2026 16:40:01 -0700
Subject: [PATCH 03/43] feat(ssh): verify host keys, and restore panes
correctly across a reconnect (#14844)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* docs(ssh): design for real host key verification (STA-4319)
Today's ssh2 verifier records a fingerprint and returns true — every host key is
accepted, with no known_hosts consult and no change detection anywhere in
src/main/ssh/. Scope is per-connection, so exec, SFTP, port forwarding, the
watcher and relay deploy all ride that one unverified handshake, and the
ProxyJump path puts the final hop — the topology most likely to cross untrusted
network — on ssh2 specifically.
Decisions worth calling out:
- Read the user's known_hosts as a trust source but NEVER write to it. That file
is shared with every other SSH tool on the machine; appending means line
endings, permissions, concurrent writers and a corruption blast radius well
beyond us. Accepted keys go to our own per-target store. Reading theirs is also
the entire migration story: most developers already have their hosts there.
- Mismatch is scoped to the SAME key type. A host with only an RSA entry that
presents ed25519 is unknown, not changed. ssh2 negotiates ed25519 first, so
without this we would fire a change-of-key alarm at nearly every existing user
on their first upgraded connect — training them to dismiss the one warning that
is supposed to mean something. Flagged in review as the decision I am least
sure of; a downgrade-vector argument against it is being tested.
- Changed key hard-fails with no override button; recovery is a separate explicit
action, offered only when OUR store is what disagreed, because forgetting our
record cannot unblock a known_hosts conflict.
- Background reconnects deny rather than prompt. A dialog the user cannot place
in context only teaches click-through.
Two traps are documented because either would make the fix silently do nothing:
an async verifier returns a Promise, which ssh2 reads as truthy and accepts
immediately; and the existing test mock invokes hostVerifier with one argument
and ignores the return, so it would pass against a verifier that never decides.
Design only — no behaviour change. The doc is added to the tracked-reference
allowlist in .gitignore alongside the other docs/reference entries.
* docs(ssh): revise the host key design after security and migration review
Three things the reviews changed, kept visible rather than quietly edited out.
THREAT MODEL WAS WRONG IN THREE PLACES. Jump hosts are not the worst case — they
are already safe: shouldUseSystemSshTransport branches on exactly the inputs
resolveEffectiveProxy does, and attemptConnect returns after the system probe, so
ProxyJump goes through OpenSSH and is verified. Agent forwarding was overstated
(gated on the user's ForwardAgent). Credential theft was understated: any auth
error counts as agent fallback, so a MITM walks the user to the password AND
private-key passphrase prompts, and cachedPassword replays without prompting. The
relay claim was backwards — the attacker owns their own machine; the real impact
is the return direction, where they become the host our workspace trusts.
TYPE SCOPING IS A DOWNGRADE VECTOR WITHOUT ALGORITHM ORDERING. This was the
decision I flagged as least certain and asked to have argued both ways. OpenSSH
is safe only because order_hostkeyalgs() puts known types first and RFC 4253
gives the client's order priority. ssh2 negotiates ed25519 first regardless, so
an attacker who cannot forge the RSA key on file just presents ed25519 and gets a
friendly first-contact prompt instead of a hard failure. Keep scoping, but set
algorithms.serverHostKey to lead with the types on file — and add a sixth
outcome for 'unknown type, known host', which must never read as first contact.
SHIP THE DEFENCE BEFORE THE DIALOG. Startup restore fires eager connects for all
targets in parallel with a 15s timeout while a prompt would live 120s; ephemeral
VM targets present a new key every launch; paired-web connects run on the host
desktop, so the dialog opens on someone else's screen. Phase 1 is therefore no
modal at all: consult known_hosts and our store, match connects, unknown persists
with accept-new semantics, mismatch and revoked hard-fail. That is the whole MITM
defence with none of the migration risk.
Also folded in, verified live against OpenSSH 10.2p1: the without-port fallback
(bracketed lookup first, then bare, where the second pass can only yield match or
unknown — otherwise a bare line plus a non-default port produces a spurious
prompt); hashed entries hash the candidate form; multiple files union; a
cert-authority line does not match a plain key. IPv6 and bracket parsing moved
INTO scope — that is a parser requirement, not a scope call, and getting it wrong
produces the prompt-training harm the design exists to avoid.
* feat(ssh): parse and match OpenSSH known_hosts
The matcher half of STA-4319. No behaviour change yet — nothing calls this.
Hand-rolled because no maintained JS implementation exists, and written against
behaviour observed from OpenSSH 10.2p1 rather than inferred from the man page.
Three of those behaviours a reasonable reading gets wrong:
- A non-default port is TWO ordered lookups, not one candidate set: '[host]:port'
first, then bare host ('checking without port identifier' in ssh -v). The
fallback pass can only yield match or unknown — OpenSSH downgrades a wrong key
there rather than reporting a change. Collapse them and anyone holding a bare
line who connects off-port gets a spurious first-contact result; treat the
fallback as authoritative and they get a false change-of-key alarm.
- Revocation resolves in its own pass so the verdict cannot depend on line order.
Verified both orderings.
- A cert-authority line never matches a plain host key; it only validates
certificates. A normal line alongside it still decides.
Mismatch is scoped to the same key type, and a host known by a DIFFERENT type
returns unknown-type-known-host rather than plain unknown — an attacker who
cannot forge the key on file must not get a friendly first-contact result by
presenting another type. That outcome is only half the defence; the other half
(leading serverHostKey with known types) lands with the wiring.
47 tests from vectors executed against real sshd, including ssh-keygen -H hashed
entries. Each of six mutations reddens it: collapsing the passes, letting the
fallback report mismatch, dropping type scoping, resolving revocation in line
order, honouring an unrecognised marker, and skipping the blob/type agreement
check.
* feat(ssh): decide what to do with a presented host key
The policy half of STA-4319, kept separate from the ssh2 wiring so it is testable
without a handshake and injected rather than importing its sources, so a test
states its own trust state instead of writing files.
Phase 1 ships no dialog — a test asserts the decision is never 'prompt'. Startup
restore opens every previously-active target at once, ephemeral VM targets would
ask every launch, and paired-web connects run on the host desktop where the
dialog would appear on someone else's screen.
Ordering that matters: revocation outranks everything including
StrictHostKeyChecking=no, because a revoked key is a statement that this key is
known-bad rather than merely unrecognised. known_hosts is named before our own
store on a change, because its remedy (ssh-keygen -R) is the one that also
unblocks ssh and git — pointing at a remedy that cannot work is worse than none.
Two carve-outs with reasons: an ephemeral runtime target accepts WITHOUT
recording, since a fresh VM presents a new key every launch and a stored record
would accumulate per launch and eventually read as a spurious change; and when
ssh -G ran on the HOME-divergent path that suppresses /etc/ssh/ssh_config, an
unknown host is denied, because a site-wide policy may forbid it and being laxer
than ssh is the one outcome that is never acceptable.
Rejection text deliberately avoids 'authentication failed' and 'permission
denied': the reconnect ladder classifies on those substrings, so a denial phrased
that way is retried forever against a decision that will never change. Pinned by
a test.
* feat(ssh): build the host key verifier and the algorithm order that makes it safe
Still not wired into the handshake — that lands next. This is the piece that
turns a decision into an ssh2 callback, plus the half of the design that is easy
to forget because it lives in a different config field.
The verifier MUST be a plain function returning undefined. ssh2 does
'const ret = verifier(key, verify); if (ret !== undefined) verify(ret)', so an
async function returns a Promise — neither undefined nor falsy — and ssh2 accepts
the key immediately while ignoring whatever the callback later decides. Making
this async would silently restore exactly the accept-everything behaviour the
module exists to remove, so a test asserts the return value is undefined.
orderServerHostKeyAlgorithms is what makes type-scoped matching safe rather than
a downgrade. RFC 4253 gives the client's algorithm order priority, so leading
with the types we already hold for a host denies a server the choice of
presenting some other type to convert a hard failure into first contact. Without
it, an attacker who cannot forge the key on file just offers a different
algorithm. Revoked entries never contribute to that order.
Also fails closed on two paths that would otherwise hang or over-trust: a key
whose own length-prefixed header cannot be read is refused rather than reasoned
about, and a throw from any dependency denies, because ssh2 may not catch an
exception raised inside the verifier and the handshake would hang instead of
failing.
18 tests. Includes the two negative cases that matter — first-contact keys are
recorded, but keys we already know, rejected keys, ephemeral runtime targets and
a lax StrictHostKeyChecking are not.
* fix(ssh): promote every RSA signature algorithm for a known ssh-rsa key
A known_hosts entry names the KEY type, which is not the negotiated ALGORITHM
name. One ssh-rsa key is offered as rsa-sha2-512, rsa-sha2-256 or ssh-rsa
depending on the signature algorithm, so matching the literal name only would
leave a host we know by RSA ordered behind ed25519 — precisely the ordering this
function exists to prevent, and precisely the population (RSA-era known_hosts
entries) it was written for.
Verified from ssh2's own negotiation while wiring this: kex.js iterates the
CLIENT list and takes the first entry the server also offers, so client order
does decide, as RFC 4253 says. ssh2's default order leads with ed25519 and places
the RSA algorithms fifth through seventh.
* fix(ssh): verify host keys instead of accepting every one (STA-4319)
The actual fix. ssh-connection's verifier recorded a fingerprint and returned
true, so every ssh2 connection accepted every host key — no known_hosts consult,
no change detection. It now consults the user's known_hosts plus our own store
and refuses a changed, revoked or unverifiable key.
Phase 1 by design: no dialog. Unknown hosts are accepted and recorded
(accept-new semantics), because startup restore opens every previously-active
target at once, ephemeral VM targets present a new key each launch, and
paired-web connects run on the host desktop where a prompt would appear on
someone else's screen. The MITM defence lands now; the prompt is Phase 2.
Also sets algorithms.serverHostKey to lead with the types already known for the
host. Without it the type-scoped matching is a downgrade — an attacker who cannot
forge the key on file just presents another type and turns a hard failure into
first contact. Verified from ssh2's kex.js that the client list decides.
Denial replaces ssh2's generic handshake error with the specific reason, because
the reconnect ladder cannot distinguish a generic failure from a transient fault
and would retry forever against a decision that will never change.
An unreadable trust store degrades to known_hosts only rather than failing the
connect: a changed key is still refused, and a host trusted only by us falls back
to first contact and is re-recorded, reaching the same decision.
The ssh2 mock now uses the callback form and aborts the handshake on denial. As
written it called hostVerifier(key) with one argument and ignored the result, so
it would have passed against a verifier that never decides — flagged in the
design as a mock that had to change, not a test to quietly rewrite. Two new tests
pin the wiring rather than the module: an unidentifiable blob is refused, and a
well-formed key is accepted.
Note for review: commit 2d2a0880bac unintentionally swept in two modules built
concurrently (ssh-known-hosts-source, ssh-host-key-store) because I staged with
'git add -A'; its message describes only the verifier. Both are covered by their
own tests, but the attribution in that commit is wrong.
1461 SSH tests pass.
* fix(ssh): bind the host key store to the active profile at startup
Without this the store reports nothing trusted on every launch. Safe — known_hosts
still decides, and a host trusted only by us degrades to first contact and is
re-recorded — but it silently discarded our own accept records, so the store the
design calls for was not actually in use.
Bound beside the profile Store, since it is a sidecar of the same data file.
Also records why the paired-web carve-out the migration review asked for is NOT
implemented in Phase 1, rather than leaving it looking forgotten. That carve-out
exists to stop a web client waiting out the 120s prompt timeout — a hang only
reachable if a prompt exists, and Phase 1 has none, which the decision function
pins with a test asserting it never returns 'prompt'. An RPC connect therefore
behaves exactly like a local one. Adding a fail-fast path now would introduce a
failure mode for a hang that cannot occur; it becomes load-bearing when the
dialog lands and is listed under Phase 2.
Noted there for whoever builds Phase 2: runtime/rpc/methods/ssh.ts already
swallows the specific error and rethrows a generic one, so the host-key reason
will not reach a web user without a change there too.
Full unit suite: 52,352 pass. The 8 failures are the known environment baseline
(5 osc8, 2 IME) plus one browser-cookie suite-ordering flake that passes in
isolation — none in src/main/ssh, and none related to this change.
* fix(ssh): close two downgrades the implementation review found
Both were in my wiring, not the design, and two reviewers found the first
independently.
1. OUR STORE WAS TYPE-DOWNGRADABLE. The inline lookup filtered by key type first
and could only answer match/mismatch/unknown, so a record of a DIFFERENT type for
the same endpoint read as "unknown". A host learned on first contact — ed25519,
since ssh2 proposes it first — and absent from known_hosts could then be
impersonated by presenting RSA: both sources say unknown, so accept-and-remember,
silently. That is exactly the downgrade D3 says the design cannot ship without,
applied to the records we create ourselves. The store's own isTrusted already
computed the right answer and had no production caller. Stored types now also
feed the algorithm ordering, without which the guard is only half present.
2. WE KEYED ON THE ORCA LABEL, NOT THE DIALED HOST. "ssh -G" echoes its own
argument back as its hostname field when no Host block matches, so for a manual
target that field IS the Orca label — the one name D2 forbids keying on, and one
ssh never wrote. We consulted no entries at all, so an impersonated host read as
first contact. Now keys on the dialed host, which buildConnectConfig has already
resolved through HostName, with HostKeyAlias still winning.
That inverted an existing test rather than deleting it: "uses the resolved
hostname, never the Orca label" encoded an assumption disproved against OpenSSH
10.2p1, so it is renamed and reversed with the reason recorded in the test.
3. NO READABLE SOURCE IS NOT FIRST CONTACT. Every known_hosts file failing to
read was indistinguishable from "this host is unknown", so a changed key would be
accepted the one time we could not check. The loader now reports how many files
it could read, and zero readable sources with an empty store takes the strict
path instead of recording trust.
4. A superseded attempt's rejection could replace the live attempt's error, and
substituting a new Error drops ssh2's code, so a transient ECONNRESET would stop
being classified as retryable. The rejection is now local to its attempt.
5. displayHost was the Orca label, so a mismatch could print
"ssh-keygen -R " — a remedy that removes nothing.
Also adds the tests that would have caught 1 and 2, the stale-attempt denial, and
IPv6 literals, which the design moved into scope and nothing covered.
1,469 SSH tests pass.
* fix(ssh): stop offering credentials to a host we just refused
A refused host key ended the handshake and then fell into the credential
ladder, because ssh2 reports a denied key as a generic auth failure and the
passphrase branch is eligible on message shape alone whenever an encrypted
identity file is configured. So the sequence was: decide this host may not be
who it claims to be, then ask the user for their passphrase and hand it to it.
Failing that, prompt for a password. Failing that, retry over the system ssh
binary, which for a disagreement with our own store rather than known_hosts
would simply connect.
That inverts the point of checking at all. A denied key is now final for the
attempt: recognised by type before any fallback runs, and again inside the
agent-fallback retry, which re-runs the handshake and so can be the attempt that
denies.
Two things had to change for that to hold.
The rejection is now a HostKeyVerificationError rather than a rebuilt Error, so
the connect path recognises it by type. Substring matching would have worked
today and quietly stopped working the first time a reason string was reworded —
and these strings are already worded around the auth-error classifier, so they
are exactly the kind that get edited.
And the verifier now reports the denials that skipped the policy: an
unreadable key blob and an internal failure both denied without calling
onDecision, so the connect path saw only ssh2's generic failure and walked the
ladder. That was the actual path the new test hit first. The report carries no
fingerprint, since there is no host key to identify, and the connection no
longer overwrites the fingerprint it holds with an empty one — the relay keys
install-lock isolation on that value.
The reconnect ladder also refuses to retry it. Its classifier is otherwise
substring-driven, so a reason containing "connection reset" would have been
retried until the ladder gave up, burying the reason under nine attempts.
Tests: no credential prompt after a refusal, an encrypted key configured so the
passphrase branch is eligible; refused reports as 'error', not 'auth-failed',
which would invite the user to re-enter credentials that are not the problem;
no retry; both bypassing denials report; a throwing listener still denies rather
than hanging the handshake.
Also drops three test files a bisect resurrected from before the revert that
deleted them.
172 tests pass across the three touched files; typecheck and lint clean.
Pre-existing on origin/main and untouched here: 3 failures in
ssh-connection-sftp-namespace.test.ts.
* fix(ssh): stop treating an absent known_hosts as a source we failed to read
The previous commit's "no readable source is not first contact" guard was right
about the danger and wrong about how to detect it, and the version that shipped
would have refused every connection a new profile ever makes.
A file that does not exist and a file that refuses to open both arrive as a
rejected readFile, and I counted them the same way. They are opposites. An
absent known_hosts is the normal state — ssh creates it on its own first connect,
and an Orca profile that has never connected has none — and it is real evidence
that no host is known. A file that exists and will not open is evidence withheld:
the entry that would have said "this key changed" may be sitting in it.
The default list is the reason this was fatal rather than obscure. It always
names known_hosts2, which essentially never exists, so on a machine with a normal
known_hosts the count was 1-of-2 and everything worked; on a fresh profile it was
0-of-2 and every connect failed with "the system SSH configuration could not be
read" — a message about a file the user does not have and an error they cannot
act on. The suite passed only because the machine running it happens to have a
known_hosts. Pointing HOME at an empty directory fails 62 connection tests, which
is what the new wiring test does.
So the count is now unreadableFileCount: files that exist and could not be read,
which is the condition the guard was always trying to express. Any one of them
takes the strict path; an absent or empty file takes none. The store clause is
gone with it — a store hit already returns accept before this is consulted, so it
never changed an outcome.
Tests: absent, empty, permission-denied, directory, and all-parsed at the source;
first contact with no known_hosts at all at the wiring level.
1,482 SSH tests pass. The 3 failures in ssh-connection-sftp-namespace.test.ts are
pre-existing on origin/main and untouched here.
* fix(ssh): let an ephemeral runtime outrank sources we could not read
Three things, all about the same question: when we cannot see everything that
decides a host key, what does that actually license us to refuse?
1. ON-DEMAND RUNTIMES WERE REFUSED FOR A POLICY THEY COULD NEVER SATISFY.
A machine provisioned a minute ago cannot be in known_hosts, by construction —
which is why it has a carve-out at all. But the carve-out sat BELOW the
incomplete-sources check, so anyone whose HOME diverges from their passwd home
(sandboxes, our own E2E isolation) took the `-F` path, and every on-demand
runtime connection was refused, pointing at a config file the user cannot fix.
Refusing there buys nothing. No policy, seen or unseen, is satisfiable by a host
that did not exist yesterday; the trust comes from the provisioning channel. So
the carve-out now outranks it. An EXPLICIT StrictHostKeyChecking=yes still wins
over both — that one we can read, and the user asked for it.
2. THE FLAG WAS NAMED FOR ONE OF ITS TWO MEANINGS.
`siteConfigSuppressed` started as "-F hid /etc/ssh/ssh_config" and had since
grown "a known_hosts file exists and would not open" — which is not a site
config, and reading the name in the decision function told you nothing about
why an unreadable file landed there. It is `verificationSourcesIncomplete` now:
we could not see something that decides this, so do not extend NEW trust. A host
we already know still connects, because a match is decided before this is
reached, and that is now pinned by name.
3. THE COPIED ssh2 ALGORITHM LIST HAD NO DRIFT ALARM.
We reorder ssh2's default host-key proposal, which meant hand-copying a list
ssh2 exports only from a deep internal path. ssh2 throws `Unsupported algorithm`
on anything outside its supported list, so drift does not degrade — every
target stops connecting, before a socket opens, with a message about an
algorithm the user never chose. Worth knowing that the list is also built
conditionally on ed25519 support.
Kept as a literal rather than a deep import, since silently adopting a new
proposal order is the wrong default — the order is what makes type-scoped
matching safe, so a change deserves review. A test now compares it against
ssh2's real constant and checks every entry is one ssh2 accepts. Moved next to
the ordering function it feeds, and out from between the import statements.
1,488 SSH tests pass. The 3 in ssh-connection-sftp-namespace.test.ts are
pre-existing on origin/main.
* docs(ssh): record what review changed and the STA-4319 follow-ups
The design survived implementation; every defect found afterwards was in the
wiring. Worth recording the pattern, because it repeated five times: each one
made us either blind or unusable, never subtly wrong — and four of the five
broke legitimate hosts rather than admitting bad ones.
Action items separate the things Phase 2 must decide (UpdateHostKeys, which is
now the likeliest way a legitimate user meets a rejection; the web client never
seeing the reason; RPC fail-fast) from the gaps Phase 1 knowingly accepts (WSL,
CheckHostIP, ca-only hosts, the hand-copied ssh2 algorithm list).
Also flags the rollout risk plainly: this is the first release in which Orca can
refuse an SSH connection at all.
* test(ssh): pin the ephemeral carve-out where it is actually observable
The first version of this test asserted an on-demand runtime connects on first
contact, which every target does — it would have passed with the carve-out
deleted. Pointing HOME at a home whose known_hosts exists and will not open makes
the two cases diverge: a normal target is refused there, an on-demand runtime is
not. Removing the carve-out now fails three tests instead of none.
Also covers the wiring for the unreadable-source refusal itself, which until now
was only pinned at the decision level.
* test(ssh): pin the parser against real ssh -G output, and accept-new against ask
Two gaps the audit named.
The ssh -G fixtures were all hand-written, which means they encode what I expect
ssh to print. This one is verbatim OpenSSH_10.2p1 output for a Host block using
HostName, HostKeyAlias, StrictHostKeyChecking accept-new, two UserKnownHostsFile
paths and a non-default port. The format detail that matters: each file list
arrives space-separated on ONE line, so reading it as a single path would consult
nothing for anyone with more than one file configured.
And accept-new was untested despite being a real OpenSSH value. It currently
behaves identically to ask, which is exactly right while no dialog exists and is
what lets the defence ship without a modal — so the equivalence is now pinned,
and Phase 2 has to break it deliberately rather than discover it. Plus a guard
that accept-new never falls into the strict branch.
* test(ssh): cover the wire from an accepted key to a record on disk
Nothing covered the store end to end. Every connection test runs with it
unwired — a real path, since it degrades to known_hosts only — so an accepted
first-contact key was never observed becoming a record, and the record was never
observed being believed on the next connection. Without that wire the store is
dead weight: an unknown host connects every time and is never learned.
Its own file for two reasons. initSshHostKeyStoreFile binds module-level state
for the rest of the process, and binding it makes the connect prelude do real
disk I/O — which the shared suite cannot absorb, because its reconnect tests
drive the clock with fake timers and an fs round trip does not complete inside an
advanced tick. Adding these there turned 12 unrelated tests red.
Covers: the record is written; it is read back as a match; the same key is not
recorded twice; a DIFFERENT key for a host we recorded ourselves is refused
(the store's entire security value, and a case known_hosts cannot catch since it
has never heard of the host); and an on-demand runtime records nothing.
Stubbing rememberHostKey to a no-op fails four of the five.
* fix(settings): stop truncating the SSH connection error to one line
The host key messages are written to be actionable — a mismatch ends in
"Run: ssh-keygen -R ", which is the remedy that also unblocks ssh and git.
The only place in the renderer that displays an SSH connection error clamped it
to a single line with `truncate` and carried no title attribute, so the remedy
was unreachable, not even on hover. The careful wording reached a CSS ellipsis.
Wraps instead, with [overflow-wrap:anywhere] because a long hostname offers no
break opportunity and would overflow the column on its own. The paragraph only
renders on failure, so the extra height costs nothing in the normal case.
CORRECTION to the four preceding commits: they each claimed 3 pre-existing
failures in ssh-connection-sftp-namespace.test.ts. That was my error — I had been
running `npx vitest` without `--config config/vitest.config.ts`, so the project's
setupFiles and execArgv were absent. Under the real config those 3 pass, and have
throughout. The whole SSH suite is green: 1,504 passed, 13 skipped.
Still failing on this branch and unrelated to it (different subsystems, no file
overlap): 5 in terminal-snapshot-osc8-roundtrip and 2 in browser-cookie-import.
* fix(terminal): show why the SSH connection failed, not just that it did
The reconnect overlay took only a status, so every failure rendered the same
sentence: "The SSH connection to devbox failed. Connect again to continue this
terminal session." A refused host key and a network timeout were indistinguishable
there, and the host key message — the only place that names the remedy, down to
`ssh-keygen -R ` — reached no terminal user at all. The state carried it the
whole way; the overlay simply never asked for it.
Adds it as a second line rather than replacing the sentence. The sentence says
what to do, the detail says what happened, and keeping both means an errno
failure does not lose its guidance to make room for "connect ETIMEDOUT". Wrapped,
for the same reason as the settings card: the remedy is at the end.
Suppressed for a removed target, which already explains itself and can never
reconnect — a stale connection error underneath would contradict it.
The new selector mirrors selectRuntimeAwareSshStatus branch for branch, including
the unreachable-environment and un-hydrated-bucket nulls, so the pair cannot
disagree about which source they read and a detail is never shown next to a status
it did not come from.
Known and NOT addressed here: "Connect again" is still the wrong advice for a
decision that will never change. Telling those apart needs a typed reason on the
wire rather than a string, which is a remote-wire-compatibility decision; it is
recorded in the STA-4319 follow-ups.
Pre-existing on this branch and untouched: 2 failures in
terminal-ime-xterm-resumed-preedit-visibility.
* docs(ssh): record where the rejection message actually lands
Traced end to end, because a rejection the user cannot read is a half-shipped
feature — and two of the surfaces were dropping it entirely, both now fixed.
What is left is written down rather than guessed at: the status bar renders only
'Error', the terminal overlay's call to action still invites a retry that cannot
succeed, toasts carry Electron's remote-method prefix, and the paired-web path
replaces the text with 'SSH connection unavailable' on every route — which also
affects a DESKTOP user viewing a host owned by a remote Orca server, not just web
clients.
* refactor(ssh): give the store one matcher instead of two
The connect path had its own copy of the store comparison, because ssh2's
verifier decides synchronously and cannot await the file, so records are
preloaded. That copy is precisely where the type downgrade came from: it answered
only match/mismatch/unknown, so a record of a different type for the same
endpoint read as first contact and a host learned on first contact could be
impersonated by presenting another key type. Fixing it left two implementations
that have to agree forever, which is the same bug waiting to happen.
matchTrustedHostKeys is now the single pure matcher; isTrusted is a load plus a
call to it, and the connect path calls it directly on preloaded records. Same for
the key types that feed the algorithm ordering, which the connect path was also
filtering by hand.
The two copies had in fact already drifted: the connect path lower-cased the
query host where the write trims AND lower-cases. Not reachable today — the host
is trimmed before it reaches there, which I confirmed by mutating the
normalisation and watching the wiring test pass anyway. So this is not a bug fix,
and the wiring-level test I first wrote for it proved nothing and is gone. The
unit test that replaces it drives the matcher directly, where the input is mine
to control, and it does fail when the normalisation diverges.
Also adds an equivalence test across all six outcomes between the preloaded and
awaited paths, so the two can never answer differently again.
Restores the local name siteConfigSuppressed for the `-F` check; it was renamed
along with the decision input, but at that site it really does mean only the one
thing, and the union with the unreadable-file count happens one line later.
1,509 SSH tests pass.
* test(ssh): check the matcher against a live OpenSSH client, not against my beliefs
Every other test in the parser file states what I believe ssh does. These state
what it did: an OpenSSH 10.2p1 client against a real sshd on 127.0.0.1:2222, with
the client's own verdict recorded from its output, and ssh-keygen -H's own salt
and hash pinned as a vector.
Two assumptions the design leans on were worth more than an argument.
THE FALLBACK PASS MAY NOT REPORT A CHANGE. We look up `[host]:port` first and
retry the bare host, and only the first pass may answer `mismatch`. With
StrictHostKeyChecking=accept-new, a bare line holding a DIFFERENT key, dialed on
2222, ssh connected and appended a new `[127.0.0.1]:2222` line — no
IDENTIFICATION HAS CHANGED banner. It read that as first contact. Had we reported
a change there we would refuse hosts ssh connects to happily, and the wrongness
would have been invisible: refusing looks like the cautious choice.
AND THE TYPE-SCOPING REJECTION IS NOT AN INVENTION. known_hosts holding an ssh-rsa
key while the server offers ed25519 makes ssh print IDENTIFICATION HAS CHANGED and
refuse. So unknown-type-known-host is neither stricter nor laxer than ssh —
treating it as first contact, which is what a naive type-scoped lookup does, is
the laxer mistake.
That second result also fixes the message. ssh is blocked too, so
`ssh-keygen -R ` is the remedy that unblocks both, and we were naming it
only for a same-type mismatch — leaving this case with a diagnosis and no way
out. Named now when known_hosts is the source that disagrees, and still not named
when it is our own store, which ssh-keygen would not touch.
1,516 SSH tests pass.
* docs(ssh): record the two assumptions a live client confirmed
Both were load-bearing and neither was obvious: the bare-host fallback pass may
not report a change, and unknown-type-known-host is what ssh itself does rather
than something we invented. Getting the first backwards would have refused hosts
ssh connects to happily, which is the failure mode that looks like caution.
* style(ssh): satisfy the code-quality lints in the two new test files
A string concatenation that should be a template literal, and an inline
import() type annotation that should be a type-only namespace import — erased
before vi.mock's hoisted factory runs, so the mock is unaffected.
pnpm lint is clean.
* fix(ssh): honour StrictHostKeyChecking, which had never once been read correctly
`ssh -G` does not echo the value the user wrote. StrictHostKeyChecking is
rendered through fmt_multistate_int, which prints the first entry of
multistate_strict_hostkey, and that table lists true/false before yes/no:
yes -> true | no -> false | off -> false | accept-new -> accept-new | ask -> ask
Verified against OpenSSH 10.2p1 from both a config file and -o. Not
10.2-specific; the table ordering is old.
The decision function tested only 'yes'/'always' and 'no'/'off' — spellings that
cannot arrive. So `StrictHostKeyChecking yes` fell through to the default branch
and we accepted AND PERSISTED a host the user's config explicitly says to refuse.
That is the worst outcome this feature can produce, and it was the behaviour for
every strict user from the first commit. `no`/`off` landed there too, breaking
the documented "lax settings never persist" invariant.
Every unit test passed throughout, because they fed the function 'yes' — the
value a human writes, not the one that reaches the code. My ssh -G parity test
did capture real output, but I happened to configure accept-new, one of only two
values that round-trip unchanged. The new table is keyed on configured value ->
what ssh -G actually prints, and asserts both reach the same verdict, so the
question "is this the spelling that arrives?" cannot be assumed again.
Found by a parity review against a live OpenSSH client.
* fix(ssh): stop the fallback pass accepting a changed key, and refusing a new one
One wrong loop scope, two opposite errors, both reproduced against a live
OpenSSH 10.2p1 client and an ed25519-only sshd on 127.0.0.1:2223.
ACCEPTING A CHANGED KEY. ssh runs the bare-host fallback only when the
port-qualified lookup matched no plain entry of ANY key type. We ran it unless
pass 0 produced a match or a SAME-TYPE mismatch. So with an off-port RSA entry
plus a bare, correct ed25519 line — an ordinary shape, an old off-port entry
beside one written by a port-22 connect — ssh printed IDENTIFICATION HAS CHANGED
and refused, with no "checking without port identifier" in -v because the
fallback never ran, while we reached the bare line and returned `match`.
REFUSING A NEW ONE. sawKnownHostOtherType and sawCertAuthority were declared
outside the pass loop, so an entry found only on the fallback pass could set
them. A bare ssh-rsa entry, dialed on a non-default port against an ed25519-only
server, made ssh add the host and connect — plain first contact — where we
returned unknown-type-known-host and hard-failed. That is Gitea/Forgejo, dev
containers, Gerrit, Vagrant: an off-port service on a host already in
known_hosts.
So the flags are per-pass now, and pass 0 decides as soon as it finds any plain
entry for the host. Which of the two rejections it reports only picks the
message; ssh calls both HOST_CHANGED.
Also drops the type check from the match test: byte equality already implies the
types agree, because the blob carries its own algorithm name and parsing rejects
any line whose declared type disagrees with it.
Reverting either half of the scope fix fails exactly the two new tests.
1,526 SSH tests pass.
* fix(ssh): refuse the known_hosts lines ssh itself refuses to parse
Three ways a line could be trusted by us and invisible to the user's own ssh —
or, worse, raise a CHANGED alarm from an entry ssh drops.
Buffer.from does not fail on bad base64, it SKIPS invalid characters, so
`!!!` and a blob with `@@` spliced into it both decoded to the correct key
and matched. Verified live against OpenSSH 10.2p1 on 127.0.0.1:2224: the
unmodified control reached authentication and all three malformed variants
produced "No ED25519 host key is known". A re-encode-and-compare makes us agree.
`AAAA` is the interesting one, and the reason the first fix was not enough:
68 characters plus 4 is still legal base64, and the algorithm header still reads
ssh-ed25519, so neither the base64 check nor the existing header check sees
anything wrong. ssh parses the whole key structure. We decoded 54 bytes where an
ed25519 key is 51 and reported `mismatch` — a man-in-the-middle warning caused by
a typo in a file ssh silently ignores.
So the blob is now walked as what it is: a run of length-prefixed fields that
must consume it exactly. Algorithm-agnostic on purpose, so a key type we do not
model is checked as well as one we do. It also rejects a length prefix that
overruns the buffer, which readHostKeyType only checked for the first field.
And ssh's extract_salt demands exactly one SHA1 digest — "expected salt len 20,
got 16" — where we accepted any non-empty salt. A short salt is still a usable
HMAC key for us, so a hand-crafted line could match for us and be a parse error
for ssh. ssh-keygen -H always writes 20 bytes, so refusing loses no real entry.
Worth recording that ssh-keygen -F cannot answer any of this: it matches host
names and prints lines without ever decoding the key, so it reports "found" for
all four blobs. The real client was the only instrument that worked.
Found by a parity review; the base64 finding as reported was right about the
behaviour and wrong about the mechanism for the padded case, which is what led to
the structural check.
1,533 SSH tests pass.
* fix(ssh): name a ssh-keygen -R target that actually removes the entry
Verified against OpenSSH 10.2p1: with both `[h.example]:2222` and `h.example`
on file, `ssh-keygen -R h.example` removes only the bare line and leaves the
bracketed one — and there is no port flag, `-R host -p 2222` is "Too many
arguments". An off-port target is keyed `[host]:port` in known_hosts, so the
command we printed removed nothing: the user runs it, reconnects, and meets the
identical failure with no indication of why.
The message now names the bracketed form, quoted because the brackets are shell
glob characters, whenever the port is not 22.
Found by a parity review.
* fix(ssh): read ssh2's host key algorithm list instead of copying it
ssh2 builds DEFAULT_SERVER_HOST_KEY at load time and prepends ssh-ed25519 only
when a RUNTIME PROBE succeeds — it signs and verifies with a fixed Ed25519 key.
On a build where that probe fails, ssh-ed25519 is absent from ssh2's SUPPORTED
list too, and generateAlgorithmList throws `Unsupported algorithm: ssh-ed25519`
from inside client.connect. That throw matches no retry classifier and no
transport-fallback classifier, so it is permanent — and because we only set
`algorithms` for hosts we already know, it would fire on trusted hosts while new
ones kept working. A copied list cannot be merely stale here; it can be wrong.
So it is read from ssh2 now, which also removes the drift risk the previous
commit could only report. ssh2 is external in the main bundle and the bundle is
CJS, so the deep path resolves at runtime from packaged node_modules.
The copy stays as a fallback in case a future ssh2 moves the file — losing the
proposal order degrades the type-scoping guarantee, but refusing to connect at
all is worse. The test that used to pin the copy against ssh2 now pins the
fallback, which is the only part that can still drift.
Found by an availability review.
pnpm lint clean; 1,537 SSH tests pass.
* fix(ssh): look a HostKeyAlias up the way ssh does — without the port
HostKeyAlias suppresses the port entirely. Verified against OpenSSH 10.2p1 on
port 2225 with HostKeyAlias=myalias: an entry keyed `myalias` authenticates, and
one keyed `[myalias]:2225` gives "No ED25519 host key is known for myalias". We
built [['[alias]:port'], ['alias']] and consulted a form ssh never writes.
On its own that was a stale-entry false alarm. The previous commit made it worse:
now that the first pass decides as soon as it finds any entry for the host, a
leftover `[alias]:2225` line STOPS the bare lookup ssh actually performs — so the
one population D2 cites HostKeyAlias for, bastions tunnelled through
localhost:port, would get a hard failure on a host ssh connects to.
So resolveKnownHostsLookupHost reports whether the name came from the alias, not
just what it is, and that flag reaches both the matcher and the algorithm
ordering. Returning the name alone is what made the bug invisible: the caller had
no way to know it was holding something that must not be bracketed.
Found by a parity review.
1,543 SSH tests pass.
* fix(ssh): only claim the site config was suppressed when ssh -G actually ran
sshGArgsForHost reports which arguments WOULD be used, not what happened. It
returns the -F form whenever ~/.ssh/config exists and os.homedir() diverges from
the passwd home, so a machine with no usable ssh at all — Windows without
OpenSSH, a restricted sandbox, a timed-out probe — was judged by whether it
happens to have a ~/.ssh/config, and rejected every unknown host permanently if
it did. The same broken machine WITHOUT one stayed fully permissive, which is the
tell: the flag is a claim about a config file we could not read, and when ssh
never ran there is no such claim to make.
Narrow but total where it lands: anything that sets HOME explicitly (wrapper
scripts, sudo -E, devcontainers), macOS mobile and network accounts, and the E2E
isolation this branch was written for.
Found by an availability review.
* fix(ssh): stop refusing hosts that ssh itself connects to
Two product decisions, both taken deliberately after a review priced their blast
radius, and both moving us from stricter-than-ssh to matching it.
CERTIFICATE-AUTHORITY HOSTS NO LONGER FAIL. The point of an SSH CA is that the
client holds ONE line — very often `@cert-authority *` — instead of per-host
entries. That line matches every candidate, so for a Teleport / Vault-SSH /
Smallstep / in-house-CA user EVERY target failed, not just CA-signed ones,
including on-demand runtime VMs, and StrictHostKeyChecking=no did not help. The
documented escape was an environment variable, which an Electron app launched
from the Dock or Start Menu never sees. Meanwhile OpenSSH, verified live, treats
a CA-covered host presenting a plain key as first contact and connects: ssh2
cannot validate certificates at all, so refusing bought nothing ssh was not
already giving up. The residual risk is real and accepted — for a CA-protected
host we take a plain key we cannot tie to the CA — and the ca-only outcome is
carried through the decision so it stays visible in the log.
AN UNREADABLE known_hosts NO LONGER REFUSES EVERYTHING. Any non-ENOENT read
error on any configured file rejected every unknown host, with a message blaming
the system SSH configuration, which was not what happened. The common trigger is
not exotic: a Windows OneDrive Known Folder Move placeholder while offline fails
with a cloud-file error, not ENOENT. It was also asymmetric with our own store,
which degrades an unreadable file to "nothing trusted" and connects. We now
connect as ssh does — it warns and treats the host as unknown — but record
NOTHING, so a first contact we could not check never becomes durable trust. That
second half is the reason the first is acceptable, so it is pinned end to end
with the store actually bound.
Which meant splitting verificationSourcesIncomplete back apart. It had been one
flag for two claims that now diverge: "a site policy may exist that we cannot
read" still refuses, "a file we could not open may contradict this" does not.
Merging them was what made the second inherit a strictness only the first
justified.
Both still lose to evidence we DID read: a mismatch, a revoked key, or an
explicit StrictHostKeyChecking still refuse in either state.
pnpm lint clean; 1,549 SSH tests pass.
* docs(ssh): correct the design where a live client disproved it
D2, D3 and D4 each stated something about OpenSSH that turned out to be wrong
when tested against a real client and sshd rather than read from the source.
D3's premise is the notable one: OpenSSH is not type-scoped at all, so it does
not avoid the RSA-era false alarm the way the doc claimed. It avoids the
situation via order_hostkeyalgs and hard-fails when the situation arises anyway.
The conclusion survives — the ordering is still what makes our scoping safe —
but for a different reason than the one written down, and a reader would have
drawn the wrong lesson.
D2 gains the two rules that actually bite: the entry condition to the fallback
pass, and HostKeyAlias suppressing the port. D4 records both reversals with their
reasoning and the residual risk each one accepts, and the ssh -G spelling trap
that made StrictHostKeyChecking dead on arrival.
Corrections are kept visible rather than edited out, per the note at the top of
the file.
* fix(ssh): repaint the panes after a reconnect, not just reattach them
Reported: disconnect an SSH host from the Remote Hosts popup, reconnect, and the
terminals come back blank — but resizing a split or toggling the sidebar makes
them render correctly.
That last detail is the diagnosis. The panes were never broken: reattach restores
each pane's buffer but not its painted frame. xterm repaints on a write or a
resize, and a reconnect produces neither for a pane that was already correctly
sized — so nothing paints until a relayout forces it, which is exactly what
resizing or toggling the sidebar does.
The renderer already has refitAndRefreshAllTerminalPanes for this shape ('after
bulk desktop restore, background panes may have correct cols/rows but a stale
xterm renderer until focus forces a repaint'). Its only callers were the mobile
fit-reclaim paths; the SSH reconnect path never used it.
Scheduled from finalizeHydratedTerminalPanes, on both a frame and a 100ms settled
pass — the same pattern the desktop-restore path uses, because rAF alone lands
while panes are still remounting.
Mutation-proved: removing the schedule reddens the new test, which is the
reported symptom.
* fix(ssh): repaint background-tab panes revealed after a reconnect
Completes 834a4950383, which only fixed the ACTIVE tab. Reported: split panes of
plain shells on another tab were still blank after reconnect until a divider drag
or a sidebar toggle.
The repaint did reach background managers — they stay mounted, only
rendererVisible flips — but it could not land. A tab-hidden pane measures as a
0-size box, so canMeasurePaneForFit bails and the fit is a no-op, and
refreshAllPanes marks rows dirty on a pane with no presented frame, which cannot
repair a grid the reattach's direct terminal.resize left diverged. The reveal
then takes the light resume path, which deliberately does not fit, and
scheduleRevealRepaint only reattaches WebGL. So nothing ever fixed the geometry —
and a divider drag or sidebar toggle is a real fit, which is why those appeared
to work.
Parks the repaint on a hidden manager and replays it on reveal, reusing the
existing reveal-fit machinery rather than adding a mechanism. Flag-gated so the
light path still does not fit in the ordinary case — 'does not fit on a light tab
reveal' stays green.
Splits are not special: the gap is per-manager, so it is identical for 1 or N
panes. Splits just expose it, because users find the workaround (drag a divider)
that a single full-tab pane rarely gets. A never-mounted tab is unaffected — it
has no live manager and fits through the normal initial-fit lifecycle.
Mutation-proved twice: removing the deferral, and reverting the reveal-side
condition. Each reddens only the new tests.
* test(terminal): pin that panes are PAINTED, not merely bound — and fix a broken commit
Two problems, both mine.
1) 0103a80b484 swept in an untracked fixture and left the branch failing
typecheck (unused Terminal import in painted-pane-fixture.ts). Its canvas stub
also threw 'clearRect is not a function' on every refresh. Fixed here.
2) direct-ssh-reconnect-repaint.test.ts, which I wrote to guard the reconnect
repaint, is VACUOUS: it re-implements finalizeHydratedTerminalPanes inside the
test and mocks the registry, so deleting the real fix from useIpcEvents leaves it
green. direct-ssh-reconnect-repaint-wiring.test.ts replaces that guarantee by
capturing the real callback the hook hands the coordinator and running it against
live panes — deleting the two scheduling lines now reddens it.
The gap this closes: content survival was already well covered at the BYTE layer
(snapshot roundtrip, hide/reveal stitching, cold-restore scrollback), but every
pane test stubbed terminal as {cols, rows, refresh: vi.fn()}, so 'repainted' only
ever meant 'a spy fired'. No test ran a real xterm through a real PaneManager.
pane-content-survival.test.ts does, reading .xterm-rows — what the user actually
sees — across reconnect, restart-shaped restore, tab reveal, window show, split
and unsplit, for plain shells and alt-screen TUIs.
The alt-screen distinction is now pinned explicitly: forcing a resize inside
fitAllPanes reddens only the TUI test, because a plain shell reflows and survives
while a TUI frame does not. That asymmetry is why the reported bug looked like a
plain-shell problem.
11 tests, each mutation-proven to redden only its own. 760 pane-manager tests
green; the 2 failures here are the known environmental IME baseline.
Flagged, not fixed: the unsplit path reparents the DOM without the dispose/
reattach that splitManagedPane does explicitly because 'DOM reparenting can
silently invalidate a WebGL context without firing contextlost', and follows it
with a safeFit that no-ops when the box is unchanged. Same shape as the reconnect
bug. happy-dom has no WebGL, so only a real-GPU E2E can confirm it.
* fix(ssh): send the pane its screen back on reconnect
A reconnect left every remote terminal blank. Measured on a live relay, not
inferred: pty.attach returned no replay for every pane, taking the
activation === 'existing' early return in the relay's attach.
'existing' means a source delivery is already open for this client, so it must
already be receiving live output and cannot need its screen re-sent. That holds
for a duplicate attach. It is false for a reconnect, for a reason neither side
can see alone: the client keeps its id across the drop (detachClient refuses to
detach the primary, and setWrite revives that same id) so the delivery outlives
the dead transport, while the RENDERER has already thrown its terminal away. A
reconnect bumps tab.generation, which is the pane's React key, so TerminalPane
remounts and the old xterm is disposed with its buffer, and nothing on that path
captures it first. Both halves are individually reasonable and together they
guarantee a blank pane: the relay reports the client already has the screen, to a
client holding a brand-new empty terminal, and nothing paints until new output
happens to arrive. Resizing appeared to fix it only because a TUI redraws itself.
So the client says which case it is. reattachSshPtySession is by definition
painting into a new terminal, so it asks; nobody else does, and the early return
keeps working for them. Optional on the wire, so an older relay ignores it and
behaves exactly as it does today.
Falling through rather than returning the replay inline is deliberate: the path
below already drops the pending batched bytes that are also in the buffer, which
is what stops the live delivery rendering them twice.
Reproduced first as a test against the real dispatcher, source publication and
PTY handler (the second attach for one client, which is what a reconnect is) and
it fails on the exact symptom before the fix. A second test pins that a caller
which does NOT ask still gets nothing, so this cannot become a double-render for
the duplicate-attach case the early return exists for.
Also updates four provider tests that assert the exact attach params.
NOT yet verified in the running app; the log will show replay=true on reconnect.
* chore(ssh): drop the temporary reconnect-replay diagnostic
Served its purpose: it is what turned 'the panes look blank' into
replay=false, replayLen=0 on every pane, and then into replay=true with real
byte counts once the relay fix landed. The permanent log line keeps the boolean,
which is the part worth having.
* revert: drop the reconnect repaint commits; they cannot fix the blank panes
Reverts 2fdab478c08, 34fc1424f0d and dc6f6bf685e, which I cherry-picked onto
this branch to test alongside the host key work.
Their stated premise is 'reattach restores each pane's buffer but not its
painted frame'. That is false for this flow: a reconnect bumps tab.generation,
which is the pane's React key, so TerminalPane remounts and the old xterm is
disposed WITH its buffer, and nothing on that path captures it first. Refitting
and refreshing a terminal whose buffer is empty paints an empty pane. The blank
screen was the relay declining to re-send the scrollback, fixed separately and
verified on screen.
Their tests pass without exercising the real case: the fixture blanks the
painted rows and deliberately LEAVES THE BUFFER INTACT, which is the one
situation that never occurs here, and the hidden-tab test replaces the pane
manager with a stub that reports no panes.
They may still address a separate symptom — a diverged grid after a resize on a
hidden tab — but that is unproven, unrelated to this branch, and the originals
are untouched on nwparker/sta-3077-fix-v3 where they came from. Carrying
unproven renderer changes with a false premise in their message on a
security-focused branch is not worth it.
Reverting first and re-running the full two-step reconnect test is the point:
the earlier verification passed with these present, so it did not establish that
the relay fix stands alone.
* fix(ssh): repaint a reconnected pane from the grid, not a byte tail
A reconnect restored plain shells correctly but was reported to bring full-screen
apps back as fragments of a frame — Claude Code showed a few rules and its cost
line until a resize forced it to repaint.
The two payloads are not interchangeable. Relay replay is a byte TAIL: it can
begin mid-escape, and it misses the alt-screen enter, the clears and the absolute
cursor positioning that built the frame, so replaying it into a fresh terminal
paints whatever fragments survive. The model snapshot is a serialized GRID —
which is what tmux repaints on attach, and the only payload that reliably
restores a TUI.
Orca already had the grid path and already preferred it; it was gated to PARKING.
A reconnect needs it for the same underlying reason a park does: the pane paints
into a terminal holding nothing, because a reconnect bumps tab.generation, which
is the pane's React key, so TerminalPane remounts and the old xterm is disposed
with its buffer. So the gate now admits both, and prepaintParkedSshSnapshot is
prepaintSshModelSnapshot since parking is no longer the only caller.
Deliberately NOT inheriting the parking kill switch: main keeps its headless
model regardless of terminalSshViewParking, so a user who turns view parking off
would otherwise be stranded on the tail.
Every safety gate below eligibility is untouched, and pinned that way: null,
renderer-sourced, sourceless, empty, and escape-tail-only snapshots all still
degrade to relay replay, so widening WHY the model is trusted cannot widen WHAT
is trusted and cannot regress to a blank pane. Reverting either half of the gate
fails three of the new tests.
HONESTY ABOUT WHAT THIS IS VERIFIED TO DO. I could not reproduce the corruption
it targets. Two attempts against a live host, both on a build WITHOUT this
change, both restored correctly: a freshly started Claude Code and Codex side by
side, and an alt-screen `less` scrolled 4000 lines so its original full paint had
aged out of the relay's 100KB tail. The reporter's case also involved pulling
wifi — an abrupt drop rather than a clean disconnect — which is the one variable
I cannot simulate here.
So this is verified to be correct-by-construction and non-regressing: with it
applied, the same scenarios still restore correctly (top live, less at its
scrolled offset in alt-screen, both agent TUIs coherent). It is NOT verified to
fix the reported symptom, because the symptom did not reproduce. Treat the
symptom as open until someone confirms it on an abrupt drop.
Also: top was a poor proxy for a TUI in my earlier verification precisely because
it repaints every second and therefore self-heals within a tick.
* fix(ssh): stop the reconnect prepaint firing after its mount is spent
Regression I introduced with the snapshot-first reconnect paint. The payload path
consumes mountFollowsTerminalPark — it clears the flag after the first reattach
so a later in-place reconnect on the SAME mount cannot repaint. I replaced the
prepaint's read of that mutable flag with a const snapshot of it, so my combined
flag stayed true for the life of the mount. A snapshot could then be written on a
later reattach, into a terminal that already had live content, and its own
isCurrent() guard could no longer go false either.
The visible symptom was a tab that came up blank with no prompt and stayed
generically titled Terminal N — the title only stays generic when the shell never
printed a prompt for Orca to read one from. Every such tab in my session had been
through a remount; four tabs created cleanly with Cmd+T were all fine.
So the flag is mutable again and is consumed alongside the one it was derived
from. Both reasons a mount paints into an empty terminal — a park and a reconnect
— are spent by the first reattach, which is what the original code meant.
Worth stating plainly: my earlier claim that the snapshot change was
non-regressing was tested only against reconnect scenarios. I never exercised
creating a tab afterwards, which is exactly where this showed up.
* test(ssh): cover what a pane SHOWS after a reconnect, and after a new tab
The gap that let both regressions reach a user. Nothing asserted the rendered
pane: the existing SSH coverage checks pty ids, statuses and spy calls, and every
one of those was correct while the screen was blank.
Covers one flow end to end against the dockerized relay: write a marker,
reconnect, require the marker to still be on screen, then open a tab and require
the new shell to answer.
Three choices worth keeping:
A MARKER, NOT A PROMPT. A prompt reappears on its own after a reconnect, so
asserting one cannot tell restored scrollback from a fresh shell. The marker only
exists if the pane kept what it had.
ECHO, NOT EXISTENCE. The new tab must run a command and show its output. A pty
id proves a session was created; it does not prove the pane is usable, which is
the exact distinction the reported bug lived in.
AND THE TAB TITLE. It stays 'Terminal N' only when the shell never printed a
prompt for Orca to read one from, which is what the report showed and the
cheapest signal available.
Gated on ORCA_E2E_SSH_DOCKER=1 like the other relay specs.
* chore(ssh): rename the snapshot prefetch off its park-only name
The probe serves reconnect remounts as well now, so parkedSshSnapshotPrefetch
described only half of what it holds.
* revert: drop the snapshot-first reconnect paint; unproven and it regressed
Reverts e6541fe9b87, its follow-up c497a26788d, and the rename f680a0b1cb9.
The reasoning behind it still looks right — a byte tail cannot rebuild an
alt-screen application, a grid snapshot can, and that is what tmux repaints on
attach. What I could never do is show it fixing the reported symptom. Two
attempts to reproduce the corruption on a build WITHOUT it both restored
correctly: freshly started Claude Code and Codex, and an alt-screen `less`
scrolled 4000 lines so its full paint had aged out of the relay's 100KB tail.
Meanwhile it cost two real regressions. It fired on mounts that were not
reconnects, leaving a new tab with no prompt and a placeholder title, which a
user hit within minutes. The fix for that consumed the eligibility flag with the
one it was derived from — and after it, a reconnected Claude Code came back as
fragments of a frame, the exact symptom the change was meant to remove. So the
consume-once semantics that stop stale paints and the repaint a reconnect needs
are in direct tension, and I do not yet understand the ordering well enough to
satisfy both.
Shipping an unproven change that has already broken two things twice is worse
than shipping the blank-pane fix alone, which IS reproduced, A/B'd and visually
verified. The TUI corruption goes back to open — but now with something it never
had before: a reproduction. It shows up on a reconnect against a Claude Code
that has been running a while, not one just started, which is why my earlier
checks kept passing.
The e2e coverage stays. It asserts what the relay fix guarantees — a marker
surviving a reconnect, and a tab opened afterwards reaching a shell that answers
— and neither of those depends on this change.
* docs(ssh): name the root cause the reconnect replay fix does not address
requireReplay fixes the blank pane at the symptom. The cause is that a PTY
source delivery is the only per-client relay state that outlives its client
detaching: fs-handler, git-handler and relay-filesystem-watch-registry all
subscribe to dispatcher.onClientDetached and release theirs, and
relay-pty-source-publication never does. The primary client keeps its id across
a transport replacement, so its delivery survives a dead transport and
activate() answers 'existing' to a client that cannot receive anything.
Retiring the delivery on detach is the real fix. Not doing it here is a choice,
not an oversight — it is the flow-control and credit path, and I could not
verify it before handing this over. Recorded in the test that guards the
symptom, which is where someone changing this will actually look.
* docs(ssh): record the three root-cause routes that do not work
I went after the cause and failed three times. Each attempt looks correct until
it runs, so the dead ends are worth more written down than the time they cost:
RETIRING THE DELIVERY ON onClientDetached — the obvious fix, and the one I
argued for, since fs-handler, git-handler and the watch registry all release
their per-client state exactly there. It breaks checkpoint recovery: 10 tests
across relay-pty-source-recovery-interleavings and restore-retry. A delivery
outliving its client is DELIBERATE; that is what lets a reconnecting client
resume from a checkpoint instead of re-receiving everything. This class omits
the subscription on purpose, and that omission is not the bug.
RETIRING WITHOUT session.cancelDelivery() — the credit ledger keeps one upstream
owner per pty, so dropping the record without releasing it leaves the slot
taken and the next open throws 'PTY source delivery already has an upstream
owner'. I saw that live as an error toast over a blank pane.
COMPARING clientGeneration — the delivery identity carries one, but it is
client-supplied through pty.openClient and RequestContext has none to compare
against, so the relay cannot tell the generations apart on its own.
Which points where I would start next, unverified: the SSH client presents the
SAME clientGeneration across a reconnect, so the relay cannot distinguish the new
connection and reuses its delivery. reattachSshPtySession never sends
sourceRecovery at all — the recovery protocol exists and the SSH reattach path
simply does not participate in it. That is likely the real fix, and it is on the
client, not in the relay.
The symptom fix stays because it is verified and the tree is green; the cause
stays open with a map instead of a guess.
* fix(ssh): repaint a reconnected full-screen app from the grid
A reconnected TUI came back as fragments of a frame — Claude Code showed a few
rules and its cost line until a resize made it repaint itself.
Relay replay is a byte TAIL. It can begin mid-escape and it misses the
alt-screen enter, the clears and the absolute positioning that built the frame,
so replaying it into the fresh xterm a remount just created paints whatever
fragments survive. Main already keeps the thing that does restore a frame: a
real @xterm/headless grid, alt-screen aware, fed unconditionally for SSH. Local
terminals already repaint from it; SSH was the only path that did not.
So this routes an SSH reconnect into the painter that already exists, at the one
expression that chooses model over tail. No new call site, no second lifecycle,
and every existing gate still applies — a null, renderer-sourced or empty
snapshot still degrades to the tail, so it cannot paint blank.
ONLY ON THE ALTERNATE SCREEN, and that is the whole design. The reconnect replay
reaches the renderer without passing through main's model — forwardReattachReplay
and the inline attach replay both bypass onPtyData — so at that moment the model
is stale by exactly the outage. For a full-screen app that trade is right: a tail
cannot rebuild a frame it no longer contains, a grid can, and the SIGWINCH the
restore already sends makes the app redraw the delta. For a scrolling shell it
would be wrong: the tail holds output the model never saw, and preferring the
grid would drop it for good. A park has no such hole, so it keeps using the model
either way.
Derived from the PENDING retry, not directSshRetryAttempt. That also matches the
live binding, which is written at the same tab generation once a reconnect
succeeds and then outlives it — so it stays truthy for every later remount of
that generation. Reading it directly is what made my first attempt fire on mounts
that were not reconnects. Consumed alongside mountFollowsTerminalPark for the
same reason.
Verified live against the reported app: Claude Code restores identical to its
pre-disconnect frame, top restores coherent and live, and a plain shell still
shows output written before the disconnect. 5,470 tests pass across the touched
suites.
Not the whole story, and the remaining half is already written down: the model's
gap exists because the SSH reattach asks for a tail instead of participating in
the checkpointed resume the relay already implements. Close that and this paint
is not merely coherent but exactly correct, for shells too.
* test(ssh): cover a full-screen frame across a reconnect, not just scrollback
The case a byte tail cannot serve, and the one that reached a user twice. A tail
can begin mid-escape and misses the alt-screen enter and absolute positioning
that built the frame, so replaying it paints fragments — which is what a
reconnected Claude Code showed.
Uses top: present on any Linux image, and it repaints on a fixed interval, so a
whole header after the reconnect is unambiguous rather than a timing artifact.
Asserts the header AND the column row, because a tail that lost the frame start
still shows rows.
The spec now covers all three payloads one reconnect has to get right: a shell's
scrollback, a full-screen app's frame, and a tab opened afterwards reaching a
shell that answers.
* ci(e2e): actually run the Docker-SSH specs in the changed-specs lane
"I am surprised this was not caught" has a mechanical answer: these tests do not
run. A spec that reads ORCA_E2E_SSH_DOCKER test.skip()s itself when it is unset,
and exactly one place in CI set it — gated on tests/e2e/ephemeral-vm-provisioned-
root.spec.ts being among the changed files. So editing any SSH spec ran it as a
skip and reported green. Eighteen specs reference that variable, including both
reconnect regressions I have been chasing.
Now it is also enabled when any changed spec references the variable, which is
the same grep -l idiom the @headful check two lines below already uses. The
original clause stays: that spec needs Docker without naming the variable, so
replacing it rather than adding to it would have traded one silent skip for
another.
Simulated against the real files — the reconnect spec, the original trigger, a
multi-spec change, a non-Docker SSH spec, and a deleted path — enabling in the
first three, staying off in the last two, and not failing the step on a path that
no longer exists.
* fix(ssh): let the replay veto a stale alternate-screen belief
Adversarial review of the previous commit found a case where it is worse than
the bug it fixes, and it is the exact inverse of what that commit reasoned about.
The model reports alternateScreen from bytes it consumed, and it never consumes
the outage. So if a full-screen app EXITS during the disconnect — an agent
finishes, a command ends, the process dies — the model still says alternate. The
gate then painted a frozen frame of an application that no longer exists and, via
the else-if chain, discarded the replay carrying the shell's real output. Frozen
and wrong beats fragments, which were at least current bytes.
The replay is the only witness to the outage, so it now gets a veto: its last
47/1047/1049 transition, if any, outranks the model's belief. Leaving reset means
the frame is gone and the tail wins; re-entering means the model is right after
all. Same review found the width-mismatch guard drops the alt frame and leaves a
cleared screen for the app to repaint — free for a park with no tail to lose, but
here it meant discarding a usable one for a blank pane, so that degrades too.
Both vetoes are skipped when there is no replay, where they would only trade a
stale frame for an empty one.
Extracted as sshReconnectPaintsFromModel rather than more inline ternary, because
every interesting case is a disagreement between a stale belief and a replay —
awkward to stage end-to-end, trivial to state as a table. 14 unit tests, including
the two that fail against the previous commit. The e2e comment is corrected in the
same spirit: top redraws itself, so it never discriminated the paint source and
should not have claimed to.
Also from the review: the kitty flag stack was left stale on this path, since the
app's pushes during the outage exist only in the replay we discard — scanned now,
after the snapshot so the outage layers on the pre-outage baseline. And the
consume-once comment asserted an invariant that does not exist;
followsDirectSshReconnect is a const captured per connect, bounded by
connectStarted and the gates rather than by the read. Corrected rather than
restructured.
Known and NOT fixed, because it predates this work and is a behavior change of
its own: the model probe is gated on the terminalSshViewParking kill switch, so
turning off view parking also silently disables this repaint. Defaults on.
* docs(ssh): make the parking kill switch's reach over the reconnect repaint deliberate
Review flagged that terminalSshViewParking silently disables the full-screen
reconnect repaint, since both go through the same model probe, and that nothing
said so.
Keeping the coupling and documenting it rather than threading a reason through.
The switch is the kill for painting an SSH pane from main's model at all, and a
reconnect does exactly that; off should restore the relay-tail behavior that
predates the machinery, which is what an escape hatch is for. That matters more
than usual here: this repaint is new and review already found one case where it
was worse than the bug, so a way to turn it off in the field is worth its cost —
a user who disables parking also loses the reconnect repaint.
The alternative is worse than it looks anyway: the probe memo is keyed on ptyId
and shared with the park path, so a per-call reason would be reused by whichever
path created it first.
* docs(ssh): record why the obvious reconnect follow-up does not work
I proposed making the pane-retry path request source recovery the way
reattachKnownPtys does, and argued it was probably client-side routing. Tracing it
says the wiring is indeed trivial and the checkpoint state does survive a drop —
and that the change would still be wrong three ways, one of them harmful.
The relay short-circuits to 'existing' on a same-clientId attach BEFORE it looks
at the recovery argument, and the reconnecting client has already rotated the
delivery onto its id. A failed reattachKnownPtys then deletes the checkpoint on
purpose, so a later pane retry presents checkpointUnavailable, which becomes
restoreRequired and then SSH_SESSION_EXPIRED_ERROR — trading a blank pane with a
tail for a killed session. And the payloads answer different questions anyway:
recovery replays the post-checkpoint delta to keep main's model whole, while the
tail is a screen snapshot for a fresh empty xterm. Even a successful recovery
would put almost nothing in a remounted pane.
Also corrects the argument I had been leaning on hardest. "Old relays ignore
requireReplay, so those users still get blank panes" is false for the SSH relay:
the client deploys its own relay into a version-scoped directory and rejects any
grant whose serverBuildId differs, because client and relay ship in one build.
Mixed versions cannot occur on this channel. The independent-update rule still
governs remote runtime hosts, just not this one — so there is no stranded
population, and the urgency that framing created was imaginary.
What replaces it is a sharper question. Source recovery is gated on
outputFlowControl and on the client presenting a NEW clientId. We have empirical
evidence it does not: the blank-pane bug existed because the relay concluded this
client already held the stream, and the shipped fix works by bypassing that exact
early return. If the id is reused, reattachKnownPtys' recovery hits the same
short-circuit — meaning checkpointed recovery may never have run for SSH
reconnects, and the tail is not a fallback but the only path. Whether that is so
turns on daemon versus stdio-primary relay mode, which I did not verify and which
decides whether the work is "extend recovery" or "recovery has never run here."
* docs(ssh): the root cause — checkpointed recovery never runs on a reconnect
Chasing why the pane-retry path could not request source recovery turned up the
real answer: nothing can. Recovery is dead on every SSH reconnect, and the byte
tail is not a fallback but the only path that has ever run.
Five links, each read rather than inferred. setWrite reuses primaryClient
including its id, so a reconnected client presents the SAME clientId. activate()
tests exactly that at line 99 and returns 'existing' at 108, which makes the
rotateDelivery branch at 118-142 reachable only when the ids differ — never here.
So no sourceRecovery comes back, so finishSourceRecovery fails its
!pendingRecovery guard and abandons, cancelling the delivery and deleting the
checkpoint. The pane retry then opens fresh and takes the tail.
This also explains the blank panes exactly. The relay concluded that this client
already held the stream because, by its own identity rule, it does.
The fix that implies is smaller than anything proposed so far and avoids what
sank the three earlier attempts: bump a transport generation on the client record
in setWrite and compare it alongside clientId, so a reconnect rotates the delivery
instead of matching as 'existing'. Deliveries still outlive their clients and
nothing retires on onClientDetached — the rotation happens on re-attach, which is
what the recovery design already intends. RequestContext, setWrite and the
publication are all relay-internal, and client and relay ship in one build, so
there is no wire change and no compatibility exposure.
Left explicitly unverified: whether rotateDelivery's identity preconditions hold
at that moment, whether outputFlowControl is granted on the reconnected session,
and what a rotation gives the RENDERER — which still remounts an empty xterm and
needs a screen, not a post-checkpoint delta. Recovery keeps main's model whole; it
does not by itself repaint a fresh terminal, so the tail may still be wanted for
the pane even once the model stops going stale.
* ci(e2e): run the Docker-SSH specs when SSH SOURCE changes, not just specs
The earlier fix only helped when a spec file itself changed. Edit pty-connection,
pty-handler or ssh-relay-session and touch no test — which is what every one of
these regressions actually looked like — and the lane still did not run.
pr.yml now maps SSH source paths onto five Docker-backed specs. Five rather than
all fifteen because the rest are covered by unit tests that prove the same source
without paying for a container; that is a deliberate narrowing and this comment is
where it is admitted rather than left implicit. Test files are excluded from
triggering, since they prove themselves.
Simulated against the real paths this PR touches: pty-connection.ts,
pty-handler.ts, ssh-relay-session.ts and ssh-pty-session-reattach.ts all now pull
the SSH specs in, while pty-connection.test.ts, ssh-known-hosts.test.ts,
SshTargetCard.tsx and README.md correctly do not. The gate contract test covers
the mapping: 11 pass.
e2e.yml pays for it — 30 to 45 minutes, because the lane can now build a container
image and run SSH specs serially on top of whatever changed — and installs
openssh-client, which the fixture shells out to and which the lane did not need
back when it never received these specs.
* test(ssh): make the reconnect spec actually run — it now fails on a real bug
It had never executed once. The CI condition that enables Docker-SSH was gated on
an unrelated spec, so this skipped and reported green — and running it for the
first time found two bugs in the spec itself, both of which a typechecked tests/
would have caught instantly.
startDockerSshRelayTarget returns a DockerSshRelayTarget, which has no targetId;
the id comes from connectDockerSshRelayTarget's return value, which the spec
discarded. So every reconnect call passed undefined and the relay answered
'SSH target "undefined" not found'. And openNewTerminalTabInActiveWorkspace takes
the group to open into; called with no argument the new tab lands nowhere.
The third problem was the fixture rather than the spec. The image ships Debian's
/etc/bash.bashrc with the xterm title block commented out and an all-comments
/root/.bashrc, so its shell never emits OSC 0 — which is what Orca derives a tab
title from. The title assertion could not have passed for any shell, healthy or
not, so it was proving nothing. enableDockerSshRelayTargetShellTitle opts a spec
into the title-setting PS1 a real user's shell already has.
IT STILL FAILS, and that is the point: it fails on a PRODUCT bug it was written to
catch. An SSH reconnect destroys the terminal state behind a tab whose local
creation has not yet reached the host. remote-workspace-session-merge.ts:86-89
spreads the host's tab list over the local one for that worktree, so a local tab
missing from the host snapshot has no surviving branch; the upload that would have
put it there is DROPPED rather than deferred inside the 1s suppression window
after a snapshot apply. The tab bar still renders the tab, correctly titled, but
the terminal slice holds one tab and no pane manager exists for the second — so
the user clicks a tab that never paints, with no error and no recovery, while the
process keeps running on the host.
Pre-existing: none of remote-workspace-target-sync.ts,
remote-workspace-session-merge.ts, use-app-session-persistence.ts or
remote-workspace-snapshot-apply.ts is touched by this branch, and nothing in the
merge range touches them either.
Not worked around here. Waiting for the upload would hide it, and a user opening a
tab right after a reconnect has no such signal to wait on.
An earlier version of this message claimed the spec passes. It does not; I had
seen five green runs out of six and generalised from them. Sustained runs are
about three in eleven before the merge and zero in four after.
* chore(e2e): add a typecheck entry point for tests/, unenforced for now
tests/ has never been typechecked. That is how a spec could read target.targetId
off a type with no such field, and call a function without its required argument,
while the suite reported green — the spec was skipping, so nothing ever
disagreed with it.
Pointing tsc at tests/ finds both immediately. It also finds ~198 errors across
~94 files, which is a cleanup project rather than a change to make here, so this
ships as pnpm typecheck:e2e and is deliberately NOT added to the typecheck chain
or to CI. An unenforced script is worth less than a gate, but it is worth more
than nothing: it is runnable, it is discoverable, and the header says plainly
what it is so nobody mistakes it for coverage we have.
runtime-types.ts is the one fix included, because it was actively misleading:
every PaneManagerLike method was optional, so every call site was a
possibly-undefined invocation that TypeScript could not help with. They are real
methods on a real instance. Also widens AppStore to the StoreApi that
window.__store actually is.
* test(ssh): separate the reconnect paint guard from the tab-destruction bug
The paint guard was failing about two runs in three, and after the merge every
run, for a reason that has nothing to do with painting. It staged its full-screen
check in a tab it had opened AFTER a reconnect — which is exactly the tab an
unrelated session-sync bug destroys on the NEXT reconnect. Two independent
failures were riding on one assertion, and the one that fired was not the one the
spec is for.
Running top in the ORIGINAL tab fixes it. That tab predates every reconnect, so it
is in the host snapshot and survives. No assertion changed, none were weakened,
and the new-tab case simply moves after the full-screen case rather than before
it — it still opens its tab after a reconnect, which is the regression it exists
to cover. Five consecutive runs pass at ~13s, against three in eleven before.
The bug itself is not swept up. ssh-reconnect-tab-destruction.spec.ts records it
as a fixme with the mechanism written down: session-merge spreads the host tab
list over the local one, so a local tab missing from the host snapshot has no
surviving branch, and the upload that would have put it there is dropped rather
than deferred inside the 1s window after a snapshot apply. It is worse than a
vanishing tab — the tab bar keeps rendering it, correctly titled, while the
terminal slice has dropped it and no pane manager exists, so the user clicks a
selected tab that never paints, with no error and no recovery, while the process
runs on untouched.
fixme rather than a workaround because waiting for the upload would hide it, and a
user opening a tab right after a reconnect has no such signal to wait on. It is
pre-existing: none of the four files in that path is touched by this branch, and
nothing in the merge range touches them either.
Also lifts openTerminalTab into a shared helper, since both specs need it and the
group argument it must pass is the kind of thing worth stating once.
* fix(ssh): stop a reconnect deleting local state the host has not seen
Reported from a 60-second manual test: reconnect an SSH workspace and the app
drops to the home screen, a second tab running pnpm install is gone entirely, and
one launched agent is listed twice. Three symptoms, one cause.
The snapshot is applied as the whole truth for the reconnecting target. The tab
merge iterates only the host's worktrees, then the result is spread over a gap
where every local tab for that worktree has just been dropped — so a tab created
locally whose upload has not landed has no branch that keeps it. Not a race: it
cannot survive. Same for the pointers, where a snapshot that names no active
worktree nulls activeWorktreeId and activeWorkspaceKey, which is the home screen
while the user's terminals are still running.
So the host is now authoritative for what it knows and not for what it has never
been told. A local tab absent from the snapshot is kept, the worktree union is
used so a snapshot with no entry for it at all cannot erase it, and a null active
worktree only defers to local state when that workspace demonstrably still exists
in the merged result.
Two guards this change had to earn rather than assume. A null activeTabId is NOT
missing information — it is a deliberate deselect that arms the duplicate-tab
repair, and my first attempt defeated it and broke that test; it is honoured
verbatim now. And preserving by tab id alone reintroduces the duplicate agent,
because the host can carry the same session under a new tab id, so the preserve
also checks the remote session id — the identity that survives a tab-id change.
Testing, which is the part that failed here before. Eight tests fail on the
unfixed code and pass on this one, at two levels: the merge decision table, and
the real apply path driven through a store. The end-to-end version of the same
scenario is deliberately NOT the guard and now says so in its header — measured
against unfixed code it only reproduces about one run in three, because the
destruction needs the tab created inside the debounced upload's suppression
window and nothing external can force that. Its earlier green run is exactly why
this shipped.
* fix(ssh): let agent session history recover once the relay is ready
Reported against the adhoc build: a workspace whose editor was loading remote
files perfectly still showed "SSH relay is not ready" and "0 shown · 0 recent" in
the Agent Session History panel, permanently.
That string is what the relay throws before it is ready, which is ordinary at
startup and again for the window a reconnect leaves the session not-ready. The
panel had three refresh triggers — mount, window refocus, and a newly seen agent
session id — and none of them fire when the relay simply becomes ready. So a
transient startup error became a stuck panel next to a workspace that plainly
worked, which is why the report described it as broken while everything else was
fine.
The file explorer already recovers from exactly this, off exactly this signal,
with the rationale written down at use-file-explorer-tree-load-effects.ts: it
loads before SSH providers are registered, so it retries when
sshConnectedGeneration bumps. This panel simply never did. Same idiom, same gate —
only retries when there was a prior error, so a local workspace or one that
already listed fine does not rescan every time some unrelated host connects.
Two tests in the existing suite. The retry one fails on the unfixed code with
"the panel never retried after SSH became ready"; the second pins the gate, since
a retry that fires on every connection bump would turn one bug into a rescan
storm.
* fix(worktrees): name the create route when a raw filesystem error escapes
A worktree create over SSH failed with a bare
"ENOENT: no such file or directory, lstat '/home/neil/projects/orca-test1234'".
That message names nothing. An lstat is Node's LOCAL filesystem, so hitting one
against a path that lives on an SSH host means creation ran a local
implementation for a remote repo — but the user cannot know that, and neither
could I without re-deriving the routing by hand and then failing to reproduce it.
worktrees:create picks between three implementations, and the order matters:
isFolderRepo is consulted BEFORE connectionId, so a folder-kind repo on an SSH
host never reaches the remote path at all. Which route ran, and what the repo
looked like when it was chosen, is the entire diagnosis — and it is knowable
exactly at the throw site, where the decision was just made. So it is stated
there now: route, repo kind, connection id, path, and the original message.
Deliberately additive and deliberately narrow. Only ENOENT/EACCES/EPERM are
rewritten; a git failure, a relay-not-ready, or a validation error already says
what went wrong and burying it under a worse message would be a regression. The
original error is kept as `cause`, so anything matching on `code` or reading the
stack is unaffected.
This does NOT fix the reported failure — I could not reproduce it. On current
code I created a worktree at that exact path, at a second path, with a leftover
directory already present remotely (correctly suffixed -2), and with the SSH
target disconnected (clean actionable error, no ENOENT). What it does is make the
next occurrence identify itself in one screenshot instead of costing another
investigation.
* fix(ssh): recognise a missing path reported by the relay
Creating a worktree over SSH failed with a raw
"ENOENT: no such file or directory, lstat '/home/neil/projects/orca-test1234'".
The path was the one about to be created, so its absence was correct. The caller
asks exactly that question — remotePathExists returns false on ENOENT — and could
not get an answer, so it rethrew at the user instead.
The trace log settles where the error comes from, and it is not where I spent a
long time looking. The stack starts at SshChannelMultiplexer.handleResponse: the
lstat ran on the SSH HOST, and the failure travelled back as JSON-RPC. An lstat in
an ENOENT message is normally Node's local filesystem, which sent me hunting for a
local fs call on a remote path; there is none.
handleResponse rebuilds the error as `new Error(msg.error.message)` and then sets
`code` from `msg.error.code` — the TRANSPORT's numeric JSON-RPC code. Node's
'ENOENT' string code does not survive that, and isENOENT tested only for the
string, so a remote missing path could never be recognised as missing. Every
caller of that predicate asks the same question, so this was wrong for all of
them, not just worktree create.
The message is now consulted as well, matched on Node's full canonical phrase so a
branch name or log line that merely contains the word cannot make an existing path
look absent — that would silently skip a collision check rather than report one.
Fixed on the client because it holds for every relay version, including ones
already deployed; teaching the relay to send the original code would only help
hosts redeployed afterwards.
The two other copies of this predicate, in filesystem-rename-collision and
git-discard-path-safety, are deliberately left alone: both run against a local
filesystem — one inside the relay, one on the desktop — where the string code is
intact and broadening would only add false positives.
Seven tests, three of which fail on the unfixed code: the relay-rebuilt error, the
same error through the IPC wrapper the renderer sees, and one carrying no code at
all.
* revert: drop the worktree-create error-context wrapper
Written to make an unexplained ENOENT self-identifying when creating a worktree
over SSH. The cause is now known and fixed — the error came back from the RELAY
and isENOENT could not recognise it, because the multiplexer rebuilds a remote
error with the transport's numeric code — so the wrapper is scaffolding for a
solved problem.
Worse, its central claim is false. It reported 'the remote (SSH) path failed on a
local filesystem call', and the trace log shows the lstat ran on the SSH host, not
locally. Keeping a message that asserts the wrong thing about the one failure it
was built for is worse than not having it.
183 lines and a rewritten error at the IPC boundary, removed.
* docs(ssh): drop a comment claim about older relays that is not true
The requireReplay comment said the field is optional on the wire so an older relay
ignores it. It cannot happen: the client deploys its own relay into a
version-scoped directory and validateGrant rejects any grant whose serverBuildId
differs, so client and relay are the same build by construction.
The field IS optional, which is why the relay reads it as !== true — that part
stands on its own and needs no story about versions. A comment asserting a
compatibility property the code does not have is worse than no comment, because
the next person plans around it.
* fix(ssh): act on the host key review — three must-fixes and two hazards
M1. A stale record of ours outranked known_hosts, so the remedy we print did not
work. `ssh-keygen -R host` then reconnect leaves known_hosts holding the NEW key
while our store still holds the old one, and the store was consulted first — the
one state that cure produces was the one state we refused. Permanently, since
nothing in the app clears the store. known_hosts now decides a match first, which
concedes nothing: it is the artefact ssh itself obeys, so an attacker who can
rewrite it has already won. Both directions of the precedence are pinned now; the
rotation case fails without this change.
That leaves one rejection known_hosts cannot cure — a host trusted only on first
contact that later rotates its key. "Remove the saved key" named nothing a user
could find, so it now names the store file.
M2. A superseded attempt could put a passphrase prompt in front of a host we had
just refused. The verifier deliberately does not record a rejection for an attempt
nobody is waiting on, so nothing identified it and ssh2's generic handshake error
walked the credential ladder. Guarded on the generation, which catches it whatever
the error turned out to be. Deliberately NOT by rejecting with a cancellation: an
existing test pins that connect() still reports the raw late-startup error, and
that behaviour did not need to change to fix this.
M3. Every unknown host was refused whenever HOME diverges from the passwd home —
devcontainers, `su`, Nix shells, some corporate launchers — because `-F` makes ssh
ignore /etc/ssh/ssh_config and being blind to a site policy was treated as reason
to refuse. Being blind is only a reason to refuse if we cannot go and look, so it
now asks ssh for the system config on its own and takes the stricter of the two.
Only a probe that fails leaves the strict rule standing. Costs one `ssh -G` on the
rare path that already needed -F.
N1. A rejected key's fingerprint was still adopted, and the relay scopes install
locks by it — locks keyed to a host we refused to talk to.
N2. The fallback algorithm list re-introduced the throw its own comment describes.
ssh2 prepends ssh-ed25519 only when a runtime probe succeeds, so on a build where
that probe fails, proposing it makes generateAlgorithmList throw inside
client.connect — and only for hosts we already know. Not reading ssh2's list is a
reason to leave its defaults alone, not to guess: it returns null now and the
caller skips reordering.
1555 tests pass in src/main/ssh.
* docs(ssh): state the merge's real trade instead of claiming it has none
The preserve comment said a genuinely closed tab is never in the local list,
because closing removes it. True for a close on THIS client; false for one closed
on another client sharing the host, where the tab is still local, still absent
from the snapshot, and now kept.
That is a deliberate trade, not an oversight — absence cannot distinguish 'never
uploaded' from 'closed elsewhere', and the outcomes are not symmetric: keeping a
tab a moment too long is recoverable by closing it, deleting a live one with a
process in it is not. But the comment asserted the case could not arise, which is
the kind of claim that gets planned around. Now stated, and pinned by a test so
the next person can see it was chosen rather than missed.
* fix(ssh): stop a newer host key store being silently downgraded
The store writes a version and never read it back. A file from a future Orca would
have had every record dropped by validateRecord — the shape would not match — and
then been REWRITTEN as version 1, so a rollback silently discarded whatever that
version knew. Trust records are user-owned state; losing them costs a
first-contact prompt per host and, worse, re-establishes trust from nothing.
v1 is the only place this can be made safe, because v2 cannot retrofit a v1 that
already clobbers it. A newer file is now left alone: nothing is trusted from it,
and trustHostKey declines to write rather than downgrade. The check sits inside
the snapshot queue so it cannot be separated from the write by another writer, and
declining is not an error the caller fails on — the key still verified, and the
next connect re-derives the same decision from known_hosts.
The test writes a version-99 file and asserts it is byte-identical afterwards; it
fails on the unfixed code with the file rewritten as version 1.
* perf(ssh): skip the reconnect snapshot probe the replay has already ruled out
Every SSH reconnect paid up to the 750ms model-snapshot timeout, including the
ones where the answer was discarded. The gate needs the snapshot's alternate-screen
flag, so the probe looked unavoidable — but one of its two vetoes does not: if the
replay shows the app LEFT the alternate screen, no snapshot can be used whatever it
says.
Asking that first costs a regex over the replay and removes the probe entirely for
that case. It also shrinks the window that matters most: the await sits inside the
structural replay coordinator with live PTY bytes deferred, and the payload can be
superseded while it runs.
Behaviour is unchanged — sshReconnectPaintsFromModel returns false for a null
snapshot exactly as it did for a fetched one it then vetoed, and its tests still
pin both vetoes.
* test(ssh): cover the site host key policy probe
It shipped untested. Three cases, and the third is the one that matters: a system
config naming no policy answers 'ask', not null, because parseSshGOutput fills the
OpenSSH default — and that distinction is exactly what the caller keys on. Null
means 'we could not look', which is the only state that keeps refusing unknown
hosts; a successful read that sets nothing clears the blindness without relaxing
anything, since strictestHostKeyChecking leaves the user's value alone against
'ask'.
I expected null there and was wrong about my own code; the test now records the
behaviour rather than my assumption. Also pins that the probe passes the null
device and terminates its args with -- so a host starting with '-' stays a host.
* fix(ssh): three release blockers from the readiness review
P1-1 was my own fix from the previous round, and it was wrong. I claimed
`ssh -G -F /dev/null` reads the system config while excluding the user's. It does
not: -F excludes /etc/ssh/ssh_config too, which sshGArgsForHost's own comment says
and I quoted before contradicting. Confirmed live against OpenSSH 10.2p1 — plain
`ssh -G` reports the sendenv lines from /etc/ssh/ssh_config, `ssh -F /dev/null -G`
reports none. So the probe returned built-in defaults on every machine, the
fail-closed guard never engaged, and a site-wide StrictHostKeyChecking yes was
silently ignored while we accepted AND durably recorded a key the user's own ssh
refuses. That is worse than the lockout it was meant to fix.
There is no ssh-only way to ask this, so the file is read directly — and the
question asked is deliberately weaker than "what is the policy". Anything
ambiguous (unreadable, an Include that will not resolve, the directive present at
all) answers yes and the caller stays fail-closed. Only a site config that
demonstrably says nothing about host keys clears it, which is the common case that
was being punished. Includes are followed, since macOS and most distros ship
`Include /etc/ssh/ssh_config.d/*` and missing that would read as "no policy" on
nearly every machine that has one. strictestHostKeyChecking goes with it: there is
no separately-read site value left to merge.
P1-2. `ssh -G` prints UserKnownHostsFile unquoted and space-separated even when
the config quoted it — verified the same way. One path containing a space is
therefore indistinguishable from two, and splitting shreds
C:\Users\John Doe\.ssh\known_hosts into fragments that resolve to nothing. Every
fragment misses with ENOENT, which reads as "absent" rather than "unreadable", so
the user appears to know no hosts and a CHANGED key is accepted as first contact.
The filesystem is the only thing that can disambiguate, so it decides: if no
fragment exists but the rejoined path does, it was one path. A list where any
fragment exists is a genuine multi-file config and is left alone.
P1-3. oxlint is a PR gate and this diff failed it on two lines. Both fixed —
including by splitting the replay on ESC rather than matching it, which is
equivalent since every private-mode sequence begins right after one, and respects
no-control-regex instead of suppressing it.
That gate failure is on me twice over: I reported LINT clean repeatedly while
filtering oxlint's output with a grep that could never match its
`path:line:col: error` format. Verification is by exit code now.
5183 tests pass; each fix has a test that fails without it.
* fix(ssh): the readiness review's P2s
P2-1. activeRepoId and activeWorktreeId could describe different workspaces. The
repo followed the host while the worktree came from local state, and it split in
exactly the case the preservation exists for — "the host named no worktree" is
precisely when it can still name a repo. All three active-* fields now derive from
whichever worktree won, rather than each picking a source. The nested ternaries
that hid it are gone.
P2-2. The trust-source reads sit AHEAD of client.connect, and readyTimeout only
covers the handshake — nothing wrapped attemptConnect. A home directory on a
stalled NFS or SMB mount made readFile hang forever, leaving the connection wedged
in `connecting` with no ladder entry and no recovery. Bounded at 5s, reusing the
existing withTimeout helper. The fallback is the one an unreadable file already
produces — evidence withheld, connect as ssh does but record nothing — not the far
worse "no hosts known" that would let a changed key through as first contact.
That helper absorbs rejections into its fallback, so the store's catch had to move
INSIDE the timeout; wrapping the other way silently swallowed the warning that is
the only signal the store is unwired rather than merely slow.
P2-4. doSsh2Connect runs up to five times per attempt as the credential ladder
advances, and each run re-read every known_hosts file, re-read the store, and
re-scanned the system config. Nothing writes those while a handshake is in flight,
so they are read once per attempt — which matters more now that each read can cost
up to 5s. Keyed by connect generation rather than cleared, so a superseded attempt
can never hand its sources to the live one.
P2-3. forgetHostKey was exported, tested and referenced by nothing. The
store-mismatch rejection now names the store file, so the case it was meant to cure
has a cure without it; an exported API nothing can reach is unverified in
production. Removed until D5 ships its UI, and the doc says so.
P2-5 needed no change: the site-policy branch it called dead is reachable again now
that the probe reads the real config.
The design doc drifted from the code in the two places this review checks, and both
are corrected: revocation now propagates for the ordinary rotation because a
known_hosts match is decided first, and the -F blindness is resolved by reading the
file rather than by refusing.
* fix(ssh): rejoin a spaced known_hosts path even beside an ordinary one
The whole-list check only fired when NOTHING in the reported list existed, so a
config naming both a spaced path and an ordinary one kept the spaced one in
fragments — the ordinary path existing was enough to leave it alone. The file the
user actually verified their hosts in then never got read, which is the same
failure the rejoin exists to prevent, just harder to notice.
Longest run first now: the longest sequence of tokens that resolves to a real file
is taken as one path and the scan continues after it, falling back to the single
token when no run resolves. A genuinely absent path is still reported as-is rather
than invented.
The mixed case fails against the previous version.
* test(ssh): pin the site config scanner's edge cases
This control decides whether an unknown host is refused when we cannot see the
site policy, and my first attempt at it was a security regression, so the cases
that decide 'policy present' deserve to be written down rather than assumed.
Seven, and each could have gone the wrong way. A commented-out directive must NOT
read as a policy or the lockout returns for every distro shipping the line
commented. A directive inside a Host or Match block MUST read as one, because no
attempt is made to evaluate whether the block applies — guessing wrong in the
permissive direction is the failure that matters. The equals form counts;
StrictHostKeyCheckingExtended does not. A nested Include is followed, since a
policy one level down is still a policy. An Include cycle terminates and answers
false, which is knowledge rather than doubt: both files were read in full and
neither mentions it.
All seven passed as written, so this pins behaviour rather than fixing it.
* test(ssh): assert tab survival, record the reattach gap rather than flake on it
Running the two SSH e2e specs — which neither review executed — showed the
tab-destruction spec failing on liveness three times out of three. The screenshot
disproved the obvious reading: the marker was on screen, echoed by a live shell.
getTerminalContent resolves the store's active tab id and returns '' when
paneManagers has no entry under it, which is indistinguishable from 'the shell said
nothing', and across a reconnect those two disagree.
Scanning every mounted pane instead fixed the read, and then measured the real
thing: three runs in four. The tab survives every time; the reattach behind it does
not. So the merge fix is real and incomplete — the store keeps the tab, the tab bar
renders it, and the pane sometimes never rebinds, which is the frozen-tab shape the
original report described, one layer down from the deletion that used to cause it.
Asserting that would put a one-in-four flake into the lane built to catch this
class, and a lane nobody trusts is how the original silent-skip failure happened.
So the spec asserts survival, which is deterministic at five runs in five, and the
liveness gap is written down in docs/reference/ssh-reconnect-source-recovery.md
with the first place to look.
* fix(ssh): stop an unreadable host key store from wiping every pinned key
Second readiness pass, checking each of the first pass's ten fixes rather than
taking them on trust. Nine held. This is the one that did not, plus three
fail-open shapes in the site-config scanner that a live OpenSSH disproved.
P1 — the store. loadTrustedHostKeys returns [] for ANY read failure, and
trustHostKey then wrote [...that empty list, newRecord]: one transient EMFILE
followed by one first-contact accept replaced the file with a single record.
Every other host re-TOFUs, and one whose key genuinely changed in between is
accepted as first contact rather than refused — the exact outcome pinning
exists to prevent. It also contradicted the doctrine this PR applies to
known_hosts two files away, where a file that exists and refuses to open is
evidence withheld.
Fixed by classifying one read instead of guessing twice: readStore returns
ok/absent/withheld, the read path flattens withheld to 'nothing trusted' so it
still fails closed, and the write path declines. That subsumes the separate
newer-version probe, so trustHostKey now reads the file once inside the queue
rather than twice. The 'Trusted host key' log moved inside the branch that
actually writes — it was already claiming success on the newer-version path.
P2 — the site-config scanner documents 'doubt wins on every path' and had
three where it did not, each the same shape: a path resolved WRONG still
resolves to something, and a nonexistent Include reads as 'nothing there',
which is indistinguishable from 'no policy'. Verified against OpenSSH 10.2p1:
relative Includes resolve against a fixed dir, not the including file's, so a
directive two deep was missed; ? and [...] are globs it honours; ~ and %-tokens
expand before use. All three now answer doubt.
P2 — credential prompts are gated on the attempt generation in one place
rather than per rung. A superseded attempt is denied without a recorded
decision, so isHostKeyVerificationError reads false and the ladder ran on to
prompt for a passphrase nobody was waiting on.
P2 — resolveKnownHostsFiles is async. Its rejoin existsSync-scans the very
paths the 5s bound protects, and sat outside it as an eagerly-evaluated
argument, so a stalled NFS/SMB mount blocked the whole main process.
Tests fail against the pre-fix code: 2 for the store wipe, 3 for the scanner.
Also: the 4 IME failures I previously reported as pre-existing main breakage
were a stale node_modules — the xterm patch from #14758 was not applied here
(402,643 bytes installed vs 403,181 expected). pnpm install applies it and all
4 pass. The OSC8 and SFTP failures were the same cause.
* fix(ssh): make the merge non-duplicating, and close the last scanner hole
Third readiness pass. Three P2s, all fixed.
The merge one is the one I most wanted a verdict on, and it is real: hostUnknown
filtered against ids the HOST knows and never against ids this same merge had
already emitted, so a tab id local state holds under two worktrees was re-added
under both. Two panes then share one terminalLayoutsByTabId entry and one
remoteSessionIdsByTabId entry — one remote PTY — plus an activeTabId that never
converges, which is the self-retriggering repair loop active-tab-owner-worktree
.ts exists to mitigate (React #185).
This PR does not create that state. It used to DESTROY it, by deleting every
local tab under a replaced worktree, and keeping live panes cost that accidental
cure. So the guarantee is made explicit rather than incidental: the merge now
never emits one tab id twice, whatever it is handed. The active worktree is
walked first so the surviving copy is the one the user is looking at, which is
the owner resolveActiveTabOwnerWorktreeId already prefers — merge and repair now
agree instead of each picking differently.
Scanner: an Include path that is quoted AND contains a space was split before it
was unquoted, so both halves missed and two absent paths read as 'no site
policy'. OpenSSH honours that form -- 10.2p1 applies an Include of a quoted
spaced path -- and it is likelier on Windows. Quote-aware splitting rather than
'any quote is doubt', because answering doubt for an ordinary quoted Include
with no space would reinstate the lockout this scanner exists to avoid. An
unclosed quote is doubt. Unquoted spaces still split, which is also what OpenSSH does.
The reconnect paint gate took the replay and re-scanned it, having already been
scanned by the caller that decides whether to fetch a snapshot at all — two full
splits of up to 100KB per pane per reconnect. It now takes the transition.
hasReplay is passed separately because it cannot be inferred: a replay with no
mode change and no replay at all both give null.
Tests fail against the pre-fix code for the merge and all four scanner shapes.
Correcting my own evidence claim from last round: of the two store tests, only
the wipe one fails pre-fix. The other guards the asymmetry the fix creates and
passes either way — worth keeping, but I should not have counted it.
* fix(ssh): honour every Include quoting form OpenSSH does
Fourth readiness pass. Two findings; one fixed, one deliberately not, with the
evidence for refusing it.
The tokenizer modelled double quotes only. A live 10.2p1 honours single quotes
and backslash-escaped spaces too, and both fell into the same silent fail-open
the double-quote case was raised for: fragments that resolve to nothing, and
'nothing there' is indistinguishable from 'no site policy'.
The escape is limited to a backslash before whitespace, NOT a general one. A
general escape would be catastrophic on the platform this most needs to be right
for: the Windows site config lives at C:\ProgramData\ssh\ssh_config, so it
would eat every separator in an Include beneath it and resolve to nothing --
reintroducing the fail-open it was meant to close. The test for that is
discriminating rather than incidental: it gives the file a literal backslash in
its name, so a swallowed separator resolves elsewhere and fails, where a plain
'expect false' could not tell the two apart. Verified it catches the naive
version, and that the other two catch the old tokenizer.
NOT fixed: the non-duplication guarantee still stops at the worktrees the merge
rewrites. A worktree that is neither replaced nor named by the host is never
walked, so a duplicate straddling that boundary survives.
Extending the guarantee to the assembly point was implemented and REVERTED. Any
rule there has to pick a survivor, and the ones available are wrong during a
worktree-id change -- which is the very thing that produces these duplicates.
Preferring the active worktree keeps the OLD id's copy at the moment a rename
lands, because the active worktree has not moved yet; the new worktree was left
with no tabs and its groups were never created.
remote-workspace-snapshot-duplicate-tab-repair.test.ts caught it, which is the
only reason I know the stronger version was wrong rather than merely bolder. A
surviving duplicate is mitigated by active-tab-owner-worktree.ts; deleting the
tabs of the worktree the user is about to land in is not. The comment now claims
only what holds, and says why it is not stronger.
Also records the exit from the isENOENT message-matching trade in
remote-wire-compatibility.md, where someone touching the relay error path will
be standing.
* fix(ssh): expand Windows OpenSSH's __PROGRAMDATA__ token in known_hosts paths
Captured real 'ssh -G' output from a Windows host rather than reasoning about
it, which is the one thing that could not be inferred from the POSIX format.
Two things came back that the code did not handle correctly, and one of them is
the security failure mode this work exists to prevent.
Native Windows OpenSSH prints the system paths with its own token UNEXPANDED:
globalknownhostsfile __PROGRAMDATA__\ssh/ssh_known_hosts __PROGRAMDATA__\ssh/ssh_known_hosts2
userknownhostsfile C:\Users\neil/.ssh/known_hosts C:\Users\neil/.ssh/known_hosts2
Passed through as a literal path, __PROGRAMDATA__\ssh/ssh_known_hosts misses
with ENOENT -- and an absent file is deliberately treated as 'no host is known
there' rather than 'evidence withheld', because that is the normal state. So a
site-managed known_hosts on Windows was silently invisible: every host in it
read as first contact, and one whose key an admin had rotated produced a TOFU
accept where it should have produced a mismatch. Now expanded from
process.env.ProgramData, and left literal when that is unset rather than
guessed -- a wrong path reads as absent, which is the very failure being fixed.
The second finding is reassurance rather than a bug: separators are MIXED within
one path (C:\Users\neil/.ssh/...), which Node's fs accepts on Windows, and a
spaced home prints unquoted exactly as it does on POSIX. So the space-rejoin
design is confirmed against the real format rather than assumed -- its
motivating example, C:\Users\John Doe, splits the way the rejoin expects.
The captured output is pinned as a literal fixture. Parsing and the rejoin are
pure string work, so this covers the input shape honestly off Windows; it does
not pretend to cover the platform's path arithmetic. The expansion test fails
without the fix.
Also confirms C:\ProgramData\ssh is the right site-config directory -- it
exists on the host, empty -- so the scanner is looking in the right place.
* fix(ssh): branch Include backslash handling on platform, both halves measured
Fifth readiness pass found that the previous narrowing traded one fail-open for
another. Both rules are right, on different platforms:
POSIX 10.2p1: Include conf\.d/x.conf resolves as conf.d/x.conf
four backslashes needed to survive as one -- argv_split and
glob() each consume a level
Windows: Include C:\Users\...\x.conf resolves, separators intact
So a backslash before an ordinary character ESCAPES on POSIX and SEPARATES on
Windows, and either rule applied everywhere fails open on the other platform.
Preserving on POSIX means looking for a path with a literal backslash, missing,
and reading 'no site policy'. Answering doubt on Windows means every absolute
Include is doubt, which is the lockout the scanner exists to avoid.
Now branched. POSIX answers doubt rather than emulating two rounds of glob
escaping for a question this coarse -- a backslash in a POSIX system config path
is vanishingly rare, so fail-closed costs nothing there.
The review offered the Windows half as a reasoned assumption and flagged it as
such. It is now measured on a real Windows host instead: backslash separators
resolve, AND an escaped space still escapes amid them
(C:\Users\neil\sshprobe\sp\ ace\x.conf -> port 2802), which is exactly the rule
implemented. Two other worries were checked and came back unfounded -- a
backslash-space inside EITHER quote is consumed by ssh, and a single quote
inside double quotes is an ordinary character, which the single quote-state
variable already reproduced.
The tokenizer takes the platform as a parameter, so both sides are pinned from
one host. Every expectation in the new oracle came from running a real ssh and
reading what it resolved to, not from reading source or shell convention -- the
tokenizer's whole job is to agree with ssh about which file it would read.
Also narrows an overclaiming comment: the dedupe set is consulted only by the
host-unknown filter, so a duplicate in the HOST's own snapshot still propagates.
Pre-existing and unchanged; the comment now says what the code actually does.
* fix(ssh): only expand __PROGRAMDATA__ when it is a whole path segment
Found by probing the expansion I had just written, rather than by reading it: a
bare startsWith also matches a path that merely BEGINS with those characters, so
__PROGRAMDATA__evil/known_hosts was rewritten to C:\ProgramData\evil\known_hosts
-- a directory the user never named. Same prefix-collision class I checked the
site-config scanner for and then did not check here.
Low reachability, since the token only appears because Windows OpenSSH emitted
it, and it emits it as a whole segment. Fixed because the expansion is one review
pass old and sits in the security path: a rewritten known_hosts path resolves
somewhere unintended, and a path that resolves to nothing reads as 'no host is
known', which is the fail-open this whole line of work has been closing.
Now requires the token to be the entire path or be followed by a separator --
both separators, since the path is Windows-shaped but may be parsed anywhere. The
test fails without the check.
* test(ssh): split the pty provider spawn tests into their own file
CI's static analysis went red on the merge of main: ssh-pty-provider.test.ts
reached 803 counted lines against a maximum of 800. Both sides contributed --
main grew the file and this branch added 7 lines to it -- so neither shows the
violation alone, which is why local lint stayed green until main was merged in.
AGENTS.md forbids disabling max-lines or bumping a per-file limit, and that rule
is right here: the file was doing two jobs. Spawn owns the startup contract --
ingress version, env scrubbing, execution ownership, and the reconnect races --
and is 630 of the 922 lines. It reads as its own unit rather than as an overflow
file, so it moves to ssh-pty-provider-spawn.test.ts and the shared relay stub
moves beside it under a name that says what it is.
Same tests, same count: 713 provider tests pass, and the line total is unchanged
across the two files.
* fix(ssh): remove the dead lint suppressions, and reach Terminal 1 in the restore spec
Two CI failures, both surfaced by this branch rather than caused by it.
Static analysis: the two no-require-imports suppressions on the ssh2 constants
require() are now unused -- main's config no longer reports that rule there --
and the changed-code audit treats a dead directive as an error. Removed; the
audit CI runs passes locally on the merge.
E2E: ssh-cold-activation-restore failed on clicking Terminal 1. This PR is what
routes that spec into the changed-e2e lane at all -- before, the Docker-SSH
specs only ran when someone edited a spec file, which is the gap this branch set
out to close -- so its first run in CI was here, and the failure is pre-existing
rather than new. The trace shows the cause: six restored tabs overflow the strip
at CI's window size and the restore pins it to the END, so Terminal 1 sits
outside the scroll viewport. Playwright's own scroll-into-view loses that race
against the sticky-to-end effect and times out on an element it can see but
never reaches.
The spec's intent is to activate the first tab and prove it remounted, not to
exercise strip scrolling, so it now scrolls the strip to the start first. Not
papering over a product bug: the strip is a native overflow container with
working arrow controls, so a user can reach the tab -- it is Playwright that
cannot drive a moving target.
Six specs pass locally in CI's exact order and worker count.
* test(ssh): press the restored first tab directly instead of waiting for it to hold still
The previous attempt swapped a click for scrollIntoViewIfNeeded and hit the same
30s timeout, which identifies the real cause: not that Terminal 1 is out of view,
but that it never holds STILL. Both APIs wait for the element to stop moving, and
the strip keeps re-laying-out while the relay reconnects behind it -- so both
time out on an element they can see and never settle on.
Driving the pointer directly needs no element to be stable, only to be somewhere
at the moment it is pressed, and the attempt is retried against the store rather
than believed. Activation is deferred to pointerup and suppressed past a drag
threshold, so it has to be a real down/up pair at one position -- a synthetic
click event would not select the tab at all.
Passes twice locally. The previous version also passed locally, so the honest
statement is that the local runs prove the interaction still works, not that they
reproduce CI's instability -- CI is the oracle for that.
---
.github/workflows/e2e.yml | 14 +-
.github/workflows/pr.yml | 13 +
.gitignore | 2 +
config/scripts/pr-e2e-gate-contract.test.mjs | 52 +-
config/tsconfig.e2e.json | 28 +
docs/reference/remote-wire-compatibility.md | 18 +
docs/reference/ssh-host-key-verification.md | 423 +++++++++++
.../ssh-reconnect-source-recovery.md | 147 ++++
package.json | 1 +
src/main/index.ts | 5 +
...tem-path-containment-remote-enoent.test.ts | 73 ++
src/main/ipc/filesystem-path-containment.ts | 27 +-
.../ssh-pty-provider-mock-multiplexer.ts | 29 +
.../providers/ssh-pty-provider-spawn.test.ts | 657 +++++++++++++++++
src/main/providers/ssh-pty-provider.test.ts | 663 +-----------------
.../providers/ssh-pty-session-reattach.ts | 5 +
src/main/ssh/ssh-config-host-picker.test.ts | 5 +
src/main/ssh/ssh-config-path-expansion.ts | 37 +
...h-connection-host-key-store-wiring.test.ts | 212 ++++++
...h-connection-host-key-verification.test.ts | 138 ++++
src/main/ssh/ssh-connection-test-fixtures.ts | 5 +
src/main/ssh/ssh-connection-test-harness.ts | 37 +-
src/main/ssh/ssh-connection-utils.test.ts | 5 +
src/main/ssh/ssh-connection.test.ts | 19 +-
src/main/ssh/ssh-connection.ts | 291 +++++++-
src/main/ssh/ssh-g-config-resolution.test.ts | 330 ++++++++-
src/main/ssh/ssh-g-config-resolution.ts | 240 ++++++-
src/main/ssh/ssh-host-key-decision.test.ts | 433 ++++++++++++
src/main/ssh/ssh-host-key-decision.ts | 307 ++++++++
src/main/ssh/ssh-host-key-store.test.ts | 353 ++++++++++
src/main/ssh/ssh-host-key-store.ts | 334 +++++++++
src/main/ssh/ssh-host-key-verifier.test.ts | 290 ++++++++
src/main/ssh/ssh-host-key-verifier.ts | 255 +++++++
src/main/ssh/ssh-known-hosts-source.test.ts | 536 ++++++++++++++
src/main/ssh/ssh-known-hosts-source.ts | 155 ++++
src/main/ssh/ssh-known-hosts.test.ts | 435 ++++++++++++
src/main/ssh/ssh-known-hosts.ts | 324 +++++++++
.../ssh/ssh-multi-key-authentication.test.ts | 5 +
...ssh-reconnect-error-classification.test.ts | 16 +
.../ssh/ssh-reconnect-error-classification.ts | 7 +
.../pty-handler-source-publication.test.ts | 43 ++
src/relay/pty-handler.ts | 18 +-
.../ai-vault-session-refresh.test.ts | 45 ++
.../right-sidebar/ai-vault-session-refresh.ts | 21 +
.../settings/SshTargetCard.test.tsx | 84 +++
.../src/components/settings/SshTargetCard.tsx | 4 +-
.../components/terminal-pane/TerminalPane.tsx | 2 +
.../TerminalSshReconnectOverlay.test.tsx | 49 ++
.../TerminalSshReconnectOverlay.tsx | 12 +
.../terminal-pane/pty-connection.ts | 60 +-
.../ssh-reattach-model-restore.ts | 80 ++-
.../ssh-reconnect-model-paint-gate.test.ts | 127 ++++
.../terminal-pane-host-state.test.ts | 30 +
.../terminal-pane/terminal-pane-host-state.ts | 9 +
...space-session-merge-local-survival.test.ts | 340 +++++++++
.../hooks/remote-workspace-session-merge.ts | 133 +++-
...kspace-snapshot-local-tab-survival.test.ts | 209 ++++++
.../store/slices/runtime-environment-ssh.ts | 25 +
tests/e2e/helpers/docker-ssh-relay-target.ts | 13 +
tests/e2e/helpers/runtime-types.ts | 35 +-
tests/e2e/helpers/terminal-tab-open.ts | 27 +
tests/e2e/ssh-cold-activation-restore.spec.ts | 37 +-
.../ssh-docker-reconnect-pane-restore.spec.ts | 161 +++++
.../e2e/ssh-reconnect-tab-destruction.spec.ts | 136 ++++
64 files changed, 7902 insertions(+), 724 deletions(-)
create mode 100644 config/tsconfig.e2e.json
create mode 100644 docs/reference/ssh-host-key-verification.md
create mode 100644 docs/reference/ssh-reconnect-source-recovery.md
create mode 100644 src/main/ipc/filesystem-path-containment-remote-enoent.test.ts
create mode 100644 src/main/providers/ssh-pty-provider-mock-multiplexer.ts
create mode 100644 src/main/providers/ssh-pty-provider-spawn.test.ts
create mode 100644 src/main/ssh/ssh-connection-host-key-store-wiring.test.ts
create mode 100644 src/main/ssh/ssh-connection-host-key-verification.test.ts
create mode 100644 src/main/ssh/ssh-host-key-decision.test.ts
create mode 100644 src/main/ssh/ssh-host-key-decision.ts
create mode 100644 src/main/ssh/ssh-host-key-store.test.ts
create mode 100644 src/main/ssh/ssh-host-key-store.ts
create mode 100644 src/main/ssh/ssh-host-key-verifier.test.ts
create mode 100644 src/main/ssh/ssh-host-key-verifier.ts
create mode 100644 src/main/ssh/ssh-known-hosts-source.test.ts
create mode 100644 src/main/ssh/ssh-known-hosts-source.ts
create mode 100644 src/main/ssh/ssh-known-hosts.test.ts
create mode 100644 src/main/ssh/ssh-known-hosts.ts
create mode 100644 src/renderer/src/components/settings/SshTargetCard.test.tsx
create mode 100644 src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts
create mode 100644 src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts
create mode 100644 src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts
create mode 100644 tests/e2e/helpers/terminal-tab-open.ts
create mode 100644 tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts
create mode 100644 tests/e2e/ssh-reconnect-tab-destruction.spec.ts
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index d06bbeb298b..da7aebdb2cc 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -193,7 +193,9 @@ jobs:
needs: build
if: inputs.test_files != ''
runs-on: ubuntu-latest
- timeout-minutes: 30
+ # Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can
+ # pay a container image build plus ~22 serial SSH tests on top of the changed specs.
+ timeout-minutes: 45
steps:
- name: Checkout
@@ -202,7 +204,9 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
+ # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this
+ # lane now receives those specs from pr.yml's SSH source mapping.
+ run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb zsh
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -241,7 +245,11 @@ jobs:
exit 0
fi
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1)
- if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'; then
+ # Second clause: a spec that reads ORCA_E2E_SSH_DOCKER test.skip()s itself without it, so
+ # naming only one trigger silently skipped every other Docker-SSH spec in this lane.
+ # The first clause stays because that spec needs Docker without referencing the variable.
+ if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' \
+ || grep -l 'ORCA_E2E_SSH_DOCKER' "${TEST_FILES[@]}" >/dev/null 2>&1; then
E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1)
fi
E2E_PROJECT_ARGS=()
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index c44b2abaa56..4327dbeff24 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -515,6 +515,19 @@ jobs:
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
fi
+ # Why: the Docker-SSH specs only ever ran when someone edited a spec, so four
+ # pane-restore regressions shipped from SSH source edits that touched no test.
+ # Unit tests are excluded because they prove the same source without Docker.
+ # ssh-startup-exec-readiness.spec.ts is listed to route e2e.yml's dedicated
+ # ssh-docker-watcher-isolation lane, which filters it out of changed-e2e.
+ if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(src/main/ssh/|src/main/providers/ssh-|src/main/ipc/(ssh-|pty)|src/relay/|src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty))'; then
+ TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
+ 'tests/e2e/pty-input-write-queue-ssh.spec.ts' \
+ 'tests/e2e/ssh-cold-activation-restore.spec.ts' \
+ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' \
+ 'tests/e2e/ssh-startup-exec-readiness.spec.ts' \
+ 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)"
+ fi
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
if [ "$TEST_FILES_JSON" != '[]' ]; then
diff --git a/.gitignore b/.gitignore
index 39b3f792e0f..2ec8b30d17d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -105,6 +105,8 @@ docs/**
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/renderer-agent-status-performance.md
+!docs/reference/ssh-host-key-verification.md
+!docs/reference/ssh-reconnect-source-recovery.md
!docs/reference/windows-setup-shell.md
!docs/reference/worktree-scan-fingerprint.md
diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs
index e8b7c19c5d8..9cd82863760 100644
--- a/config/scripts/pr-e2e-gate-contract.test.mjs
+++ b/config/scripts/pr-e2e-gate-contract.test.mjs
@@ -1,4 +1,4 @@
-import { readFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
@@ -125,6 +125,56 @@ describe('PR E2E gate contract', () => {
expect(filterStep.run).toContain('set -euo pipefail')
})
+ it('maps SSH source edits onto the Docker-backed specs they can break', () => {
+ // Why: the Docker-SSH specs self-skip without ORCA_E2E_SSH_DOCKER, and the only
+ // trigger used to be "someone edited a spec" — four pane-restore regressions shipped
+ // through that hole. Each mapped spec must exist, or the lane runs an empty file list.
+ const sshSourceAuthorities = [
+ 'src/main/ssh/',
+ 'src/main/providers/ssh-',
+ 'src/main/ipc/(ssh-|pty)',
+ 'src/relay/',
+ 'src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty)'
+ ]
+ for (const authority of sshSourceAuthorities) {
+ expect(filterStep.run).toContain(authority)
+ }
+
+ const mappedSpecs = [
+ 'tests/e2e/pty-input-write-queue-ssh.spec.ts',
+ 'tests/e2e/ssh-cold-activation-restore.spec.ts',
+ 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
+ 'tests/e2e/ssh-startup-exec-readiness.spec.ts',
+ 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
+ ]
+ for (const spec of mappedSpecs) {
+ expect(filterStep.run).toContain(`'${spec}'`)
+ expect(existsSync(join(projectDir, spec)), spec).toBe(true)
+ // Why: a spec that stops reading the flag would silently run without Docker.
+ if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') {
+ expect(readFileSync(join(projectDir, spec), 'utf8'), spec).toContain(
+ 'ORCA_E2E_SSH_DOCKER'
+ )
+ }
+ }
+
+ // Why: unit tests next to the mapped sources prove the same code without Docker.
+ expect(filterStep.run).toContain("grep -Ev '\\.test\\.tsx?$'")
+
+ // Why: startup readiness is filtered out of changed-e2e, so listing it is only
+ // meaningful while it still routes the dedicated Docker lane.
+ expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toContain(
+ 'tests/e2e/ssh-startup-exec-readiness.spec.ts'
+ )
+
+ // Why: this lane can now pay a Docker image build plus serial SSH specs.
+ expect(e2eWorkflow.jobs['changed-e2e']['timeout-minutes']).toBeGreaterThanOrEqual(45)
+ const changedInstall = e2eWorkflow.jobs['changed-e2e'].steps.find((step) =>
+ step.name.startsWith('Install native build')
+ )
+ expect(changedInstall.run).toContain('openssh-client')
+ })
+
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
diff --git a/config/tsconfig.e2e.json b/config/tsconfig.e2e.json
new file mode 100644
index 00000000000..1304f50f963
--- /dev/null
+++ b/config/tsconfig.e2e.json
@@ -0,0 +1,28 @@
+{
+ // NOT ENFORCED YET, and deliberately so: tests/ has never been typechecked, so this currently
+ // reports ~198 errors across ~94 files. Wiring it into `typecheck` or CI today would just paint
+ // the whole build red. It earns its place anyway — pointing it at the SSH reconnect spec found
+ // two real bugs a passing suite had hidden (a targetId read off the wrong type, and a required
+ // argument omitted), because that spec had never actually executed. Run `pnpm typecheck:e2e`,
+ // fix a file, repeat; when the count reaches zero, add it to the `typecheck` chain.
+ "extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
+ // Why: specs import renderer and main modules directly, so the ambient declarations those
+ // modules rely on (vite client types, build-time defines) have to be in the program too.
+ "include": [
+ "../tests/**/*.ts",
+ "../tests/**/*.tsx",
+ "../src/renderer/src/env.d.ts",
+ "../src/types/**/*"
+ ],
+ "exclude": ["../tests/e2e/.cross-version-checkouts/**"],
+ "compilerOptions": {
+ // Why: specs run browser code inside page.evaluate, so the DOM lib has to be visible here
+ // even though the harness itself is Node.
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "types": ["node"],
+ "paths": {
+ "@renderer/*": ["../src/renderer/src/*"],
+ "@/*": ["../src/renderer/src/*"]
+ }
+ }
+}
diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md
index 6ca32f49c48..4cfe9545d2e 100644
--- a/docs/reference/remote-wire-compatibility.md
+++ b/docs/reference/remote-wire-compatibility.md
@@ -99,3 +99,21 @@ The harness covers the terminal stream only. It does **not** cover the session-t
sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or
the relay transport. A change on those paths still needs its own reasoning against
the three rules above.
+
+## Known debt: JSON-RPC errors drop Node's string code
+
+An error raised on an SSH host crosses the relay as JSON-RPC, and
+`ssh-channel-multiplexer` rebuilds it with the TRANSPORT's numeric `code`. Node's
+string code — `'ENOENT'`, `'EACCES'` — does not survive, so a caller on this side
+cannot ask what kind of failure it was.
+
+`isENOENT` in `src/main/ipc/filesystem-path-containment.ts` pays for that by also
+matching Node's canonical message text, which is what makes remote worktree creation
+work. The cost is that a host can make an unrelated failure read as "absent" by
+putting that sentence in a message.
+
+The exit is Rule 1: carry the original string code in a new optional field on the
+error payload and read that instead. An old host omits it and the message match still
+covers them; once hosts that send it are the floor, the message match can be deleted
+rather than lived with at its ~10 call sites. Narrowing `isENOENT` back to `.code`
+without doing this reinstates the bug — the transport has already overwritten it.
diff --git a/docs/reference/ssh-host-key-verification.md b/docs/reference/ssh-host-key-verification.md
new file mode 100644
index 00000000000..a84ea7ee4ea
--- /dev/null
+++ b/docs/reference/ssh-host-key-verification.md
@@ -0,0 +1,423 @@
+# SSH host key verification (STA-4319)
+
+Revised after security and migration review. Where a first draft was wrong, the correction is kept
+visible rather than quietly edited out — the reasoning matters for anyone changing this later.
+
+## The defect
+
+`src/main/ssh/ssh-connection.ts:1184` installs a `hostVerifier` that records a SHA-256 fingerprint
+and then `return true`. Every ssh2 connection accepts every host key. There is no `known_hosts`
+consult, no trust record, and no change detection anywhere in `src/main/ssh/`. There is exactly one
+ssh2 `Client` construction site, so the fix has a single chokepoint.
+
+Scope is per-connection, not per-feature: one `SshConnection` per target serves exec, SFTP, port
+forwarding, the filesystem watcher and relay deploy.
+
+### Threat model, corrected
+
+Traffic is still encrypted, so a passive observer gets nothing. The exposure is an **active**
+attacker who can redirect the connection — ARP/DNS spoofing, hostile Wi-Fi, a hijacked internal name.
+
+Three corrections to the first draft:
+
+- **Jump hosts are NOT the worst case; they are already safe.** `shouldUseSystemSshTransport`
+ (`ssh-transport-selection.ts:71-91`) returns true for exactly the conditions under which
+ `resolveEffectiveProxy` (`ssh-proxy-command.ts:17-38`) returns a proxy — the two branch on the same
+ inputs in the same order — and `attemptConnect` returns unconditionally after the system probe
+ (`ssh-connection.ts:670-673`). So ProxyJump/ProxyCommand go through OpenSSH and are already
+ verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and
+ the first draft's motivating example was simply wrong.
+- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes`
+ (`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent *auth*, whose
+ signatures bind the session id and cannot be replayed onward. The risk applies to users who opted
+ into `ForwardAgent`, not everyone.
+- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError`
+ treats *any* auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
+ publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key
+ **passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every
+ reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already
+ owns their machine. The real client-side impact is the **return** direction: the attacker becomes
+ the host our workspace trusts, driving relay protocol frames, landing SFTP content in local
+ worktrees, and feeding agent-hook payloads in.
+
+## Decisions
+
+### D1. Read the user's `known_hosts`; write only to our own store
+
+Consult the user's real `known_hosts` as a trust source — most developers already have their hosts
+there from `ssh` and `git`, which is the entire migration story. Do **not** write to it: that file is
+shared with every other SSH tool on the machine, and appending brings line-endings, permissions,
+concurrent writers, and a corruption blast radius well beyond us.
+
+Two consequences to own rather than discover:
+
+- **Revocation does not propagate into our store.** `ssh-keygen -R host` clears `known_hosts` but not
+ our record. That is survivable for the ordinary rotation, because a `known_hosts` MATCH is now
+ decided before our store's mismatch — running the remedy we print and reconnecting works, which it
+ did not when the store was consulted first. What it does not cure is a host we only ever knew
+ ourselves, never written to `known_hosts`: there is no `ssh-keygen -R` for that one, so the
+ rejection names the store file directly. The "forget" action (D5) replaces that with a button; its
+ helper is deliberately absent until then, since an exported API nothing can reach is unverified in
+ production. Mismatch messaging must keep naming which source disagreed.
+- **`ssh -G` on the HOME-divergent `-F` path suppresses `/etc/ssh/ssh_config`**
+ (`ssh-g-config-resolution.ts:44-52`), hiding site-wide `StrictHostKeyChecking yes` and
+ `GlobalKnownHostsFile`. On that path we must fail **strict**, never laxer than `ssh` would.
+
+ There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports
+ built-in defaults, so a probe built on it looks permissive on every machine. Verified against
+ OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — *could*
+ the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable
+ `Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably
+ says nothing about host keys clears it, which is what stops the rule punishing every devcontainer,
+ `su` shell and Nix shell.
+
+### D2. Ask `ssh -G`, do not reimplement config resolution
+
+`ssh -G` reports `userknownhostsfile`, `globalknownhostsfile`, `stricthostkeychecking`,
+`checkhostip`, `hostkeyalgorithms`, `fingerprinthash`, `hashknownhosts`, `updatehostkeys` and
+**`hostkeyalias`** — with `Match` and `Include` already applied. `resolveWithSshG` exists and simply
+does not read them yet.
+
+`userknownhostsfile` is a space-separated list on one line, may contain `~`, and may contain
+double-quoted paths with spaces. When `ssh -G` is unavailable (no `ssh`, non-zero exit, >5s timeout)
+fall back to `~/.ssh/known_hosts` + `known_hosts2` — never to accept.
+
+`HostKeyAlias` must be honoured: users tunnelling bastions through `localhost:port` depend on it and
+would otherwise hit spurious mismatches. It appears nowhere in `src/main/ssh/` today.
+
+**Lookup key.** Config resolution uses `configHost || label` (`ssh-connection.ts:660`) while ssh2
+dials `effectiveHost` (`ssh-connection-utils.ts:188`). The `known_hosts` lookup must use
+`HostKeyAlias` if set, else the **resolved hostname** — keying on the Orca label would miss every
+existing entry.
+
+**Two ordered lookup passes, not one candidate set.** Verified against OpenSSH 10.2p1: a non-default
+port looks up `[host]:port` first, and if that finds nothing it retries the **bare** host. Crucially,
+on that second pass a wrong key is downgraded to `unknown` rather than reported as changed. So the
+passes are `[['[host]:port'], ['host']]`, and the fallback pass can only yield `match` or `unknown`.
+Collapsing them into one set would give a spurious first-contact prompt to anyone who has a bare
+line and connects on a non-default port; treating the fallback as authoritative would raise a false
+change-of-key alarm.
+
+**The entry condition to that second pass is the part that bites.** ssh runs it only when the
+port-qualified lookup matched no plain entry of ANY key type — not "no match". Gating it on
+"no match and no same-type mismatch" reaches the bare line when an off-port entry of another type
+exists, and returns `match` where ssh prints `IDENTIFICATION HAS CHANGED`: an accept-a-changed-key
+path, reproduced live. And the observations from each pass must not leak into the other, or an entry
+found only on the fallback refuses a host ssh accepts as first contact.
+
+**`HostKeyAlias` suppresses the port entirely.** ssh looks the alias up bare and never brackets it,
+so an alias gets ONE pass regardless of port. Combined with the rule above, a stale `[alias]:port`
+line would otherwise block the bare lookup ssh actually performs — turning the bastion case this
+feature cites `HostKeyAlias` for into a hard failure.
+
+**Hashed entries hash the candidate form, not the bare host** — `[example.com]:2222` is what gets
+HMAC'd for a bracketed entry, so each candidate must be hashed separately.
+
+**Multiple files union.** Any exact hit in any file wins; a disagreeing entry in another file does
+not make it a mismatch. Confirmed live in both orderings.
+
+**A `@cert-authority` line whose key equals the presented plain host key is not a match** — a CA line
+only validates certificates. A normal line alongside it still decides. But ssh's verdict for a
+CA-covered host presenting a plain key is `HOST_NEW`, not a failure: it connects. See D4.
+
+### D3. Six outcomes, and type scoping is only safe with algorithm ordering
+
+`match | mismatch | revoked | ca-only | unknown-type-known-host | unknown`.
+
+Mismatch is scoped to the same key type: a host with only an RSA entry that presents ed25519 is not
+"changed". Without scoping we would false-alarm nearly every RSA-era user on their first upgraded
+connect, training them to dismiss the one warning that matters.
+
+> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the
+> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for
+> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — known_hosts
+> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and
+> refuses. So ssh does not avoid the false alarm by scoping; it avoids the *situation* via
+> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch`
+> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's
+> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises.
+
+**But scoping alone is a downgrade vector, and this is the correction that most changes the design.**
+OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key
+algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the *client's* order
+priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first
+regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a
+friendly first-contact prompt instead of a hard failure.
+
+Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that
+host.** Type scoping without algorithm ordering is not a safe design.
+
+And when the presented type is unknown *while other types are known for this host*, that is
+`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key
+for this host.
+
+### D4. Outcomes
+
+- **match** → connect silently.
+- **unknown** → trust-on-first-use (see the phasing below for whether that is silent or prompted).
+- **mismatch** → hard fail, no override in the failure surface.
+- **revoked** → hard fail, always.
+- **ca-only** → ~~hard fail~~ **REVERSED: treated as first contact.** See below.
+- **unknown-type-known-host** → treat as suspicious, not first contact.
+
+`StrictHostKeyChecking` is honoured: `no`/`off` accepts unknown but **never persists** and still
+hard-fails changed and revoked; `accept-new` persists silently; `yes` denies unknown.
+
+> **`ssh -G` does not report the spelling the user wrote.** StrictHostKeyChecking is rendered through
+> `fmt_multistate_int`, which prints the first entry of `multistate_strict_hostkey`, and that table
+> lists true/false before yes/no. So `yes` arrives as `true`, `no` and `off` both as `false`; only
+> `ask` and `accept-new` pass through unchanged. Matching on `yes`/`no`/`off` matches nothing a real
+> config can produce. `UpdateHostKeys` has the same shape (`true`, not `yes`).
+
+**ca-only, reversed after review.** The rejection was stricter than ssh, and the blast radius was
+mispriced. An SSH CA user holds ONE line — very often `@cert-authority *` — which matches every
+candidate, so EVERY target failed, not just CA-signed ones, including on-demand runtime VMs, and
+`StrictHostKeyChecking=no` did not help. `ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1` is read from the
+process environment, which an Electron app launched from the Dock or Start Menu does not have, so
+the documented escape was unreachable for exactly the people who needed it. And OpenSSH's own
+verdict for a CA-covered host presenting a plain key is `HOST_NEW`: it connects. ssh2 cannot
+validate certificates at all, so refusing conceded nothing ssh was not already conceding.
+
+The residual risk is accepted, not resolved: for a CA-protected host we take a plain key we cannot
+tie to the CA. Certificate support is Phase 2 work. The `ca-only` outcome is still produced and
+carried through the decision so the log shows a CA line was involved.
+
+**An unreadable known_hosts connects but records nothing.** A file that EXISTS and will not open is
+the absence of evidence, and the common trigger is not exotic — a Windows OneDrive Known Folder Move
+placeholder while offline fails with a cloud-file error, not ENOENT. Refusing there broke an
+ordinary corporate laptop while blaming a config file that was fine, and was asymmetric with our own
+store, which degrades to "nothing trusted" and connects. ssh warns and treats the host as unknown;
+so do we — but we write no record, so a first contact we could not check never becomes durable
+trust. An ABSENT file is not this case: that is the normal state for a fresh profile and genuinely
+means nothing is known.
+
+### D5. Recovery must not live in the failure dialog
+
+A "forget this host key" button *in* the mismatch dialog is D4's rejected "trust anyway" with one
+extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it
+shows the stored fingerprint so the user is choosing knowingly.
+
+Offer it only when **our** store is what disagreed; when `known_hosts` disagrees, forgetting our
+record cannot unblock the connect. Messages, written to avoid naming internals:
+
+> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you
+> rebuilt or reprovisioned this machine, this is expected." → *Forget the saved key* / *Cancel*
+
+> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in
+> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." →
+> no button, because a button would not help.
+
+### D6. Never prompt on a background reconnect
+
+A prompt only means something when a human initiated the connect. `userInitiated` does not exist on
+the connect path today and must be threaded through `connect → attemptConnect → doSsh2Connect`,
+defaulting **false**.
+
+Two traps: `useAutomationDispatchEvents.ts:203` and `pty-connection.ts:857` reach `ssh:connect`
+without a human click — automation must pass `false`, but **terminal-pane focus reconnects must count
+as user-initiated** or terminals die silently. And the denial string must avoid "authentication
+failed"/"permission denied", or `isAgentFallbackError`/`isAuthError`
+(`ssh-connection-utils.ts:46-61`) misclassifies it and the reconnect ladder retries a decision that
+will never change.
+
+### D7. Fail closed — three known fail-open shapes
+
+1. The existing generation/disposed guard at `:1185` has the fail-open shape today: skip recording,
+ still `return true`. Post-fix that branch must **deny**.
+2. A synchronous throw inside the verifier may not be caught by ssh2 — wrap and `verify(false)`.
+3. Any non-`undefined` return accepts immediately (see Traps).
+
+Plus: no prompt channel registered → deny (the load-bearing default lives in `doSsh2Connect`, not in
+IPC, so a caller that forgets to wire it cannot accidentally accept); no window → deny; timeout →
+deny; dialog dismissed → deny.
+
+### D8. Store shape and scope
+
+Accepted keys are scoped to **host + port + key type**, not target id — aliases point at different
+machines, two targets can name one machine, and a re-created target must not lose trust.
+
+The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings
+restore or rollback must not silently reset trust. Accept and mismatch events are logged.
+
+`hostKeyFingerprint` is now security-relevant *and* wire-relevant — it is an isolation namespace sent
+to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on
+the system transport, so **no trust logic may key off it**, and its format must not change (see
+Traps).
+
+## Phasing — ship the defence before the dialog
+
+Review made the case that the riskiest part of this change is not the security model but the modal.
+Startup restore fires eager connects for *all* previously-active targets in parallel (`App.tsx:1041`)
+with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs
+outliving the timeout that already deferred them. Runtime-owned ephemeral VMs
+(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every
+launch. Paired-web connects run on the *host desktop* (`runtime/rpc/methods/ssh.ts:32`), so the
+dialog would open on someone else's screen while the web user watches a spinner.
+
+**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists
+silently with `accept-new` semantics and a passive notification naming the host and fingerprint.
+`mismatch` (same type) and `revoked` hard-fail. This is the entire MITM defence with zero prompts,
+zero startup storms and zero web hang.
+
+**Phase 2** — the TOFU dialog, `StrictHostKeyChecking` honouring, `ca-only`, `userInitiated`
+plumbing, and the D5 settings surface.
+
+Carve-outs required before Phase 1 ships:
+
+- **Runtime-owned ephemeral targets are exempt from persistence** — a new key every launch is
+ expected, not suspicious, and recording one would accumulate a row per launch that eventually
+ reads as a spurious change. Implemented via `target.owner?.type === 'on-demand-runtime'`.
+- **RPC-originated connects: NOT needed in Phase 1, required in Phase 2.** The review asked for
+ these to fail fast rather than leave a paired-web user watching a spinner for the 120s prompt
+ timeout. That hang is only reachable if a prompt exists, and Phase 1 has none — the decision
+ function is pinned by a test asserting it never returns `prompt`. An RPC connect therefore behaves
+ exactly like a local one: it accepts and records on first contact, or fails immediately with the
+ host-key reason. Adding a fail-fast path now would introduce a failure mode for a hang that cannot
+ occur. It becomes load-bearing the moment the dialog lands, and is listed in Phase 2.
+
+ Worth noting for Phase 2: `runtime/rpc/methods/ssh.ts` already swallows the specific error and
+ rethrows `getPublicSshError(status)`, so a web client sees a generic failure rather than the
+ host-key reason. Pre-existing, but it means the Phase 2 message will not reach the web user
+ without a change there too.
+
+## Traps
+
+Each of these makes the fix silently do nothing. All confirmed in our tree.
+
+1. **An `async` verifier defeats it entirely.** ssh2 does
+ `const ret = hashCb(key, verify); if (ret !== undefined) verify(ret)`. An async function returns a
+ Promise — not `undefined`, and truthy — so ssh2 accepts before our callback settles.
+2. **Do not set ssh2's `hostHash`.** It hands the callback a hex digest and discards the raw blob we
+ must compare — and it would change `hostKeyFingerprint`'s format, which is a cross-version state
+ break, not a local refactor.
+3. **The existing test mock calls `hostVerifier(key)` with one argument** and ignores the return
+ (`ssh-connection.test.ts:86-91`). Under an async verifier every connect test there breaks. The
+ mock must change — flagged deliberately, not rewritten silently.
+4. **Validate the blob**: embedded algorithm name must match the line's key-type field; reject empty
+ decodes, empty salts, and hashed entries whose hash is not 20 bytes.
+5. **`ssh-relay-live-connect.test.ts:59`** constructs a connection with no credential callback —
+ headless with no prompt channel must deny, not hang.
+
+## Scope
+
+**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this
+is a *parser* requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
+`unknown`, which is the prompt-training harm D3 exists to avoid.
+
+**Out of scope, with consequences stated:**
+- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only.
+- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL
+ users get first-contact treatment for hosts they already verified.
+- **`UpdateHostKeys`** — we read it and use nothing, so we never learn a rotated key, which makes D5
+ the routine path for key rotation rather than an exception.
+- **Moving SFTP to the system transport** — correct direction, separate change.
+
+## Test plan
+
+**Parser** (against the file format, not our code's shape): plain lines, `host,host2` lists,
+`[host]:port` used only when port ≠ 22, IPv6 literals, hashed `|1|salt|hash` with a real computable
+vector, `@revoked`, `@cert-authority`, `*`/`?` globs, `!` negation vetoing a whole line, unrecognised
+`@marker` skipping the line, malformed lines skipped not fatal, multiple keys per host, CRLF, blank
+lines, comments, user file and global file disagreeing.
+
+**Decision function**: all six outcomes; type scoping; revocation resolved before match regardless of
+line order; every `StrictHostKeyChecking` value; `no`/`off` never persists.
+
+**Algorithm ordering**: `algorithms.serverHostKey` leads with types on file — the test that makes D3
+safe rather than merely scoped.
+
+**Wiring**: unknown persists (Phase 1) without a prompt; match never notifies; mismatch fails with no
+accept path; revoked fails; background reconnect denies; aborted connect settles pending verify
+false; no prompt channel denies; runtime-owned targets are exempt; the denial string does not match
+`isAuthError`; and — catching the worst regression — **the verifier returns nothing**, so a refactor
+to `async` reddens a test rather than reaching a user.
+
+**Checked against a live client, not just the file format.** Two assumptions the design leans on were
+verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1:2222` and recording
+its verdict:
+
+- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a
+ bare line holding a *different* key, dialed on a non-default port, made ssh connect and append a
+ new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch`
+ on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious
+ choice.
+- **`unknown-type-known-host` is ssh's own behaviour.** known_hosts holding `ssh-rsa` while the
+ server offers ed25519 makes ssh print `IDENTIFICATION HAS CHANGED` and refuse. So the rejection is
+ neither stricter nor laxer than ssh — and treating it as first contact, which a naive type-scoped
+ lookup does, is the laxer mistake. It also means `ssh-keygen -R` is the right remedy to name there.
+
+## What Phase 1 shipped, and what review changed
+
+The design above survived implementation. Every defect found afterwards was in the wiring, and the
+pattern is worth recording because it repeats: **each one made us either blind or unusable, never
+subtly wrong.**
+
+Fixed after review:
+
+1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could
+ only answer match/mismatch/unknown, so a record of a *different* type read as `unknown`. D3's
+ downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm
+ ordering — without that the guard is only half present.
+2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host
+ block matches, so for a manual target `resolved.hostname` *is* the label — the one name D2
+ forbids. We consulted no entries at all.
+3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth
+ failure, so we went on to prompt for the passphrase and hand it to the host we had just refused.
+ Rejections are now a typed error recognised before any fallback.
+4. **Fail-closed nearly became fail-always.** "No readable known_hosts" counted a *missing* file the
+ same as an unreadable one, so a profile that had never connected — everyone's first run — would
+ have been refused, and the suite passed only because dev machines have a `known_hosts`.
+5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the
+ incomplete-sources check, so a HOME-divergent environment turned on-demand runtimes off entirely.
+
+A second review round, run against a live OpenSSH client and sshd rather than against the source,
+found five more — and the pattern held: the two that mattered most were both cases where we refused
+a host `ssh` connects to, and the worst single defect was that **`StrictHostKeyChecking` had never
+been read correctly at all**, so a config saying `yes` was silently accepted AND persisted. See the
+D2/D3/D4 corrections above. The lesson worth keeping: every one of these was invisible to unit tests
+that fed the code the value a human writes, rather than the value the tool emits.
+
+## Action items (STA-4319)
+
+**Where the message actually lands.** Traced end to end, because a rejection the user cannot read is
+a half-shipped feature. Fixed in this branch: the settings card clamped it to one line with no
+tooltip, and the terminal reconnect overlay never asked for it at all. Still open:
+
+- **The "Remote Hosts" status bar shows only `Error`.** `SshTargetStatusRow` does not receive the
+ error, so the status bar is a dead end for the most likely place a user notices the failure.
+- **"Connect again" is the wrong advice for a decision that will never change.** The terminal overlay
+ now prints the reason underneath, but its call to action still invites an action that cannot
+ succeed. Telling a permanent rejection from a transient fault in the renderer needs a typed reason
+ on the wire rather than a string — a remote-wire-compatibility decision, so deliberately deferred.
+- **Toasts carry Electron's `Error invoking remote method 'ssh:connect':` prefix.** The repo has
+ strippers for exactly this; no SSH call site uses one. Also worth noting sonner auto-dismisses in
+ 4s, which is short for a message ending in a command the user is meant to copy.
+
+**Before Phase 2:**
+
+- **`UpdateHostKeys` (out of scope above, now the highest-value gap).** We read it and use nothing,
+ so a rotated key is a hard failure the user must resolve by hand. Combined with D5 this is the
+ routine path for key rotation, and it will be the most common way a legitimate user meets a
+ rejection. Decide whether Phase 2 honours it or D5's recovery surface absorbs it.
+- **The web user never sees the reason.** `runtime/rpc/methods/ssh.ts` rethrows
+ `getPublicSshError(status)` on all three paths, and push events are redacted through
+ `getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`.
+ Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the
+ redaction is not web-only: any target owned by a paired runtime environment is redacted, so a
+ *desktop* user viewing a remote-Orca-server-owned host gets the same generic string.
+- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing).
+
+**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:**
+
+- **WSL** — a distro's `known_hosts` is unreachable, so WSL users get first-contact treatment for
+ hosts they already verified through `ssh` inside the distro.
+- **`CheckHostIP`** — candidates are formed from the hostname only.
+- **Certificate validation** is still absent — a CA-covered host is now accepted on first contact
+ rather than refused (D4), so those users connect, but the CA itself verifies nothing for us.
+- **`DEFAULT_SERVER_HOST_KEY_ALGORITHMS`** is a hand-copy of an ssh2 internal. A test pins it, so an
+ ssh2 upgrade that changes it fails CI rather than shipping — but the pin has to be honoured, not
+ deleted, because ssh2 throws `Unsupported algorithm` and every target stops connecting.
+
+**Rollout:** the first release carrying this is the first time Orca can refuse an SSH connection at
+all. Worth a staged rollout or a kill switch: the failure modes we could not find are, by the shape
+of the five above, far more likely to be "a legitimate host is refused" than "a bad key is accepted".
diff --git a/docs/reference/ssh-reconnect-source-recovery.md b/docs/reference/ssh-reconnect-source-recovery.md
new file mode 100644
index 00000000000..5dffc50e7dd
--- /dev/null
+++ b/docs/reference/ssh-reconnect-source-recovery.md
@@ -0,0 +1,147 @@
+# SSH reconnect: why the pane retry gets a byte tail, and what would actually change it
+
+Status: investigation result. The obvious follow-up to PR #14844 was traced and **rejected**, and
+tracing it turned up the actual root cause: checkpointed source recovery has never run on an SSH
+reconnect. Both are recorded here — the rejected shape so nobody re-proposes it, and the verified
+cause with the fix it implies.
+
+## The shape of the problem
+
+A reconnect remounts the pane (`tab.generation` is its React key), so the xterm is disposed with its
+buffer and something must repaint it. Today that is a **byte tail**: `reattachSshPtySession` sends
+`requireReplay: true` and the relay returns `RecentPtyOutputBuffer.read()` — the last 100KB, read
+non-destructively, with no notion of what this client already consumed.
+
+Two costs follow. Main's `@xterm/headless` model never sees those bytes (the tail bypasses
+`onPtyData`), so it is stale by exactly the outage — which is what forces
+`sshReconnectPaintsFromModel` to restrict the grid repaint to the alternate screen. And a shell loses
+outage output past 100KB permanently.
+
+## The proposal that does not work
+
+"Make the pane-retry path request source recovery like `reattachKnownPtys` does." Mechanically this
+is trivial — `sourceRecovery` is already an optional `pty.attach` param the relay parses, Path C
+already calls the same `requestSshPtyAttach` helper and already parses the response field. The
+required checkpoint state also survives a transport drop, in the module-level `recoveryByTarget` map
+(`ssh-pty-consumer-recovery.ts:17`), reachable from `connectionId` because `connectionId === targetId`.
+
+It still fails, three ways:
+
+1. **The relay answers `'existing'` before it looks at the recovery argument.**
+ `relay-pty-source-publication.ts:99-109` short-circuits on a same-`clientId` attach, and a
+ reconnected client presents the same id — see the root-cause section below, where this turns out
+ to be the whole story rather than an obstacle specific to this proposal.
+2. **A failed `reattachKnownPtys` deletes the checkpoint on purpose** (`ssh-relay-session.ts:3006-3007`)
+ and detaches the lease (`:3008`). The pane retry runs _after_ that, so it would present
+ `checkpointUnavailable`, which the relay converts to `restoreRequired`
+ (`relay-pty-source-publication.ts:124-130`) and the provider converts to
+ `SSH_SESSION_EXPIRED_ERROR` (`ssh-pty-provider.ts:103-107`). We would trade a blank-pane-with-tail
+ for a **killed session**.
+3. **Wrong payload shape.** Recovery replays only the post-checkpoint delta
+ `(acceptedSourceEndSu → receivedEndSu]`. The byte tail is a screen snapshot for a _fresh, empty_
+ xterm. Even a successful recovery returns roughly nothing in the common case, and the pane stays
+ blank.
+
+These two mechanisms answer different questions. Recovery keeps main's model whole; the tail repaints
+a new terminal. Substituting one for the other is a category error.
+
+## A correction worth recording
+
+The motivating argument was "`requireReplay` is optional, so older relays ignore it and still show
+blank panes." **That is wrong for the SSH relay.** The client deploys and launches its own relay
+build into a version-scoped directory (`ssh-relay-deploy.ts:231`, `:594`), and `validateGrant`
+rejects any grant whose `serverBuildId` differs from the expected one
+(`ssh-pty-consumer-session.ts:58-65`, rationale in-code: _"client and relay ship in one build"_).
+Client and SSH relay are version-locked; mixed versions do not occur on this channel. The
+independent-update rule in `remote-wire-compatibility.md` still governs remote _runtime_ hosts — just
+not this one.
+
+So there is no old-host population to rescue, and the urgency that argument created was false.
+
+## ANSWERED: checkpointed recovery never runs on an SSH reconnect
+
+The question above was "does the reconnecting client present a new `clientId`?" It does not, and the
+consequence is that the whole checkpoint mechanism is dead on this path. Every link verified:
+
+1. **The client keeps its id.** A reconnect calls `Dispatcher.setWrite`
+ (`src/relay/dispatcher.ts:149-157`), which reuses `this.primaryClient` — including its `id` — and
+ replaces only the writer. The dispatcher refuses to detach the primary. This is already stated
+ in-repo at `src/relay/pty-handler.ts:1736-1742`.
+2. **So `activate()` short-circuits.** `relay-pty-source-publication.ts:99` tests
+ `current?.clientId === context.clientId` and returns `'existing'` at `:108`. The `rotateDelivery`
+ recovery path at `:118-142` is reachable **only** when the ids differ — i.e. never, here.
+3. **So the relay returns no `sourceRecovery`.**
+4. **So the client abandons.** `finishSourceRecovery` (`ssh-relay-session.ts:2766-2785`) fails its
+ `!pendingRecovery` guard, calls `abandonPtySourceRecovery`, and returns false — which cancels the
+ delivery and deletes the checkpoint (`:3006-3008`).
+5. **So the pane retry opens fresh and gets the byte tail**, via the `requireReplay` fix.
+
+The byte tail is therefore not a fallback. It is the only path that has ever run for an SSH
+reconnect, and the flow-control/checkpoint machinery is inert on this path.
+
+That also explains the original blank-pane bug exactly: the relay concluded "this client already
+holds the stream" because, by its own identity rule, it does.
+
+### The fix this implies
+
+Give a reconnected primary a distinguishable identity — a transport generation on the client record,
+bumped in `setWrite` — and have `activate()` compare it alongside `clientId`, so a reconnect takes
+`rotateDelivery` instead of `'existing'`.
+
+Why this is the tractable shape:
+
+- **No wire change.** `RequestContext`, `setWrite` and the publication are all relay-internal.
+- **No compatibility exposure.** Client and relay ship in one build and are version-locked.
+- **It does not disturb the invariant that broke three earlier attempts.** Deliveries still outlive
+ their clients; nothing retires on `onClientDetached`. The delivery is _rotated on re-attach_,
+ which is what the recovery design already intends and what its tests already cover.
+
+**UNVERIFIED and to be checked before implementing:** that `rotateDelivery`'s preconditions hold at
+that moment (the checkpoint's `deliveryToken`, `clientGeneration`, `ownerGeneration` and
+`ptyIncarnation` must match the live identity, `:124-128`); that `outputFlowControl` is granted on
+the reconnected session; and what a rotation implies for the _renderer_, which still remounts with an
+empty xterm and needs a screen, not a post-checkpoint delta. Recovery keeps main's model whole — it
+does not by itself repaint a fresh terminal, so the tail may still be wanted for the pane even once
+the model stops going stale.
+
+## Do not start at `onClientDetached`
+
+Three attempts failed there, each plausible until run:
+
+- Retiring the delivery on `dispatcher.onClientDetached` **breaks checkpoint recovery** (10 tests).
+ A delivery outliving its client is deliberate — it is what lets a client resume from a checkpoint.
+- Retiring without `session.cancelDelivery()` orphans the credit ledger's one-upstream-owner slot;
+ the next open throws `PTY source delivery already has an upstream owner`. Seen live as a toast and
+ a blank pane.
+- Comparing `record.identity.clientGeneration` to the request is impossible: that value is
+ client-supplied via `pty.openClient`, and `RequestContext` carries no generation of its own.
+
+## The lead that survives
+
+`reattachRejectedPty` (`ssh-relay-session.ts:1957-2004`) is an existing **single-PTY** entry point
+into the `reattachKnownPtys` machinery, taking `(relayPtyId, mux, providerGeneration, mode)` and
+driving recovery with `targetedDeliveryRecovery`. If per-pane recovery is wanted, that is the hook —
+and it does not involve the pane-retry path at all. Unverified whether it is reachable at the moment
+the renderer retries.
+
+## Preconditions, unchanged
+
+The SSH e2e lane must be green and triggering on **source** changes before any of this is attempted.
+It was skipping for 15 specs; four regressions reached a user during that window.
+
+## Open: the pane behind a preserved tab does not always rebind
+
+The merge now keeps a local tab the host has never been told about, so the tab and its title survive
+a reconnect. The reattach behind it does not, reliably — measured at three runs in four against the
+Docker-SSH lane. When it misses, the store holds the tab, the tab bar renders it, and the pane never
+rebinds: the "frozen tab" shape the original report described, one layer down from the deletion that
+used to cause it.
+
+Deliberately NOT asserted in `ssh-reconnect-tab-destruction.spec.ts`. A one-in-four flake in the lane
+that exists to catch this class costs more than it proves — the lane stops being trusted, which is
+exactly how the earlier silent-skip failure happened. Tab survival is asserted there and is
+deterministic; the liveness gap is recorded here instead.
+
+Worth checking first, since it is the same shape as everything else in this file: the tab is absent
+from the host snapshot, so whatever drives the per-tab reattach after an apply may simply not know to
+reattach a tab the snapshot never mentioned.
diff --git a/package.json b/package.json
index aa31c469d87..eef30962ce9 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"typecheck:node": "tsc --noEmit -p config/tsconfig.node.json",
"typecheck:cli": "tsc --noEmit -p config/tsconfig.tc.cli.json",
"typecheck:web": "tsc --noEmit -p config/tsconfig.tc.web.json",
+ "typecheck:e2e": "tsc --noEmit -p config/tsconfig.e2e.json",
"typecheck": "tsc --noEmit -p config/tsconfig.node.json && tsc --noEmit -p config/tsconfig.tc.cli.json && tsc --noEmit -p config/tsconfig.tc.web.json",
"typecheck:tsc:node": "tsc --noEmit -p config/tsconfig.node.json --composite false",
"typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false",
diff --git a/src/main/index.ts b/src/main/index.ts
index 90aed9840ef..94c06be69f1 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -18,6 +18,7 @@ import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths'
import { applyAppIcon } from './app-icon'
import { relaunchApp } from './app-relaunch'
import { StatsCollector, initStatsPath } from './stats/collector'
+import { initSshHostKeyStoreFile } from './ssh/ssh-host-key-store'
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store'
@@ -2227,6 +2228,10 @@ void app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
+ // Why here: the host key store is a sidecar of the same profile, and every SSH connect consults
+ // it. Left unbound it reports nothing trusted, which is safe but silently discards our own
+ // accept records on every launch.
+ initSshHostKeyStoreFile(activeOrcaProfile.dataFile)
// Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow.
neutralizeLegacyTerminalShimDir(app.getPath('userData'))
const windowsShellPathHydration = createWindowsShellPathHydration()
diff --git a/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts b/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts
new file mode 100644
index 00000000000..79a8c401e9d
--- /dev/null
+++ b/src/main/ipc/filesystem-path-containment-remote-enoent.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest'
+
+import { isENOENT } from './filesystem-path-containment'
+
+/**
+ * Creating a worktree over SSH failed with a raw
+ * `ENOENT: no such file or directory, lstat '/home/neil/projects/orca-test1234'`.
+ *
+ * The path was the one about to be created, so its absence was correct and expected — the caller
+ * (`remotePathExists`) asks exactly that question and returns false on ENOENT. It could not: the
+ * error was raised on the SSH host, crossed the relay as JSON-RPC, and
+ * `ssh-channel-multiplexer.handleResponse` rebuilds it with the TRANSPORT's numeric code. Node's
+ * 'ENOENT' string code is gone by construction, so the check never matched and the error was
+ * rethrown at the user.
+ *
+ * The stack in the trace log is what settles it — the throw originates in `handleResponse`, not in
+ * any local fs call, which is why hunting for a local `lstat` found nothing.
+ */
+describe('isENOENT across the SSH relay boundary', () => {
+ it('recognises a local Node error by its code', () => {
+ const local = new Error(
+ "ENOENT: no such file or directory, lstat '/tmp/nope'"
+ ) as NodeJS.ErrnoException
+ local.code = 'ENOENT'
+
+ expect(isENOENT(local)).toBe(true)
+ })
+
+ it('recognises an error rebuilt from the relay with a numeric transport code', () => {
+ // Exactly what handleResponse produces: message preserved, code replaced by the JSON-RPC code.
+ const fromRelay = new Error(
+ "ENOENT: no such file or directory, lstat '/home/neil/projects/orca-test1234'"
+ )
+ Object.defineProperty(fromRelay, 'code', { value: -32000 })
+
+ expect(isENOENT(fromRelay), 'a remote missing path must read as absent, not as a failure').toBe(
+ true
+ )
+ })
+
+ it('recognises it through the IPC wrapper the renderer sees', () => {
+ const throughIpc = new Error(
+ "Error invoking remote method 'worktrees:create': Error: ENOENT: no such file or directory, lstat '/home/neil/projects/orca-test1234-3'"
+ )
+
+ expect(isENOENT(throughIpc)).toBe(true)
+ })
+
+ it('recognises an error carrying no code at all', () => {
+ expect(isENOENT(new Error("ENOENT: no such file or directory, stat '/x'"))).toBe(true)
+ })
+
+ it('does not treat a different errno as absent', () => {
+ const denied = new Error("EACCES: permission denied, lstat '/root/x'") as NodeJS.ErrnoException
+ denied.code = 'EACCES'
+
+ expect(isENOENT(denied)).toBe(false)
+ })
+
+ it('does not match a message that merely mentions the word', () => {
+ // The full canonical phrase is required, so a branch name, a log line, or a user's commit
+ // message cannot make an existing path look missing — which would silently skip collision
+ // checks rather than report them.
+ expect(isENOENT(new Error('fix: handle ENOENT better'))).toBe(false)
+ expect(isENOENT(new Error('branch enoent-handling created'))).toBe(false)
+ })
+
+ it('ignores non-errors', () => {
+ expect(isENOENT('ENOENT: no such file or directory')).toBe(false)
+ expect(isENOENT(null)).toBe(false)
+ expect(isENOENT(undefined)).toBe(false)
+ })
+})
diff --git a/src/main/ipc/filesystem-path-containment.ts b/src/main/ipc/filesystem-path-containment.ts
index 016f0d64609..b236c90a94e 100644
--- a/src/main/ipc/filesystem-path-containment.ts
+++ b/src/main/ipc/filesystem-path-containment.ts
@@ -16,12 +16,31 @@ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string
}
/**
- * Returns true if the error is an ENOENT (file-not-found) error.
+ * Node's canonical ENOENT message. Matched in full so a message that merely mentions the word — a
+ * log line, a user's branch name — cannot be mistaken for a missing path.
*/
+const ENOENT_MESSAGE = /\bENOENT: no such file or directory\b/
+
export function isENOENT(error: unknown): boolean {
- return (
- error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
- )
+ if (!(error instanceof Error)) {
+ return false
+ }
+ if ('code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return true
+ }
+ // Why the message is also consulted: an error raised on an SSH host crosses the relay as JSON-RPC,
+ // and ssh-channel-multiplexer rebuilds it with the TRANSPORT's numeric code (msg.error.code), so
+ // Node's 'ENOENT' string code no longer exists on the object by the time any caller sees it. Every
+ // caller here asks the same question — "is this path simply absent?" — and for a remote path the
+ // answer was unreachable: remotePathExists rethrew instead of returning false, which surfaced as a
+ // raw "ENOENT: no such file or directory, lstat ''" when creating a worktree over SSH.
+ //
+ // The cost, accepted rather than overlooked: a remote host can make an unrelated failure read as
+ // "absent" by putting that sentence in a message. Narrowing back to the code alone is not an
+ // option — the transport overwrites it, which IS the bug — and the blast radius is a host already
+ // trusted to run our relay. normalizeExistingPath is unaffected either way: its realpath runs
+ // locally and always carries a real .code, so symlink containment cannot be spoofed this way.
+ return ENOENT_MESSAGE.test(error.message)
}
export async function normalizeExistingPath(resolvedPath: string): Promise {
diff --git a/src/main/providers/ssh-pty-provider-mock-multiplexer.ts b/src/main/providers/ssh-pty-provider-mock-multiplexer.ts
new file mode 100644
index 00000000000..69952b7869a
--- /dev/null
+++ b/src/main/providers/ssh-pty-provider-mock-multiplexer.ts
@@ -0,0 +1,29 @@
+import { expect, vi } from 'vitest'
+
+/** The relay channel SshPtyProvider talks to, stubbed for the provider's own tests. */
+export type MockMultiplexer = {
+ request: ReturnType
+ notify: ReturnType
+ onNotification: ReturnType
+ dispose: ReturnType
+ isDisposed: ReturnType
+}
+
+export function createMockMux(): MockMultiplexer {
+ return {
+ request: vi.fn().mockResolvedValue(undefined),
+ notify: vi.fn(),
+ onNotification: vi.fn(),
+ dispose: vi.fn(),
+ isDisposed: vi.fn().mockReturnValue(false)
+ }
+}
+
+export const sourceActivationRequestOptions = expect.objectContaining({
+ beforeResolve: expect.any(Function)
+})
+
+/** Asserts one request was made, ignoring arguments past the ones named. */
+export function expectRequest(request: ReturnType, ...expected: unknown[]): void {
+ expect(request.mock.calls.map((call) => call.slice(0, expected.length))).toContainEqual(expected)
+}
diff --git a/src/main/providers/ssh-pty-provider-spawn.test.ts b/src/main/providers/ssh-pty-provider-spawn.test.ts
new file mode 100644
index 00000000000..06eb32d586f
--- /dev/null
+++ b/src/main/providers/ssh-pty-provider-spawn.test.ts
@@ -0,0 +1,657 @@
+import { describe, expect, it, beforeEach } from 'vitest'
+import { SshPtyProvider } from './ssh-pty-provider'
+import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env'
+import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress'
+import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
+import {
+ createMockMux,
+ expectRequest,
+ sourceActivationRequestOptions,
+ type MockMultiplexer
+} from './ssh-pty-provider-mock-multiplexer'
+
+// Split from ssh-pty-provider.test.ts, which spawn alone had grown past the max-lines ratchet.
+// Spawn owns the startup contract — ingress version, env scrubbing, execution ownership, and the
+// reconnect races — so it reads as its own unit rather than as an overflow file.
+let mux: MockMultiplexer
+let provider: SshPtyProvider
+const scopedPty1 = 'ssh:conn-1@@pty-1'
+
+beforeEach(() => {
+ mux = createMockMux()
+ provider = new SshPtyProvider('conn-1', mux as never)
+})
+
+describe('spawn', () => {
+ const claim = {
+ digestVersion: 1 as const,
+ keyId: 'key',
+ identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ agent: 'codex' as const
+ }
+ const surface = {
+ worktreeId: 'worktree',
+ tabId: 'tab',
+ leafId: '11111111-1111-4111-8111-111111111111',
+ terminalHandle: 'term_claimed'
+ }
+
+ it('proves relay claim support before a claimed spawn', async () => {
+ mux.request.mockImplementation(async (method: string) => {
+ if (method === 'pty.getCapabilities') {
+ return {
+ agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
+ }
+ }
+ if (method === 'pty.spawn') {
+ return {
+ id: 'pty-1',
+ incarnationId: 'incarnation-1',
+ agentSessionEnsure: {
+ disposition: 'created',
+ owner: {
+ claim,
+ generation: 'generation-1',
+ phase: 'live',
+ ptyId: 'pty-1',
+ surface
+ }
+ }
+ }
+ }
+ return undefined
+ })
+
+ await expect(
+ provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
+ ).resolves.toMatchObject({
+ id: scopedPty1,
+ agentSessionEnsure: { owner: { ptyId: scopedPty1 } }
+ })
+ expect(mux.request.mock.calls.map((call) => call[0])).toEqual([
+ 'pty.getCapabilities',
+ 'pty.spawn'
+ ])
+ })
+
+ it('fails before spawn when the relay cannot prove claim support', async () => {
+ mux.request.mockRejectedValue(new Error('method not found'))
+
+ await expect(
+ provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
+ ).rejects.toThrow('agent_session_claim_unavailable')
+ expect(mux.request).toHaveBeenCalledTimes(1)
+ expect(mux.request).not.toHaveBeenCalledWith('pty.spawn', expect.anything())
+ })
+
+ it('fails closed without killing when a claimed response omits its disposition', async () => {
+ mux.request.mockImplementation(async (method: string) => {
+ if (method === 'pty.getCapabilities') {
+ return {
+ agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
+ }
+ }
+ if (method === 'pty.spawn') {
+ return { id: 'pty-unclaimed' }
+ }
+ return undefined
+ })
+
+ await expect(
+ provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
+ ).rejects.toThrow('execution_owner_unavailable')
+ expect(mux.request).not.toHaveBeenCalledWith('pty.shutdown', expect.anything())
+ })
+
+ it.each([
+ {
+ name: 'PTY identity',
+ mutate: (owner: Record) => ({ ...owner, ptyId: 'other-pty' })
+ },
+ {
+ name: 'claim',
+ mutate: (owner: Record) => ({
+ ...owner,
+ claim: { ...claim, identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' }
+ })
+ },
+ {
+ name: 'fresh surface',
+ mutate: (owner: Record) => ({
+ ...owner,
+ surface: { ...surface, tabId: 'other-tab' }
+ })
+ }
+ ])('physically retires a created owner with mismatched $name', async ({ mutate }) => {
+ mux.request.mockImplementation(async (method: string) => {
+ if (method === 'pty.getCapabilities') {
+ return {
+ agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
+ }
+ }
+ if (method === 'pty.spawn') {
+ return {
+ id: 'pty-malformed',
+ incarnationId: 'incarnation-malformed',
+ agentSessionEnsure: {
+ disposition: 'created',
+ owner: mutate({
+ claim,
+ generation: 'generation-malformed',
+ phase: 'live',
+ ptyId: 'pty-malformed',
+ surface
+ })
+ }
+ }
+ }
+ return undefined
+ })
+
+ await expect(
+ provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
+ ).rejects.toThrow('agent_session_ownership_unknown')
+ expectRequest(mux.request, 'pty.shutdown', {
+ id: 'pty-malformed',
+ immediate: true
+ })
+ })
+
+ it('does not kill a canonical adopted owner when its response is semantically invalid', async () => {
+ mux.request.mockImplementation(async (method: string) => {
+ if (method === 'pty.getCapabilities') {
+ return {
+ agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
+ }
+ }
+ if (method === 'pty.spawn') {
+ return {
+ id: 'pty-canonical',
+ incarnationId: 'incarnation-canonical',
+ agentSessionEnsure: {
+ disposition: 'adopted',
+ owner: {
+ claim: { ...claim, identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' },
+ generation: 'generation-canonical',
+ phase: 'live',
+ ptyId: 'pty-canonical',
+ surface
+ }
+ }
+ }
+ }
+ return undefined
+ })
+
+ await expect(
+ provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
+ ).rejects.toThrow('agent_session_ownership_unknown')
+ expect(mux.request).not.toHaveBeenCalledWith('pty.shutdown', expect.anything())
+ })
+
+ it('retains the unavailable fence when physical cleanup cannot be proven', async () => {
+ mux.request.mockImplementation(async (method: string) => {
+ if (method === 'pty.getCapabilities') {
+ return {
+ agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
+ }
+ }
+ if (method === 'pty.spawn') {
+ return {
+ id: 'pty-malformed',
+ incarnationId: 'incarnation-malformed',
+ agentSessionEnsure: {
+ disposition: 'created',
+ owner: {
+ claim,
+ generation: 'generation-malformed',
+ phase: 'live',
+ ptyId: 'other-pty',
+ surface
+ }
+ }
+ }
+ }
+ if (method === 'pty.shutdown') {
+ throw new Error('Timed out waiting for PTY process exit')
+ }
+ return undefined
+ })
+
+ await expect(
+ provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
+ ).rejects.toThrow('execution_owner_unavailable')
+ })
+
+ it('sends pty.spawn request through multiplexer', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-1' })
+
+ const result = await provider.spawn({ cols: 80, rows: 24 })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 80,
+ rows: 24,
+ cwd: undefined,
+ env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }
+ })
+ expect(result).toEqual({ id: scopedPty1 })
+ expect(provider.hasPty(scopedPty1)).toBe(true)
+ })
+
+ it('keeps a spawned PTY live across an overlapping stale process list', async () => {
+ mux.request.mockResolvedValueOnce({ id: 'pty-new' }).mockResolvedValueOnce([])
+
+ const result = await provider.spawn({ cols: 80, rows: 24 })
+ await provider.listProcesses()
+
+ expect(provider.hasPty(result.id)).toBe(true)
+ })
+
+ it('gates fresh startup intent with the relay ingress capability version', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-1' })
+ const startupIngress = {
+ colors: { foreground: '#eeeeee', background: '#111111' },
+ deadlineMs: 5_000
+ }
+
+ await provider.spawn({ cols: 80, rows: 24, startupIngress })
+
+ expectRequest(
+ mux.request,
+ 'pty.spawn',
+ expect.objectContaining({
+ startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
+ startupIngress
+ })
+ )
+ })
+
+ it('passes cwd and env through', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-2' })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ cwd: '/home/user',
+ env: { FOO: 'bar' }
+ })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: '/home/user',
+ env: { FOO: 'bar', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }
+ })
+ })
+
+ it('forwards trusted agent identity for wrapped remote commands', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-agent' })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ command: 'cd /repo && custom-agent-wrapper',
+ launchAgent: 'claude'
+ })
+
+ expectRequest(
+ mux.request,
+ 'pty.spawn',
+ expect.objectContaining({
+ command: 'cd /repo && custom-agent-wrapper',
+ launchAgent: 'claude'
+ })
+ )
+ })
+
+ it('forwards pane identity as relay metadata on fresh spawn', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-2' })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ paneKey: 'tab-a:leaf-a',
+ tabId: 'tab-a'
+ })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
+ paneKey: 'tab-a:leaf-a',
+ tabId: 'tab-a'
+ })
+ })
+
+ it('forwards shell, WSL, and scoped-history options to the relay mux', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-2' })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ shellOverride: 'powershell.exe',
+ terminalWindowsWslDistro: 'Ubuntu',
+ worktreeId: 'repo-1::/remote/wt',
+ historyIsolationEnabled: true
+ })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
+ shellOverride: 'powershell.exe',
+ terminalWindowsWslDistro: 'Ubuntu',
+ worktreeId: 'repo-1::/remote/wt',
+ historyIsolationEnabled: true
+ })
+ })
+
+ it('preserves an explicit remote Powerlevel10k wizard env value', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-2' })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' }
+ })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' }
+ })
+ })
+
+ it('honors requests to delete the remote Powerlevel10k wizard env value', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-2' })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' },
+ envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
+ })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: {},
+ envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
+ })
+ })
+
+ it('preserves explicit TERM and forwards final env deletions to the relay', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-env-precedence' })
+ const envToDelete = ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV']
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ env: {
+ TERM: 'screen-256color',
+ TERM_PROGRAM: 'stale-terminal',
+ ORCA_STALE_TEST_ENV: '/tmp/stale-env'
+ },
+ envToDelete
+ })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: {
+ TERM: 'screen-256color',
+ [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true'
+ },
+ envToDelete
+ })
+ const spawnCall = mux.request.mock.calls.find((call) => call[0] === 'pty.spawn')
+ expect(spawnCall?.[1]?.env).not.toHaveProperty('TERM_PROGRAM')
+ expect(spawnCall?.[1]?.env).not.toHaveProperty('ORCA_STALE_TEST_ENV')
+ })
+
+ it('forwards provider command delivery to the relay', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-provider-command' })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ command: 'echo from-runtime',
+ commandDelivery: 'provider',
+ startupCommandDelivery: 'shell-ready'
+ })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
+ command: 'echo from-runtime',
+ commandDelivery: 'provider',
+ startupCommandDelivery: 'shell-ready'
+ })
+ })
+
+ it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-bridge' })
+ provider = new SshPtyProvider('conn-1', mux as never, {
+ binDir: '/home/user/.orca-relay/bin',
+ relayDir: '/home/user/.orca-relay/relay-v1',
+ nodePath: '/usr/bin/node',
+ sockPath: '/home/user/.orca-relay/relay.sock'
+ })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ env: { PATH: '/usr/bin', ORCA_TERMINAL_HANDLE: 'term_ssh' }
+ })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: {
+ PATH: '/home/user/.orca-relay/bin:/usr/bin',
+ ORCA_TERMINAL_HANDLE: 'term_ssh',
+ [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
+ ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
+ ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1',
+ ORCA_RELAY_NODE_PATH: '/usr/bin/node',
+ ORCA_RELAY_SOCKET_PATH: '/home/user/.orca-relay/relay.sock'
+ }
+ })
+ })
+
+ it('does not clobber the remote relay PATH when caller env has no PATH', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-bridge' })
+ provider = new SshPtyProvider('conn-1', mux as never, {
+ binDir: '/home/user/.orca-relay/bin',
+ relayDir: '/home/user/.orca-relay/relay-v1',
+ nodePath: '/usr/bin/node',
+ sockPath: '/home/user/.orca-relay/relay.sock'
+ })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
+ })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: {
+ ORCA_TERMINAL_HANDLE: 'term_ssh',
+ [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
+ ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
+ ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1',
+ ORCA_RELAY_NODE_PATH: '/usr/bin/node',
+ ORCA_RELAY_SOCKET_PATH: '/home/user/.orca-relay/relay.sock'
+ }
+ })
+ })
+
+ it('uses Windows PATH delimiters for native Windows SSH bridge env', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-bridge' })
+ provider = new SshPtyProvider('conn-1', mux as never, {
+ binDir: 'C:/Users/me/.orca-relay/bin',
+ relayDir: 'C:/Users/me/.orca-remote/relay-v1',
+ nodePath: 'C:/Program Files/nodejs/node.exe',
+ sockPath: '\\\\.\\pipe\\orca-relay-123',
+ pathDelimiter: ';'
+ })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ env: { Path: 'C:/Windows/System32;C:/Tools' }
+ })
+
+ expectRequest(mux.request, 'pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: {
+ Path: 'C:/Users/me/.orca-relay/bin;C:/Windows/System32;C:/Tools',
+ [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
+ ORCA_REMOTE_CLI_BIN_DIR: 'C:/Users/me/.orca-relay/bin',
+ ORCA_RELAY_DIR: 'C:/Users/me/.orca-remote/relay-v1',
+ ORCA_RELAY_NODE_PATH: 'C:/Program Files/nodejs/node.exe',
+ ORCA_RELAY_SOCKET_PATH: '\\\\.\\pipe\\orca-relay-123'
+ }
+ })
+ })
+
+ it('reattaches an existing session and returns attach replay separately from snapshot', async () => {
+ mux.request.mockResolvedValue({
+ replay: 'buffered-output',
+ incarnationId: 'incarnation-reattached'
+ })
+
+ const result = await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })
+
+ expectRequest(
+ mux.request,
+ 'pty.attach',
+ {
+ id: 'pty-old',
+ cols: 80,
+ rows: 24,
+ suppressReplayNotification: true,
+ requireReplay: true
+ },
+ sourceActivationRequestOptions
+ )
+ expect(result).toEqual({
+ id: 'ssh:conn-1@@pty-old',
+ isReattach: true,
+ replay: 'buffered-output',
+ incarnationId: 'incarnation-reattached'
+ })
+ })
+
+ it('never sends fresh startup intent on relay reattach', async () => {
+ mux.request.mockResolvedValue({ replay: 'restored' })
+
+ await provider.spawn({
+ cols: 80,
+ rows: 24,
+ sessionId: scopedPty1,
+ startupIngress: {
+ colors: { foreground: '#eeeeee', background: '#111111' },
+ deadlineMs: 5_000
+ }
+ })
+
+ expectRequest(
+ mux.request,
+ 'pty.attach',
+ expect.not.objectContaining({ startupIngress: expect.anything() })
+ )
+ expect(mux.request).not.toHaveBeenCalledWith('pty.spawn', expect.anything())
+ })
+
+ it('reattaches scoped app ids using raw relay ids', async () => {
+ mux.request.mockResolvedValue({ replay: 'buffered-output' })
+
+ const result = await provider.spawn({
+ cols: 80,
+ rows: 24,
+ sessionId: 'ssh:conn-1@@pty-old'
+ })
+
+ expectRequest(mux.request, 'pty.attach', {
+ id: 'pty-old',
+ cols: 80,
+ rows: 24,
+ suppressReplayNotification: true,
+ requireReplay: true
+ })
+ expect(result).toEqual({
+ id: 'ssh:conn-1@@pty-old',
+ isReattach: true,
+ replay: 'buffered-output'
+ })
+ })
+
+ it('reattaches with explicit pane identity when hook env was stripped', async () => {
+ mux.request.mockResolvedValue({ replay: 'buffered-output' })
+
+ await provider.spawn({
+ cols: 80,
+ rows: 24,
+ sessionId: 'pty-old',
+ paneKey: 'tab-a:leaf-a',
+ tabId: 'tab-a'
+ })
+
+ expectRequest(mux.request, 'pty.attach', {
+ id: 'pty-old',
+ cols: 80,
+ rows: 24,
+ suppressReplayNotification: true,
+ requireReplay: true,
+ expectedPaneKey: 'tab-a:leaf-a',
+ expectedTabId: 'tab-a'
+ })
+ })
+
+ it('does not fresh-spawn over an expired reattach session', async () => {
+ mux.request.mockRejectedValueOnce(new Error('PTY "pty-old" not found'))
+
+ await expect(provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })).rejects.toThrow(
+ 'SSH_SESSION_EXPIRED: pty-old'
+ )
+
+ expect(mux.request).toHaveBeenNthCalledWith(
+ 1,
+ 'pty.attach',
+ {
+ id: 'pty-old',
+ cols: 80,
+ rows: 24,
+ suppressReplayNotification: true,
+ requireReplay: true
+ },
+ sourceActivationRequestOptions
+ )
+ expect(mux.request).toHaveBeenCalledTimes(1)
+ })
+
+ it('preserves transient reattach failures for retry handling', async () => {
+ mux.request.mockRejectedValueOnce(new Error('SSH connection lost, reconnecting...'))
+
+ await expect(provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })).rejects.toThrow(
+ 'SSH connection lost, reconnecting...'
+ )
+
+ expect(mux.request).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts
index b3b64be7a71..0fc8702750d 100644
--- a/src/main/providers/ssh-pty-provider.test.ts
+++ b/src/main/providers/ssh-pty-provider.test.ts
@@ -1,34 +1,11 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
-import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env'
-import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress'
import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
-
-type MockMultiplexer = {
- request: ReturnType
- notify: ReturnType
- onNotification: ReturnType
- dispose: ReturnType
- isDisposed: ReturnType
-}
-
-function createMockMux(): MockMultiplexer {
- return {
- request: vi.fn().mockResolvedValue(undefined),
- notify: vi.fn(),
- onNotification: vi.fn(),
- dispose: vi.fn(),
- isDisposed: vi.fn().mockReturnValue(false)
- }
-}
-
-const sourceActivationRequestOptions = expect.objectContaining({
- beforeResolve: expect.any(Function)
-})
-
-function expectRequest(request: ReturnType, ...expected: unknown[]): void {
- expect(request.mock.calls.map((call) => call.slice(0, expected.length))).toContainEqual(expected)
-}
+import {
+ createMockMux,
+ expectRequest,
+ type MockMultiplexer
+} from './ssh-pty-provider-mock-multiplexer'
describe('SshPtyProvider', () => {
let mux: MockMultiplexer
@@ -66,636 +43,6 @@ describe('SshPtyProvider', () => {
expect(mux.request).toHaveBeenCalledOnce()
})
- describe('spawn', () => {
- const claim = {
- digestVersion: 1 as const,
- keyId: 'key',
- identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
- worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
- agent: 'codex' as const
- }
- const surface = {
- worktreeId: 'worktree',
- tabId: 'tab',
- leafId: '11111111-1111-4111-8111-111111111111',
- terminalHandle: 'term_claimed'
- }
-
- it('proves relay claim support before a claimed spawn', async () => {
- mux.request.mockImplementation(async (method: string) => {
- if (method === 'pty.getCapabilities') {
- return {
- agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
- }
- }
- if (method === 'pty.spawn') {
- return {
- id: 'pty-1',
- incarnationId: 'incarnation-1',
- agentSessionEnsure: {
- disposition: 'created',
- owner: {
- claim,
- generation: 'generation-1',
- phase: 'live',
- ptyId: 'pty-1',
- surface
- }
- }
- }
- }
- return undefined
- })
-
- await expect(
- provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
- ).resolves.toMatchObject({
- id: scopedPty1,
- agentSessionEnsure: { owner: { ptyId: scopedPty1 } }
- })
- expect(mux.request.mock.calls.map((call) => call[0])).toEqual([
- 'pty.getCapabilities',
- 'pty.spawn'
- ])
- })
-
- it('fails before spawn when the relay cannot prove claim support', async () => {
- mux.request.mockRejectedValue(new Error('method not found'))
-
- await expect(
- provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
- ).rejects.toThrow('agent_session_claim_unavailable')
- expect(mux.request).toHaveBeenCalledTimes(1)
- expect(mux.request).not.toHaveBeenCalledWith('pty.spawn', expect.anything())
- })
-
- it('fails closed without killing when a claimed response omits its disposition', async () => {
- mux.request.mockImplementation(async (method: string) => {
- if (method === 'pty.getCapabilities') {
- return {
- agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
- }
- }
- if (method === 'pty.spawn') {
- return { id: 'pty-unclaimed' }
- }
- return undefined
- })
-
- await expect(
- provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
- ).rejects.toThrow('execution_owner_unavailable')
- expect(mux.request).not.toHaveBeenCalledWith('pty.shutdown', expect.anything())
- })
-
- it.each([
- {
- name: 'PTY identity',
- mutate: (owner: Record) => ({ ...owner, ptyId: 'other-pty' })
- },
- {
- name: 'claim',
- mutate: (owner: Record) => ({
- ...owner,
- claim: { ...claim, identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' }
- })
- },
- {
- name: 'fresh surface',
- mutate: (owner: Record) => ({
- ...owner,
- surface: { ...surface, tabId: 'other-tab' }
- })
- }
- ])('physically retires a created owner with mismatched $name', async ({ mutate }) => {
- mux.request.mockImplementation(async (method: string) => {
- if (method === 'pty.getCapabilities') {
- return {
- agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
- }
- }
- if (method === 'pty.spawn') {
- return {
- id: 'pty-malformed',
- incarnationId: 'incarnation-malformed',
- agentSessionEnsure: {
- disposition: 'created',
- owner: mutate({
- claim,
- generation: 'generation-malformed',
- phase: 'live',
- ptyId: 'pty-malformed',
- surface
- })
- }
- }
- }
- return undefined
- })
-
- await expect(
- provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
- ).rejects.toThrow('agent_session_ownership_unknown')
- expectRequest(mux.request, 'pty.shutdown', {
- id: 'pty-malformed',
- immediate: true
- })
- })
-
- it('does not kill a canonical adopted owner when its response is semantically invalid', async () => {
- mux.request.mockImplementation(async (method: string) => {
- if (method === 'pty.getCapabilities') {
- return {
- agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
- }
- }
- if (method === 'pty.spawn') {
- return {
- id: 'pty-canonical',
- incarnationId: 'incarnation-canonical',
- agentSessionEnsure: {
- disposition: 'adopted',
- owner: {
- claim: { ...claim, identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' },
- generation: 'generation-canonical',
- phase: 'live',
- ptyId: 'pty-canonical',
- surface
- }
- }
- }
- }
- return undefined
- })
-
- await expect(
- provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
- ).rejects.toThrow('agent_session_ownership_unknown')
- expect(mux.request).not.toHaveBeenCalledWith('pty.shutdown', expect.anything())
- })
-
- it('retains the unavailable fence when physical cleanup cannot be proven', async () => {
- mux.request.mockImplementation(async (method: string) => {
- if (method === 'pty.getCapabilities') {
- return {
- agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
- }
- }
- if (method === 'pty.spawn') {
- return {
- id: 'pty-malformed',
- incarnationId: 'incarnation-malformed',
- agentSessionEnsure: {
- disposition: 'created',
- owner: {
- claim,
- generation: 'generation-malformed',
- phase: 'live',
- ptyId: 'other-pty',
- surface
- }
- }
- }
- }
- if (method === 'pty.shutdown') {
- throw new Error('Timed out waiting for PTY process exit')
- }
- return undefined
- })
-
- await expect(
- provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
- ).rejects.toThrow('execution_owner_unavailable')
- })
-
- it('sends pty.spawn request through multiplexer', async () => {
- mux.request.mockResolvedValue({ id: 'pty-1' })
-
- const result = await provider.spawn({ cols: 80, rows: 24 })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 80,
- rows: 24,
- cwd: undefined,
- env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }
- })
- expect(result).toEqual({ id: scopedPty1 })
- expect(provider.hasPty(scopedPty1)).toBe(true)
- })
-
- it('keeps a spawned PTY live across an overlapping stale process list', async () => {
- mux.request.mockResolvedValueOnce({ id: 'pty-new' }).mockResolvedValueOnce([])
-
- const result = await provider.spawn({ cols: 80, rows: 24 })
- await provider.listProcesses()
-
- expect(provider.hasPty(result.id)).toBe(true)
- })
-
- it('gates fresh startup intent with the relay ingress capability version', async () => {
- mux.request.mockResolvedValue({ id: 'pty-1' })
- const startupIngress = {
- colors: { foreground: '#eeeeee', background: '#111111' },
- deadlineMs: 5_000
- }
-
- await provider.spawn({ cols: 80, rows: 24, startupIngress })
-
- expectRequest(
- mux.request,
- 'pty.spawn',
- expect.objectContaining({
- startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
- startupIngress
- })
- )
- })
-
- it('passes cwd and env through', async () => {
- mux.request.mockResolvedValue({ id: 'pty-2' })
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- cwd: '/home/user',
- env: { FOO: 'bar' }
- })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 120,
- rows: 40,
- cwd: '/home/user',
- env: { FOO: 'bar', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' }
- })
- })
-
- it('forwards trusted agent identity for wrapped remote commands', async () => {
- mux.request.mockResolvedValue({ id: 'pty-agent' })
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- command: 'cd /repo && custom-agent-wrapper',
- launchAgent: 'claude'
- })
-
- expectRequest(
- mux.request,
- 'pty.spawn',
- expect.objectContaining({
- command: 'cd /repo && custom-agent-wrapper',
- launchAgent: 'claude'
- })
- )
- })
-
- it('forwards pane identity as relay metadata on fresh spawn', async () => {
- mux.request.mockResolvedValue({ id: 'pty-2' })
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- paneKey: 'tab-a:leaf-a',
- tabId: 'tab-a'
- })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 120,
- rows: 40,
- cwd: undefined,
- env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
- paneKey: 'tab-a:leaf-a',
- tabId: 'tab-a'
- })
- })
-
- it('forwards shell, WSL, and scoped-history options to the relay mux', async () => {
- mux.request.mockResolvedValue({ id: 'pty-2' })
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- shellOverride: 'powershell.exe',
- terminalWindowsWslDistro: 'Ubuntu',
- worktreeId: 'repo-1::/remote/wt',
- historyIsolationEnabled: true
- })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 120,
- rows: 40,
- cwd: undefined,
- env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
- shellOverride: 'powershell.exe',
- terminalWindowsWslDistro: 'Ubuntu',
- worktreeId: 'repo-1::/remote/wt',
- historyIsolationEnabled: true
- })
- })
-
- it('preserves an explicit remote Powerlevel10k wizard env value', async () => {
- mux.request.mockResolvedValue({ id: 'pty-2' })
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' }
- })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 120,
- rows: 40,
- cwd: undefined,
- env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' }
- })
- })
-
- it('honors requests to delete the remote Powerlevel10k wizard env value', async () => {
- mux.request.mockResolvedValue({ id: 'pty-2' })
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'already-set' },
- envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
- })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 120,
- rows: 40,
- cwd: undefined,
- env: {},
- envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV]
- })
- })
-
- it('preserves explicit TERM and forwards final env deletions to the relay', async () => {
- mux.request.mockResolvedValue({ id: 'pty-env-precedence' })
- const envToDelete = ['TERM_PROGRAM', 'ORCA_STALE_TEST_ENV']
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- env: {
- TERM: 'screen-256color',
- TERM_PROGRAM: 'stale-terminal',
- ORCA_STALE_TEST_ENV: '/tmp/stale-env'
- },
- envToDelete
- })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 120,
- rows: 40,
- cwd: undefined,
- env: {
- TERM: 'screen-256color',
- [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true'
- },
- envToDelete
- })
- const spawnCall = mux.request.mock.calls.find((call) => call[0] === 'pty.spawn')
- expect(spawnCall?.[1]?.env).not.toHaveProperty('TERM_PROGRAM')
- expect(spawnCall?.[1]?.env).not.toHaveProperty('ORCA_STALE_TEST_ENV')
- })
-
- it('forwards provider command delivery to the relay', async () => {
- mux.request.mockResolvedValue({ id: 'pty-provider-command' })
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- command: 'echo from-runtime',
- commandDelivery: 'provider',
- startupCommandDelivery: 'shell-ready'
- })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 120,
- rows: 40,
- cwd: undefined,
- env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
- command: 'echo from-runtime',
- commandDelivery: 'provider',
- startupCommandDelivery: 'shell-ready'
- })
- })
-
- it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => {
- mux.request.mockResolvedValue({ id: 'pty-bridge' })
- provider = new SshPtyProvider('conn-1', mux as never, {
- binDir: '/home/user/.orca-relay/bin',
- relayDir: '/home/user/.orca-relay/relay-v1',
- nodePath: '/usr/bin/node',
- sockPath: '/home/user/.orca-relay/relay.sock'
- })
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- env: { PATH: '/usr/bin', ORCA_TERMINAL_HANDLE: 'term_ssh' }
- })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 120,
- rows: 40,
- cwd: undefined,
- env: {
- PATH: '/home/user/.orca-relay/bin:/usr/bin',
- ORCA_TERMINAL_HANDLE: 'term_ssh',
- [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
- ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
- ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1',
- ORCA_RELAY_NODE_PATH: '/usr/bin/node',
- ORCA_RELAY_SOCKET_PATH: '/home/user/.orca-relay/relay.sock'
- }
- })
- })
-
- it('does not clobber the remote relay PATH when caller env has no PATH', async () => {
- mux.request.mockResolvedValue({ id: 'pty-bridge' })
- provider = new SshPtyProvider('conn-1', mux as never, {
- binDir: '/home/user/.orca-relay/bin',
- relayDir: '/home/user/.orca-relay/relay-v1',
- nodePath: '/usr/bin/node',
- sockPath: '/home/user/.orca-relay/relay.sock'
- })
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
- })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 120,
- rows: 40,
- cwd: undefined,
- env: {
- ORCA_TERMINAL_HANDLE: 'term_ssh',
- [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
- ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
- ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1',
- ORCA_RELAY_NODE_PATH: '/usr/bin/node',
- ORCA_RELAY_SOCKET_PATH: '/home/user/.orca-relay/relay.sock'
- }
- })
- })
-
- it('uses Windows PATH delimiters for native Windows SSH bridge env', async () => {
- mux.request.mockResolvedValue({ id: 'pty-bridge' })
- provider = new SshPtyProvider('conn-1', mux as never, {
- binDir: 'C:/Users/me/.orca-relay/bin',
- relayDir: 'C:/Users/me/.orca-remote/relay-v1',
- nodePath: 'C:/Program Files/nodejs/node.exe',
- sockPath: '\\\\.\\pipe\\orca-relay-123',
- pathDelimiter: ';'
- })
-
- await provider.spawn({
- cols: 120,
- rows: 40,
- env: { Path: 'C:/Windows/System32;C:/Tools' }
- })
-
- expectRequest(mux.request, 'pty.spawn', {
- cols: 120,
- rows: 40,
- cwd: undefined,
- env: {
- Path: 'C:/Users/me/.orca-relay/bin;C:/Windows/System32;C:/Tools',
- [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true',
- ORCA_REMOTE_CLI_BIN_DIR: 'C:/Users/me/.orca-relay/bin',
- ORCA_RELAY_DIR: 'C:/Users/me/.orca-remote/relay-v1',
- ORCA_RELAY_NODE_PATH: 'C:/Program Files/nodejs/node.exe',
- ORCA_RELAY_SOCKET_PATH: '\\\\.\\pipe\\orca-relay-123'
- }
- })
- })
-
- it('reattaches an existing session and returns attach replay separately from snapshot', async () => {
- mux.request.mockResolvedValue({
- replay: 'buffered-output',
- incarnationId: 'incarnation-reattached'
- })
-
- const result = await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })
-
- expectRequest(
- mux.request,
- 'pty.attach',
- {
- id: 'pty-old',
- cols: 80,
- rows: 24,
- suppressReplayNotification: true
- },
- sourceActivationRequestOptions
- )
- expect(result).toEqual({
- id: 'ssh:conn-1@@pty-old',
- isReattach: true,
- replay: 'buffered-output',
- incarnationId: 'incarnation-reattached'
- })
- })
-
- it('never sends fresh startup intent on relay reattach', async () => {
- mux.request.mockResolvedValue({ replay: 'restored' })
-
- await provider.spawn({
- cols: 80,
- rows: 24,
- sessionId: scopedPty1,
- startupIngress: {
- colors: { foreground: '#eeeeee', background: '#111111' },
- deadlineMs: 5_000
- }
- })
-
- expectRequest(
- mux.request,
- 'pty.attach',
- expect.not.objectContaining({ startupIngress: expect.anything() })
- )
- expect(mux.request).not.toHaveBeenCalledWith('pty.spawn', expect.anything())
- })
-
- it('reattaches scoped app ids using raw relay ids', async () => {
- mux.request.mockResolvedValue({ replay: 'buffered-output' })
-
- const result = await provider.spawn({
- cols: 80,
- rows: 24,
- sessionId: 'ssh:conn-1@@pty-old'
- })
-
- expectRequest(mux.request, 'pty.attach', {
- id: 'pty-old',
- cols: 80,
- rows: 24,
- suppressReplayNotification: true
- })
- expect(result).toEqual({
- id: 'ssh:conn-1@@pty-old',
- isReattach: true,
- replay: 'buffered-output'
- })
- })
-
- it('reattaches with explicit pane identity when hook env was stripped', async () => {
- mux.request.mockResolvedValue({ replay: 'buffered-output' })
-
- await provider.spawn({
- cols: 80,
- rows: 24,
- sessionId: 'pty-old',
- paneKey: 'tab-a:leaf-a',
- tabId: 'tab-a'
- })
-
- expectRequest(mux.request, 'pty.attach', {
- id: 'pty-old',
- cols: 80,
- rows: 24,
- suppressReplayNotification: true,
- expectedPaneKey: 'tab-a:leaf-a',
- expectedTabId: 'tab-a'
- })
- })
-
- it('does not fresh-spawn over an expired reattach session', async () => {
- mux.request.mockRejectedValueOnce(new Error('PTY "pty-old" not found'))
-
- await expect(provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })).rejects.toThrow(
- 'SSH_SESSION_EXPIRED: pty-old'
- )
-
- expect(mux.request).toHaveBeenNthCalledWith(
- 1,
- 'pty.attach',
- {
- id: 'pty-old',
- cols: 80,
- rows: 24,
- suppressReplayNotification: true
- },
- sourceActivationRequestOptions
- )
- expect(mux.request).toHaveBeenCalledTimes(1)
- })
-
- it('preserves transient reattach failures for retry handling', async () => {
- mux.request.mockRejectedValueOnce(new Error('SSH connection lost, reconnecting...'))
-
- await expect(provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })).rejects.toThrow(
- 'SSH connection lost, reconnecting...'
- )
-
- expect(mux.request).toHaveBeenCalledTimes(1)
- })
- })
-
it('attach sends pty.attach request', async () => {
await provider.attach(scopedPty1)
expectRequest(mux.request, 'pty.attach', { id: 'pty-1' })
diff --git a/src/main/providers/ssh-pty-session-reattach.ts b/src/main/providers/ssh-pty-session-reattach.ts
index 06e4c9ae7c5..3a7b8b15c79 100644
--- a/src/main/providers/ssh-pty-session-reattach.ts
+++ b/src/main/providers/ssh-pty-session-reattach.ts
@@ -196,6 +196,11 @@ export async function reattachSshPtySession(args: {
cols: args.options.cols,
rows: args.options.rows,
suppressReplayNotification: true,
+ // A reattach always paints into a NEW terminal: a reconnect bumps tab.generation, which is
+ // the pane's React key, so TerminalPane remounts and the old xterm is disposed with its
+ // buffer. Without this the relay sees a delivery still open under our unchanged client id,
+ // answers "you already have this", and the pane stays blank until new output arrives.
+ requireReplay: true,
...(expectedPaneKey ? { expectedPaneKey } : {}),
...(expectedTabId ? { expectedTabId } : {})
},
diff --git a/src/main/ssh/ssh-config-host-picker.test.ts b/src/main/ssh/ssh-config-host-picker.test.ts
index 4d7666fa457..783eef679ca 100644
--- a/src/main/ssh/ssh-config-host-picker.test.ts
+++ b/src/main/ssh/ssh-config-host-picker.test.ts
@@ -29,6 +29,11 @@ function resolved(overrides: Partial = {}): SshResolvedConfig
proxyUseFdpass: false,
controlMaster: 'no',
controlPersist: 'no',
+ userKnownHostsFiles: [],
+ globalKnownHostsFiles: [],
+ strictHostKeyChecking: 'ask',
+ hashKnownHosts: false,
+ updateHostKeys: 'no',
...overrides
}
}
diff --git a/src/main/ssh/ssh-config-path-expansion.ts b/src/main/ssh/ssh-config-path-expansion.ts
index 959d4ba8771..1fe7b5f1eaa 100644
--- a/src/main/ssh/ssh-config-path-expansion.ts
+++ b/src/main/ssh/ssh-config-path-expansion.ts
@@ -1,7 +1,44 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
+/**
+ * Windows OpenSSH's own placeholder for the system config directory's parent.
+ *
+ * It prints this token UNEXPANDED from `ssh -G` — captured from a real Windows host:
+ * `globalknownhostsfile __PROGRAMDATA__\ssh/ssh_known_hosts __PROGRAMDATA__\ssh/ssh_known_hosts2`.
+ * Passed through as a literal it misses with ENOENT, and an absent file is indistinguishable from
+ * "no host is known there", so a site-managed `known_hosts` would be silently invisible — including
+ * one holding a rotated key that should have produced a mismatch.
+ */
+const PROGRAMDATA_TOKEN = '__PROGRAMDATA__'
+
+/**
+ * What follows the token when this path really starts with it, or null when it does not.
+ *
+ * A bare `startsWith` also matches a path that merely BEGINS with those characters, so a file named
+ * `__PROGRAMDATA__evil/x` would be rewritten to a directory the user never named. It has to be the
+ * whole path or be followed by a separator.
+ */
+function programDataTokenRemainder(filepath: string): string | null {
+ if (!filepath.startsWith(PROGRAMDATA_TOKEN)) {
+ return null
+ }
+ const rest = filepath.slice(PROGRAMDATA_TOKEN.length)
+ if (rest === '') {
+ return rest
+ }
+ // Both separators, because this path is Windows-shaped but may be parsed anywhere.
+ return rest.startsWith('\\') || rest.startsWith('/') ? rest : null
+}
+
export function resolveSshConfigHomePath(filepath: string): string {
+ const programDataRest = programDataTokenRemainder(filepath)
+ if (programDataRest !== null) {
+ // Left alone when the variable is unset rather than guessed: a wrong path reads as "absent",
+ // which is the failure this expansion exists to prevent, so it must not be invented.
+ const programData = process.env.ProgramData
+ return programData ? join(programData, programDataRest) : filepath
+ }
if (filepath === '~') {
return homedir()
}
diff --git a/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts b/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts
new file mode 100644
index 00000000000..50b4e9eb1cc
--- /dev/null
+++ b/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts
@@ -0,0 +1,212 @@
+/**
+ * The wire from "the verifier accepted a first-contact key" to "a record exists on disk".
+ *
+ * Its own file because `initSshHostKeyStoreFile` binds module-level state for the rest of the
+ * process, and because binding it makes the connect prelude do real disk I/O — which the shared
+ * connection suite cannot absorb, since its reconnect tests drive the clock with fake timers and an
+ * fs round trip does not complete inside an advanced tick.
+ *
+ * Without this, nothing covers the store end to end: an unknown host would connect every time and
+ * never be learned, and the second connection would be first contact again.
+ */
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
+import { Socket } from 'node:net'
+import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+// Type-only, so it is erased before vi.mock's hoisted factory runs.
+import type * as SshConfigParser from './ssh-config-parser'
+
+const VALID_ED25519_HOST_KEY = Buffer.from(
+ 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq',
+ 'base64'
+)
+/** A second, different key for the same host — the shape of an impersonation. */
+const OTHER_ED25519_HOST_KEY = Buffer.from(
+ 'AAAAC3NzaC1lZDI1NTE5AAAAILu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7',
+ 'base64'
+)
+
+let eventHandlers: Map void>>
+let presentedHostKey: Buffer
+let hostKeyAccepted: boolean | undefined
+
+vi.mock('ssh2', () => {
+ class MockSshClient {
+ setNoDelay = vi.fn()
+ _sock: Socket | undefined = new Socket()
+ on(event: string, handler: (...args: unknown[]) => void) {
+ const handlers = eventHandlers.get(event) ?? new Set<(...args: unknown[]) => void>()
+ handlers.add(handler)
+ eventHandlers.set(event, handlers)
+ }
+ off(event: string, handler: (...args: unknown[]) => void) {
+ eventHandlers.get(event)?.delete(handler)
+ }
+ end() {}
+ destroy() {}
+ // ssh2 calls hostVerifier(key, verify) and only accepts synchronously when the return is not
+ // undefined; a mock that ignored the callback would pass against a verifier that never decides.
+ connect(config?: unknown) {
+ const hostVerifier = (
+ config as
+ | { hostVerifier?: (key: Buffer, verify: (ok: boolean) => void) => undefined }
+ | undefined
+ )?.hostVerifier
+ hostKeyAccepted = undefined
+ hostVerifier?.(presentedHostKey, (ok) => {
+ hostKeyAccepted = ok
+ })
+ setTimeout(() => {
+ for (const handler of eventHandlers.get(hostKeyAccepted === false ? 'error' : 'ready') ??
+ []) {
+ handler(new Error('All configured authentication methods failed'))
+ }
+ }, 0)
+ }
+ }
+ class MockBaseAgent {}
+ return {
+ Client: MockSshClient,
+ BaseAgent: MockBaseAgent,
+ default: { Client: MockSshClient, BaseAgent: MockBaseAgent }
+ }
+})
+
+vi.mock('./ssh-config-parser', async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveWithSshG: vi.fn(async () => null)
+}))
+
+import { SshConnection } from './ssh-connection'
+import { initSshHostKeyStoreFile, isTrusted, loadTrustedHostKeys } from './ssh-host-key-store'
+import type { SshTarget } from '../../shared/ssh-types'
+
+const target = (overrides?: Partial): SshTarget => ({
+ id: 'target-1',
+ label: 'Test Server',
+ host: 'example.com',
+ port: 22,
+ username: 'deploy',
+ ...overrides
+})
+
+const callbacks = () => ({ onStateChange: vi.fn() })
+
+let profileDir: string
+
+beforeEach(() => {
+ eventHandlers = new Map()
+ presentedHostKey = VALID_ED25519_HOST_KEY
+ hostKeyAccepted = undefined
+ profileDir = mkdtempSync(join(tmpdir(), 'orca-ssh-store-'))
+ initSshHostKeyStoreFile(join(profileDir, 'orca.json'))
+})
+
+afterEach(() => {
+ rmSync(profileDir, { recursive: true, force: true })
+})
+
+/**
+ * The record is written fire-and-forget on purpose — a failed write must not fail a connection whose
+ * key we already verified — so the test waits for it rather than assuming it landed.
+ */
+async function waitForStoredKeys(count: number): Promise {
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ const trusted = await loadTrustedHostKeys()
+ if (trusted.length >= count) {
+ return trusted
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ }
+ return loadTrustedHostKeys()
+}
+
+describe('recording a first-contact host key', () => {
+ it('writes a record for the accepted key', async () => {
+ await new SshConnection(target(), callbacks()).connect()
+
+ const trusted = await waitForStoredKeys(1)
+ expect(trusted[0]).toEqual(
+ expect.objectContaining({ host: 'example.com', port: 22, keyType: 'ssh-ed25519' })
+ )
+ })
+
+ // A record is only worth writing if the next connection reads it back and believes it.
+ it('accepts the same key from the store on the next connection', async () => {
+ await new SshConnection(target(), callbacks()).connect()
+ await waitForStoredKeys(1)
+
+ await expect(
+ isTrusted({
+ host: 'example.com',
+ port: 22,
+ keyType: 'ssh-ed25519',
+ key: VALID_ED25519_HOST_KEY
+ })
+ ).resolves.toBe('match')
+ })
+
+ // Two records for one endpoint would grow a row per connection and eventually read as a change
+ // against a host that did nothing wrong.
+ it('does not record the same key twice', async () => {
+ await new SshConnection(target(), callbacks()).connect()
+ await waitForStoredKeys(1)
+ await new SshConnection(target(), callbacks()).connect()
+ // Give a second write the same chance to land before asserting it did not happen.
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ expect(await loadTrustedHostKeys()).toHaveLength(1)
+ })
+
+ // The whole point of recording: a host we learned once must be refused when it changes, even
+ // though known_hosts has never heard of it.
+ it('refuses a different key for a host it recorded itself', async () => {
+ await new SshConnection(target(), callbacks()).connect()
+ await waitForStoredKeys(1)
+
+ presentedHostKey = OTHER_ED25519_HOST_KEY
+ const second = new SshConnection(target(), callbacks())
+
+ await expect(second.connect()).rejects.toThrow(/host key verification failed/i)
+ expect(hostKeyAccepted).toBe(false)
+ })
+
+ // The load-bearing half of the unreadable-known_hosts decision. We connect as ssh does, but the
+ // whole reason that is acceptable is that we write nothing: a first contact we could not check
+ // must never become durable trust that a later connection then believes.
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
+ 'records nothing when a known_hosts file exists but cannot be read',
+ async () => {
+ const home = mkdtempSync(join(tmpdir(), 'orca-ssh-home-'))
+ mkdirSync(join(home, '.ssh'))
+ const knownHosts = join(home, '.ssh', 'known_hosts')
+ writeFileSync(knownHosts, '')
+ chmodSync(knownHosts, 0o000)
+ vi.stubEnv('HOME', home)
+
+ try {
+ await new SshConnection(target(), callbacks()).connect()
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ expect(hostKeyAccepted).toBe(true)
+ expect(await loadTrustedHostKeys()).toEqual([])
+ } finally {
+ vi.unstubAllEnvs()
+ rmSync(home, { recursive: true, force: true })
+ }
+ }
+ )
+
+ // A record per launch would accumulate, and a stale one would eventually read as a mismatch
+ // against a VM that is behaving exactly as designed.
+ it('records nothing for an on-demand runtime target', async () => {
+ await new SshConnection(
+ target({ owner: { type: 'on-demand-runtime', runtimeId: 'rt-1' } }),
+ callbacks()
+ ).connect()
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ expect(await loadTrustedHostKeys()).toEqual([])
+ })
+})
diff --git a/src/main/ssh/ssh-connection-host-key-verification.test.ts b/src/main/ssh/ssh-connection-host-key-verification.test.ts
new file mode 100644
index 00000000000..4b0c10210f8
--- /dev/null
+++ b/src/main/ssh/ssh-connection-host-key-verification.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { connectAttempts, resetSshConnectionMocks, ssh2Mock } from './ssh-connection-test-harness'
+import { createCallbacks, createTarget } from './ssh-connection-test-fixtures'
+import { SshConnection } from './ssh-connection'
+
+vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module())
+vi.mock('./system-ssh-binary', async () =>
+ (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule()
+)
+vi.mock('./ssh-system-fallback', async () =>
+ (await import('./ssh-connection-test-harness')).createSystemFallbackModule()
+)
+vi.mock('./ssh-control-socket', async () =>
+ (await import('./ssh-connection-test-harness')).createControlSocketModule()
+)
+vi.mock('./ssh-config-parser', async () =>
+ (await import('./ssh-connection-test-harness')).createSshConfigParserModule()
+)
+
+const UNIDENTIFIABLE_HOST_KEY = Buffer.from('not-a-real-host-key-blob')
+
+describe('SshConnection host key verification', () => {
+ beforeEach(() => {
+ resetSshConnectionMocks()
+ })
+
+ // Proves the WIRING, not just the module: a verifier that decides correctly is worthless if the
+ // handshake never consults it, and until this change ssh-connection accepted every key.
+ it('refuses a host key whose own blob cannot be identified', async () => {
+ ssh2Mock.presentedHostKey = UNIDENTIFIABLE_HOST_KEY
+ const conn = new SshConnection(createTarget(), createCallbacks())
+
+ await expect(conn.connect()).rejects.toThrow()
+ expect(ssh2Mock.lastHostKeyAccepted, 'the handshake accepted an unidentifiable key').toBe(false)
+ })
+
+ it('accepts a well-formed host key on first contact', async () => {
+ const conn = new SshConnection(createTarget(), createCallbacks())
+ await conn.connect()
+
+ expect(ssh2Mock.lastHostKeyAccepted).toBe(true)
+ })
+
+ // The point of refusing the key is that we do not trust who is on the other end. ssh2 reports a
+ // denied key as a generic auth failure, and an encrypted identity file makes the passphrase branch
+ // eligible on message shape alone — so without an explicit abort we would hand the secret to the
+ // party we just refused.
+ it('never asks for a credential after refusing a host key', async () => {
+ vi.stubEnv('SSH_AUTH_SOCK', '/tmp/agent.sock')
+ const tempDir = mkdtempSync(join(tmpdir(), 'orca-ssh-key-'))
+ const keyPath = join(tempDir, 'id_ed25519')
+ writeFileSync(keyPath, 'test-key')
+ ssh2Mock.presentedHostKey = UNIDENTIFIABLE_HOST_KEY
+ const onCredentialRequest = vi.fn(async () => 'secret')
+
+ try {
+ const conn = new SshConnection(
+ createTarget({ identityFile: keyPath }),
+ createCallbacks({ onCredentialRequest })
+ )
+
+ await expect(conn.connect()).rejects.toThrow(/host key verification failed/i)
+ expect(onCredentialRequest).not.toHaveBeenCalled()
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true })
+ }
+ })
+
+ // 'auth-failed' invites the user to re-enter credentials, which is the wrong thing to offer and
+ // the wrong thing to blame; nothing about their credentials is wrong.
+ it('surfaces a refused host key as an error rather than an auth failure', async () => {
+ ssh2Mock.presentedHostKey = UNIDENTIFIABLE_HOST_KEY
+ const conn = new SshConnection(createTarget(), createCallbacks())
+
+ await expect(conn.connect()).rejects.toThrow()
+ expect(conn.getState().status).toBe('error')
+ expect(conn.getState().error).toMatch(/host key verification failed/i)
+ })
+
+ // Everyone's first connection happens before ~/.ssh/known_hosts exists — ssh creates it on its
+ // own first connect. Treating a file that was never there as a source we failed to read would
+ // refuse every connection a new profile ever makes, which is how a fail-closed rule turns into a
+ // product that does not work.
+ it('connects on first contact when no known_hosts file exists yet', async () => {
+ const emptyHome = mkdtempSync(join(tmpdir(), 'orca-ssh-home-'))
+ vi.stubEnv('HOME', emptyHome)
+
+ try {
+ const conn = new SshConnection(createTarget(), createCallbacks())
+ await conn.connect()
+
+ expect(ssh2Mock.lastHostKeyAccepted).toBe(true)
+ expect(conn.getState().status).toBe('connected')
+ } finally {
+ rmSync(emptyHome, { recursive: true, force: true })
+ }
+ })
+
+ // A known_hosts that exists and will not open is the absence of evidence, so by product decision
+ // we connect as ssh does — it warns and treats the host as unknown — rather than refusing an
+ // ordinary offline Windows laptop whose OneDrive-backed file is a cloud placeholder. That we
+ // record NOTHING in that state is covered end to end in
+ // ssh-connection-host-key-store-wiring.test.ts, where the store is actually bound.
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
+ 'still connects when a known_hosts file exists but cannot be read',
+ async () => {
+ const home = mkdtempSync(join(tmpdir(), 'orca-ssh-home-'))
+ mkdirSync(join(home, '.ssh'))
+ const knownHosts = join(home, '.ssh', 'known_hosts')
+ writeFileSync(knownHosts, '')
+ chmodSync(knownHosts, 0o000)
+ vi.stubEnv('HOME', home)
+
+ try {
+ const conn = new SshConnection(createTarget(), createCallbacks())
+ await conn.connect()
+
+ expect(ssh2Mock.lastHostKeyAccepted).toBe(true)
+ expect(conn.getState().status).toBe('connected')
+ } finally {
+ rmSync(home, { recursive: true, force: true })
+ }
+ }
+ )
+
+ // Retrying re-derives the same decision, so a ladder that treated this as transient would back off
+ // against a host it has already refused until it gave up — burying the reason.
+ it('does not retry a refused host key', async () => {
+ ssh2Mock.presentedHostKey = UNIDENTIFIABLE_HOST_KEY
+ const conn = new SshConnection(createTarget(), createCallbacks())
+
+ await expect(conn.connect()).rejects.toThrow()
+ expect(connectAttempts).toBe(1)
+ })
+})
diff --git a/src/main/ssh/ssh-connection-test-fixtures.ts b/src/main/ssh/ssh-connection-test-fixtures.ts
index d3384aacaa8..cfed6a01061 100644
--- a/src/main/ssh/ssh-connection-test-fixtures.ts
+++ b/src/main/ssh/ssh-connection-test-fixtures.ts
@@ -26,6 +26,11 @@ export function createResolvedConfig(overrides?: Partial): Ss
proxyUseFdpass: true,
controlMaster: 'no',
controlPersist: 'no',
+ userKnownHostsFiles: [],
+ globalKnownHostsFiles: [],
+ strictHostKeyChecking: 'ask',
+ hashKnownHosts: false,
+ updateHostKeys: 'no',
...overrides
}
}
diff --git a/src/main/ssh/ssh-connection-test-harness.ts b/src/main/ssh/ssh-connection-test-harness.ts
index a18603af6db..fb61e94ea3b 100644
--- a/src/main/ssh/ssh-connection-test-harness.ts
+++ b/src/main/ssh/ssh-connection-test-harness.ts
@@ -50,8 +50,17 @@ export let connectAttempts = 0
export let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
export let pendingSftpCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
+/** Lets a test present a real key blob instead of the placeholder. */
+export const VALID_ED25519_HOST_KEY = Buffer.from(
+ 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq',
+ 'base64'
+)
+
// Knobs tests assign to; grouped because imported bindings cannot be reassigned.
export const ssh2Mock = {
+ presentedHostKey: undefined as Buffer | undefined,
+ /** What the verifier decided about the presented key on the most recent connect. */
+ lastHostKeyAccepted: undefined as boolean | undefined,
connectBehavior: 'ready' as 'ready' | 'error',
connectErrorMessage: '',
connectErrorCode: '',
@@ -136,9 +145,29 @@ export function createSsh2Module(): Ssh2ModuleMock {
connect(config?: unknown) {
connectAttempts += 1
this.lastConnectConfig = config
- const hostVerifier = (config as { hostVerifier?: (key: Buffer) => boolean } | undefined)
- ?.hostVerifier
- hostVerifier?.(Buffer.from('mock-ssh-host-key'))
+ // Why the callback form: ssh2 calls hostVerifier(key, verify) and only accepts synchronously
+ // when the return is not undefined. A mock that passed one argument and ignored the result
+ // would pass against a verifier that never decides — which is the regression host key
+ // verification exists to prevent.
+ const hostVerifier = (
+ config as
+ | { hostVerifier?: (key: Buffer, verify: (ok: boolean) => void) => undefined }
+ | undefined
+ )?.hostVerifier
+ const presentedHostKey = ssh2Mock.presentedHostKey ?? VALID_ED25519_HOST_KEY
+ ssh2Mock.lastHostKeyAccepted = undefined
+ hostVerifier?.(presentedHostKey, (ok) => {
+ ssh2Mock.lastHostKeyAccepted = ok
+ })
+ if (ssh2Mock.lastHostKeyAccepted === false) {
+ // ssh2 aborts the handshake when the verifier denies; a mock that carried on to 'ready'
+ // would let a rejected host key look like a successful connect.
+ setTimeout(
+ () => emitSshEvent('error', new Error('All configured authentication methods failed')),
+ 0
+ )
+ return
+ }
setTimeout(() => {
const next = ssh2Mock.connectSequence.shift()
if (next instanceof Error) {
@@ -242,6 +271,8 @@ export function resetSshConnectionMocks(): void {
ssh2Mock.sftpBehavior = 'callback'
pendingSftpCallback = null
ssh2Mock.notifyClientCreated = undefined
+ ssh2Mock.presentedHostKey = undefined
+ ssh2Mock.lastHostKeyAccepted = undefined
getOrcaControlSocketPathMock.mockReset()
getOrcaControlSocketPathMock.mockReturnValue(null)
removeControlSocketPathMock.mockReset()
diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts
index 72dd7991d71..308acc7c59f 100644
--- a/src/main/ssh/ssh-connection-utils.test.ts
+++ b/src/main/ssh/ssh-connection-utils.test.ts
@@ -385,6 +385,11 @@ function makeResolved(overrides?: Partial): SshResolvedConfig
proxyUseFdpass: false,
controlMaster: 'no',
controlPersist: 'no',
+ userKnownHostsFiles: [],
+ globalKnownHostsFiles: [],
+ strictHostKeyChecking: 'ask',
+ hashKnownHosts: false,
+ updateHostKeys: 'no',
...overrides
}
}
diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts
index 248ba70ee7c..a25b4c9881d 100644
--- a/src/main/ssh/ssh-connection.test.ts
+++ b/src/main/ssh/ssh-connection.test.ts
@@ -3,6 +3,7 @@ import {
clientInstances,
eventHandlers,
resetSshConnectionMocks,
+ VALID_ED25519_HOST_KEY,
ssh2Mock
} from './ssh-connection-test-harness'
import { createCallbacks, createTarget } from './ssh-connection-test-fixtures'
@@ -60,20 +61,30 @@ describe('SshConnection', () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
const firstVerifier = (
- clientInstances[0].lastConnectConfig as { hostVerifier?: (key: Buffer) => boolean }
+ clientInstances[0].lastConnectConfig as {
+ hostVerifier?: (key: Buffer, verify: (ok: boolean) => void) => undefined
+ }
).hostVerifier
const privateConn = conn as unknown as { attemptConnect: () => Promise }
await privateConn.attemptConnect()
const secondVerifier = (
- clientInstances[1].lastConnectConfig as { hostVerifier?: (key: Buffer) => boolean }
+ clientInstances[1].lastConnectConfig as {
+ hostVerifier?: (key: Buffer, verify: (ok: boolean) => void) => undefined
+ }
).hostVerifier
expect(firstVerifier).toBeTypeOf('function')
expect(secondVerifier).toBeTypeOf('function')
- secondVerifier?.(Buffer.from('newer-ssh-host-key'))
+ // Real blobs: the verifier now identifies the key before recording a fingerprint, so a
+ // placeholder string would be refused before it could reach the generation check this covers.
+ const newerKey = Buffer.from(
+ 'AAAAC3NzaC1lZDI1NTE5AAAAILu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7',
+ 'base64'
+ )
+ secondVerifier?.(newerKey, () => {})
const currentFingerprint = conn.getHostKeyFingerprint()
- firstVerifier?.(Buffer.from('obsolete-ssh-host-key'))
+ firstVerifier?.(VALID_ED25519_HOST_KEY, () => {})
expect(conn.getHostKeyFingerprint()).toBe(currentFingerprint)
})
diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts
index c8db921537f..6c276675a4c 100644
--- a/src/main/ssh/ssh-connection.ts
+++ b/src/main/ssh/ssh-connection.ts
@@ -1,6 +1,5 @@
/* eslint-disable max-lines -- Why: SSH connection lifecycle, credential retries, reconnect policy, and transport fallback are intentionally co-located so state transitions stay auditable in one file. */
import * as net from 'node:net'
-import { createHash } from 'node:crypto'
import { Client as SshClient } from 'ssh2'
import type { ChildProcess } from 'node:child_process'
import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2'
@@ -39,6 +38,25 @@ import {
type SshConnectionCallbacks
} from './ssh-connection-utils'
import { resolveEffectiveProxy, spawnProxyCommand } from './ssh-proxy-command'
+import {
+ createHostKeyVerifier,
+ DEFAULT_SERVER_HOST_KEY_ALGORITHMS,
+ orderServerHostKeyAlgorithms
+} from './ssh-host-key-verifier'
+import { HostKeyVerificationError, isHostKeyVerificationError } from './ssh-host-key-decision'
+import { siteConfigMayRestrictHostKeys, sshGArgsForHost } from './ssh-g-config-resolution'
+import {
+ boundSshHostKeyStoreFile,
+ loadTrustedHostKeys,
+ matchTrustedHostKeys,
+ storedKeyTypesForEndpoint,
+ trustHostKey
+} from './ssh-host-key-store'
+import {
+ loadKnownHostsEvidence,
+ resolveKnownHostsFiles,
+ resolveKnownHostsLookupHost
+} from './ssh-known-hosts-source'
import {
createCancelledConnectAttemptError,
isCancelledConnectAttemptError
@@ -60,18 +78,29 @@ import {
} from './sftp-namespace-resolution'
import type { FileUploadSession } from '../providers/types'
import { isSshSessionLimitError } from './ssh-session-limit-error'
+import { withTimeout } from '../../shared/promise-timeout-fallback'
import {
createLinkedSshFileTransferSignal,
raceSftpFileTransferWithAbort
} from './ssh-file-transfer-abort'
export type { SshConnectionCallbacks } from './ssh-connection-utils'
+type HostKeyTrustSources = {
+ /** Whether the system-wide ssh_config could be restricting host keys; doubt reads as true. */
+ siteMayRestrict: boolean
+ knownHostsEvidence: Awaited>
+ trustedHostKeys: Awaited>
+}
+
type SshRemoteFileOptions = {
hostPlatform?: RemoteHostPlatform
// Only uploadDirectory and writeFile honor this, and only on the non-Windows ssh2 branch.
sftpNamespace?: SftpNamespacePathMapping
}
+/** Bounds the trust-source reads that run before the handshake, which nothing else times out. */
+const HOST_KEY_SOURCE_READ_TIMEOUT_MS = 5_000
+
// Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway.
const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000
@@ -83,7 +112,14 @@ function cloneResolvedConfig(config: SshResolvedConfig | null): SshResolvedConfi
if (!config) {
return null
}
- return { ...config, identityFile: [...config.identityFile] }
+ // Every array field needs its own copy — a shared reference lets one connection's clone mutate
+ // another's view of which files were consulted.
+ return {
+ ...config,
+ identityFile: [...config.identityFile],
+ userKnownHostsFiles: [...config.userKnownHostsFiles],
+ globalKnownHostsFiles: [...config.globalKnownHostsFiles]
+ }
}
function isGitHubRestrictedShellProbeSuccess(
@@ -127,6 +163,21 @@ export class SshConnection {
private systemCommandChannels = new Set()
private systemOperationAbortController = new AbortController()
private systemSshResolvedConfig: SshResolvedConfig | null = null
+ /** Set by attemptConnect so doSsh2Connect can build a verifier without threading it through. */
+ private hostKeyResolvedConfig: SshResolvedConfig | null = null
+ /**
+ * The trust sources for ONE connect attempt, keyed by its generation.
+ *
+ * doSsh2Connect runs up to five times per attemptConnect as the credential ladder advances, and
+ * each run re-read every known_hosts file, re-read the store, and on the -F path re-scanned the
+ * system config. The answer cannot change between rungs of the same attempt — nothing writes these
+ * while a handshake is in flight — so it is read once. Keyed by generation rather than cleared,
+ * because a superseded attempt must never hand its sources to the live one.
+ */
+ private hostKeyTrustSources: {
+ generation: number
+ sources: Promise
+ } | null = null
private systemSshControlMasterDisabledForSession = false
private systemSshGssapiOnlyForSession = false
private useSystemSshTransport = false
@@ -652,6 +703,27 @@ export class SshConnection {
}
// Why: callers claim the generation so their catch can tell their own failure from a superseded one.
+ /**
+ * A credential prompt, unless this attempt has already been superseded.
+ *
+ * Gated here rather than at each ladder rung because every rung ends in this one call, and the
+ * rungs below the first are exactly where the generation check was missing: a superseded attempt
+ * is denied by the verifier WITHOUT a recorded decision (deliberate — a dead attempt must not
+ * clobber the live one's outcome), so `isHostKeyVerificationError` reads false and the fallback
+ * runs on. The user then gets a passphrase dialog for a connection nobody is waiting on, possibly
+ * racing the live attempt's own. Returning undefined drops each rung through to its throw.
+ */
+ private async requestCredential(
+ kind: 'passphrase' | 'password',
+ detail: string,
+ connectGeneration: number
+ ): Promise {
+ if (this.disposed || connectGeneration !== this.connectGeneration) {
+ return undefined
+ }
+ return this.callbacks.onCredentialRequest?.(this.target.id, kind, detail)
+ }
+
private async attemptConnect(connectGeneration = ++this.connectGeneration): Promise {
this.setState('connecting')
this.proxyProcess?.kill()
@@ -660,6 +732,7 @@ export class SshConnection {
const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(
() => null
)
+ this.hostKeyResolvedConfig = resolved
const usesConfiguredSystemTransport = shouldUseSystemSshTransport(this.target, resolved)
const requiresSecurityKeyTransport = usesConfiguredSystemTransport
? false
@@ -718,6 +791,30 @@ export class SshConnection {
throw err
}
+ // Why generation and not the error: a superseded attempt's host key denial is deliberately
+ // NOT recorded — the verifier withholds it so a dead attempt cannot clobber the live one's
+ // outcome — so nothing about the error identifies it, and ssh2's generic handshake failure
+ // reads as an ordinary fault. Everything below either offers a credential or probes another
+ // transport, and doing that for a connection nobody is waiting on can put a passphrase prompt
+ // in front of a host we just refused. Checking the generation catches it whatever the error
+ // turned out to be, and leaves what connect() rejects with unchanged.
+ if (this.disposed || connectGeneration !== this.connectGeneration) {
+ this.proxyProcess?.kill()
+ this.proxyProcess = null
+ throw err
+ }
+
+ // Why first: everything below this line either offers a credential or retries on another
+ // transport. We have just decided this host may not be the one it claims to be, so prompting
+ // would collect the user's passphrase or password on its behalf, and the system-ssh probe
+ // would connect anyway whenever the disagreement is with our own store rather than
+ // known_hosts. A denied key is final for this attempt.
+ if (isHostKeyVerificationError(err)) {
+ this.proxyProcess?.kill()
+ this.proxyProcess = null
+ throw err
+ }
+
if (isSystemSshFallbackError(err)) {
this.proxyProcess?.kill()
this.proxyProcess = null
@@ -758,7 +855,9 @@ export class SshConnection {
await this.doSsh2Connect(keyConfig, connectGeneration)
return
} catch (keyErr) {
- if (!(keyErr instanceof Error)) {
+ // Same reason as above: the retry re-runs the handshake, so it can be the attempt that
+ // denies the key, and the passphrase prompt is directly below.
+ if (!(keyErr instanceof Error) || isHostKeyVerificationError(keyErr)) {
this.proxyProcess?.kill()
this.proxyProcess = null
throw keyErr
@@ -777,11 +876,7 @@ export class SshConnection {
this.target.identityFile ||
resolved?.identityFile?.[0] ||
'(unknown)'
- const val = await this.callbacks.onCredentialRequest?.(
- this.target.id,
- 'passphrase',
- detail
- )
+ const val = await this.requestCredential('passphrase', detail, connectGeneration)
if (val) {
this.cachedPassphrase = val
keyConfig.passphrase = val
@@ -831,7 +926,7 @@ export class SshConnection {
this.target.identityFile ||
resolved?.identityFile?.[0] ||
'(unknown)'
- const val = await this.callbacks.onCredentialRequest(this.target.id, 'passphrase', detail)
+ const val = await this.requestCredential('passphrase', detail, connectGeneration)
if (val) {
this.cachedPassphrase = val
credentialRetryConfig.passphrase = val
@@ -842,10 +937,10 @@ export class SshConnection {
}
// Why: an agent socket failure can still be recovered by password auth, but the retry must use the no-agent config selected above.
if (isAgentFallbackError(authError) && !this.cachedPassword) {
- const val = await this.callbacks.onCredentialRequest(
- this.target.id,
+ const val = await this.requestCredential(
'password',
- config.host || this.target.label
+ config.host || this.target.label,
+ connectGeneration
)
if (val) {
this.cachedPassword = val
@@ -1174,19 +1269,170 @@ export class SshConnection {
config.sock = p.sock
}
- private doSsh2Connect(config: ConnectConfig, connectGeneration: number): Promise {
+ /** Reads known_hosts, our store and the site policy once per connect attempt. */
+ private loadHostKeyTrustSources(
+ connectGeneration: number,
+ hostKeyResolved: SshResolvedConfig | null,
+ sshGSuppressedSiteConfig: boolean
+ ): Promise {
+ if (this.hostKeyTrustSources?.generation === connectGeneration) {
+ return this.hostKeyTrustSources.sources
+ }
+ const sources = (async (): Promise => {
+ const [siteMayRestrict, knownHostsEvidence, trustedHostKeys] = await Promise.all([
+ // Doubt wins on timeout for the same reason it wins on an unreadable file.
+ sshGSuppressedSiteConfig
+ ? withTimeout(siteConfigMayRestrictHostKeys(), HOST_KEY_SOURCE_READ_TIMEOUT_MS, true)
+ : Promise.resolve(false),
+ // Why bounded: these reads sit AHEAD of client.connect, and readyTimeout only covers the
+ // handshake — nothing else wraps attemptConnect. A home directory on a stalled NFS or SMB
+ // mount makes readFile hang indefinitely, which left the connection wedged in `connecting`
+ // with no ladder entry and no recovery. The fallback is the one an unreadable file already
+ // produces: evidence withheld, so we connect as ssh does but record nothing, rather than the
+ // far worse "no hosts known" that would let a changed key through as first contact.
+ withTimeout(
+ resolveKnownHostsFiles(hostKeyResolved).then(loadKnownHostsEvidence),
+ HOST_KEY_SOURCE_READ_TIMEOUT_MS,
+ { entries: [], unreadableFileCount: 1 }
+ ),
+ // The catch sits INSIDE the timeout on purpose: withTimeout absorbs rejections into its
+ // fallback, so wrapping the other way round would silently swallow the warning below — the
+ // one signal that the store is unwired rather than merely slow.
+ withTimeout(
+ loadTrustedHostKeys().catch((err) => {
+ // Losing our own records is not a security regression: known_hosts is still consulted,
+ // so a changed key is still refused. A host trusted only by us degrades to first contact
+ // and is re-recorded, which reaches the same decision. Loud because it means the store
+ // is unwired.
+ console.warn('[ssh] host key store unavailable; falling back to known_hosts only:', err)
+ return []
+ }),
+ HOST_KEY_SOURCE_READ_TIMEOUT_MS,
+ []
+ )
+ ])
+ return { siteMayRestrict, knownHostsEvidence, trustedHostKeys }
+ })()
+ this.hostKeyTrustSources = { generation: connectGeneration, sources }
+ return sources
+ }
+
+ private async doSsh2Connect(config: ConnectConfig, connectGeneration: number): Promise {
+ const hostKeyResolved = this.hostKeyResolvedConfig
+ const { host: hostKeyLookupHost, isHostKeyAlias } = resolveKnownHostsLookupHost(
+ hostKeyResolved,
+ config.host ?? ''
+ )
+ // `-F` suppresses /etc/ssh/ssh_config, so a site-wide StrictHostKeyChecking is invisible to us
+ // on that path. Joined below with the other way a source can go silent: an unreadable file.
+ //
+ // Gated on `ssh -G` having actually produced something. sshGArgsForHost only reports which args
+ // WOULD be used, so without this a machine where ssh is missing, broken or timed out was judged
+ // by whether it happens to have a ~/.ssh/config — incoherently, since the same broken machine
+ // WITHOUT one was fully permissive. The flag is a claim about a config file we could not read,
+ // and when ssh never ran there is no such claim to make.
+ const sshGSuppressedSiteConfig =
+ hostKeyResolved !== null &&
+ sshGArgsForHost(this.target.configHost || this.target.label).includes('-F')
+ // Being blind to the site policy is only a reason to refuse if the site config could actually be
+ // setting one. ssh cannot answer that — `-F` excludes the system config as surely as the user's,
+ // and `-F /dev/null` reports built-in defaults rather than inverting the exclusion — so the file
+ // is read instead, conservatively: any doubt keeps the refusal.
+ const { siteMayRestrict, knownHostsEvidence, trustedHostKeys } =
+ await this.loadHostKeyTrustSources(
+ connectGeneration,
+ hostKeyResolved,
+ sshGSuppressedSiteConfig
+ )
+ const siteConfigSuppressed = siteMayRestrict
+ const knownHostsEntries = knownHostsEvidence.entries
+ // Null means ssh2's own list was unreadable, so there is nothing safe to reorder against and
+ // ssh2 keeps proposing whatever it supports.
+ const serverHostKeyOrder = DEFAULT_SERVER_HOST_KEY_ALGORITHMS
+ ? orderServerHostKeyAlgorithms(
+ knownHostsEntries,
+ hostKeyLookupHost,
+ config.port ?? 22,
+ DEFAULT_SERVER_HOST_KEY_ALGORITHMS,
+ storedKeyTypesForEndpoint(trustedHostKeys, hostKeyLookupHost, config.port ?? 22),
+ isHostKeyAlias
+ )
+ : undefined
return new Promise((resolve, reject) => {
const client = new SshClient()
let settled = false
+ // Local to this attempt: an instance field would let a superseded attempt's rejection replace
+ // the live attempt's error, and substituting a new Error drops ssh2's `code`, so a transient
+ // ECONNRESET would stop being classified as retryable.
+ let hostKeyRejection: HostKeyVerificationError | null = null
- // Why: the relay uses the negotiated server key to isolate shared-home
- // install locks without comparing PIDs from an unrelated SSH host.
- config.hostVerifier = (key: Buffer): boolean => {
- if (!this.disposed && connectGeneration === this.connectGeneration) {
- const digest = createHash('sha256').update(key).digest('base64').replace(/=+$/, '')
- this.hostKeyFingerprint = `SHA256:${digest}`
+ // Why the fingerprint is still recorded: the relay uses the negotiated server key to isolate
+ // shared-home install locks without comparing PIDs from an unrelated SSH host. Its format is
+ // load-bearing across versions, which is a second reason never to set ssh2's `hostHash`.
+ config.hostVerifier = createHostKeyVerifier({
+ host: hostKeyLookupHost,
+ port: config.port ?? 22,
+ // The name we looked up, not the label: the mismatch message prints `ssh-keygen -R `
+ // and a label removes nothing from known_hosts.
+ displayHost: hostKeyLookupHost,
+ hostKeyStoreFile: boundSshHostKeyStoreFile() ?? undefined,
+ strictHostKeyChecking: hostKeyResolved?.strictHostKeyChecking ?? 'ask',
+ isHostKeyAlias,
+ isEphemeralRuntimeTarget: this.target.owner?.type === 'on-demand-runtime',
+ siteConfigSuppressed,
+ // A file that EXISTS and will not open is the absence of evidence, not evidence of a new
+ // host — the entry that would have said "this key changed" may be in it. An ABSENT file is
+ // not this: that is the normal state for a fresh profile and genuinely means nothing is
+ // known. The decision connects anyway, as ssh does, but records nothing.
+ knownHostsUnreadable: knownHostsEvidence.unreadableFileCount > 0,
+ entries: knownHostsEntries,
+ // The store's own matcher, against records preloaded above because ssh2's verifier decides
+ // synchronously. Reimplementing the comparison here is exactly how the type downgrade got
+ // in, and how the host normalisation drifted from the one used to write the records.
+ isTrusted: (query) => matchTrustedHostKeys(trustedHostKeys, query),
+ rememberHostKey: (record) => {
+ void trustHostKey({
+ host: record.host,
+ port: record.port,
+ keyType: record.keyType,
+ key: record.key
+ }).catch((err) => {
+ // A failed write must not fail the connection: we verified the key, we just could not
+ // record it, so the next connect re-derives the same answer from known_hosts.
+ console.warn('[ssh] failed to record accepted host key:', err)
+ })
+ },
+ isCurrentAttempt: () => !this.disposed && connectGeneration === this.connectGeneration,
+ onDecision: (decision) => {
+ // Empty when the blob was not a readable host key: there is nothing to identify, and the
+ // relay uses this fingerprint to isolate install locks, so blanking it would be worse
+ // than leaving the previous one. A REJECTED key is excluded for the same reason read the
+ // other way: we refused to talk to that host, so adopting its fingerprint would scope the
+ // relay's locks to a host we never accepted.
+ if (
+ decision.fingerprint &&
+ decision.action !== 'reject' &&
+ !this.disposed &&
+ connectGeneration === this.connectGeneration
+ ) {
+ this.hostKeyFingerprint = decision.fingerprint
+ }
+ if (decision.action === 'reject') {
+ hostKeyRejection = new HostKeyVerificationError(
+ decision.reason ?? 'Host key verification failed.',
+ decision.outcome
+ )
+ }
+ }
+ })
+
+ if (serverHostKeyOrder) {
+ config.algorithms = {
+ ...config.algorithms,
+ serverHostKey: serverHostKeyOrder as NonNullable<
+ NonNullable['serverHostKey']
+ >
}
- return true
}
const cleanupStartupListeners = (): void => {
@@ -1239,7 +1485,10 @@ export class SshConnection {
cleanupStartupListeners()
settled = true
client.destroy()
- reject(err)
+ // Why replace the error: ssh2 reports a denied host key as a generic handshake failure,
+ // which the reconnect ladder cannot distinguish from a transient fault and would retry
+ // forever against a decision that will never change.
+ reject(hostKeyRejection ?? err)
}
client.on('ready', onReady)
diff --git a/src/main/ssh/ssh-g-config-resolution.test.ts b/src/main/ssh/ssh-g-config-resolution.test.ts
index 4f21c896376..6cb6d4094b2 100644
--- a/src/main/ssh/ssh-g-config-resolution.test.ts
+++ b/src/main/ssh/ssh-g-config-resolution.test.ts
@@ -1,4 +1,10 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as NodeOs from 'node:os'
+import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+type NodeOsModule = typeof NodeOs
const { existsSyncMock, homedirMock, userInfoMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn<(path: string) => boolean>(() => true),
@@ -7,9 +13,19 @@ const { existsSyncMock, homedirMock, userInfoMock } = vi.hoisted(() => ({
}))
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
-vi.mock('node:os', () => ({ homedir: homedirMock, userInfo: userInfoMock }))
+// Why importOriginal: the site-config tests need a real tmpdir(), and a bare factory would replace
+// the whole module and leave every other export undefined.
+vi.mock('node:os', async (importOriginal) => ({
+ ...(await importOriginal()),
+ homedir: homedirMock,
+ userInfo: userInfoMock
+}))
-import { sshGArgsForHost } from './ssh-g-config-resolution'
+import {
+ siteConfigMayRestrictHostKeys,
+ splitIncludeArguments,
+ sshGArgsForHost
+} from './ssh-g-config-resolution'
describe('sshGArgsForHost', () => {
beforeEach(() => {
@@ -61,3 +77,311 @@ describe('sshGArgsForHost', () => {
])
})
})
+
+/**
+ * Whether the system-wide ssh_config could restrict host keys.
+ *
+ * `-F` excludes /etc/ssh/ssh_config as well as the per-user file, and there is no ssh-only way to
+ * read one while suppressing the other — `-F /dev/null` reports built-in defaults, which would make
+ * every machine look permissive. So the file is read, and the question asked is deliberately weaker
+ * than "what is the policy": anything ambiguous keeps the caller fail-closed.
+ */
+describe('siteConfigMayRestrictHostKeys', () => {
+ let dir: string
+
+ beforeEach(async () => {
+ dir = await mkdtemp(join(tmpdir(), 'orca-site-ssh-config-'))
+ })
+
+ afterEach(async () => {
+ await rm(dir, { recursive: true, force: true })
+ })
+
+ it('answers no when there is no system config at all', async () => {
+ // The case that used to lock people out: nothing to be blind to.
+ await expect(siteConfigMayRestrictHostKeys([join(dir, 'absent')])).resolves.toBe(false)
+ })
+
+ it('answers no for a config that says nothing about host keys', async () => {
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, 'Host *\n SendEnv LANG LC_*\n', 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(false)
+ })
+
+ it('answers yes when the directive is present, whatever its value', async () => {
+ // The value is not parsed on purpose: presence alone is enough to stay strict.
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, 'Host *\n StrictHostKeyChecking no\n', 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('follows the Include OpenSSH ships by default', async () => {
+ // macOS and most distros ship `Include /etc/ssh/ssh_config.d/*`, so missing this would read as
+ // "no policy" on nearly every machine that has one.
+ const includeDir = join(dir, 'ssh_config.d')
+ await mkdir(includeDir)
+ await writeFile(join(includeDir, '10-site.conf'), 'StrictHostKeyChecking yes\n', 'utf-8')
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, 'Include ssh_config.d/*\nHost *\n', 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('answers no when an Included directory holds nothing relevant', async () => {
+ const includeDir = join(dir, 'ssh_config.d')
+ await mkdir(includeDir)
+ await writeFile(join(includeDir, '10-site.conf'), 'SendEnv LANG\n', 'utf-8')
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, 'Include ssh_config.d/*\n', 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(false)
+ })
+
+ it('finds a directive behind a RELATIVE Include nested two deep', async () => {
+ // OpenSSH resolves a relative Include against a FIXED base (SSHDIR), not the including file's
+ // own directory — verified against 10.2p1, where a nested `Include sibling` picked up the
+ // sibling of the TOP config. Passing dirname(file) agreed at depth 1 and diverged below it, so
+ // this directive was silently missed and the scanner answered "no site policy".
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, 'Include nested/inner\n', 'utf-8')
+ await mkdir(join(dir, 'nested'), { recursive: true })
+ await writeFile(join(dir, 'nested', 'inner'), 'Include sibling\n', 'utf-8')
+ await writeFile(join(dir, 'sibling'), 'StrictHostKeyChecking yes\n', 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('stays strict for a glob it cannot expand', async () => {
+ // `?` and `[…]` are globs OpenSSH honours (10.2p1 applies `Include /tmp/b/?.conf`), but only
+ // `*` is expanded here. Treated as a literal path they resolve to nothing, and "nothing there"
+ // is indistinguishable from "no policy" — so an unexpanded glob has to count as doubt.
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, `Include ${join(dir, '?.conf')}\n`, 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('stays strict for an Include path it would have to expand tokens in', async () => {
+ // `~` and `%d`-style tokens expand before OpenSSH uses the path. Joining them verbatim produced
+ // a path that cannot exist, which is the same fail-open as the glob above.
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, 'Include ~/.ssh/site.conf\n', 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('follows a quoted Include path that contains a space', async () => {
+ // OpenSSH honours the quoted form — 10.2p1 applies `Include "/sp ace/x.conf"`. Splitting on
+ // whitespace before unquoting shredded it into two tokens that resolved to nothing, and two
+ // missing paths read as "no site policy". Likelier on Windows, where `C:\\Program Files` is
+ // ordinary. The unquoted form still splits, which is also what OpenSSH does.
+ const spaced = join(dir, 'my confs')
+ await mkdir(spaced, { recursive: true })
+ await writeFile(join(spaced, 'site.conf'), 'StrictHostKeyChecking accept-new\n', 'utf-8')
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, `Include "${join(spaced, 'site.conf')}"\n`, 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('follows a single-quoted Include path that contains a space', async () => {
+ // OpenSSH quotes with either character — 10.2p1 honours the single-quoted form too. Modelling
+ // only double quotes left this splitting into fragments that resolve to nothing, which is the
+ // same fail-open the double-quote case was raised for.
+ const spaced = join(dir, 'sq ace')
+ await mkdir(spaced, { recursive: true })
+ await writeFile(join(spaced, 'site.conf'), 'StrictHostKeyChecking yes\n', 'utf-8')
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, `Include '${join(spaced, 'site.conf')}'\n`, 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('follows an Include path whose space is backslash-escaped', async () => {
+ // The third spelling OpenSSH honours (verified against 10.2p1).
+ const spaced = join(dir, 'esc ape')
+ await mkdir(spaced, { recursive: true })
+ await writeFile(join(spaced, 'site.conf'), 'StrictHostKeyChecking yes\n', 'utf-8')
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, `Include ${join(spaced, 'site.conf').replace(' ', '\\ ')}\n`, 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('keeps backslashes that are path separators rather than escapes', async () => {
+ // The Windows trap: the site config lives at C:\\ProgramData\\ssh\\ssh_config, so treating every
+ // backslash as an escape would eat the separators of any Include beneath it, resolve to nothing,
+ // and reintroduce the fail-open on the platform this matters most for. Only a backslash before
+ // whitespace escapes.
+ //
+ // Discriminating on POSIX by giving the file a literal backslash in its NAME, which is legal
+ // here: preserved, the path resolves and the directive is found; swallowed, it resolves to
+ // `winlikesite.conf`, which does not exist — and a bare `.resolves.toBe(false)` could not tell
+ // those apart, since a missing path answers false either way.
+ const literal = join(dir, 'winlike\\site.conf')
+ await writeFile(literal, 'StrictHostKeyChecking yes\n', 'utf-8')
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, `Include ${literal}\n`, 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('stays strict when an Include quote never closes', async () => {
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, `Include "${join(dir, 'unterminated.conf')}\n`, 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('still reads a quoted path with no space, rather than calling every quote doubt', async () => {
+ // The lockout this scanner exists to avoid: answering "doubt" for `Include "/etc/ssh/x.conf"`
+ // would punish an ordinary quoted config that plainly says nothing about host keys.
+ const included = join(dir, 'plain.conf')
+ await writeFile(included, 'Compression yes\n', 'utf-8')
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, `Include "${included}"\n`, 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(false)
+ })
+
+ it('ignores a commented-out directive', async () => {
+ // A commented directive is not a policy. Reading it as one would reinstate the lockout for
+ // anyone whose distro ships the line commented, which is the common shape.
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, 'Host *\n # StrictHostKeyChecking yes\n', 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(false)
+ })
+
+ it('sees the directive inside Host and Match blocks', async () => {
+ // No attempt is made to evaluate whether the block applies to this host — presence anywhere is
+ // enough, because guessing wrong in the permissive direction is the failure that matters.
+ const hostScoped = join(dir, 'host-scoped')
+ await writeFile(hostScoped, 'Host prod\n StrictHostKeyChecking yes\n', 'utf-8')
+ const matchScoped = join(dir, 'match-scoped')
+ await writeFile(matchScoped, 'Match exec "true"\n StrictHostKeyChecking yes\n', 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([hostScoped])).resolves.toBe(true)
+ await expect(siteConfigMayRestrictHostKeys([matchScoped])).resolves.toBe(true)
+ })
+
+ it('sees the equals form', async () => {
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, 'StrictHostKeyChecking=yes\n', 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ })
+
+ it('does not match a directive that merely starts the same way', async () => {
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, 'StrictHostKeyCheckingExtended yes\n', 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(false)
+ })
+
+ it('follows a nested Include', async () => {
+ const inner = join(dir, 'inner')
+ await writeFile(inner, 'StrictHostKeyChecking yes\n', 'utf-8')
+ const middle = join(dir, 'middle')
+ await writeFile(middle, `Include ${inner}\n`, 'utf-8')
+ const outer = join(dir, 'ssh_config')
+ await writeFile(outer, `Include ${middle}\n`, 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([outer])).resolves.toBe(true)
+ })
+
+ it('terminates on an Include cycle', async () => {
+ const a = join(dir, 'a')
+ const b = join(dir, 'b')
+ await writeFile(a, `Include ${b}\n`, 'utf-8')
+ await writeFile(b, `Include ${a}\n`, 'utf-8')
+
+ await expect(siteConfigMayRestrictHostKeys([a])).resolves.toBe(false)
+ })
+
+ it('treats an unreadable config as doubt rather than permission', async () => {
+ const file = join(dir, 'ssh_config')
+ await writeFile(file, 'Host *\n', 'utf-8')
+ await chmod(file, 0o000)
+
+ try {
+ await expect(siteConfigMayRestrictHostKeys([file])).resolves.toBe(true)
+ } finally {
+ await chmod(file, 0o600)
+ }
+ })
+})
+
+/**
+ * The measured oracle. Every expectation below was produced by running a real `ssh` and reading what
+ * it resolved to — OpenSSH 10.2p1 for the POSIX rows, Windows OpenSSH for the win32 ones — not by
+ * reading the source or reasoning from shell conventions.
+ *
+ * Pinned because the tokenizer's whole job is to agree with `ssh` about which file it would read: a
+ * spelling we disagree on resolves for ssh and misses for us, and a path that misses is silently
+ * read as "there is no site policy". Every fix in this area has been one of these.
+ */
+describe('splitIncludeArguments against a measured ssh', () => {
+ const posix = (pattern: string) => splitIncludeArguments(pattern, false)
+ const windows = (pattern: string) => splitIncludeArguments(pattern, true)
+
+ it('holds a spaced path together in all three spellings both platforms honour', () => {
+ for (const split of [posix, windows]) {
+ expect(split('"/etc/ssh/sp ace/x.conf"')).toEqual(['/etc/ssh/sp ace/x.conf'])
+ expect(split("'/etc/ssh/sp ace/x.conf'")).toEqual(['/etc/ssh/sp ace/x.conf'])
+ expect(split('/etc/ssh/sp\\ ace/x.conf')).toEqual(['/etc/ssh/sp ace/x.conf'])
+ }
+ })
+
+ it('consumes an escaped space INSIDE either quote, as ssh does', () => {
+ // Measured: `"a\ b.conf"` and `'a\ b.conf'` both resolve to the SPACE-named file, not the
+ // backslash-named one. Shell-style single-quote literalness would have been the wrong model.
+ expect(posix('"/etc/a\\ b.conf"')).toEqual(['/etc/a b.conf'])
+ expect(posix("'/etc/a\\ b.conf'")).toEqual(['/etc/a b.conf'])
+ })
+
+ it('treats a single quote inside double quotes as an ordinary character', () => {
+ // Measured as resolved. One quote-state variable reproduces this; two independent toggles would
+ // have closed the double quote early and split the path.
+ expect(posix('"/etc/it\'s here/x.conf"')).toEqual(["/etc/it's here/x.conf"])
+ })
+
+ it('splits an UNQUOTED space into two paths, as ssh does', () => {
+ expect(posix('/etc/a.conf /etc/b.conf')).toEqual(['/etc/a.conf', '/etc/b.conf'])
+ })
+
+ it('answers doubt on POSIX for a backslash before an ordinary character', () => {
+ // Measured: ssh resolves `conf\.d` as `conf.d`, and four backslashes are needed to survive as
+ // one — argv_split and glob() each eat a level. Preserving it means looking for a path with a
+ // literal backslash, missing, and reading "no site policy", which is the fail-open. We answer
+ // uncertain rather than reimplement two rounds of glob escaping.
+ expect(posix('/etc/ssh/conf\\.d/x.conf')).toBeNull()
+ expect(posix('/etc/ssh/a\\\\b/x.conf')).toBeNull()
+ })
+
+ it('keeps backslashes as separators on Windows', () => {
+ // Measured on Windows OpenSSH: `Include C:\\Users\\...\\x.conf` resolves. Answering doubt here
+ // would reinstate the lockout, since every absolute Windows Include contains backslashes.
+ expect(windows('C:\\Users\\neil\\conf\\x.conf')).toEqual(['C:\\Users\\neil\\conf\\x.conf'])
+ // Measured too: an escaped space still escapes, even amid separators.
+ expect(windows('C:\\Users\\neil\\sp\\ ace\\x.conf')).toEqual([
+ 'C:\\Users\\neil\\sp ace\\x.conf'
+ ])
+ })
+
+ it('answers doubt when a quote never closes, which ssh rejects outright', () => {
+ expect(posix('"/etc/ssh/x.conf')).toBeNull()
+ expect(windows('"C:\\x.conf')).toBeNull()
+ })
+
+ it('returns an ordinary path unchanged, on both platforms', () => {
+ // The invariant every fix in this area has had to preserve: no whitespace, quote or backslash
+ // means one token, byte-for-byte.
+ for (const path of ['/etc/ssh/ssh_config.d/10-site.conf', 'relative/x.conf', 'x']) {
+ expect(posix(path)).toEqual([path])
+ expect(windows(path)).toEqual([path])
+ }
+ })
+})
diff --git a/src/main/ssh/ssh-g-config-resolution.ts b/src/main/ssh/ssh-g-config-resolution.ts
index e329806ef3c..0566ad0d526 100644
--- a/src/main/ssh/ssh-g-config-resolution.ts
+++ b/src/main/ssh/ssh-g-config-resolution.ts
@@ -1,6 +1,7 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
-import { join } from 'node:path'
+import { readdir, readFile } from 'node:fs/promises'
+import { dirname, isAbsolute, join } from 'node:path'
import { homedir, userInfo } from 'node:os'
import { resolveSshConfigHomePath } from './ssh-config-path-expansion'
@@ -23,6 +24,13 @@ export type SshResolvedConfig = {
controlMaster: string
controlPath?: string
controlPersist: string
+ /** May hold the literal `none`, which OpenSSH prints for an explicit opt-out. */
+ userKnownHostsFiles: string[]
+ globalKnownHostsFiles: string[]
+ strictHostKeyChecking: string
+ hostKeyAlias?: string
+ hashKnownHosts: boolean
+ updateHostKeys: string
}
const SSH_G_TIMEOUT_MS = 5000
@@ -52,6 +60,192 @@ export function sshGArgsForHost(host: string): string[] {
return ['-G', '--', host]
}
+/** OpenSSH's system-wide client config, which `-F` excludes along with the per-user one. */
+const SITE_SSH_CONFIG_FILES =
+ process.platform === 'win32'
+ ? [join(process.env.ProgramData ?? 'C:\\ProgramData', 'ssh', 'ssh_config')]
+ : ['/etc/ssh/ssh_config']
+
+const STRICT_HOST_KEY_DIRECTIVE = /^\s*stricthostkeychecking\b/i
+const INCLUDE_DIRECTIVE = /^\s*include\s+(.+?)\s*$/i
+/** Glob syntax OpenSSH honours that expandSiteInclude does not expand; `*` is handled separately. */
+const OTHER_GLOB_METACHARACTER = /[?[\]]/
+
+/**
+ * Whether the system-wide ssh_config could be setting StrictHostKeyChecking.
+ *
+ * Needed because `-F` excludes /etc/ssh/ssh_config as well as the per-user file, so on that path the
+ * resolved config cannot represent a site policy — and there is no ssh-only way to read one while
+ * suppressing the other. `-F /dev/null` does NOT invert the exclusion; it reports built-in defaults,
+ * which would make this look permissive on every machine.
+ *
+ * So this reads the file instead, and deliberately answers a WEAKER question than "what is the
+ * policy". Anything ambiguous — unreadable, an Include we cannot resolve, the directive present at
+ * all — answers true and the caller stays fail-closed. Only a site config that demonstrably says
+ * nothing about host keys clears it, which is the common case this exists to stop punishing.
+ *
+ * "Ambiguous" has to include every path we do not fully model, and the failures here were all the
+ * same shape: a path we resolved WRONG still resolved to something, and a nonexistent Include reads
+ * as "nothing there" — which is indistinguishable from "no policy". So an Include we would have to
+ * expand tokens or unsupported globs in returns doubt rather than a literal path, and relative
+ * Includes resolve the way OpenSSH resolves them rather than the way that merely looks right.
+ */
+export async function siteConfigMayRestrictHostKeys(
+ files: readonly string[] = SITE_SSH_CONFIG_FILES
+): Promise {
+ const seen = new Set()
+ const pending = [...files]
+ // OpenSSH resolves a relative Include against a FIXED directory — SSHDIR for the system config —
+ // not against the including file's own directory. Those agree at depth 1 and diverge below it, so
+ // passing dirname(file) silently missed a directive one level down. Verified against 10.2p1.
+ // One base for the whole walk, which holds only while SITE_SSH_CONFIG_FILES names a single
+ // directory. A second entry elsewhere would need its own base tracked per queued file.
+ const siteConfigDir = dirname(files[0] ?? SITE_SSH_CONFIG_FILES[0])
+ // Bounded so an Include cycle cannot spin; OpenSSH allows nesting, we only need "any mention".
+ for (let visited = 0; pending.length > 0 && visited < 32; visited += 1) {
+ const file = pending.shift()
+ if (!file || seen.has(file)) {
+ continue
+ }
+ seen.add(file)
+ let contents: string
+ try {
+ contents = await readFile(file, 'utf-8')
+ } catch (error) {
+ // Absent is a real answer: no file, no site policy. Anything else is doubt, so stay strict.
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ continue
+ }
+ return true
+ }
+ for (const line of contents.split(/\r?\n/)) {
+ if (STRICT_HOST_KEY_DIRECTIVE.test(line)) {
+ return true
+ }
+ const include = INCLUDE_DIRECTIVE.exec(line)
+ if (include) {
+ const expanded = await expandSiteInclude(include[1], siteConfigDir)
+ if (expanded === null) {
+ return true
+ }
+ pending.push(...expanded)
+ }
+ }
+ }
+ // Ran out of budget with files still queued: unresolved, so doubt wins.
+ return pending.length > 0
+}
+
+/**
+ * One Include's arguments, or null when the line cannot be read with confidence.
+ *
+ * Every spelling OpenSSH accepts for a path with a space has to hold together here, because the ones
+ * that don't fail OPEN: splitting produces fragments that resolve to nothing, and "nothing there" is
+ * indistinguishable from "no site policy". `"a b/x"`, `'a b/x'` and `a\ b/x` are all honoured, on
+ * BOTH platforms — measured against 10.2p1 and against Windows OpenSSH, where a path with a space is
+ * ordinary. An UNQUOTED space still splits, which also matches OpenSSH: it reads that as two paths.
+ *
+ * `backslashIsSeparator` exists because there is no single correct rule, and shipping either one
+ * everywhere fails open on the other platform. Both halves are measured, not reasoned:
+ * - POSIX (10.2p1): a backslash before an ordinary character ESCAPES it — `conf\.d` resolves as
+ * `conf.d`, and it takes four backslashes to survive as one, because argv_split and glob() each
+ * consume a level. Preserving it there means looking for a path with a literal backslash, missing,
+ * and reading "no policy". Rather than reimplement two rounds of glob escaping for a question this
+ * coarse, POSIX answers doubt — a backslash in a POSIX system config path is vanishingly rare, so
+ * fail-closed costs nothing here.
+ * - Windows: a backslash is the SEPARATOR and is preserved. `Include C:\Users\...\x.conf` resolves,
+ * as does `C:\...\sp\ ace\x.conf` — so escaping-before-whitespace holds there too. Consuming
+ * backslashes generally would eat every separator beneath C:\ProgramData\ssh and reinstate the
+ * fail-open on the platform this matters most for; answering doubt for any backslash would
+ * reinstate the lockout, since every absolute Windows Include contains them.
+ */
+export function splitIncludeArguments(
+ pattern: string,
+ backslashIsSeparator: boolean = process.platform === 'win32'
+): string[] | null {
+ const tokens: string[] = []
+ let current = ''
+ let quote: '"' | "'" | null = null
+ let started = false
+ for (let index = 0; index < pattern.length; index += 1) {
+ const char = pattern[index]
+ if (char === '\\' && /\s/.test(pattern[index + 1] ?? '')) {
+ // An escaped space, honoured on both platforms.
+ current += pattern[index + 1]
+ index += 1
+ started = true
+ } else if (char === '\\' && !backslashIsSeparator) {
+ // POSIX: this escapes the next character and we do not emulate that. See the header.
+ return null
+ } else if (quote ? char === quote : char === '"' || char === "'") {
+ quote = quote ? null : (char as '"' | "'")
+ started = true
+ } else if (!quote && /\s/.test(char)) {
+ if (started) {
+ tokens.push(current)
+ }
+ current = ''
+ started = false
+ } else {
+ current += char
+ started = true
+ }
+ }
+ // An unterminated quote means we cannot know where the path ended — OpenSSH rejects the whole
+ // config for it — so there is no reading to trust and doubt wins.
+ if (quote) {
+ return null
+ }
+ if (started) {
+ tokens.push(current)
+ }
+ return tokens
+}
+
+/** Resolves one Include's globs, or null when it cannot be resolved and doubt must win. */
+async function expandSiteInclude(pattern: string, baseDir: string): Promise {
+ const resolved: string[] = []
+ const tokens = splitIncludeArguments(pattern)
+ if (tokens === null) {
+ return null
+ }
+ for (const token of tokens) {
+ // `~` and OpenSSH's `%d`/`%u`-style tokens both expand before the path is used. Resolving them
+ // is not worth it for a question this coarse, but treating them as ordinary characters is what
+ // made this fail OPEN: the join produced a path that does not exist, and a nonexistent Include
+ // reads as "nothing there" — indistinguishable from a site that says nothing about host keys.
+ if (token.startsWith('~') || token.includes('%')) {
+ return null
+ }
+ const absolute = isAbsolute(token) ? token : join(baseDir, token)
+ // `?` and `[…]` are globs to OpenSSH too, and it does honour them (verified against 10.2p1).
+ // Only `*` is expanded below, so any other metacharacter is doubt rather than a literal — taking
+ // it literally is the same fail-open as `~`: the path does not exist, so it reads as "nothing".
+ if (OTHER_GLOB_METACHARACTER.test(absolute)) {
+ return null
+ }
+ const star = absolute.indexOf('*')
+ if (star === -1) {
+ resolved.push(absolute)
+ continue
+ }
+ // Only the trailing `dir/*` form OpenSSH ships by default is expanded; anything fancier is doubt.
+ const dir = dirname(absolute.slice(0, star + 1))
+ if (absolute.slice(star + 1).includes('/')) {
+ return null
+ }
+ try {
+ const entries = await readdir(dir)
+ resolved.push(...entries.map((entry) => join(dir, entry)))
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+ return null
+ }
+ }
+ }
+ return resolved
+}
+
// Why: `ssh -G ` asks OpenSSH for the effective config, including
// Include/Match/wildcard inheritance, without reimplementing OpenSSH matching.
export function resolveWithSshG(host: string): Promise {
@@ -118,6 +312,41 @@ export function parseSshGOutput(stdout: string): SshResolvedConfig {
return buildSshResolvedConfig(map, identityFiles)
}
+/**
+ * `userknownhostsfile` / `globalknownhostsfile` arrive as one space-separated line; a path
+ * containing spaces is double-quoted. Older OpenSSH leaves `~` unexpanded, newer expands it.
+ */
+function parseKnownHostsFileList(value: string | undefined): string[] {
+ if (!value) {
+ return []
+ }
+ const paths: string[] = []
+ let current = ''
+ let inQuotes = false
+ let hasToken = false
+ for (const char of value) {
+ if (char === '"') {
+ inQuotes = !inQuotes
+ hasToken = true
+ continue
+ }
+ if (!inQuotes && /\s/.test(char)) {
+ if (hasToken) {
+ paths.push(current)
+ current = ''
+ hasToken = false
+ }
+ continue
+ }
+ current += char
+ hasToken = true
+ }
+ if (hasToken) {
+ paths.push(current)
+ }
+ return paths.map(resolveSshConfigHomePath)
+}
+
function buildSshResolvedConfig(
map: Map,
identityFiles: string[]
@@ -150,6 +379,13 @@ function buildSshResolvedConfig(
proxyJump,
controlMaster: map.get('controlmaster') ?? 'no',
controlPath,
- controlPersist: map.get('controlpersist') ?? 'no'
+ controlPersist: map.get('controlpersist') ?? 'no',
+ userKnownHostsFiles: parseKnownHostsFileList(map.get('userknownhostsfile')),
+ globalKnownHostsFiles: parseKnownHostsFileList(map.get('globalknownhostsfile')),
+ // OpenSSH's own default, so an unreadable line never resolves laxer than ssh would.
+ strictHostKeyChecking: map.get('stricthostkeychecking') ?? 'ask',
+ hostKeyAlias: map.get('hostkeyalias') || undefined,
+ hashKnownHosts: map.get('hashknownhosts') === 'yes',
+ updateHostKeys: map.get('updatehostkeys') ?? 'no'
}
}
diff --git a/src/main/ssh/ssh-host-key-decision.test.ts b/src/main/ssh/ssh-host-key-decision.test.ts
new file mode 100644
index 00000000000..19472eb27a1
--- /dev/null
+++ b/src/main/ssh/ssh-host-key-decision.test.ts
@@ -0,0 +1,433 @@
+import { describe, expect, it } from 'vitest'
+import {
+ decideHostKey,
+ strictestHostKeyChecking,
+ type HostKeyDecisionInput
+} from './ssh-host-key-decision'
+
+function input(overrides: Partial = {}): HostKeyDecisionInput {
+ return {
+ knownHostsOutcome: 'unknown',
+ storeOutcome: 'unknown',
+ strictHostKeyChecking: 'ask',
+ isEphemeralRuntimeTarget: false,
+ siteConfigSuppressed: false,
+ knownHostsUnreadable: false,
+ displayHost: 'build-01',
+ port: 22,
+ ...overrides
+ }
+}
+
+describe('deciding what to do with a presented host key', () => {
+ it('accepts a key either source already holds', () => {
+ expect(decideHostKey(input({ knownHostsOutcome: 'match' })).action).toBe('accept')
+ expect(decideHostKey(input({ storeOutcome: 'match' })).action).toBe('accept')
+ })
+
+ // Precedence between the two sources, pinned in both directions. It was unpinned, and the order
+ // was wrong: a legitimate key rotation is exactly the case where they disagree.
+ it('accepts a rotated key once known_hosts holds it, even though our record is stale', () => {
+ // What the remedy we print produces: `ssh-keygen -R host` then reconnect re-adds the NEW key to
+ // known_hosts while our store still holds the old one. Refusing here was a permanent lockout,
+ // because nothing in the app clears the store.
+ expect(
+ decideHostKey(input({ knownHostsOutcome: 'match', storeOutcome: 'mismatch' })).action
+ ).toBe('accept')
+ })
+
+ it('still refuses a key known_hosts disagrees with, whatever our record says', () => {
+ // The direction that must NOT be relaxed: known_hosts is authoritative for refusal too, so a
+ // stale accept in our store cannot rescue a key ssh itself would reject.
+ const decision = decideHostKey(input({ knownHostsOutcome: 'mismatch', storeOutcome: 'match' }))
+
+ expect(decision.action).toBe('reject')
+ expect(decision.disagreeingSource).toBe('known-hosts')
+ })
+
+ it('names the store file in a changed-key rejection so the user can act on it', () => {
+ // Reachable for a host we trusted on first contact and never wrote to known_hosts: there is no
+ // `ssh-keygen -R` that cures it, so "remove the saved key" has to name a file or it names
+ // nothing. M1 fixed the case known_hosts CAN rescue; this is the one it cannot.
+ const decision = decideHostKey(
+ input({ storeOutcome: 'mismatch', hostKeyStoreFile: '/data/orca/ssh-host-keys.json' })
+ )
+
+ expect(decision.action).toBe('reject')
+ expect(decision.reason).toContain('/data/orca/ssh-host-keys.json')
+ })
+
+ it('falls back to the generic hint when the store path is unknown', () => {
+ const decision = decideHostKey(input({ storeOutcome: 'mismatch' }))
+
+ expect(decision.action).toBe('reject')
+ expect(decision.reason).toContain('remove the saved key')
+ })
+
+ it('remembers a first-contact key', () => {
+ expect(decideHostKey(input()).action).toBe('accept-and-remember')
+ })
+
+ describe('rejections', () => {
+ it.each([
+ ['a revoked key', { knownHostsOutcome: 'revoked' as const }, 'revoked'],
+ ['a changed key in known_hosts', { knownHostsOutcome: 'mismatch' as const }, 'mismatch'],
+ ['a changed key in our own record', { storeOutcome: 'mismatch' as const }, 'mismatch'],
+ [
+ 'an unfamiliar key type for a host we know',
+ { knownHostsOutcome: 'unknown-type-known-host' as const },
+ 'unknown-type-known-host'
+ ],
+ // Same downgrade, our own records. Without this the guard covers known_hosts only, so a key
+ // we learned on first contact could be sidestepped by presenting another type.
+ [
+ 'an unfamiliar key type for a host only we know',
+ { storeOutcome: 'unknown-type-known-host' as const },
+ 'unknown-type-known-host'
+ ]
+ ])('rejects %s', (_label, overrides, outcome) => {
+ const decision = decideHostKey(input(overrides))
+ expect(decision.action).toBe('reject')
+ expect(decision.outcome).toBe(outcome)
+ expect(decision.reason).toBeTruthy()
+ })
+
+ // Revocation is a statement that this key is known-bad, so it outranks a lax setting.
+ it('rejects a revoked key even when checking is disabled', () => {
+ const decision = decideHostKey(
+ input({ knownHostsOutcome: 'revoked', strictHostKeyChecking: 'no' })
+ )
+ expect(decision.action).toBe('reject')
+ })
+
+ it('rejects a changed key even when checking is disabled', () => {
+ const decision = decideHostKey(
+ input({ knownHostsOutcome: 'mismatch', strictHostKeyChecking: 'off' })
+ )
+ expect(decision.action).toBe('reject')
+ })
+
+ // The reconnect ladder classifies on these substrings; a denial that reads as an auth error
+ // gets retried forever against a decision that will never change.
+ it.each([
+ ['a revoked key', { knownHostsOutcome: 'revoked' as const }],
+ ['a changed key', { knownHostsOutcome: 'mismatch' as const }],
+ ['an unknown host under strict checking', { strictHostKeyChecking: 'yes' }]
+ ])('does not phrase %s as an authentication error', (_label, overrides) => {
+ const reason = decideHostKey(input(overrides)).reason ?? ''
+ expect(reason.toLowerCase()).not.toContain('authentication failed')
+ expect(reason.toLowerCase()).not.toContain('permission denied')
+ })
+
+ it('names the remedy that also unblocks ssh when known_hosts disagrees', () => {
+ const decision = decideHostKey(input({ knownHostsOutcome: 'mismatch' }))
+ expect(decision.disagreeingSource).toBe('known-hosts')
+ expect(decision.reason).toContain('ssh-keygen -R build-01')
+ })
+
+ // Verified against OpenSSH 10.2p1: with both `[h]:2222` and `h` on file, `ssh-keygen -R h`
+ // removes ONLY the bare line, and there is no port flag — `-R h -p 2222` is "Too many
+ // arguments". So naming the bare host for an off-port target sends the user to run a command
+ // that removes nothing and reconnect into the identical failure.
+ it('names the bracketed entry when the port is not the default', () => {
+ const decision = decideHostKey(input({ knownHostsOutcome: 'mismatch', port: 2222 }))
+ expect(decision.reason).toContain("ssh-keygen -R '[build-01]:2222'")
+ })
+
+ it('does not bracket the host on the default port', () => {
+ const decision = decideHostKey(input({ knownHostsOutcome: 'mismatch', port: 22 }))
+ expect(decision.reason).toContain('ssh-keygen -R build-01')
+ expect(decision.reason).not.toContain('[build-01]')
+ })
+
+ it('names the bracketed entry for an unfamiliar key type on a non-default port too', () => {
+ const decision = decideHostKey(
+ input({ knownHostsOutcome: 'unknown-type-known-host', port: 2222 })
+ )
+ expect(decision.reason).toContain("ssh-keygen -R '[build-01]:2222'")
+ })
+
+ it('does not tell the user to edit known_hosts when our own record disagrees', () => {
+ const decision = decideHostKey(input({ storeOutcome: 'mismatch' }))
+ expect(decision.disagreeingSource).toBe('orca-store')
+ expect(decision.reason).not.toContain('ssh-keygen -R')
+ })
+
+ // Verified live against OpenSSH 10.2p1: an ed25519 key offered where known_hosts holds only
+ // ssh-rsa makes ssh print IDENTIFICATION HAS CHANGED and refuse. ssh is blocked too, so the
+ // same remedy applies — without it this case diagnosed the problem and offered no way out.
+ it('names the remedy for an unfamiliar key type known_hosts disagrees on', () => {
+ const decision = decideHostKey(input({ knownHostsOutcome: 'unknown-type-known-host' }))
+ expect(decision.disagreeingSource).toBe('known-hosts')
+ expect(decision.reason).toContain('ssh-keygen -R build-01')
+ })
+
+ // Our own record is not in known_hosts, so ssh-keygen -R would remove nothing.
+ it('does not name ssh-keygen for an unfamiliar key type only we know about', () => {
+ const decision = decideHostKey(input({ storeOutcome: 'unknown-type-known-host' }))
+ expect(decision.disagreeingSource).toBe('orca-store')
+ expect(decision.reason).not.toContain('ssh-keygen -R')
+ expect(decision.reason).toContain('rebuilt')
+ })
+
+ // INVERTED, by product decision. ssh2 cannot validate certificates at all, and OpenSSH itself
+ // treats a CA-covered host presenting a plain key as first contact and connects. Refusing was
+ // stricter than ssh, and because `@cert-authority *` is the normal Teleport/Vault-SSH/Smallstep
+ // shape it failed EVERY target for those users — with an escape hatch that is an environment
+ // variable, unreachable when Orca is launched from the Dock.
+ it('does not refuse a certificate-authority host', () => {
+ const decision = decideHostKey(input({ knownHostsOutcome: 'ca-only' }))
+ expect(decision.action).toBe('accept-and-remember')
+ })
+
+ // The residual risk is accepted, not hidden: we take a plain key we cannot tie to the CA. The
+ // outcome survives so the choice stays auditable in the decision log.
+ it('still reports the certificate-authority outcome for audit', () => {
+ expect(decideHostKey(input({ knownHostsOutcome: 'ca-only' })).outcome).toBe('ca-only')
+ })
+
+ // A CA line does not license a CHANGED key: a plain entry for the host still decides.
+ it('still refuses a changed key on a certificate-authority host', () => {
+ const decision = decideHostKey(
+ input({ knownHostsOutcome: 'ca-only', storeOutcome: 'mismatch' })
+ )
+ expect(decision.action).toBe('reject')
+ })
+ })
+
+ describe('StrictHostKeyChecking', () => {
+ // What `ssh -G` ACTUALLY prints for each configured value, captured from OpenSSH 10.2p1 (same
+ // output from a config file and from -o). Driving the table from this rather than from the
+ // configured spellings is the whole point: matching 'yes'/'no'/'off' matched nothing a real
+ // config can produce, so config honouring was entirely dead and every unit test passed.
+ const SSH_G_OUTPUT: Record = {
+ yes: 'true',
+ no: 'false',
+ off: 'false',
+ 'accept-new': 'accept-new',
+ ask: 'ask'
+ }
+
+ it.each([['yes'], ['always']])('denies an unknown host under %s', (value) => {
+ expect(decideHostKey(input({ strictHostKeyChecking: value })).action).toBe('reject')
+ })
+
+ it('denies an unknown host for a config that says yes, as ssh -G reports it', () => {
+ const asReported = SSH_G_OUTPUT.yes
+ expect(asReported).toBe('true')
+ expect(decideHostKey(input({ strictHostKeyChecking: asReported })).action).toBe('reject')
+ })
+
+ // OpenSSH accepts here but does not write; persisting would turn a deliberately lax setting
+ // into a permanent trust record.
+ it.each([['no'], ['off']])('accepts without remembering under %s', (value) => {
+ expect(decideHostKey(input({ strictHostKeyChecking: value })).action).toBe('accept')
+ })
+
+ it.each([['no'], ['off']])(
+ 'accepts without remembering for a config that says %s, as ssh -G reports it',
+ (value) => {
+ const asReported = SSH_G_OUTPUT[value]
+ expect(asReported).toBe('false')
+ expect(decideHostKey(input({ strictHostKeyChecking: asReported })).action).toBe('accept')
+ }
+ )
+
+ // The end-to-end statement: for every value a user can write, the decision we reach from what
+ // ssh -G reports must equal the decision we would reach from the value they wrote.
+ it.each(Object.entries(SSH_G_OUTPUT))(
+ 'reaches the same verdict for a configured %s as for the %s ssh -G prints',
+ (configured, reported) => {
+ expect(decideHostKey(input({ strictHostKeyChecking: reported })).action).toBe(
+ decideHostKey(input({ strictHostKeyChecking: configured })).action
+ )
+ }
+ )
+
+ // Phase 1 has no dialog, so `ask` behaves as `accept-new` — deliberate, and the reason the
+ // whole defence can ship without a modal. This pins the equivalence so Phase 2 has to break it
+ // ON PURPOSE: `accept-new` must still not prompt, `ask` must.
+ it('treats accept-new and ask alike while no dialog exists', () => {
+ const acceptNew = decideHostKey(input({ strictHostKeyChecking: 'accept-new' }))
+ const ask = decideHostKey(input({ strictHostKeyChecking: 'ask' }))
+ expect(acceptNew.action).toBe('accept-and-remember')
+ expect(ask.action).toBe(acceptNew.action)
+ })
+
+ // accept-new is a real OpenSSH value, not a typo — it must never fall into the strict branch.
+ it('does not treat accept-new as strict', () => {
+ expect(decideHostKey(input({ strictHostKeyChecking: 'accept-new' })).action).not.toBe(
+ 'reject'
+ )
+ })
+
+ it('treats an unrecognised value as ask', () => {
+ expect(decideHostKey(input({ strictHostKeyChecking: 'banana' })).action).toBe(
+ 'accept-and-remember'
+ )
+ })
+
+ it('is case-insensitive', () => {
+ expect(decideHostKey(input({ strictHostKeyChecking: 'YES' })).action).toBe('reject')
+ })
+ })
+
+ // By product decision: ssh warns and treats the host as unknown rather than refusing, and
+ // refusing breaks an ordinary offline Windows laptop whose OneDrive-backed known_hosts is a cloud
+ // placeholder — while blaming a config file that is fine. We connect as ssh does, but write
+ // nothing, so a first contact we could not check never becomes durable trust.
+ describe('an unreadable known_hosts file', () => {
+ it('connects rather than refusing', () => {
+ expect(decideHostKey(input({ knownHostsUnreadable: true })).action).toBe('accept')
+ })
+
+ it('does not record what it could not verify', () => {
+ expect(decideHostKey(input({ knownHostsUnreadable: true })).action).not.toBe(
+ 'accept-and-remember'
+ )
+ })
+
+ // The file we could not read cannot excuse a key that a source we COULD read says has changed.
+ it('still refuses a changed key', () => {
+ const decision = decideHostKey(
+ input({ knownHostsUnreadable: true, knownHostsOutcome: 'mismatch' })
+ )
+ expect(decision.action).toBe('reject')
+ })
+
+ it('still refuses a revoked key', () => {
+ const decision = decideHostKey(
+ input({ knownHostsUnreadable: true, knownHostsOutcome: 'revoked' })
+ )
+ expect(decision.action).toBe('reject')
+ })
+
+ // An unreadable file is weaker evidence than a policy we know exists but cannot read.
+ it('does not override an explicit StrictHostKeyChecking', () => {
+ const decision = decideHostKey(
+ input({ knownHostsUnreadable: true, strictHostKeyChecking: 'true' })
+ )
+ expect(decision.action).toBe('reject')
+ })
+ })
+
+ describe('carve-outs', () => {
+ // A fresh VM presents a new key every launch, so recording one would accumulate a row per
+ // launch and eventually turn a stale record into a spurious mismatch.
+ it('accepts an ephemeral runtime target without remembering it', () => {
+ expect(decideHostKey(input({ isEphemeralRuntimeTarget: true })).action).toBe('accept')
+ })
+
+ it('still rejects a changed key for an ephemeral target', () => {
+ const decision = decideHostKey(
+ input({ isEphemeralRuntimeTarget: true, knownHostsOutcome: 'mismatch' })
+ )
+ expect(decision.action).toBe('reject')
+ })
+
+ // We could not read the system ssh_config, so a site-wide policy may forbid this and we cannot
+ // see it. Being laxer than a policy we cannot read is the one outcome that is never acceptable.
+ it('denies an unknown host when a source could not be read', () => {
+ expect(decideHostKey(input({ siteConfigSuppressed: true })).action).toBe('reject')
+ })
+
+ // Only NEW trust is withheld. A host we already know is decided before this is reached, so
+ // being unable to read the site config does not disconnect everything the user already verified.
+ it('still accepts a known host when a source could not be read', () => {
+ const decision = decideHostKey(
+ input({ siteConfigSuppressed: true, knownHostsOutcome: 'match' })
+ )
+ expect(decision.action).toBe('accept')
+ })
+
+ // A VM provisioned a minute ago cannot be in known_hosts, so no policy — seen or unseen — is
+ // satisfiable by it. Refusing would not make the connection safer, it would turn on-demand
+ // runtimes off for everyone whose HOME diverges from their passwd home.
+ it('still accepts an ephemeral target when a source could not be read', () => {
+ const decision = decideHostKey(
+ input({ isEphemeralRuntimeTarget: true, siteConfigSuppressed: true })
+ )
+ expect(decision.action).toBe('accept')
+ })
+
+ // The exception to the exception: an explicit StrictHostKeyChecking=yes is a policy we can
+ // actually read and the user actually asked for, so it outranks the carve-out.
+ it('denies an ephemeral target under an explicit StrictHostKeyChecking=yes', () => {
+ const decision = decideHostKey(
+ input({ isEphemeralRuntimeTarget: true, strictHostKeyChecking: 'yes' })
+ )
+ expect(decision.action).toBe('reject')
+ })
+
+ // The carve-out is about first contact only; a key that CHANGED is still a change.
+ it('still rejects a changed key for an ephemeral target with sources incomplete', () => {
+ const decision = decideHostKey(
+ input({
+ isEphemeralRuntimeTarget: true,
+ siteConfigSuppressed: true,
+ storeOutcome: 'mismatch'
+ })
+ )
+ expect(decision.action).toBe('reject')
+ })
+ })
+
+ // Phase 1 ships no dialog at all: startup restore opens many connections at once, ephemeral
+ // targets would prompt every launch, and paired-web connects run on someone else's desktop.
+ it('never asks for a prompt', () => {
+ const cases: Partial[] = [
+ {},
+ { knownHostsOutcome: 'match' },
+ { knownHostsOutcome: 'mismatch' },
+ { knownHostsOutcome: 'revoked' },
+ { knownHostsOutcome: 'ca-only' },
+ { knownHostsOutcome: 'unknown-type-known-host' },
+ { storeOutcome: 'match' },
+ { storeOutcome: 'mismatch' },
+ { strictHostKeyChecking: 'yes' },
+ { strictHostKeyChecking: 'no' },
+ { isEphemeralRuntimeTarget: true },
+ { siteConfigSuppressed: true },
+ { knownHostsUnreadable: true }
+ ]
+ for (const overrides of cases) {
+ expect(decideHostKey(input(overrides)).action).not.toBe('prompt')
+ }
+ })
+})
+
+/**
+ * Merging a user-resolved policy with a separately-probed site policy.
+ *
+ * Only reachable on the `-F` path, where OpenSSH ignores /etc/ssh/ssh_config so the per-user
+ * resolution cannot represent a site policy at all. Before this, being unable to see the site policy
+ * meant refusing every unknown host — which locked out anyone whose HOME diverges from their passwd
+ * home (devcontainers, `su`, Nix shells) with no override.
+ */
+describe('merging the user and site host key policies', () => {
+ it('keeps the user value when the site could not be read', () => {
+ // The blind case that still fails strict: a null probe must not invent a policy.
+ expect(strictestHostKeyChecking('ask', null)).toBe('ask')
+ expect(strictestHostKeyChecking(undefined, null)).toBe('ask')
+ })
+
+ it('takes a strict site policy over a laxer user value', () => {
+ expect(strictestHostKeyChecking('ask', 'yes')).toBe('yes')
+ expect(strictestHostKeyChecking('no', 'true')).toBe('true')
+ })
+
+ it('does not let a lax site policy loosen the user', () => {
+ // The direction that must never relax: a permissive site config cannot downgrade a user who
+ // asked for strict, or we end up laxer than ssh itself.
+ expect(strictestHostKeyChecking('yes', 'no')).toBe('yes')
+ expect(strictestHostKeyChecking('ask', 'off')).toBe('ask')
+ })
+
+ it('lets the site raise ask to accept-new', () => {
+ expect(strictestHostKeyChecking('ask', 'accept-new')).toBe('accept-new')
+ })
+
+ it('keeps a strict user value against accept-new', () => {
+ expect(strictestHostKeyChecking('yes', 'accept-new')).toBe('yes')
+ })
+})
diff --git a/src/main/ssh/ssh-host-key-decision.ts b/src/main/ssh/ssh-host-key-decision.ts
new file mode 100644
index 00000000000..f0eb1afd2f5
--- /dev/null
+++ b/src/main/ssh/ssh-host-key-decision.ts
@@ -0,0 +1,307 @@
+/**
+ * What to do about a presented host key.
+ *
+ * Kept separate from the ssh2 wiring so the policy is testable without a handshake, and injected
+ * rather than importing its sources so a test states its own trust state instead of writing files.
+ * See docs/reference/ssh-host-key-verification.md.
+ */
+import type { KnownHostsOutcome } from './ssh-known-hosts'
+
+/** Phase 1 ships no dialog; `prompt` is reserved for Phase 2 and never produced today. */
+export type HostKeyAction = 'accept' | 'accept-and-remember' | 'reject' | 'prompt'
+
+export type HostKeyDecision = {
+ action: HostKeyAction
+ outcome: KnownHostsOutcome
+ /** Which source disagreed, so the failure message can point at a remedy that exists. */
+ disagreeingSource?: 'orca-store' | 'known-hosts'
+ /** Non-null only when the connection must fail; already user-facing. */
+ reason?: string
+}
+
+export type HostKeyDecisionInput = {
+ /** From the user's known_hosts files, unioned. */
+ knownHostsOutcome: KnownHostsOutcome
+ /** From our own store: does it already hold this exact key for host+port+type? */
+ storeOutcome: 'match' | 'mismatch' | 'unknown-type-known-host' | 'unknown'
+ /** Effective `StrictHostKeyChecking`; anything unrecognised is treated as `ask`. */
+ strictHostKeyChecking: string
+ /**
+ * A freshly provisioned VM presents a new key every launch, so first contact is expected rather
+ * than suspicious — and persisting would accumulate a record per launch.
+ */
+ isEphemeralRuntimeTarget: boolean
+ /**
+ * `ssh -G` ran on the HOME-divergent `-F` path, which suppresses /etc/ssh/ssh_config, so a
+ * site-wide StrictHostKeyChecking may exist that we cannot see. We refuse a NEW host rather than
+ * risk being laxer than the policy; a host we already know still connects, because a match is
+ * decided before this is consulted.
+ */
+ siteConfigSuppressed: boolean
+ /**
+ * A known_hosts file exists and would not open — a Windows OneDrive placeholder while offline,
+ * EACCES on a hardened image, an NFS blip. The entry that would have said "this key changed" may
+ * be in it.
+ *
+ * Deliberately NOT a refusal: ssh warns and treats the host as unknown, and refusing here breaks
+ * an ordinary offline corporate laptop while blaming a config file that is fine. We connect as ssh
+ * does but record nothing, so a first contact we could not check never becomes durable trust.
+ */
+ knownHostsUnreadable: boolean
+ displayHost: string
+ /** Needed for the remedy string: an off-port entry is keyed `[host]:port` in known_hosts. */
+ port: number
+ /**
+ * Our own store's path, named in a changed-key rejection.
+ *
+ * Only that rejection needs it, and only because known_hosts cannot cure it: a host we trusted on
+ * first contact and never wrote to known_hosts has no `ssh-keygen -R` to run, so without naming a
+ * file the message describes a remedy the user cannot locate.
+ */
+ hostKeyStoreFile?: string
+}
+
+/**
+ * The name `ssh-keygen -R` must be given, which is NOT always the host name.
+ *
+ * Verified against OpenSSH 10.2p1: with both `[h.example]:2222` and `h.example` on file,
+ * `ssh-keygen -R h.example` removes only the bare line and leaves the bracketed one — and there is
+ * no port flag, `-R host -p 2222` is "Too many arguments". So for an off-port target the obvious
+ * command removes nothing, the user reconnects, and it fails identically.
+ */
+function keygenRemoveTarget(displayHost: string, port: number): string {
+ return port === 22 ? displayHost : `'[${displayHost}]:${port}'`
+}
+
+/**
+ * `true` and `false` are not defensive extras — they are the ONLY spellings that reach us.
+ *
+ * `ssh -G` renders StrictHostKeyChecking through fmt_multistate_int, which prints the first entry of
+ * multistate_strict_hostkey, and that table lists true/false before yes/no. Verified against
+ * OpenSSH 10.2p1 from both a config file and `-o`: `yes` prints `true`, `no` and `off` both print
+ * `false`, while `ask` and `accept-new` pass through unchanged. Matching only yes/no/off therefore
+ * matched nothing a real config can produce — `StrictHostKeyChecking yes` fell through to the
+ * default and we accepted AND persisted a host the user had told ssh to refuse.
+ */
+const STRICT_VALUES = new Set(['true', 'yes', 'always'])
+const LAX_VALUES = new Set(['false', 'no', 'off'])
+
+/**
+ * The stricter of a user-resolved and a site-resolved StrictHostKeyChecking.
+ *
+ * Needed only where the two were read separately: `ssh -F` makes OpenSSH ignore /etc/ssh/ssh_config,
+ * so the per-user resolution cannot represent a site policy and the site has to be probed on its
+ * own. Ordering is strict > accept-new > ask > lax, and anything unrecognised is left to the
+ * caller's own handling by falling through to the user value.
+ */
+export function strictestHostKeyChecking(
+ userValue: string | undefined,
+ siteValue: string | null
+): string {
+ const user = userValue ?? 'ask'
+ if (siteValue === null) {
+ return user
+ }
+ const site = siteValue.trim().toLowerCase()
+ if (STRICT_VALUES.has(site) && !STRICT_VALUES.has(user.trim().toLowerCase())) {
+ return site
+ }
+ // A site that is lax never loosens a user who is not: refusing to write trust is the safe side.
+ return LAX_VALUES.has(site) ? user : site === 'accept-new' && user === 'ask' ? site : user
+}
+
+const CHANGED_KEY_HINT =
+ 'If you rebuilt or reprovisioned this machine, remove the saved key and reconnect.'
+
+/**
+ * The same hint, naming the file when we know it.
+ *
+ * "Remove the saved key" names nothing a user can find. This rejection is reachable for a host we
+ * trusted on first contact and that has since rotated its key legitimately — known_hosts cannot
+ * rescue that one, so without a concrete path there is no way out of it at all.
+ */
+function changedKeyHint(hostKeyStoreFile: string | undefined): string {
+ return hostKeyStoreFile
+ ? `If you rebuilt or reprovisioned this machine, remove its entry from ${hostKeyStoreFile} and reconnect.`
+ : CHANGED_KEY_HINT
+}
+
+/**
+ * Deliberately avoids the words "authentication failed" and "permission denied": the reconnect
+ * ladder classifies on those substrings, and a denial that reads as an auth error gets retried
+ * forever against a decision that will never change.
+ */
+function rejection(displayHost: string, detail: string): string {
+ return `Host key verification failed for ${displayHost}. ${detail}`
+}
+
+/**
+ * A denied host key, carried as a type rather than sniffed from the message.
+ *
+ * The connect path must recognise this before it offers any credential: prompting for a passphrase
+ * or a password after we have decided the host may be impersonated is the one thing a host key check
+ * exists to prevent — the user types the secret straight into whatever answered. Substring matching
+ * would tie that to wording that the reason strings deliberately keep changing.
+ */
+export class HostKeyVerificationError extends Error {
+ readonly outcome: KnownHostsOutcome
+
+ constructor(message: string, outcome: KnownHostsOutcome) {
+ super(message)
+ this.name = 'HostKeyVerificationError'
+ this.outcome = outcome
+ }
+}
+
+export function isHostKeyVerificationError(err: unknown): err is HostKeyVerificationError {
+ return err instanceof HostKeyVerificationError
+}
+
+export function decideHostKey(input: HostKeyDecisionInput): HostKeyDecision {
+ const {
+ knownHostsOutcome,
+ storeOutcome,
+ isEphemeralRuntimeTarget,
+ siteConfigSuppressed,
+ knownHostsUnreadable,
+ displayHost,
+ port,
+ hostKeyStoreFile
+ } = input
+ const strict = input.strictHostKeyChecking.toLowerCase()
+
+ // Revocation outranks everything, including StrictHostKeyChecking=no. A revoked key is a
+ // statement that this key is known-bad, not merely unrecognised.
+ if (knownHostsOutcome === 'revoked') {
+ return {
+ action: 'reject',
+ outcome: 'revoked',
+ disagreeingSource: 'known-hosts',
+ reason: rejection(displayHost, 'This host key has been revoked.')
+ }
+ }
+
+ // Either source holding a different key for this host and type is a change. known_hosts is named
+ // first because its remedy (ssh-keygen -R) is the one that also unblocks ssh and git.
+ if (knownHostsOutcome === 'mismatch') {
+ return {
+ action: 'reject',
+ outcome: 'mismatch',
+ disagreeingSource: 'known-hosts',
+ reason: rejection(
+ displayHost,
+ `The key does not match the entry in your known_hosts file. ssh and git will refuse this host too. Run: ssh-keygen -R ${keygenRemoveTarget(displayHost, port)}`
+ )
+ }
+ }
+ // Why known_hosts outranks our own record here: this is what a legitimate key rotation looks like
+ // once the user has run the remedy we print. `ssh-keygen -R host` then a reconnect leaves
+ // known_hosts holding the NEW key while our store still holds the old one, and checking the store
+ // first refused exactly the state that cure produces — permanently, since nothing in the app
+ // clears the store. Deferring to known_hosts concedes nothing: it is the artefact ssh itself
+ // obeys, so an attacker who can rewrite it has already won.
+ if (knownHostsOutcome === 'match') {
+ return { action: 'accept', outcome: 'match' }
+ }
+ if (storeOutcome === 'mismatch') {
+ return {
+ action: 'reject',
+ outcome: 'mismatch',
+ disagreeingSource: 'orca-store',
+ reason: rejection(
+ displayHost,
+ `The key changed since you last connected. ${changedKeyHint(hostKeyStoreFile)}`
+ )
+ }
+ }
+
+ if (storeOutcome === 'match') {
+ return { action: 'accept', outcome: 'match' }
+ }
+
+ // We hold a key for this host, just not of the presented type. Treating that as first contact is
+ // the downgrade an attacker who cannot forge the known key would reach for.
+ if (
+ knownHostsOutcome === 'unknown-type-known-host' ||
+ storeOutcome === 'unknown-type-known-host'
+ ) {
+ const fromKnownHosts = knownHostsOutcome === 'unknown-type-known-host'
+ return {
+ action: 'reject',
+ outcome: 'unknown-type-known-host',
+ disagreeingSource: fromKnownHosts ? 'known-hosts' : 'orca-store',
+ reason: rejection(
+ displayHost,
+ // Verified live against OpenSSH 10.2p1: an ed25519 key offered where known_hosts holds only
+ // ssh-rsa makes ssh print IDENTIFICATION HAS CHANGED and refuse. So ssh is blocked too, and
+ // ssh-keygen -R is the remedy that unblocks both — naming it only for the same-type
+ // mismatch left this case with a diagnosis and no way out.
+ `The host offered a key of a type we have not seen for it before, while a key of another type is already known. This can mean the host was rebuilt, or that something is impersonating it.${
+ fromKnownHosts
+ ? ` ssh and git will refuse this host too. Run: ssh-keygen -R ${keygenRemoveTarget(displayHost, port)}`
+ : ` ${changedKeyHint(hostKeyStoreFile)}`
+ }`
+ )
+ }
+ }
+
+ // `ca-only` is carried through so the decision log still shows a CA line was involved, even
+ // though the action is the same as first contact.
+ const unknownOutcome: KnownHostsOutcome = knownHostsOutcome === 'ca-only' ? 'ca-only' : 'unknown'
+
+ // Unknown from here down — including `ca-only`, by decision. ssh2 cannot validate certificates at
+ // all, and OpenSSH itself treats a CA-covered host that presents a plain key as first contact and
+ // connects (verified live). Refusing was stricter than ssh and, because `@cert-authority *` is the
+ // normal Teleport/Vault-SSH/Smallstep shape, it failed EVERY target for those users — with an
+ // escape hatch that is an environment variable, unreachable when Orca is launched from the Dock.
+ // The residual risk is real and accepted: for a CA-protected host we accept a plain key we cannot
+ // tie to the CA. The outcome is preserved so the decision is still auditable.
+ if (STRICT_VALUES.has(strict)) {
+ return {
+ action: 'reject',
+ outcome: unknownOutcome,
+ reason: rejection(
+ displayHost,
+ 'The host is not listed in your known_hosts file and StrictHostKeyChecking is enabled.'
+ )
+ }
+ }
+ // Deliberately ABOVE the incomplete-sources check, and deliberately BELOW explicit strict.
+ //
+ // A machine provisioned a minute ago cannot be in known_hosts, by construction — no policy, seen
+ // or unseen, can be satisfied by it. So refusing here would not make the connection safer, it
+ // would turn on-demand runtimes off entirely for anyone whose HOME diverges from their passwd
+ // home (sandboxes, E2E isolation), and the reason we would print names a config file they cannot
+ // fix. Incomplete sources is a statement that we might be missing a policy; it is not a policy.
+ // An EXPLICIT StrictHostKeyChecking=yes still wins above, because that one we can actually read
+ // and the user asked for it.
+ //
+ // Accepting without recording is the rest of it: a new key every launch would otherwise grow a
+ // row per VM, and a stale row eventually reads as a mismatch against a host that did nothing
+ // wrong.
+ if (isEphemeralRuntimeTarget) {
+ return { action: 'accept', outcome: unknownOutcome }
+ }
+ if (siteConfigSuppressed) {
+ // We could not read the system ssh_config, so we cannot prove a site policy does not forbid
+ // this. Refusing to extend NEW trust while blind is the only way to avoid being laxer than ssh.
+ return {
+ action: 'reject',
+ outcome: unknownOutcome,
+ reason: rejection(
+ displayHost,
+ 'The host is unknown and the system SSH configuration could not be read, so its host key policy cannot be checked.'
+ )
+ }
+ }
+ if (knownHostsUnreadable) {
+ // Connect as ssh does, but do not write a record from evidence we could not read.
+ return { action: 'accept', outcome: unknownOutcome }
+ }
+ if (LAX_VALUES.has(strict)) {
+ // OpenSSH accepts here but does not write. Persisting would silently convert a deliberately
+ // lax setting into a permanent trust record.
+ return { action: 'accept', outcome: unknownOutcome }
+ }
+ return { action: 'accept-and-remember', outcome: unknownOutcome }
+}
diff --git a/src/main/ssh/ssh-host-key-store.test.ts b/src/main/ssh/ssh-host-key-store.test.ts
new file mode 100644
index 00000000000..4e18df099e4
--- /dev/null
+++ b/src/main/ssh/ssh-host-key-store.test.ts
@@ -0,0 +1,353 @@
+import type * as NodeFsPromises from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Why a module mock and not chmod: an unreadable file has to be EACCES/EMFILE-shaped for every
+// runner, and chmod 000 is simply readable when the suite runs as root. Why not vi.spyOn: node's
+// ESM namespace is not configurable, so spying on readFile throws.
+const { readFailure } = vi.hoisted(() => ({ readFailure: { error: null as Error | null } }))
+vi.mock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ readFile: (...args: Parameters) =>
+ readFailure.error ? Promise.reject(readFailure.error) : actual.readFile(...args)
+ }
+})
+
+const { mkdtemp, readdir, readFile, rm, stat, writeFile } = await import('node:fs/promises')
+import {
+ getSshHostKeyStoreFile,
+ isTrusted,
+ loadTrustedHostKeys,
+ matchTrustedHostKeys,
+ storedKeyTypesForEndpoint,
+ trustHostKey
+} from './ssh-host-key-store'
+
+/** A blob shaped like a real host key: length-prefixed algorithm name, then payload. */
+function hostKey(keyType: string, seed: string): Buffer {
+ const name = Buffer.from(keyType, 'utf8')
+ const length = Buffer.alloc(4)
+ length.writeUInt32BE(name.length, 0)
+ return Buffer.concat([length, name, Buffer.from(seed.padEnd(32, '.'), 'utf8')])
+}
+
+const ED25519_A = hostKey('ssh-ed25519', 'key-a')
+const ED25519_B = hostKey('ssh-ed25519', 'key-b')
+const RSA_A = hostKey('ssh-rsa', 'rsa-a')
+
+function query(overrides: Partial[0]> = {}) {
+ return {
+ host: 'build-01',
+ port: 22,
+ keyType: 'ssh-ed25519',
+ key: ED25519_A,
+ ...overrides
+ }
+}
+
+let directory: string
+let storeFile: string
+
+beforeEach(async () => {
+ directory = await mkdtemp(join(tmpdir(), 'orca-host-key-store-'))
+ storeFile = getSshHostKeyStoreFile(join(directory, 'orca-data.json'))
+})
+
+afterEach(async () => {
+ readFailure.error = null
+ vi.restoreAllMocks()
+ await rm(directory, { recursive: true, force: true })
+})
+
+describe('ssh host key store', () => {
+ it('places the store beside the profile data file, not inside it', () => {
+ expect(getSshHostKeyStoreFile(join('/profiles', 'p1', 'orca-data.json'))).toBe(
+ join('/profiles', 'p1', 'ssh-host-keys.json')
+ )
+ })
+
+ it('recognises a key it was told to trust', async () => {
+ expect(await isTrusted(query(), storeFile)).toBe('unknown')
+
+ const record = await trustHostKey(query(), storeFile)
+
+ expect(record.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/)
+ expect(await isTrusted(query(), storeFile)).toBe('match')
+ })
+
+ it('reports a different key for the same host, port and type as a mismatch', async () => {
+ await trustHostKey(query(), storeFile)
+
+ expect(await isTrusted(query({ key: ED25519_B }), storeFile)).toBe('mismatch')
+ })
+
+ it('supersedes the stored key when the same triple is trusted again', async () => {
+ await trustHostKey(query(), storeFile)
+ await trustHostKey(query({ key: ED25519_B }), storeFile)
+
+ expect(await loadTrustedHostKeys(storeFile)).toHaveLength(1)
+ expect(await isTrusted(query({ key: ED25519_B }), storeFile)).toBe('match')
+ expect(await isTrusted(query(), storeFile)).toBe('mismatch')
+ })
+
+ it('keeps a second key type for the same host alongside the first', async () => {
+ await trustHostKey(query(), storeFile)
+ await trustHostKey(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile)
+
+ expect(await loadTrustedHostKeys(storeFile)).toHaveLength(2)
+ expect(await isTrusted(query(), storeFile)).toBe('match')
+ expect(await isTrusted(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile)).toBe('match')
+ })
+
+ it('treats an unrecorded key type for a known host as suspicious, not first contact', async () => {
+ await trustHostKey(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile)
+
+ expect(await isTrusted(query(), storeFile)).toBe('unknown-type-known-host')
+ })
+
+ it('scopes trust to the endpoint, so a second target naming the same host is already trusted', async () => {
+ await trustHostKey(query({ host: 'build-01' }), storeFile)
+
+ // A different Orca target, same machine — no target id is recorded anywhere.
+ expect(await isTrusted(query({ host: 'BUILD-01' }), storeFile)).toBe('match')
+ const [record] = await loadTrustedHostKeys(storeFile)
+ expect(Object.keys(record ?? {})).toEqual([
+ 'host',
+ 'port',
+ 'keyType',
+ 'key',
+ 'fingerprint',
+ 'acceptedAt'
+ ])
+ })
+
+ it('does not carry trust across ports', async () => {
+ await trustHostKey(query(), storeFile)
+
+ expect(await isTrusted(query({ port: 2222 }), storeFile)).toBe('unknown')
+ })
+
+ it('trusts nothing when the file is missing', async () => {
+ expect(await loadTrustedHostKeys(storeFile)).toEqual([])
+ expect(await isTrusted(query(), storeFile)).toBe('unknown')
+ })
+
+ it('trusts nothing and does not throw when the file is corrupt', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ await writeFile(storeFile, '{"version":1,"hostKeys":[{"host":"build-01"', 'utf-8')
+
+ await expect(loadTrustedHostKeys(storeFile)).resolves.toEqual([])
+ await expect(isTrusted(query(), storeFile)).resolves.toBe('unknown')
+ expect(warn).toHaveBeenCalled()
+ })
+
+ it('trusts nothing when the file parses but is not a store', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ await writeFile(storeFile, '"everything"', 'utf-8')
+
+ await expect(isTrusted(query(), storeFile)).resolves.toBe('unknown')
+ })
+
+ it('drops a record whose key does not carry the type it claims', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ await trustHostKey(query(), storeFile)
+ const stored = JSON.parse(await readFile(storeFile, 'utf-8')) as {
+ hostKeys: { keyType: string }[]
+ }
+ stored.hostKeys[0]!.keyType = 'ssh-rsa'
+ await writeFile(storeFile, JSON.stringify(stored), 'utf-8')
+
+ expect(await loadTrustedHostKeys(storeFile)).toEqual([])
+ expect(await isTrusted(query({ keyType: 'ssh-rsa' }), storeFile)).toBe('unknown')
+ })
+
+ it('drops a record whose fingerprint disagrees with its key', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ await trustHostKey(query(), storeFile)
+ const stored = JSON.parse(await readFile(storeFile, 'utf-8')) as {
+ hostKeys: { fingerprint: string }[]
+ }
+ stored.hostKeys[0]!.fingerprint = 'SHA256:not-the-key'
+ await writeFile(storeFile, JSON.stringify(stored), 'utf-8')
+
+ expect(await isTrusted(query(), storeFile)).toBe('unknown')
+ })
+
+ it('never publishes a half-written store: a torn payload trusts nothing', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ await trustHostKey(query(), storeFile)
+ const whole = await readFile(storeFile, 'utf-8')
+ // A crash mid-write, had it landed on the final path: valid JSON prefix, truncated.
+ await writeFile(storeFile, whole.slice(0, Math.floor(whole.length / 2)), 'utf-8')
+
+ await expect(isTrusted(query(), storeFile)).resolves.toBe('unknown')
+ await expect(loadTrustedHostKeys(storeFile)).resolves.toEqual([])
+ })
+
+ it('leaves no temp file behind and keeps the store parseable after a write', async () => {
+ await trustHostKey(query(), storeFile)
+
+ expect(JSON.parse(await readFile(storeFile, 'utf-8'))).toMatchObject({
+ version: 1
+ })
+ expect((await readdir(directory)).filter((name) => name.endsWith('.tmp'))).toEqual([])
+ })
+
+ it('publishes every update by replacing the file, never by rewriting it in place', async () => {
+ await trustHostKey(query(), storeFile)
+ const before = await stat(storeFile)
+
+ await trustHostKey(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile)
+
+ // A temp-file + rename swaps the inode; an in-place write truncates and keeps it, which is the
+ // shape that can leave a half-written trust list readable after a crash.
+ const after = await stat(storeFile)
+ if (before.ino !== 0 && after.ino !== 0) {
+ expect(after.ino).not.toBe(before.ino)
+ }
+ expect(await isTrusted(query(), storeFile)).toBe('match')
+ })
+
+ it('keeps both accepts when two hosts are trusted concurrently', async () => {
+ await Promise.all([
+ trustHostKey(query({ host: 'host-a' }), storeFile),
+ trustHostKey(query({ host: 'host-b', key: ED25519_B }), storeFile)
+ ])
+
+ expect(await isTrusted(query({ host: 'host-a' }), storeFile)).toBe('match')
+ expect(await isTrusted(query({ host: 'host-b', key: ED25519_B }), storeFile)).toBe('match')
+ })
+
+ it('refuses to answer before the store is bound to a profile', async () => {
+ await expect(isTrusted(query())).rejects.toThrow(/initSshHostKeyStoreFile/)
+ })
+
+ // The connect path cannot await the file — ssh2's verifier decides synchronously — so it matches
+ // against preloaded records with this. It must reach the same verdict as isTrusted, because a
+ // second copy of the comparison is exactly how the type downgrade got in.
+ describe('matching preloaded records', () => {
+ it('agrees with isTrusted on every outcome', async () => {
+ await trustHostKey(query(), storeFile)
+ await trustHostKey(query({ host: 'other-host', keyType: 'ssh-rsa', key: RSA_A }), storeFile)
+ const records = await loadTrustedHostKeys(storeFile)
+
+ for (const candidate of [
+ query(),
+ query({ key: ED25519_B }),
+ query({ keyType: 'ssh-rsa', key: RSA_A }),
+ query({ host: 'unheard-of' }),
+ query({ port: 2222 }),
+ query({ host: 'other-host', keyType: 'ssh-rsa', key: RSA_A })
+ ]) {
+ expect(matchTrustedHostKeys(records, candidate)).toBe(await isTrusted(candidate, storeFile))
+ }
+ })
+
+ // trustHostKey normalises the host it writes, so the read has to normalise identically or it
+ // never finds its own record: every connection is first contact and the store grows a row each
+ // time while protecting nothing.
+ it('normalises the query host the same way the write did', async () => {
+ await trustHostKey(query({ host: ' Build-01 ' }), storeFile)
+ const records = await loadTrustedHostKeys(storeFile)
+
+ expect(records[0]?.host).toBe('build-01')
+ expect(matchTrustedHostKeys(records, query({ host: ' BUILD-01 ' }))).toBe('match')
+ expect(matchTrustedHostKeys(records, query({ host: 'build-01' }))).toBe('match')
+ })
+ })
+
+ // Feeds the algorithm ordering: without the types we hold, a host known only to us is proposed
+ // ed25519-first and an attacker can present another type to turn a hard failure into first contact.
+ describe('the key types held for one endpoint', () => {
+ it('reports every type recorded for that host and port', async () => {
+ await trustHostKey(query(), storeFile)
+ await trustHostKey(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile)
+ const records = await loadTrustedHostKeys(storeFile)
+
+ expect(storedKeyTypesForEndpoint(records, 'build-01', 22).sort()).toEqual([
+ 'ssh-ed25519',
+ 'ssh-rsa'
+ ])
+ })
+
+ it('does not leak types from another port or host', async () => {
+ await trustHostKey(query(), storeFile)
+ const records = await loadTrustedHostKeys(storeFile)
+
+ expect(storedKeyTypesForEndpoint(records, 'build-01', 2222)).toEqual([])
+ expect(storedKeyTypesForEndpoint(records, 'build-02', 22)).toEqual([])
+ })
+
+ it('normalises the host it is asked about', async () => {
+ await trustHostKey(query(), storeFile)
+ const records = await loadTrustedHostKeys(storeFile)
+
+ expect(storedKeyTypesForEndpoint(records, ' BUILD-01 ', 22)).toEqual(['ssh-ed25519'])
+ })
+ })
+})
+
+/**
+ * Rollback safety for a brand-new on-disk format.
+ *
+ * `version` was written and never read, so a store from a future Orca would have every record
+ * dropped by validation and then be rewritten as v1 — the file silently losing whatever that version
+ * knew. v1 is the only place this can be made safe, because v2 cannot retrofit it.
+ */
+describe('a host key store written by a newer version', () => {
+ it('is not trusted and not overwritten', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-host-key-store-'))
+ const storeFile = join(dir, 'ssh-host-keys.json')
+ const future = JSON.stringify({
+ version: 99,
+ hostKeys: [{ shape: 'we do not understand' }]
+ })
+ await writeFile(storeFile, future, 'utf-8')
+
+ try {
+ expect(await loadTrustedHostKeys(storeFile)).toEqual([])
+
+ await trustHostKey(
+ { host: 'build-01', port: 22, keyType: 'ssh-ed25519', key: Buffer.from('key') },
+ storeFile
+ )
+
+ expect(await readFile(storeFile, 'utf-8'), 'a newer store was downgraded').toBe(future)
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+})
+
+describe('a host key store that exists but cannot be read', () => {
+ it('does not let the next accepted key wipe every other host on file', async () => {
+ // The shape that made this a data-loss bug rather than a lost prompt: one transient read failure
+ // reported "nothing trusted", and the very next first-contact accept rewrote the file with ONLY
+ // that record. Every other host then re-TOFUs, and one whose key genuinely changed in between is
+ // accepted as first contact instead of refused — the exact outcome host key pinning prevents.
+ await trustHostKey(query({ host: 'build-01' }), storeFile)
+ await trustHostKey(query({ host: 'build-02', key: ED25519_B }), storeFile)
+ const before = await readFile(storeFile, 'utf-8')
+
+ readFailure.error = Object.assign(new Error('EMFILE: too many open files'), { code: 'EMFILE' })
+ await trustHostKey(query({ host: 'build-03', key: RSA_A, keyType: 'ssh-rsa' }), storeFile)
+ readFailure.error = null
+
+ expect(await readFile(storeFile, 'utf-8'), 'an unreadable store was overwritten').toBe(before)
+ const kept = await loadTrustedHostKeys(storeFile)
+ expect(kept.map((record) => record.host).sort()).toEqual(['build-01', 'build-02'])
+ })
+
+ it('still answers "nothing trusted" on the read path, so nothing fails open', async () => {
+ // The two paths differ on purpose: a verifier that cannot read the store must fall back to a
+ // first-contact prompt, never to trusting the key. Only the WRITE has to hold back.
+ await trustHostKey(query(), storeFile)
+ readFailure.error = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
+
+ expect(await loadTrustedHostKeys(storeFile)).toEqual([])
+ expect(await isTrusted(query(), storeFile)).toBe('unknown')
+ })
+})
diff --git a/src/main/ssh/ssh-host-key-store.ts b/src/main/ssh/ssh-host-key-store.ts
new file mode 100644
index 00000000000..d764084eb17
--- /dev/null
+++ b/src/main/ssh/ssh-host-key-store.ts
@@ -0,0 +1,334 @@
+/**
+ * Orca's own record of accepted SSH host keys, consulted alongside the user's `known_hosts`.
+ *
+ * We read `known_hosts` but never write it (D1), so accepted keys land here instead. See
+ * docs/reference/ssh-host-key-verification.md — D1, D5 and D8 are the load-bearing decisions.
+ */
+import { createHash } from 'node:crypto'
+import { mkdir, readFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { withSidecarSnapshotQueue, writeSidecarSnapshot } from '../sidecar-snapshot-file'
+import {
+ formatHostKeyFingerprint,
+ readHostKeyType,
+ type KnownHostsOutcome,
+ type KnownHostsQuery
+} from './ssh-known-hosts'
+
+const STORE_FILE_NAME = 'ssh-host-keys.json'
+const STORE_VERSION = 1
+const MAX_PORT = 65535
+
+export type TrustedHostKeyRecord = {
+ /** Lower-cased `HostKeyAlias` or resolved hostname — never the Orca target label (D2 lookup key). */
+ host: string
+ port: number
+ keyType: string
+ /** The presented blob, base64. Matching compares these bytes; the fingerprint is for display. */
+ key: string
+ fingerprint: string
+ acceptedAt: string
+}
+
+/**
+ * Our store holds neither CA nor revoked entries, so it can only reach four of the six outcomes —
+ * but they are the same four, so a caller can union this with `matchKnownHosts` untranslated.
+ */
+export type HostKeyStoreOutcome = Extract<
+ KnownHostsOutcome,
+ 'match' | 'mismatch' | 'unknown-type-known-host' | 'unknown'
+>
+
+type HostKeyStoreFile = {
+ version: number
+ hostKeys: TrustedHostKeyRecord[]
+}
+
+/** Beside the profile's data file, like the GitHub cache and scrollback snapshots. */
+export function getSshHostKeyStoreFile(dataFile: string): string {
+ return join(dirname(dataFile), STORE_FILE_NAME)
+}
+
+let configuredStoreFile: string | null = null
+
+/** Bind the store to the active profile once at startup, so connect paths need not thread `dataFile`. */
+export function initSshHostKeyStoreFile(dataFile: string): void {
+ configuredStoreFile = getSshHostKeyStoreFile(dataFile)
+}
+
+/** The bound store path, for messages that must name the artefact a user has to remove. */
+export function boundSshHostKeyStoreFile(): string | null {
+ return configuredStoreFile
+}
+
+/**
+ * Why throw rather than default to empty: an unconfigured store is a wiring bug, and answering
+ * "nothing trusted" would quietly turn every host into first contact. The verifier wraps its work
+ * and denies on throw (D7), so failing loudly here still fails closed.
+ */
+function requireStoreFile(file?: string): string {
+ const resolved = file ?? configuredStoreFile
+ if (!resolved) {
+ throw new Error('SSH host key store used before initSshHostKeyStoreFile()')
+ }
+ return resolved
+}
+
+function normalizeHost(host: string): string {
+ return host.trim().toLowerCase()
+}
+
+function isValidPort(port: unknown): port is number {
+ return typeof port === 'number' && Number.isInteger(port) && port > 0 && port <= MAX_PORT
+}
+
+function decodeStoredKey(record: TrustedHostKeyRecord): Buffer | undefined {
+ // Buffer.from never throws on bad base64, it silently truncates — so re-derive and compare.
+ const key = Buffer.from(record.key, 'base64')
+ if (
+ key.length === 0 ||
+ key.toString('base64').replace(/=+$/, '') !== record.key.replace(/=+$/, '')
+ ) {
+ return undefined
+ }
+ // The blob's own algorithm header must agree with the record's type field, or a tampered or
+ // corrupted record could claim a type it does not carry and satisfy a lookup for it.
+ return readHostKeyType(key) === record.keyType ? key : undefined
+}
+
+function fingerprintOf(key: Buffer): string {
+ return formatHostKeyFingerprint(createHash('sha256').update(key).digest('base64'))
+}
+
+function validateRecord(candidate: unknown): TrustedHostKeyRecord | undefined {
+ if (!candidate || typeof candidate !== 'object') {
+ return undefined
+ }
+ const { host, port, keyType, key, fingerprint, acceptedAt } = candidate as Record
+ if (
+ typeof host !== 'string' ||
+ host.length === 0 ||
+ !isValidPort(port) ||
+ typeof keyType !== 'string' ||
+ keyType.length === 0 ||
+ typeof key !== 'string' ||
+ typeof fingerprint !== 'string' ||
+ typeof acceptedAt !== 'string'
+ ) {
+ return undefined
+ }
+ const record: TrustedHostKeyRecord = {
+ host: normalizeHost(host),
+ port,
+ keyType,
+ key,
+ fingerprint,
+ acceptedAt
+ }
+ const decoded = decodeStoredKey(record)
+ // A fingerprint that disagrees with its key means the record was corrupted or hand-edited; D5
+ // shows this fingerprint to the user, so a record we cannot vouch for is dropped rather than shown.
+ return decoded && fingerprintOf(decoded) === fingerprint ? record : undefined
+}
+
+/**
+ * What one read of the store found. `withheld` is the distinction the read path does not care about
+ * and the WRITE path cannot do without: the file exists and holds records we could not see, so a
+ * rewrite would delete keys rather than supersede them. Same doctrine ssh-known-hosts-source.ts
+ * applies to `known_hosts` — a file that exists and refuses to open is evidence withheld, not absent.
+ */
+type StoreSnapshot =
+ | { status: 'ok'; records: TrustedHostKeyRecord[] }
+ | { status: 'absent' }
+ | { status: 'withheld'; reason: string }
+
+async function readStore(storeFile: string): Promise {
+ let contents: string
+ try {
+ contents = await readFile(storeFile, 'utf-8')
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return { status: 'absent' }
+ }
+ console.warn(`[ssh] Could not read the host key store at ${storeFile}:`, error)
+ return { status: 'withheld', reason: 'it could not be read' }
+ }
+
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(contents)
+ } catch {
+ // Deliberately NOT withheld: unparseable records are unrecoverable by any version, so there is
+ // nothing left for a rewrite to destroy and refusing to write would wedge the store permanently.
+ console.warn(`[ssh] Host key store at ${storeFile} is not valid JSON; treating it as empty`)
+ return { status: 'ok', records: [] }
+ }
+
+ // Why the version is consulted: it was written and never read, so a store from a future Orca would
+ // have every record dropped by validateRecord and then be REWRITTEN as v1 — silently discarding
+ // whatever that version knew. Refusing both to trust and to overwrite keeps the file intact for the
+ // version that owns it.
+ const onDiskVersion = (parsed as Partial | null)?.version
+ if (typeof onDiskVersion === 'number' && onDiskVersion > STORE_VERSION) {
+ console.warn(
+ `[ssh] Host key store at ${storeFile} is version ${onDiskVersion}, newer than ${STORE_VERSION}; leaving it alone and trusting nothing from it`
+ )
+ return { status: 'withheld', reason: `it is version ${onDiskVersion}` }
+ }
+
+ const hostKeys = (parsed as Partial | null)?.hostKeys
+ if (!Array.isArray(hostKeys)) {
+ console.warn(`[ssh] Host key store at ${storeFile} has no host key list; treating it as empty`)
+ return { status: 'ok', records: [] }
+ }
+
+ const records: TrustedHostKeyRecord[] = []
+ let dropped = 0
+ for (const candidate of hostKeys) {
+ const record = validateRecord(candidate)
+ if (record) {
+ records.push(record)
+ } else {
+ dropped += 1
+ }
+ }
+ if (dropped > 0) {
+ console.warn(
+ `[ssh] Ignored ${dropped} unusable record(s) in the host key store at ${storeFile}`
+ )
+ }
+ return { status: 'ok', records }
+}
+
+/**
+ * Every trusted record, or an empty list when the file is missing or unreadable.
+ *
+ * Never throws and never fails open: a corrupt file degrades to "nothing trusted", which costs a
+ * first-contact prompt, where the opposite mistake would accept anything. Callers that go on to
+ * WRITE must not use this — see readStore, whose `withheld` case this one deliberately flattens.
+ */
+export async function loadTrustedHostKeys(file?: string): Promise {
+ const snapshot = await readStore(requireStoreFile(file))
+ return snapshot.status === 'ok' ? snapshot.records : []
+}
+
+/**
+ * Whether these records hold this key for this endpoint.
+ *
+ * Scoped to host + port + key type (D8), and exactly — unlike `known_hosts` there is no bare-host
+ * fallback pass, because we only ever record the endpoint we actually connected to.
+ *
+ * Pure and separate from the load so the connect path can reuse it against records it preloaded:
+ * ssh2's verifier decides synchronously and cannot await the file. Duplicating the comparison there
+ * instead is what produced the type downgrade this outcome set exists to prevent — one copy answered
+ * only match/mismatch/unknown, so a record of another type read as first contact.
+ */
+export function matchTrustedHostKeys(
+ records: readonly TrustedHostKeyRecord[],
+ query: KnownHostsQuery
+): HostKeyStoreOutcome {
+ const host = normalizeHost(query.host)
+ let sawSameType = false
+ let sawOtherType = false
+
+ for (const record of records) {
+ if (record.host !== host || record.port !== query.port) {
+ continue
+ }
+ if (record.keyType !== query.keyType) {
+ sawOtherType = true
+ continue
+ }
+ if (decodeStoredKey(record)?.equals(query.key)) {
+ return 'match'
+ }
+ sawSameType = true
+ }
+
+ if (sawSameType) {
+ return 'mismatch'
+ }
+ // We hold a key for this endpoint, just not of the presented type. Never a first-contact result:
+ // an attacker who cannot forge the type on file would otherwise present another for a soft outcome.
+ return sawOtherType ? 'unknown-type-known-host' : 'unknown'
+}
+
+/** The key types we already hold for one endpoint, for the algorithm ordering that makes D3 safe. */
+export function storedKeyTypesForEndpoint(
+ records: readonly TrustedHostKeyRecord[],
+ host: string,
+ port: number
+): string[] {
+ const normalized = normalizeHost(host)
+ return records
+ .filter((record) => record.host === normalized && record.port === port)
+ .map((record) => record.keyType)
+}
+
+/** Whether we have previously accepted this key for this endpoint. */
+export async function isTrusted(
+ query: KnownHostsQuery,
+ file?: string
+): Promise {
+ return matchTrustedHostKeys(await loadTrustedHostKeys(file), query)
+}
+
+/**
+ * Record an accepted key, superseding any earlier key for the same host + port + type.
+ *
+ * Takes the presented blob rather than a pre-built record so the stored base64 and fingerprint
+ * cannot disagree with each other.
+ */
+export async function trustHostKey(
+ query: KnownHostsQuery,
+ file?: string
+): Promise {
+ const storeFile = requireStoreFile(file)
+ const record: TrustedHostKeyRecord = {
+ host: normalizeHost(query.host),
+ port: query.port,
+ keyType: query.keyType,
+ key: query.key.toString('base64'),
+ fingerprint: fingerprintOf(query.key),
+ acceptedAt: new Date().toISOString()
+ }
+ // Serialized: startup restore connects to every previously-active target in parallel, so two
+ // first-contact accepts can otherwise read the same snapshot and one overwrites the other.
+ await withSidecarSnapshotQueue(storeFile, async () => {
+ // Inside the queue so the read and the write cannot be separated by another writer.
+ const snapshot = await readStore(storeFile)
+ if (snapshot.status === 'withheld') {
+ // Not an error the caller should fail on: the key verified, we simply decline to write a file
+ // whose current contents we cannot see. Writing would replace every other host's pinned key
+ // with this one record, so the next connect to those hosts would re-TOFU — and one whose key
+ // had genuinely changed would be accepted as first contact instead of refused. The next
+ // connect re-derives this decision from known_hosts.
+ console.warn(
+ `[ssh] Not recording the host key for ${record.host}:${record.port}: the store at ${storeFile} was left alone because ${snapshot.reason}`
+ )
+ return
+ }
+ const kept = (snapshot.status === 'ok' ? snapshot.records : []).filter(
+ (existing) =>
+ existing.host !== record.host ||
+ existing.port !== record.port ||
+ existing.keyType !== record.keyType
+ )
+ await persist(storeFile, [...kept, record])
+ // Inside the branch that actually wrote: the withheld path logged its own reason, and claiming
+ // "Trusted" for a record that never reached disk makes the next connect's re-prompt unreadable.
+ console.warn(
+ `[ssh] Trusted host key for ${record.host}:${record.port} (${record.keyType} ${record.fingerprint})`
+ )
+ })
+ return record
+}
+
+/** Temp file + fsync + rename, so a crash mid-write can never publish a half-written trust list. */
+async function persist(storeFile: string, hostKeys: TrustedHostKeyRecord[]): Promise {
+ await mkdir(dirname(storeFile), { recursive: true }).catch(() => {})
+ await writeSidecarSnapshot(storeFile, {
+ version: STORE_VERSION,
+ hostKeys
+ } satisfies HostKeyStoreFile)
+}
diff --git a/src/main/ssh/ssh-host-key-verifier.test.ts b/src/main/ssh/ssh-host-key-verifier.test.ts
new file mode 100644
index 00000000000..3edb812ec3d
--- /dev/null
+++ b/src/main/ssh/ssh-host-key-verifier.test.ts
@@ -0,0 +1,290 @@
+import { describe, expect, it, vi } from 'vitest'
+import { parseKnownHosts } from './ssh-known-hosts'
+import {
+ createHostKeyVerifier,
+ DEFAULT_SERVER_HOST_KEY_ALGORITHMS,
+ hostKeyFingerprintOf,
+ orderServerHostKeyAlgorithms,
+ type HostKeyVerifierDeps
+} from './ssh-host-key-verifier'
+
+const ED_A = 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'
+const ED_B = 'AAAAC3NzaC1lZDI1NTE5AAAAILu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7'
+const RSA_A =
+ 'AAAAB3NzaC1yc2EAAABAzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzA=='
+
+const blob = (base64: string): Buffer => Buffer.from(base64, 'base64')
+
+function deps(overrides: Partial = {}): HostKeyVerifierDeps {
+ return {
+ host: 'example.com',
+ port: 22,
+ displayHost: 'example.com',
+ strictHostKeyChecking: 'ask',
+ isEphemeralRuntimeTarget: false,
+ siteConfigSuppressed: false,
+ knownHostsUnreadable: false,
+ entries: [],
+ isTrusted: () => 'unknown',
+ rememberHostKey: vi.fn(),
+ ...overrides
+ }
+}
+
+/** Runs the verifier synchronously and reports both the decision and what it returned. */
+function run(
+ overrides: Partial,
+ key = ED_A
+): { accepted: boolean | undefined; returned: unknown } {
+ let accepted: boolean | undefined
+ const verifier = createHostKeyVerifier(deps(overrides))
+ const returned = verifier(blob(key), (ok) => {
+ accepted = ok
+ })
+ return { accepted, returned }
+}
+
+describe('the ssh2 host key verifier', () => {
+ // The regression that would silently restore accept-everything: ssh2 does
+ // `const ret = verifier(key, verify); if (ret !== undefined) verify(ret)`, so any non-undefined
+ // return — notably the Promise from an `async` function — accepts before the callback decides.
+ it('returns nothing, so ssh2 waits for the callback', () => {
+ expect(run({}).returned).toBeUndefined()
+ })
+
+ it('accepts a key the user already has in known_hosts', () => {
+ const entries = parseKnownHosts(`example.com ssh-ed25519 ${ED_A}`)
+ expect(run({ entries }).accepted).toBe(true)
+ })
+
+ it('accepts a key our own store already holds', () => {
+ expect(run({ isTrusted: () => 'match' }).accepted).toBe(true)
+ })
+
+ it('rejects a changed key', () => {
+ const entries = parseKnownHosts(`example.com ssh-ed25519 ${ED_B}`)
+ expect(run({ entries }).accepted).toBe(false)
+ })
+
+ it('rejects a revoked key', () => {
+ const entries = parseKnownHosts(`@revoked example.com ssh-ed25519 ${ED_A}`)
+ expect(run({ entries }).accepted).toBe(false)
+ })
+
+ it('rejects a key whose own header cannot be read', () => {
+ let accepted: boolean | undefined
+ createHostKeyVerifier(deps())(Buffer.alloc(2), (ok) => {
+ accepted = ok
+ })
+ expect(accepted).toBe(false)
+ })
+
+ // ssh2 may not catch a throw from inside the verifier, which would hang the handshake instead of
+ // failing it.
+ it('denies rather than throwing when a dependency fails', () => {
+ const { accepted, returned } = run({
+ isTrusted: () => {
+ throw new Error('store unreadable')
+ }
+ })
+ expect(accepted).toBe(false)
+ expect(returned).toBeUndefined()
+ })
+
+ // A superseded attempt has nobody waiting on it; accepting would record trust for a connection
+ // that no longer exists.
+ it('denies once its connect attempt has been superseded', () => {
+ const { accepted } = run({ isCurrentAttempt: () => false })
+ expect(accepted).toBe(false)
+ })
+
+ it('does not record a key for a superseded attempt', () => {
+ const rememberHostKey = vi.fn()
+ run({ isCurrentAttempt: () => false, rememberHostKey })
+ expect(rememberHostKey).not.toHaveBeenCalled()
+ })
+
+ describe('remembering', () => {
+ it('records a first-contact key', () => {
+ const rememberHostKey = vi.fn()
+ run({ rememberHostKey })
+ expect(rememberHostKey).toHaveBeenCalledWith(
+ expect.objectContaining({
+ host: 'example.com',
+ port: 22,
+ keyType: 'ssh-ed25519',
+ fingerprint: hostKeyFingerprintOf(blob(ED_A))
+ })
+ )
+ })
+
+ it.each([
+ ['a key we already know', { entries: parseKnownHosts(`example.com ssh-ed25519 ${ED_A}`) }],
+ ['a rejected key', { entries: parseKnownHosts(`example.com ssh-ed25519 ${ED_B}`) }],
+ ['an ephemeral runtime target', { isEphemeralRuntimeTarget: true }],
+ ['a lax StrictHostKeyChecking', { strictHostKeyChecking: 'no' }]
+ ])('does not record %s', (_label, overrides) => {
+ const rememberHostKey = vi.fn()
+ run({ ...overrides, rememberHostKey })
+ expect(rememberHostKey).not.toHaveBeenCalled()
+ })
+ })
+
+ it('reports every decision for audit', () => {
+ const onDecision = vi.fn()
+ run({ onDecision })
+ expect(onDecision).toHaveBeenCalledWith(
+ expect.objectContaining({ outcome: 'unknown', keyType: 'ssh-ed25519' })
+ )
+ })
+
+ describe('denials that bypass the policy', () => {
+ // The connect path decides whether to keep offering credentials from the reported decision, so
+ // a denial the report skipped arrives as ssh2's generic handshake failure — and the user is
+ // asked for a passphrase by the host we just refused.
+ it('reports a denial for a key it could not read', () => {
+ const onDecision = vi.fn()
+ createHostKeyVerifier(deps({ onDecision }))(Buffer.alloc(2), () => {})
+ expect(onDecision).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'reject',
+ reason: expect.stringContaining('example.com')
+ })
+ )
+ })
+
+ it('reports a denial when a dependency throws', () => {
+ const onDecision = vi.fn()
+ run({
+ onDecision,
+ isTrusted: () => {
+ throw new Error('store unreadable')
+ }
+ })
+ expect(onDecision).toHaveBeenCalledWith(expect.objectContaining({ action: 'reject' }))
+ })
+
+ // There is no host key to identify here, and the relay keys its install locks on this
+ // fingerprint — reporting a bogus one would be worse than reporting none.
+ it('reports no fingerprint for a key it could not read', () => {
+ const onDecision = vi.fn()
+ createHostKeyVerifier(deps({ onDecision }))(Buffer.alloc(2), () => {})
+ expect(onDecision.mock.calls[0]?.[0]?.fingerprint).toBe('')
+ })
+
+ // This path runs from the verifier's own catch, so a throw here would escape into ssh2 and hang
+ // the handshake instead of failing it.
+ it('still denies when reporting the denial throws', () => {
+ let accepted: boolean | undefined
+ const verifier = createHostKeyVerifier(
+ deps({
+ onDecision: () => {
+ throw new Error('listener blew up')
+ }
+ })
+ )
+ expect(() =>
+ verifier(Buffer.alloc(2), (ok) => {
+ accepted = ok
+ })
+ ).not.toThrow()
+ expect(accepted).toBe(false)
+ })
+
+ // Nobody is waiting on a superseded attempt, and reporting would let it overwrite the live
+ // attempt's outcome.
+ it('reports nothing once its attempt has been superseded', () => {
+ const onDecision = vi.fn()
+ run({ onDecision, isCurrentAttempt: () => false })
+ expect(onDecision).not.toHaveBeenCalled()
+ })
+ })
+})
+
+// We now READ ssh2's list instead of copying it, which removes the drift class entirely — but the
+// fallback copy is still load-bearing if the deep path ever moves, so it is the thing worth pinning.
+describe('the ssh2 default algorithm list', () => {
+ const ssh2Constants = require('ssh2/lib/protocol/constants.js') as {
+ DEFAULT_SERVER_HOST_KEY: string[]
+ SUPPORTED_SERVER_HOST_KEY: string[]
+ }
+
+ it('is read from ssh2, not guessed', () => {
+ expect(DEFAULT_SERVER_HOST_KEY_ALGORITHMS).toEqual(ssh2Constants.DEFAULT_SERVER_HOST_KEY)
+ })
+
+ // The failure this prevents: generateAlgorithmList throws `Unsupported algorithm` from inside
+ // client.connect, and only for hosts we already know, since those are the only ones we set
+ // `algorithms` for.
+ it('proposes nothing ssh2 would refuse', () => {
+ // Null is the "could not read it" case, and the caller then leaves ssh2's defaults alone rather
+ // than proposing a guessed list — which is the only way this loop could ever have failed.
+ for (const algorithm of DEFAULT_SERVER_HOST_KEY_ALGORITHMS ?? []) {
+ expect(ssh2Constants.SUPPORTED_SERVER_HOST_KEY).toContain(algorithm)
+ }
+ })
+
+ // ssh2 prepends ssh-ed25519 only when a runtime probe succeeds, so a copy is not merely stale-able
+ // — it can be wrong on a build where the probe fails. This asserts the fallback still matches on a
+ // machine where the probe passes; it is the copy, so drift here is a review, not an outage.
+ // Type scoping is only safe because we can promote the type we hold, and RSA is negotiated under
+ // three different names for one key.
+ it('contains the RSA signature algorithms the ordering maps onto', () => {
+ expect(DEFAULT_SERVER_HOST_KEY_ALGORITHMS ?? []).toEqual(
+ expect.arrayContaining(['ssh-rsa', 'rsa-sha2-256', 'rsa-sha2-512'])
+ )
+ })
+})
+
+describe('host key algorithm ordering', () => {
+ const supported = ['ssh-ed25519', 'rsa-sha2-512', 'ssh-rsa', 'ecdsa-sha2-nistp256']
+
+ // Without this, type-scoped matching is a downgrade: an attacker who cannot forge the key on
+ // file just presents another type and turns a hard failure into first contact.
+ it('leads with the types already known for the host', () => {
+ const entries = parseKnownHosts(`example.com ssh-rsa ${RSA_A}`)
+ const ordered = orderServerHostKeyAlgorithms(entries, 'example.com', 22, supported)
+ // Any RSA algorithm leading is the property that matters; which one is ssh2 preference order.
+ expect(ordered?.[0]).toMatch(/rsa/)
+ expect(ordered?.indexOf('ssh-ed25519')).toBeGreaterThan(0)
+ })
+
+ it('keeps every supported algorithm, only reordered', () => {
+ const entries = parseKnownHosts(`example.com ssh-rsa ${RSA_A}`)
+ const ordered = orderServerHostKeyAlgorithms(entries, 'example.com', 22, supported)
+ expect([...(ordered ?? [])].sort()).toEqual([...supported].sort())
+ })
+
+ it('leaves the defaults alone for a host we know nothing about', () => {
+ const entries = parseKnownHosts(`other.com ssh-rsa ${RSA_A}`)
+ expect(orderServerHostKeyAlgorithms(entries, 'example.com', 22, supported)).toBeUndefined()
+ })
+
+ it('ignores a revoked entry when choosing what to lead with', () => {
+ const entries = parseKnownHosts(`@revoked example.com ssh-rsa ${RSA_A}`)
+ expect(orderServerHostKeyAlgorithms(entries, 'example.com', 22, supported)).toBeUndefined()
+ })
+
+ // One ssh-rsa key is negotiated as rsa-sha2-512/256 or ssh-rsa, so promoting only the literal
+ // name would leave a known RSA host ordered behind ed25519 — the exact gap this closes.
+ it('promotes every RSA signature algorithm for a known ssh-rsa key', () => {
+ const entries = parseKnownHosts(`example.com ssh-rsa ${RSA_A}`)
+ const ordered = orderServerHostKeyAlgorithms(entries, 'example.com', 22, supported)
+ expect(ordered?.slice(0, 2).sort()).toEqual(['rsa-sha2-512', 'ssh-rsa'])
+ expect(ordered?.indexOf('ssh-ed25519')).toBeGreaterThan(1)
+ })
+
+ // Types we recorded ourselves must be promoted too, or a host known only to us is left open to
+ // the same downgrade the known_hosts path is protected against.
+ it('leads with a type known only from our own store', () => {
+ const ordered = orderServerHostKeyAlgorithms([], 'example.com', 22, supported, ['ssh-rsa'])
+ expect(ordered?.[0]).toMatch(/rsa/)
+ expect(ordered?.indexOf('ssh-ed25519')).toBeGreaterThan(0)
+ })
+
+ it('does not propose a type the transport does not support', () => {
+ const entries = parseKnownHosts(`example.com ssh-ed25519 ${ED_A}`)
+ const ordered = orderServerHostKeyAlgorithms(entries, 'example.com', 22, ['rsa-sha2-512'])
+ expect(ordered).toBeUndefined()
+ })
+})
diff --git a/src/main/ssh/ssh-host-key-verifier.ts b/src/main/ssh/ssh-host-key-verifier.ts
new file mode 100644
index 00000000000..ab1b01aa05d
--- /dev/null
+++ b/src/main/ssh/ssh-host-key-verifier.ts
@@ -0,0 +1,255 @@
+/**
+ * Builds the ssh2 `hostVerifier` and the host-key algorithm order that makes it safe.
+ *
+ * Separated from `SshConnection` so the decision path can be tested without a handshake, and so the
+ * two halves that must ship together — type-scoped matching and algorithm ordering — live in one
+ * file where the dependency is visible.
+ *
+ * See docs/reference/ssh-host-key-verification.md.
+ */
+import { createHash } from 'node:crypto'
+import {
+ formatHostKeyFingerprint,
+ matchKnownHosts,
+ readHostKeyType,
+ type KnownHostsEntry,
+ type KnownHostsOutcome
+} from './ssh-known-hosts'
+import { decideHostKey, type HostKeyDecision } from './ssh-host-key-decision'
+
+export type TrustedHostKeyLookup = (query: {
+ host: string
+ port: number
+ keyType: string
+ key: Buffer
+}) => 'match' | 'mismatch' | 'unknown-type-known-host' | 'unknown'
+
+export type HostKeyVerifierDeps = {
+ host: string
+ port: number
+ displayHost: string
+ /** True when `host` came from HostKeyAlias; ssh looks those up without the port. */
+ isHostKeyAlias?: boolean
+ strictHostKeyChecking: string
+ isEphemeralRuntimeTarget: boolean
+ siteConfigSuppressed: boolean
+ knownHostsUnreadable: boolean
+ /** Already unioned across every known_hosts file. */
+ entries: readonly KnownHostsEntry[]
+ isTrusted: TrustedHostKeyLookup
+ /** Passed straight through so a changed-key rejection can name the file to edit. */
+ hostKeyStoreFile?: string
+ rememberHostKey: (record: {
+ host: string
+ port: number
+ keyType: string
+ key: Buffer
+ fingerprint: string
+ }) => void
+ /** Called on every decision so accepts and rejections are auditable. */
+ onDecision?: (decision: HostKeyDecision & { fingerprint: string; keyType: string }) => void
+ /**
+ * False once this connect attempt has been superseded or disposed. A late verifier must deny
+ * rather than accept: the attempt it belongs to is gone, so nothing will consume the result, and
+ * accepting would record trust on behalf of a connection nobody is waiting for.
+ */
+ isCurrentAttempt?: () => boolean
+}
+
+/**
+ * ssh2's own default host-key proposal order, which we reorder rather than replace.
+ *
+ * Read from ssh2 rather than copied, so we cannot propose an algorithm this ssh2 build does not
+ * support and cannot silently drop one it adds. The deep path is the only place ssh2 exports it;
+ * ssh2 is an external dependency in the main bundle, so it resolves at runtime from the packaged
+ * node_modules rather than being inlined.
+ */
+export const DEFAULT_SERVER_HOST_KEY_ALGORITHMS = readSsh2DefaultServerHostKeyAlgorithms()
+
+/**
+ * Null rather than a copied list when ssh2's own cannot be read.
+ *
+ * A hardcoded fallback cannot be safe here. ssh2 prepends `ssh-ed25519` only when a RUNTIME PROBE
+ * succeeds, and on a build where that probe fails the name is absent from ssh2's SUPPORTED list too,
+ * so proposing it makes `generateAlgorithmList` THROW `Unsupported algorithm: ssh-ed25519` inside
+ * client.connect. That throw matches no retry classifier, and it would fire only for hosts we
+ * already know — working on new hosts and failing on trusted ones. Not reading the list is a reason
+ * to leave ssh2's defaults alone, not to guess at them.
+ */
+function readSsh2DefaultServerHostKeyAlgorithms(): string[] | null {
+ try {
+ const constants = require('ssh2/lib/protocol/constants.js') as {
+ DEFAULT_SERVER_HOST_KEY?: unknown
+ }
+ const list = constants?.DEFAULT_SERVER_HOST_KEY
+ if (Array.isArray(list) && list.length > 0 && list.every((a) => typeof a === 'string')) {
+ return [...(list as string[])]
+ }
+ console.warn('[ssh] ssh2 default host key list has an unexpected shape; leaving its defaults')
+ } catch (error) {
+ // A future ssh2 could move the file. Leaving its defaults keeps connections working; the only
+ // cost is that a known host is no longer type-scoped, which is the pre-existing behaviour.
+ console.warn(
+ '[ssh] could not read ssh2 default host key algorithms; leaving its defaults:',
+ error
+ )
+ }
+ return null
+}
+
+export function hostKeyFingerprintOf(key: Buffer): string {
+ return formatHostKeyFingerprint(createHash('sha256').update(key).digest('base64'))
+}
+
+/**
+ * Host-key algorithms to propose, ordered so the types we already hold for this host come first.
+ *
+ * This is what makes type-scoped matching safe rather than a downgrade. RFC 4253 gives the client's
+ * order priority, so leading with the known types denies a server the choice of presenting some
+ * other type to convert a hard failure into first contact. Without this, an attacker who cannot
+ * forge the key on file simply offers a different algorithm.
+ *
+ * Returns undefined when we know nothing for the host, leaving ssh2's defaults alone.
+ */
+export function orderServerHostKeyAlgorithms(
+ entries: readonly KnownHostsEntry[],
+ host: string,
+ port: number,
+ supported: readonly string[],
+ /** Types we recorded ourselves. Without these, a host known only to us is never promoted, and
+ * type scoping degrades into the downgrade it exists to prevent for exactly the hosts we
+ * learned on first contact. */
+ storedKeyTypes: readonly string[] = [],
+ isHostKeyAlias = false
+): string[] | undefined {
+ const known = new Set(storedKeyTypes)
+ for (const entry of entries) {
+ if (entry.marker === 'revoked') {
+ continue
+ }
+ // Reuse the matcher's own host logic rather than re-implementing pattern/hash matching here.
+ const outcome = matchKnownHosts([entry], {
+ host,
+ port,
+ keyType: entry.keyType,
+ key: entry.key,
+ isHostKeyAlias
+ })
+ if (outcome === 'match') {
+ known.add(entry.keyType)
+ }
+ }
+ if (known.size === 0) {
+ return undefined
+ }
+ // A known_hosts entry names the KEY type, which is not always the negotiated ALGORITHM name: one
+ // `ssh-rsa` key is offered as rsa-sha2-512, rsa-sha2-256 or ssh-rsa depending on the signature
+ // algorithm. Promoting only the literal name would leave the RSA host we know behind ed25519,
+ // which is the ordering this function exists to prevent.
+ const preferred = supported.filter(
+ (algorithm) =>
+ known.has(algorithm) || (known.has('ssh-rsa') && algorithm.startsWith('rsa-sha2-'))
+ )
+ if (preferred.length === 0) {
+ return undefined
+ }
+ const preferredSet = new Set(preferred)
+ return [...preferred, ...supported.filter((algorithm) => !preferredSet.has(algorithm))]
+}
+
+export type VerifyCallback = (accept: boolean) => void
+
+/**
+ * The ssh2 `hostVerifier`.
+ *
+ * MUST be a plain function that returns `undefined`. ssh2 does
+ * `const ret = hostVerifier(key, verify); if (ret !== undefined) verify(ret)` — so an `async`
+ * function returns a Promise, which is neither undefined nor falsy, and ssh2 accepts the key
+ * immediately while ignoring whatever the callback later decides. Making this async would silently
+ * restore the accept-everything behaviour this module exists to remove.
+ */
+export function createHostKeyVerifier(
+ deps: HostKeyVerifierDeps
+): (key: Buffer, verify: VerifyCallback) => undefined {
+ // Every denial must be reported, not just the ones the policy produced: the connect path decides
+ // whether to keep offering credentials by looking at the reported decision, so a denial that
+ // slipped past onDecision would reach it as ssh2's generic handshake failure and walk the
+ // credential ladder against a host we just refused.
+ const deny = (verify: VerifyCallback, outcome: KnownHostsOutcome, reason: string): undefined => {
+ try {
+ deps.onDecision?.({
+ action: 'reject',
+ outcome,
+ reason: `Host key verification failed for ${deps.displayHost}. ${reason}`,
+ // Empty: there is no host key here to identify. Consumers must not overwrite a fingerprint
+ // they already hold with this.
+ fingerprint: '',
+ keyType: ''
+ })
+ } catch {
+ // This path already runs from the verifier's own catch; a reporting failure must not become
+ // a throw that leaves the handshake hanging instead of denied.
+ }
+ verify(false)
+ return undefined
+ }
+
+ return (key, verify) => {
+ try {
+ if (deps.isCurrentAttempt && !deps.isCurrentAttempt()) {
+ // No decision is reported: nobody is waiting on this attempt, and reporting would let a
+ // superseded verifier overwrite the live attempt's outcome.
+ verify(false)
+ return undefined
+ }
+ const keyType = readHostKeyType(key)
+ if (!keyType) {
+ // A key whose own header we cannot read is not something to reason about further.
+ return deny(
+ verify,
+ 'unknown',
+ 'The host offered a key that could not be read as an SSH host key.'
+ )
+ }
+ const fingerprint = hostKeyFingerprintOf(key)
+ const decision = decideHostKey({
+ knownHostsOutcome: matchKnownHosts(deps.entries, {
+ host: deps.host,
+ port: deps.port,
+ keyType,
+ key,
+ isHostKeyAlias: deps.isHostKeyAlias
+ }),
+ storeOutcome: deps.isTrusted({ host: deps.host, port: deps.port, keyType, key }),
+ strictHostKeyChecking: deps.strictHostKeyChecking,
+ isEphemeralRuntimeTarget: deps.isEphemeralRuntimeTarget,
+ siteConfigSuppressed: deps.siteConfigSuppressed,
+ knownHostsUnreadable: deps.knownHostsUnreadable,
+ displayHost: deps.displayHost,
+ port: deps.port,
+ hostKeyStoreFile: deps.hostKeyStoreFile
+ })
+
+ deps.onDecision?.({ ...decision, fingerprint, keyType })
+
+ if (decision.action === 'accept-and-remember') {
+ deps.rememberHostKey({
+ host: deps.host,
+ port: deps.port,
+ keyType,
+ key,
+ fingerprint
+ })
+ }
+ // `prompt` is unreachable in this phase; treating it as a denial keeps the fail-closed
+ // property if it ever becomes reachable before the dialog exists.
+ verify(decision.action === 'accept' || decision.action === 'accept-and-remember')
+ } catch {
+ // ssh2 may not catch a throw from inside the verifier, which would leave the handshake
+ // hanging rather than failing. Denying is the only safe outcome for an error we cannot
+ // interpret.
+ return deny(verify, 'unknown', 'The host key could not be checked.')
+ }
+ return undefined
+ }
+}
diff --git a/src/main/ssh/ssh-known-hosts-source.test.ts b/src/main/ssh/ssh-known-hosts-source.test.ts
new file mode 100644
index 00000000000..2515e7d511d
--- /dev/null
+++ b/src/main/ssh/ssh-known-hosts-source.test.ts
@@ -0,0 +1,536 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { parseSshGOutput, type SshResolvedConfig } from './ssh-g-config-resolution'
+import { matchKnownHosts, readHostKeyType, type KnownHostsEntry } from './ssh-known-hosts'
+import {
+ defaultKnownHostsFiles,
+ loadKnownHostsEntries,
+ loadKnownHostsEvidence,
+ resolveKnownHostsFiles,
+ resolveKnownHostsLookupHost
+} from './ssh-known-hosts-source'
+
+const ED_A = 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'
+const ED_B = 'AAAAC3NzaC1lZDI1NTE5AAAAILu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7'
+const ED_C = 'AAAAC3NzaC1lZDI1NTE5AAAAIMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM'
+
+const blob = (base64: string): Buffer => Buffer.from(base64, 'base64')
+const hostLine = (hosts: string, key: string): string => `${hosts} ssh-ed25519 ${key}\n`
+
+function verdict(entries: KnownHostsEntry[], host: string, key: string): string {
+ return matchKnownHosts(entries, {
+ host,
+ port: 22,
+ keyType: readHostKeyType(blob(key)) ?? '',
+ key: blob(key)
+ })
+}
+
+const roots: string[] = []
+const savedHome = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }
+
+async function createRoot(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-known-hosts-'))
+ roots.push(root)
+ return root
+}
+
+/** os.homedir() reads HOME on POSIX and USERPROFILE on Windows. */
+function pretendHomeIs(path: string): void {
+ process.env.HOME = path
+ process.env.USERPROFILE = path
+}
+
+function resolvedConfig(overrides: Partial = {}): SshResolvedConfig {
+ return {
+ hostname: 'prod.internal',
+ port: 22,
+ identityFile: [],
+ identitiesOnly: false,
+ forwardAgent: false,
+ proxyUseFdpass: false,
+ controlMaster: 'no',
+ controlPersist: 'no',
+ userKnownHostsFiles: [],
+ globalKnownHostsFiles: [],
+ strictHostKeyChecking: 'ask',
+ hashKnownHosts: false,
+ updateHostKeys: 'no',
+ ...overrides
+ }
+}
+
+afterEach(async () => {
+ for (const key of ['HOME', 'USERPROFILE'] as const) {
+ const value = savedHome[key]
+ if (value === undefined) {
+ delete process.env[key]
+ } else {
+ process.env[key] = value
+ }
+ }
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('resolveKnownHostsFiles', () => {
+ it('splits the space-separated list ssh -G prints on one line', async () => {
+ const resolved = parseSshGOutput(
+ [
+ 'hostname prod.internal',
+ 'userknownhostsfile /a/known_hosts /a/known_hosts2 /b/known_hosts',
+ 'globalknownhostsfile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2'
+ ].join('\n')
+ )
+
+ expect(resolved.userKnownHostsFiles).toEqual([
+ '/a/known_hosts',
+ '/a/known_hosts2',
+ '/b/known_hosts'
+ ])
+ expect(await resolveKnownHostsFiles(resolved)).toEqual([
+ '/a/known_hosts',
+ '/a/known_hosts2',
+ '/b/known_hosts',
+ '/etc/ssh/ssh_known_hosts',
+ '/etc/ssh/ssh_known_hosts2'
+ ])
+ })
+
+ it('expands ~ in a reported path', async () => {
+ pretendHomeIs(join('/pretend', 'home'))
+
+ const resolved = parseSshGOutput('userknownhostsfile ~/.ssh/known_hosts ~/other_hosts')
+
+ expect(await resolveKnownHostsFiles(resolved)).toEqual([
+ join('/pretend', 'home', '.ssh', 'known_hosts'),
+ join('/pretend', 'home', 'other_hosts')
+ ])
+ })
+
+ it('keeps a double-quoted path containing spaces whole', async () => {
+ const resolved = parseSshGOutput(
+ 'userknownhostsfile "/Users/dev/my hosts/known_hosts" /plain/known_hosts'
+ )
+
+ expect(await resolveKnownHostsFiles(resolved)).toEqual([
+ '/Users/dev/my hosts/known_hosts',
+ '/plain/known_hosts'
+ ])
+ })
+
+ it('falls back to the default files when ssh -G reported nothing', async () => {
+ pretendHomeIs(join('/pretend', 'home'))
+
+ // Why not an empty list: no ssh, a non-zero exit or a timeout must not turn a host the user
+ // already verified into first contact.
+ expect(await resolveKnownHostsFiles(null)).toEqual([
+ join('/pretend', 'home', '.ssh', 'known_hosts'),
+ join('/pretend', 'home', '.ssh', 'known_hosts2')
+ ])
+ expect(defaultKnownHostsFiles()).toEqual(await resolveKnownHostsFiles(null))
+ })
+
+ it('drops an explicit none without falling back to the defaults', async () => {
+ const resolved = parseSshGOutput(
+ ['userknownhostsfile none', 'globalknownhostsfile /etc/ssh/ssh_known_hosts'].join('\n')
+ )
+
+ expect(await resolveKnownHostsFiles(resolved)).toEqual(['/etc/ssh/ssh_known_hosts'])
+ })
+})
+
+// Verbatim `ssh -F -G probe` output from OpenSSH_10.2p1 for a Host block setting HostName,
+// HostKeyAlias, StrictHostKeyChecking accept-new, two UserKnownHostsFile paths and Port 2222.
+// Hand-written fixtures elsewhere encode what we EXPECT ssh to print; this one is what it does
+// print, which is the only version that catches us guessing the format wrong.
+const REAL_SSH_G_OUTPUT = [
+ 'hostname real.example.com',
+ 'port 2222',
+ 'checkhostip no',
+ 'hashknownhosts no',
+ 'stricthostkeychecking accept-new',
+ 'updatehostkeys false',
+ 'hostkeyalias alias-host',
+ 'globalknownhostsfile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2',
+ 'userknownhostsfile /Users/nwparker/.ssh/kh_one /Users/nwparker/.ssh/kh_two'
+].join('\n')
+
+describe('parsing real ssh -G output', () => {
+ const resolved = parseSshGOutput(REAL_SSH_G_OUTPUT)
+
+ // Every file list arrives space-separated on ONE line, not repeated per line. Reading it as a
+ // single path would silently consult nothing for anyone with more than one file configured.
+ it('splits both known_hosts lists on one line each', async () => {
+ expect(resolved.userKnownHostsFiles).toEqual([
+ '/Users/nwparker/.ssh/kh_one',
+ '/Users/nwparker/.ssh/kh_two'
+ ])
+ expect(resolved.globalKnownHostsFiles).toEqual([
+ '/etc/ssh/ssh_known_hosts',
+ '/etc/ssh/ssh_known_hosts2'
+ ])
+ })
+
+ // Drives the whole strict branch; a miss defaults it to 'ask' and quietly loses the user's policy.
+ it('reads StrictHostKeyChecking, including accept-new', async () => {
+ expect(resolved.strictHostKeyChecking).toBe('accept-new')
+ })
+
+ // The one name that outranks the dialed host. A bastion tunnelled through localhost:port depends
+ // on it, and it is the only field allowed to override what we key the lookup on.
+ it('reads HostKeyAlias and prefers it for the lookup', async () => {
+ expect(resolved.hostKeyAlias).toBe('alias-host')
+ expect(resolveKnownHostsLookupHost(resolved, '127.0.0.1').host).toBe('alias-host')
+ })
+
+ it('reads the non-default port', async () => {
+ expect(resolved.port).toBe(2222)
+ })
+
+ // Verified against OpenSSH 10.2p1 on port 2225 with HostKeyAlias=myalias: an entry keyed
+ // `myalias` authenticates, one keyed `[myalias]:2225` gives "No ED25519 host key is known for
+ // myalias". Since the first pass now decides as soon as it finds any entry, a leftover bracketed
+ // line would BLOCK the bare lookup ssh actually performs.
+ it('reports that the lookup came from an alias, so the port is not appended', async () => {
+ expect(resolveKnownHostsLookupHost(resolved, '127.0.0.1').isHostKeyAlias).toBe(true)
+ })
+
+ it('reports no alias when the dialed host is used', async () => {
+ expect(resolveKnownHostsLookupHost(null, '127.0.0.1').isHostKeyAlias).toBe(false)
+ })
+})
+
+describe('loadKnownHostsEntries', () => {
+ it('unions the entries of every file', async () => {
+ const root = await createRoot()
+ const userFile = join(root, 'known_hosts')
+ const globalFile = join(root, 'ssh_known_hosts')
+ await writeFile(userFile, hostLine('alpha.example', ED_A))
+ await writeFile(globalFile, hostLine('beta.example', ED_B))
+
+ const entries = await loadKnownHostsEntries([userFile, globalFile])
+
+ expect(entries).toHaveLength(2)
+ expect(verdict(entries, 'alpha.example', ED_A)).toBe('match')
+ expect(verdict(entries, 'beta.example', ED_B)).toBe('match')
+ })
+
+ it('lets a hit in either file win over a disagreeing entry in the other', async () => {
+ const root = await createRoot()
+ const first = join(root, 'first')
+ const second = join(root, 'second')
+ await writeFile(first, hostLine('shared.example', ED_A))
+ await writeFile(second, hostLine('shared.example', ED_B))
+
+ const entries = await loadKnownHostsEntries([first, second])
+
+ expect(verdict(entries, 'shared.example', ED_A)).toBe('match')
+ expect(verdict(entries, 'shared.example', ED_B)).toBe('match')
+ // The union still detects a key neither file holds.
+ expect(verdict(entries, 'shared.example', ED_C)).toBe('mismatch')
+ })
+
+ it('skips a missing file and keeps the rest', async () => {
+ const root = await createRoot()
+ const present = join(root, 'known_hosts')
+ await writeFile(present, hostLine('alpha.example', ED_A))
+
+ const entries = await loadKnownHostsEntries([join(root, 'absent'), present])
+
+ expect(verdict(entries, 'alpha.example', ED_A)).toBe('match')
+ })
+
+ it('skips a path that is a directory', async () => {
+ const root = await createRoot()
+ const present = join(root, 'known_hosts')
+ await mkdir(join(root, 'a_directory'))
+ await writeFile(present, hostLine('alpha.example', ED_A))
+
+ const entries = await loadKnownHostsEntries([join(root, 'a_directory'), present])
+
+ expect(verdict(entries, 'alpha.example', ED_A)).toBe('match')
+ })
+
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
+ 'skips an unreadable file and keeps the rest',
+ async () => {
+ const root = await createRoot()
+ const unreadable = join(root, 'unreadable')
+ const present = join(root, 'known_hosts')
+ await writeFile(unreadable, hostLine('secret.example', ED_B))
+ await chmod(unreadable, 0o000)
+ await writeFile(present, hostLine('alpha.example', ED_A))
+
+ const entries = await loadKnownHostsEntries([unreadable, present])
+
+ expect(verdict(entries, 'alpha.example', ED_A)).toBe('match')
+ expect(verdict(entries, 'secret.example', ED_B)).toBe('unknown')
+ }
+ )
+
+ it('returns nothing when no file can be read', async () => {
+ const root = await createRoot()
+
+ await expect(loadKnownHostsEntries([join(root, 'absent')])).resolves.toEqual([])
+ })
+})
+
+// The caller turns this count into a refusal, so the line between the two cases is the line between
+// "every connection works" and "no connection works".
+describe('loadKnownHostsEvidence', () => {
+ // A profile that has never connected has no known_hosts — ssh writes one on its own first
+ // connect. Counting that as a source we failed to read would refuse every first connection every
+ // new user ever makes.
+ it('does not count an absent file as unreadable', async () => {
+ const root = await createRoot()
+
+ const evidence = await loadKnownHostsEvidence([
+ join(root, 'known_hosts'),
+ join(root, 'known_hosts2')
+ ])
+
+ expect(evidence.unreadableFileCount).toBe(0)
+ expect(evidence.entries).toEqual([])
+ })
+
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
+ 'counts a file that exists but will not open',
+ async () => {
+ const root = await createRoot()
+ const unreadable = join(root, 'known_hosts')
+ await writeFile(unreadable, hostLine('secret.example', ED_B))
+ await chmod(unreadable, 0o000)
+
+ const evidence = await loadKnownHostsEvidence([unreadable])
+
+ // The entry that would have said "this key changed" could be in here; we cannot tell.
+ expect(evidence.unreadableFileCount).toBe(1)
+ }
+ )
+
+ it('counts a path that is a directory', async () => {
+ const root = await createRoot()
+ await mkdir(join(root, 'a_directory'))
+
+ const evidence = await loadKnownHostsEvidence([join(root, 'a_directory')])
+
+ expect(evidence.unreadableFileCount).toBe(1)
+ })
+
+ it('reports nothing unreadable when every file parses', async () => {
+ const root = await createRoot()
+ const present = join(root, 'known_hosts')
+ await writeFile(present, hostLine('alpha.example', ED_A))
+
+ const evidence = await loadKnownHostsEvidence([present])
+
+ expect(evidence.unreadableFileCount).toBe(0)
+ expect(evidence.entries).toHaveLength(1)
+ })
+
+ // An empty file is a file we read successfully. Nothing is withheld, so nothing is suppressed.
+ it('does not count an empty file as unreadable', async () => {
+ const root = await createRoot()
+ const empty = join(root, 'known_hosts')
+ await writeFile(empty, '')
+
+ const evidence = await loadKnownHostsEvidence([empty])
+
+ expect(evidence.unreadableFileCount).toBe(0)
+ })
+})
+
+describe('resolveKnownHostsLookupHost', () => {
+ it('prefers HostKeyAlias over the resolved hostname', async () => {
+ // A bastion tunnelled through localhost:2200 would otherwise mismatch on every target.
+ const resolved = resolvedConfig({ hostname: '127.0.0.1', hostKeyAlias: 'bastion' })
+
+ expect(resolveKnownHostsLookupHost(resolved, '127.0.0.1').host).toBe('bastion')
+ })
+
+ // INVERTED from 'uses the resolved hostname, never the Orca label'. That test encoded an
+ // assumption verified false against OpenSSH 10.2p1: `ssh -G` echoes its own argument back as
+ // `hostname` when no Host block matches, so for a manual target `resolved.hostname` IS the Orca
+ // label — the one name the design forbids keying on, and one `ssh` never wrote. Keying on it
+ // consults no entries at all, so an impersonated host reads as first contact.
+ //
+ // The dialed host is correct in both cases: buildConnectConfig has already applied HostName
+ // resolution, so a config alias dials the real host too.
+ it('uses the dialed host, because a resolved hostname can just echo the label', async () => {
+ const resolved = resolvedConfig({ hostname: 'my-orca-label' })
+
+ expect(resolveKnownHostsLookupHost(resolved, '10.0.0.5').host).toBe('10.0.0.5')
+ })
+
+ it('still prefers an explicit HostKeyAlias over the dialed host', async () => {
+ const resolved = resolvedConfig({ hostname: 'my-orca-label', hostKeyAlias: 'bastion' })
+
+ expect(resolveKnownHostsLookupHost(resolved, '10.0.0.5').host).toBe('bastion')
+ })
+
+ it('falls back to the dialed host when nothing was resolved', async () => {
+ expect(resolveKnownHostsLookupHost(null, 'direct.example').host).toBe('direct.example')
+ expect(
+ resolveKnownHostsLookupHost(resolvedConfig({ hostname: '' }), 'direct.example').host
+ ).toBe('direct.example')
+ })
+})
+
+/**
+ * `ssh -G` prints UserKnownHostsFile unquoted and space-separated, even when the config quoted it —
+ * verified against OpenSSH 10.2p1. So one path containing a space is indistinguishable from two
+ * paths, and splitting it shreds `C:\\Users\\John Doe\\.ssh\\known_hosts` into fragments that resolve
+ * to nothing. Every fragment then misses with ENOENT, which reads as "absent" rather than
+ * "unreadable", so the user appears to know no hosts and a CHANGED key is accepted as first contact.
+ */
+describe('a known_hosts path containing a space', () => {
+ let dir: string
+
+ beforeEach(async () => {
+ dir = await mkdtemp(join(tmpdir(), 'orca known hosts '))
+ })
+
+ afterEach(async () => {
+ await rm(dir, { recursive: true, force: true })
+ })
+
+ it('is rejoined when the split fragments resolve to nothing', async () => {
+ const spaced = join(dir, 'known_hosts')
+ expect(spaced).toContain(' ')
+ await writeFile(spaced, '', 'utf-8')
+
+ // Exactly what parseKnownHostsFileList hands over for this value.
+ const resolved = await resolveKnownHostsFiles({
+ userKnownHostsFiles: spaced.split(' '),
+ globalKnownHostsFiles: []
+ } as never)
+
+ expect(resolved).toEqual([spaced])
+ })
+
+ it('rejoins a spaced path sitting alongside an ordinary one', async () => {
+ // The mixed case a whole-list check cannot handle: it sees the ordinary path exist and leaves
+ // the spaced one in fragments, so the file the user actually verified hosts in is never read.
+ const spaced = join(dir, 'known_hosts')
+ const ordinary = join(dir, 'other')
+ await writeFile(spaced, '', 'utf-8')
+ await writeFile(ordinary, '', 'utf-8')
+
+ const resolved = await resolveKnownHostsFiles({
+ userKnownHostsFiles: [...spaced.split(' '), ordinary],
+ globalKnownHostsFiles: []
+ } as never)
+
+ expect(resolved).toEqual([spaced, ordinary])
+ })
+
+ it('leaves a genuine multi-file list alone', async () => {
+ // Any fragment existing means these really are separate paths, not one shredded one.
+ const real = join(dir, 'first')
+ await writeFile(real, '', 'utf-8')
+
+ const resolved = await resolveKnownHostsFiles({
+ userKnownHostsFiles: [real, '/tmp/orca-does-not-exist-known-hosts'],
+ globalKnownHostsFiles: []
+ } as never)
+
+ expect(resolved).toEqual([real, '/tmp/orca-does-not-exist-known-hosts'])
+ })
+
+ it('leaves a single unresolvable path alone rather than inventing one', async () => {
+ const resolved = await resolveKnownHostsFiles({
+ userKnownHostsFiles: ['/tmp/orca-absent-known-hosts'],
+ globalKnownHostsFiles: []
+ } as never)
+
+ expect(resolved).toEqual(['/tmp/orca-absent-known-hosts'])
+ })
+})
+
+/**
+ * Verbatim from a real Windows host — `C:\Windows\System32\OpenSSH\ssh.exe -G github.com`, OpenSSH
+ * for Windows. Captured rather than constructed, because the two things that bite here could not be
+ * inferred from the POSIX output: the `__PROGRAMDATA__` token arrives UNEXPANDED, and separators are
+ * MIXED within one path.
+ *
+ * Pinning it as a literal is what makes this testable off Windows at all. The parsing and the rejoin
+ * are pure string work, so the only thing a Windows runner would add is the platform's `path`
+ * arithmetic — which is why this covers the input shape and does not pretend to cover more.
+ */
+const WINDOWS_SSH_G_OUTPUT = [
+ 'hostname github.com',
+ 'port 22',
+ 'hashknownhosts no',
+ 'stricthostkeychecking ask',
+ 'globalknownhostsfile __PROGRAMDATA__\\ssh/ssh_known_hosts __PROGRAMDATA__\\ssh/ssh_known_hosts2',
+ 'userknownhostsfile C:\\Users\\neil/.ssh/known_hosts C:\\Users\\neil/.ssh/known_hosts2'
+].join('\n')
+
+describe('parsing real ssh -G output from Windows OpenSSH', () => {
+ it('expands the __PROGRAMDATA__ token rather than passing it through as a path', () => {
+ // Left literal it misses with ENOENT, and an absent file is indistinguishable from "no host is
+ // known there" — so a site-managed known_hosts is silently invisible, including one holding a
+ // rotated key that should have produced a mismatch rather than a first-contact accept.
+ const previous = process.env.ProgramData
+ process.env.ProgramData = 'C:\\ProgramData'
+ try {
+ const resolved = parseSshGOutput(WINDOWS_SSH_G_OUTPUT)
+ for (const path of resolved.globalKnownHostsFiles) {
+ expect(path).not.toContain('__PROGRAMDATA__')
+ expect(path).toContain('ProgramData')
+ }
+ expect(resolved.globalKnownHostsFiles).toHaveLength(2)
+ } finally {
+ if (previous === undefined) {
+ delete process.env.ProgramData
+ } else {
+ process.env.ProgramData = previous
+ }
+ }
+ })
+
+ it('does not rewrite a path that merely BEGINS with the token characters', () => {
+ // `startsWith` alone also matches `__PROGRAMDATA__evil/x`, rewriting a file the user never named
+ // into a directory they did not choose. The token has to be the whole path or be followed by a
+ // separator. Found by probing the expansion rather than by reading it.
+ const previous = process.env.ProgramData
+ process.env.ProgramData = 'C:\\ProgramData'
+ try {
+ const resolved = parseSshGOutput('userknownhostsfile __PROGRAMDATA__evil/known_hosts')
+ expect(resolved.userKnownHostsFiles).toEqual(['__PROGRAMDATA__evil/known_hosts'])
+ } finally {
+ if (previous === undefined) {
+ delete process.env.ProgramData
+ } else {
+ process.env.ProgramData = previous
+ }
+ }
+ })
+
+ it('keeps a drive-letter path with mixed separators intact', () => {
+ // `C:\Users\neil/.ssh/known_hosts` is what it really prints. Node's fs accepts both separators
+ // on Windows, so the requirement is that nothing here rewrites or truncates it.
+ const resolved = parseSshGOutput(WINDOWS_SSH_G_OUTPUT)
+ expect(resolved.userKnownHostsFiles).toEqual([
+ 'C:\\Users\\neil/.ssh/known_hosts',
+ 'C:\\Users\\neil/.ssh/known_hosts2'
+ ])
+ })
+
+ it('splits a spaced Windows home into the fragments the rejoin has to put back', () => {
+ // The security-critical shape, now confirmed against the real format: a spaced home prints
+ // unquoted, so `C:\Users\John Doe/.ssh/known_hosts` arrives as two tokens. This pins what the
+ // parser hands the rejoin; rejoinSpaceSplitPaths is covered against real spaced files above.
+ const spaced = parseSshGOutput(
+ 'userknownhostsfile C:\\Users\\John Doe/.ssh/known_hosts C:\\Users\\John Doe/.ssh/known_hosts2'
+ )
+ expect(spaced.userKnownHostsFiles).toEqual([
+ 'C:\\Users\\John',
+ 'Doe/.ssh/known_hosts',
+ 'C:\\Users\\John',
+ 'Doe/.ssh/known_hosts2'
+ ])
+ })
+})
diff --git a/src/main/ssh/ssh-known-hosts-source.ts b/src/main/ssh/ssh-known-hosts-source.ts
new file mode 100644
index 00000000000..f87b67660d4
--- /dev/null
+++ b/src/main/ssh/ssh-known-hosts-source.ts
@@ -0,0 +1,155 @@
+/**
+ * Where `known_hosts` entries come from for one lookup.
+ *
+ * Reading the user's real files is the entire migration story — most developers already verified
+ * their hosts through `ssh` and `git`. We only read; writing to a file shared with every other SSH
+ * tool on the machine is out of scope. See docs/reference/ssh-host-key-verification.md (D1, D2).
+ */
+import { access, readFile } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import type { SshResolvedConfig } from './ssh-g-config-resolution'
+import { parseKnownHosts, type KnownHostsEntry } from './ssh-known-hosts'
+
+/** OpenSSH's explicit opt-out for one list; the other list still applies. */
+const EXPLICIT_NONE = 'none'
+
+/** Used when `ssh -G` told us nothing — never "no trust source". */
+export function defaultKnownHostsFiles(): string[] {
+ const home = homedir()
+ return [join(home, '.ssh', 'known_hosts'), join(home, '.ssh', 'known_hosts2')]
+}
+
+/**
+ * Async because the rejoin below stats the very paths that can hang. Its one caller is already
+ * inside a bounded async read, and a synchronous stat there blocked the whole main process on a
+ * stalled NFS/SMB mount — worse than the wedged connection the bound was added to prevent.
+ */
+export async function resolveKnownHostsFiles(
+ resolved: SshResolvedConfig | null
+): Promise {
+ const reported = resolved
+ ? [...resolved.userKnownHostsFiles, ...resolved.globalKnownHostsFiles]
+ : []
+ // No `ssh`, a non-zero exit or a timeout must not turn a host the user already verified into
+ // first contact, so an empty report falls back rather than reading nothing.
+ if (reported.length === 0) {
+ return defaultKnownHostsFiles()
+ }
+ return [
+ ...new Set(await rejoinSpaceSplitPaths(reported.filter((path) => path !== EXPLICIT_NONE)))
+ ]
+}
+
+/**
+ * Undo the split when `ssh -G` reported ONE path that happens to contain a space.
+ *
+ * OpenSSH prints this value unquoted and space-separated, even when the config quoted it — verified
+ * against 10.2p1, which echoes `UserKnownHostsFile "/tmp/a b/known_hosts"` back as
+ * `userknownhostsfile /tmp/a b/known_hosts`. So the format cannot distinguish one spaced path from
+ * two paths, and splitting shreds `C:\Users\John Doe\.ssh\known_hosts` into fragments that resolve
+ * to nothing. Every fragment then misses with ENOENT, which reads as "absent" rather than
+ * "unreadable" — so the user appears to know no hosts at all, and a changed key that known_hosts
+ * would have refused is accepted as first contact instead.
+ *
+ * The filesystem is the only thing that can disambiguate, so it decides: the longest run of tokens
+ * that resolves to a real file is taken as one path, then the scan continues after it. That handles
+ * a spaced path sitting alongside ordinary ones, which a whole-list check could not — it would see
+ * the ordinary path exist and leave the spaced one in fragments.
+ */
+async function rejoinSpaceSplitPaths(paths: readonly string[]): Promise {
+ if (paths.length < 2) {
+ return [...paths]
+ }
+ const rejoined: string[] = []
+ let index = 0
+ while (index < paths.length) {
+ // Longest run first, so a spaced path wins over its own first fragment when both happen to
+ // exist. Falls back to the single token when no run resolves, which leaves a genuinely absent
+ // path reported as-is rather than inventing one.
+ let consumed = 0
+ for (let end = paths.length; end > index; end -= 1) {
+ const candidate = paths.slice(index, end).join(' ')
+ if (await exists(candidate)) {
+ rejoined.push(candidate)
+ consumed = end - index
+ break
+ }
+ }
+ if (consumed === 0) {
+ rejoined.push(paths[index])
+ consumed = 1
+ }
+ index += consumed
+ }
+ return rejoined
+}
+
+async function exists(path: string): Promise {
+ return access(path).then(
+ () => true,
+ () => false
+ )
+}
+
+/**
+ * The union across every file. The caller runs `matchKnownHosts` once over the result, so an exact
+ * hit in any file wins and a disagreeing entry in another file is not a mismatch.
+ */
+export async function loadKnownHostsEntries(files: readonly string[]): Promise {
+ return (await loadKnownHostsEvidence(files)).entries
+}
+
+export type KnownHostsEvidence = {
+ entries: KnownHostsEntry[]
+ /**
+ * Files that EXIST but could not be read — a permissions problem, a directory, an I/O error.
+ *
+ * Deliberately not "files that produced no entries": a file that is simply absent is the normal
+ * state (ssh creates known_hosts on its own first connect, and most Orca profiles start without
+ * one), and it is real evidence that no host is known. A file that exists and refuses to open is
+ * the opposite — evidence withheld, so an entry that would have said "this key changed" may be
+ * sitting in it. The caller must not record trust while any source is silent that way.
+ */
+ unreadableFileCount: number
+}
+
+export async function loadKnownHostsEvidence(
+ files: readonly string[]
+): Promise {
+ const perFile = await Promise.all(
+ files.map(async (path) => {
+ try {
+ return { entries: parseKnownHosts(await readFile(path, 'utf8')), unreadable: false }
+ } catch (err) {
+ const absent = (err as NodeJS.ErrnoException).code === 'ENOENT'
+ return { entries: [] as KnownHostsEntry[], unreadable: !absent }
+ }
+ })
+ )
+ return {
+ entries: perFile.flatMap((file) => file.entries),
+ unreadableFileCount: perFile.filter((file) => file.unreadable).length
+ }
+}
+
+/**
+ * The name the lookup keys on: `HostKeyAlias` when set, else the resolved hostname, else the host
+ * ssh2 dials. Never the Orca label — bastions tunnelled through `localhost:port` depend on the
+ * alias, and a label keys on nothing `ssh` ever wrote.
+ */
+export function resolveKnownHostsLookupHost(
+ resolved: SshResolvedConfig | null,
+ dialedHost: string
+): { host: string; isHostKeyAlias: boolean } {
+ // Deliberately NOT `resolved.hostname`: `ssh -G` echoes its own argument back as `hostname` when
+ // no Host block matches, which for a manual target is the Orca label. Keying on that consults a
+ // name `ssh` never wrote, so the real host's entries are missed entirely and an impersonated
+ // host reads as first contact. `dialedHost` is what ssh2 actually connects to, with HostName
+ // resolution already applied by buildConnectConfig.
+ const alias = resolved?.hostKeyAlias
+ // The flag matters as much as the name: ssh looks an alias up WITHOUT the port, so bracketing it
+ // would consult a form ssh never writes and, worse, let a stale `[alias]:port` line stop the bare
+ // lookup that ssh actually performs.
+ return alias ? { host: alias, isHostKeyAlias: true } : { host: dialedHost, isHostKeyAlias: false }
+}
diff --git a/src/main/ssh/ssh-known-hosts.test.ts b/src/main/ssh/ssh-known-hosts.test.ts
new file mode 100644
index 00000000000..0a204be6aa0
--- /dev/null
+++ b/src/main/ssh/ssh-known-hosts.test.ts
@@ -0,0 +1,435 @@
+/**
+ * Vectors verified against OpenSSH 10.2p1 — `ssh-keygen -H` for the hashed entries and a throwaway
+ * sshd with `-v` for the verdicts — rather than derived from this implementation. That matters:
+ * a parser test written from its own parser passes by construction.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ hostCandidatePasses,
+ matchKnownHosts,
+ parseKnownHosts,
+ parseKnownHostsLine,
+ readHostKeyType
+} from './ssh-known-hosts'
+
+const ED_A = 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'
+const ED_B = 'AAAAC3NzaC1lZDI1NTE5AAAAILu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7'
+const RSA_A =
+ 'AAAAB3NzaC1yc2EAAABAzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzA=='
+
+/** `ssh-keygen -H` output; the salt/hash pair is real, not synthesised here. */
+const HASHED_EXAMPLE_COM = '|1|qvIayk/BTpSrSmc/i3iM4cyYx+8=|6ysCq72Bg48mNavekN+FLrdPc/I='
+const HASHED_EXAMPLE_COM_2222 = '|1|qsCyiGgRmqnaNrHKZUgVKG57bnQ=|12y3NTllwASTDOM0EVoQZiVgg9U='
+
+const blob = (base64: string): Buffer => Buffer.from(base64, 'base64')
+const line = (hosts: string, key: string, type = 'ssh-ed25519'): string => `${hosts} ${type} ${key}`
+
+type Query = { host?: string; port?: number; key: string }
+
+function verdict(contents: string, query: Query): string {
+ return matchKnownHosts(parseKnownHosts(contents), {
+ host: query.host ?? 'example.com',
+ port: query.port ?? 22,
+ keyType: readHostKeyType(blob(query.key)) ?? '',
+ key: blob(query.key)
+ })
+}
+
+describe('known_hosts parsing', () => {
+ it('reads the algorithm from the blob rather than trusting the line', () => {
+ expect(readHostKeyType(blob(ED_A))).toBe('ssh-ed25519')
+ expect(readHostKeyType(blob(RSA_A))).toBe('ssh-rsa')
+ })
+
+ it.each([
+ ['blank', ' '],
+ ['comment', '# a comment'],
+ ['too few fields', 'example.com ssh-ed25519'],
+ ['unrecognised marker', `@bogus ${line('example.com', ED_A)}`],
+ ['type field disagreeing with the blob', `example.com ssh-rsa ${ED_A}`],
+ ['empty salt', `|1||6ysCq72Bg48mNavekN+FLrdPc/I= ssh-ed25519 ${ED_A}`],
+ ['hash that is not 20 bytes', `|1|qvIayk/BTpSrSmc/i3iM4cyYx+8=|AAAA ssh-ed25519 ${ED_A}`],
+ ['wrong hashed field count', `|1|a|b|c ssh-ed25519 ${ED_A}`],
+ ['undecodable key', 'example.com ssh-ed25519 !!!not-base64!!!']
+ ])('rejects %s', (_label, raw) => {
+ expect(parseKnownHostsLine(raw)).toBeUndefined()
+ })
+
+ it.each([
+ ['empty buffer', Buffer.alloc(0)],
+ ['truncated length prefix', Buffer.alloc(2)],
+ ['length running past the end', Buffer.from([0, 0, 0xff, 0xff, 1, 2, 3, 4])],
+ ['zero length', Buffer.from([0, 0, 0, 0, 1, 2, 3, 4])]
+ ])('refuses a malformed blob: %s', (_label, buffer) => {
+ expect(readHostKeyType(buffer)).toBeUndefined()
+ })
+})
+
+describe('candidate passes', () => {
+ it('uses the bare host on the default port', () => {
+ expect(hostCandidatePasses('Example.com', 22)).toEqual([['example.com']])
+ })
+
+ // OpenSSH logs "checking without port identifier" — the bare form is a real second pass.
+ it('tries the bracketed form first and falls back to bare off-port', () => {
+ expect(hostCandidatePasses('example.com', 2222)).toEqual([
+ ['[example.com]:2222'],
+ ['example.com']
+ ])
+ })
+})
+
+describe('matching a presented key', () => {
+ it('matches an exact line', () => {
+ expect(verdict(line('example.com', ED_A), { key: ED_A })).toBe('match')
+ })
+
+ it('matches case-insensitively', () => {
+ expect(verdict(line('example.com', ED_A), { host: 'EXAMPLE.COM', key: ED_A })).toBe('match')
+ })
+
+ it('reports a changed key of the same type', () => {
+ expect(verdict(line('example.com', ED_A), { key: ED_B })).toBe('mismatch')
+ })
+
+ it.each([
+ ['another host', line('example.com', ED_A), 'other.com'],
+ ['an empty file', '', 'example.com']
+ ])('reports unknown for %s', (_label, contents, host) => {
+ expect(verdict(contents, { host, key: ED_A })).toBe('unknown')
+ })
+
+ it('matches any host in a comma list', () => {
+ const contents = line('a.example.com,b.example.com,1.2.3.4', ED_A)
+ expect(verdict(contents, { host: 'b.example.com', key: ED_A })).toBe('match')
+ })
+
+ describe('ports', () => {
+ it('matches the bracketed form on its port', () => {
+ expect(verdict(line('[example.com]:2222', ED_A), { port: 2222, key: ED_A })).toBe('match')
+ })
+
+ it('does not apply a bracketed entry to the default port', () => {
+ expect(verdict(line('[example.com]:2222', ED_A), { key: ED_A })).toBe('unknown')
+ })
+
+ it('falls back to a bare line when off-port', () => {
+ expect(verdict(line('example.com', ED_A), { port: 2222, key: ED_A })).toBe('match')
+ })
+
+ // The fallback pass is advisory: OpenSSH downgrades a wrong key there to "not known".
+ it('never reports a change from the fallback pass', () => {
+ expect(verdict(line('example.com', ED_B), { port: 2222, key: ED_A })).toBe('unknown')
+ })
+
+ it('lets the bracketed pass decide when both forms exist', () => {
+ const contents = `${line('[example.com]:2222', ED_B)}\n${line('example.com', ED_A)}`
+ expect(verdict(contents, { port: 2222, key: ED_A })).toBe('mismatch')
+ })
+ })
+
+ // The design moved IPv6 into scope: a literal that comes back `unknown` because we mangled the
+ // brackets is prompt-training, the harm the whole outcome vocabulary exists to avoid.
+ describe('IPv6 literals', () => {
+ it('matches a bare literal on the default port', () => {
+ const contents = line('2001:db8::1', ED_A)
+ expect(verdict(contents, { host: '2001:db8::1', key: ED_A })).toBe('match')
+ })
+
+ it('matches a bracketed literal on its port', () => {
+ const contents = line('[2001:db8::1]:2222', ED_A)
+ expect(verdict(contents, { host: '2001:db8::1', port: 2222, key: ED_A })).toBe('match')
+ })
+
+ it('reports a change for a literal', () => {
+ const contents = line('2001:db8::1', ED_A)
+ expect(verdict(contents, { host: '2001:db8::1', key: ED_B })).toBe('mismatch')
+ })
+ })
+
+ describe('patterns', () => {
+ it.each([
+ ['a star glob', line('*.example.com', ED_A), 'host.example.com', 'match'],
+ ['a non-matching glob', line('*.example.com', ED_A), 'host.other.com', 'unknown'],
+ ['a question-mark glob', line('host?.example.com', ED_A), 'host1.example.com', 'match']
+ ])('handles %s', (_label, contents, host, expected) => {
+ expect(verdict(contents, { host, key: ED_A })).toBe(expected)
+ })
+
+ it('lets one negation veto the whole line', () => {
+ const contents = line('*.example.com,!secret.example.com', ED_A)
+ expect(verdict(contents, { host: 'secret.example.com', key: ED_A })).toBe('unknown')
+ expect(verdict(contents, { host: 'public.example.com', key: ED_A })).toBe('match')
+ })
+ })
+
+ describe('hashed entries', () => {
+ it('matches a hashed host', () => {
+ expect(verdict(`${HASHED_EXAMPLE_COM} ssh-ed25519 ${ED_A}`, { key: ED_A })).toBe('match')
+ })
+
+ it('reports a change against a hashed host', () => {
+ expect(verdict(`${HASHED_EXAMPLE_COM} ssh-ed25519 ${ED_A}`, { key: ED_B })).toBe('mismatch')
+ })
+
+ it('does not match a different host', () => {
+ const contents = `${HASHED_EXAMPLE_COM} ssh-ed25519 ${ED_A}`
+ expect(verdict(contents, { host: 'other.com', key: ED_A })).toBe('unknown')
+ })
+
+ // The bracketed string itself is hashed, so each candidate form must be hashed separately.
+ it('matches a hashed bracketed entry on its port', () => {
+ const contents = `${HASHED_EXAMPLE_COM_2222} ssh-ed25519 ${ED_A}`
+ expect(verdict(contents, { port: 2222, key: ED_A })).toBe('match')
+ })
+ })
+
+ describe('markers', () => {
+ const revoked = `@revoked ${line('example.com', ED_A)}`
+
+ it.each([
+ ['listed after the good line', `${line('example.com', ED_A)}\n${revoked}`],
+ ['listed before it', `${revoked}\n${line('example.com', ED_A)}`]
+ ])('reports revoked when %s', (_label, contents) => {
+ expect(verdict(contents, { key: ED_A })).toBe('revoked')
+ })
+
+ it('still reports a change for a different key on a revoked host', () => {
+ expect(verdict(`${line('example.com', ED_A)}\n${revoked}`, { key: ED_B })).toBe('mismatch')
+ })
+
+ it('treats a cert-authority-only host as unsupported rather than first contact', () => {
+ const contents = `@cert-authority ${line('*.example.com', ED_A)}`
+ expect(verdict(contents, { host: 'host.example.com', key: ED_B })).toBe('ca-only')
+ })
+
+ it('lets a plain line decide alongside a cert-authority line', () => {
+ const contents = `@cert-authority ${line('*.example.com', ED_A)}\n${line('host.example.com', ED_B)}`
+ expect(verdict(contents, { host: 'host.example.com', key: ED_B })).toBe('match')
+ })
+
+ it('skips a line carrying an unrecognised marker', () => {
+ expect(verdict(`@bogus ${line('example.com', ED_A)}`, { key: ED_A })).toBe('unknown')
+ })
+ })
+
+ describe('key types', () => {
+ // The distinction the whole design rests on: a host we know by another type is NOT first
+ // contact, or an attacker who cannot forge the known type just presents a different one.
+ it('does not report a change when only another key type is on file', () => {
+ const contents = line('example.com', RSA_A, 'ssh-rsa')
+ expect(verdict(contents, { key: ED_A })).toBe('unknown-type-known-host')
+ })
+
+ it('matches within the same type', () => {
+ const contents = line('example.com', RSA_A, 'ssh-rsa')
+ expect(verdict(contents, { key: RSA_A })).toBe('match')
+ })
+ })
+
+ describe('file shape', () => {
+ it('accepts CRLF terminators', () => {
+ expect(verdict(`${line('example.com', ED_A)}\r\n`, { key: ED_A })).toBe('match')
+ })
+
+ it('skips blanks, comments and surrounding whitespace', () => {
+ const contents = `\n# comment\n \n ${line('example.com', ED_A)} \n`
+ expect(verdict(contents, { key: ED_A })).toBe('match')
+ })
+
+ // Files are unioned by the caller: any exact hit wins, and disagreement elsewhere is not a
+ // change. Both orderings verified live.
+ it.each([
+ ['user file first', `${line('h', ED_A)}\n${line('h', ED_B)}`],
+ ['global file first', `${line('h', ED_B)}\n${line('h', ED_A)}`]
+ ])('accepts a hit regardless of which file holds it (%s)', (_label, contents) => {
+ expect(verdict(contents, { host: 'h', key: ED_A })).toBe('match')
+ })
+ })
+})
+
+/**
+ * Vectors produced by a real OpenSSH 10.2p1 client against a real sshd on 127.0.0.1:2222, with the
+ * client's verdict recorded from its own output. Everything else in this file states what we believe
+ * ssh does; these state what it did.
+ */
+// HostKeyAlias is the one case where the port is not part of the key. Verified live on port 2225:
+// with HostKeyAlias=myalias, ssh finds an entry keyed `myalias` and reports "No ED25519 host key is
+// known for myalias" for one keyed `[myalias]:2225`.
+describe('a lookup keyed on HostKeyAlias', () => {
+ it('never brackets the alias, whatever the port', () => {
+ expect(hostCandidatePasses('myalias', 2225, true)).toEqual([['myalias']])
+ })
+
+ it('still brackets an ordinary host on a non-default port', () => {
+ expect(hostCandidatePasses('h', 2225, false)).toEqual([['[h]:2225'], ['h']])
+ })
+
+ it('matches a bare alias entry on a non-default port', () => {
+ const ED = 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'
+ const entries = parseKnownHosts(line('myalias', ED))
+ expect(
+ matchKnownHosts(entries, {
+ host: 'myalias',
+ port: 2225,
+ keyType: 'ssh-ed25519',
+ key: blob(ED),
+ isHostKeyAlias: true
+ })
+ ).toBe('match')
+ })
+
+ // The regression the flag prevents: pass 0 finding a bracketed entry now stops the fallback, so
+ // without it this stale line turns a working bastion into a hard failure.
+ it('ignores a stale bracketed entry that ssh would never consult', () => {
+ const ED = 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'
+ const ED_OTHER = 'AAAAC3NzaC1lZDI1NTE5AAAAILu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7'
+ const entries = parseKnownHosts(`${line('[myalias]:2225', ED_OTHER)}\n${line('myalias', ED)}`)
+ expect(
+ matchKnownHosts(entries, {
+ host: 'myalias',
+ port: 2225,
+ keyType: 'ssh-ed25519',
+ key: blob(ED),
+ isHostKeyAlias: true
+ })
+ ).toBe('match')
+ })
+})
+
+describe('lines ssh itself refuses to parse', () => {
+ const ED = 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'
+
+ // Buffer.from SKIPS invalid base64 characters instead of failing, so each of these decodes to
+ // something. Verified live against OpenSSH 10.2p1 on 127.0.0.1:2224: the valid control reached
+ // authentication and ALL THREE of these produced "No ED25519 host key is known" — ssh drops the
+ // line. Honouring them means granting trust from a line the user's own ssh ignores, and for the
+ // padded variant it means raising a CHANGED alarm from one.
+ it.each([
+ ['trailing junk', `${ED}!!!`],
+ ['invalid characters spliced into the middle', ED.replace(/^(.{20})/, '$1@@')]
+ ])('drops a key field with %s, as ssh does', (_label, keyField) => {
+ expect(parseKnownHostsLine(`example.com ssh-ed25519 ${keyField}`)).toBeUndefined()
+ })
+
+ // Still VALID base64 — 68 characters plus 4 is a legal length — and the algorithm header still
+ // reads ssh-ed25519, so neither the base64 check nor the header check catches it. ssh parses the
+ // whole key structure and drops the line; we decoded 54 bytes where the key is 51 and reported a
+ // CHANGED alarm from an entry ssh ignores.
+ it('drops a key blob with trailing bytes its own structure does not account for', () => {
+ expect(parseKnownHostsLine(`example.com ssh-ed25519 ${ED}AAAA`)).toBeUndefined()
+ })
+
+ it('drops a key blob whose own length prefix overruns the buffer', () => {
+ const overrun = Buffer.concat([
+ Buffer.from([0, 0, 0, 11]),
+ Buffer.from('ssh-ed25519'),
+ Buffer.from([0, 0, 0, 99])
+ ])
+ expect(
+ parseKnownHostsLine(`example.com ssh-ed25519 ${overrun.toString('base64')}`)
+ ).toBeUndefined()
+ })
+
+ it('still accepts the unmodified key field', () => {
+ expect(parseKnownHostsLine(`example.com ssh-ed25519 ${ED}`)).toBeDefined()
+ })
+
+ // ssh's extract_salt demands exactly one SHA1 digest: "expected salt len 20, got 16". A shorter
+ // salt is still a usable HMAC key for us, so we could match a hand-crafted line that is invisible
+ // to the user's ssh. ssh-keygen -H always writes 20 bytes, so refusing loses no real entry.
+ it('drops a hashed entry whose salt is not a full SHA1 digest', () => {
+ const shortSalt = Buffer.alloc(16, 1).toString('base64')
+ const hash = Buffer.alloc(20, 2).toString('base64')
+ expect(parseKnownHostsLine(`|1|${shortSalt}|${hash} ssh-ed25519 ${ED}`)).toBeUndefined()
+ })
+
+ it('accepts a hashed entry whose salt is a full SHA1 digest', () => {
+ const salt = Buffer.alloc(20, 1).toString('base64')
+ const hash = Buffer.alloc(20, 2).toString('base64')
+ expect(parseKnownHostsLine(`|1|${salt}|${hash} ssh-ed25519 ${ED}`)).toBeDefined()
+ })
+})
+
+describe('agreement with a live OpenSSH client', () => {
+ const SERVER_KEY = 'AAAAC3NzaC1lZDI1NTE5AAAAIM2eSSqUqU9LERdg8qNjFiU59unM+JyfwFHLkMxR13oq'
+ const OTHER_KEY = 'AAAAC3NzaC1lZDI1NTE5AAAAIKmVZ4Z+MJ3VYnZmZmZmZmZmZmZmZmZmZmZmZmZmZmZm'
+
+ // `ssh-keygen -H` on `[127.0.0.1]:2222 ssh-ed25519 `. The salt and hash are its own
+ // output, so this pins that we hash the BRACKETED candidate — hashing the bare host would miss.
+ const HASHED_BRACKETED_ENTRY = `|1|nfzVRh+YQtj+wIqjDBYLW2cryfQ=|Q51GJuiWfUGYpAPuRPyFC/FpKj4= ssh-ed25519 ${SERVER_KEY}`
+
+ it('accepts the hashed bracketed entry ssh-keygen -H produced, as ssh did', () => {
+ expect(
+ verdict(HASHED_BRACKETED_ENTRY, { host: '127.0.0.1', port: 2222, key: SERVER_KEY })
+ ).toBe('match')
+ })
+
+ // THE accept-a-changed-key case. Live on 127.0.0.1:2223 against an ed25519-only server, with an
+ // off-port RSA entry AND a bare correct ed25519 line, ssh printed IDENTIFICATION HAS CHANGED and
+ // refused — and printed NO "checking without port identifier", so the fallback never ran. ssh
+ // gates that pass on the port-qualified lookup matching no plain entry of ANY type; gating it on
+ // "no match and no same-type mismatch" instead reaches the bare line and returns `match`.
+ it('does not fall back past an off-port entry of another type, as ssh did not', () => {
+ const contents = `${line('[127.0.0.1]:2223', RSA_A, 'ssh-rsa')}\n${line('127.0.0.1', SERVER_KEY)}`
+ expect(verdict(contents, { host: '127.0.0.1', port: 2223, key: SERVER_KEY })).toBe(
+ 'unknown-type-known-host'
+ )
+ })
+
+ // The same flags, the opposite error. Live, a BARE ssh-rsa entry dialed on 2223 against the same
+ // ed25519-only server made ssh add the host and connect — plain first contact. Letting the
+ // fallback pass set unknown-type-known-host refuses a host ssh accepts.
+ it('treats an other-type entry found only on the fallback pass as unknown, as ssh did', () => {
+ expect(
+ verdict(line('127.0.0.1', RSA_A, 'ssh-rsa'), {
+ host: '127.0.0.1',
+ port: 2223,
+ key: SERVER_KEY
+ })
+ ).toBe('unknown')
+ })
+
+ // Live: ssh reached authentication, so it accepted the bare line for a non-default port.
+ it('falls back to the bare host line on a non-default port, as ssh did', () => {
+ expect(
+ verdict(line('127.0.0.1', SERVER_KEY), { host: '127.0.0.1', port: 2222, key: SERVER_KEY })
+ ).toBe('match')
+ })
+
+ // THE one that decides whether the fallback pass may report a change. Live, with
+ // StrictHostKeyChecking=accept-new and a bare line holding a DIFFERENT key, ssh connected and
+ // appended a new `[127.0.0.1]:2222` line — so it read this as first contact, not as a changed
+ // key, and printed no IDENTIFICATION HAS CHANGED banner. Reporting `mismatch` here would refuse a
+ // host ssh connects to happily.
+ it('reports a wrong key on the bare fallback line as unknown, as ssh did', () => {
+ expect(
+ verdict(line('127.0.0.1', OTHER_KEY), { host: '127.0.0.1', port: 2222, key: SERVER_KEY })
+ ).toBe('unknown')
+ })
+
+ // Same file shape, but the entry is for the exact endpoint: live, ssh printed
+ // IDENTIFICATION HAS CHANGED and refused.
+ it('reports a wrong key on the exact bracketed line as a change, as ssh did', () => {
+ expect(
+ verdict(line('[127.0.0.1]:2222', OTHER_KEY), {
+ host: '127.0.0.1',
+ port: 2222,
+ key: SERVER_KEY
+ })
+ ).toBe('mismatch')
+ })
+
+ // Live: known_hosts held an ssh-rsa key for the endpoint and the server offered ed25519 — ssh
+ // printed IDENTIFICATION HAS CHANGED and refused. So our unknown-type-known-host rejection is
+ // neither stricter nor laxer than ssh; treating it as first contact would have been laxer.
+ it('refuses a key type it has not seen for a host it knows, as ssh did', () => {
+ expect(
+ verdict(line('[127.0.0.1]:2222', RSA_A, 'ssh-rsa'), {
+ host: '127.0.0.1',
+ port: 2222,
+ key: SERVER_KEY
+ })
+ ).toBe('unknown-type-known-host')
+ })
+})
diff --git a/src/main/ssh/ssh-known-hosts.ts b/src/main/ssh/ssh-known-hosts.ts
new file mode 100644
index 00000000000..6f0765ae1a4
--- /dev/null
+++ b/src/main/ssh/ssh-known-hosts.ts
@@ -0,0 +1,324 @@
+/**
+ * OpenSSH `known_hosts` parsing and matching.
+ *
+ * Hand-rolled because no maintained JS implementation exists. Behaviour was verified against
+ * OpenSSH 10.2p1 rather than inferred from the man page — the two lookup passes and the
+ * revoked-wins rule in particular are observable behaviours that a reasonable reading of the docs
+ * gets wrong. See docs/reference/ssh-host-key-verification.md.
+ */
+import { createHmac, timingSafeEqual } from 'node:crypto'
+
+/** Ordered by severity: the first one that applies decides. */
+export type KnownHostsOutcome =
+ | 'match'
+ | 'mismatch'
+ | 'revoked'
+ | 'ca-only'
+ | 'unknown-type-known-host'
+ | 'unknown'
+
+export type KnownHostsEntry = {
+ /** `@revoked` / `@cert-authority`; a line with any other marker is dropped at parse time. */
+ marker?: 'revoked' | 'cert-authority'
+ /** Literal or glob host patterns, lower-cased. Empty when the line is hashed. */
+ patterns: string[]
+ /** Present only for `|1|salt|hash` lines. */
+ hashed?: { salt: Buffer; hash: Buffer }
+ /** Whether any pattern on the line is a `!negation`. One negated match vetoes the whole line. */
+ negations: string[]
+ keyType: string
+ key: Buffer
+}
+
+const HASH_MAGIC = '|1|'
+const SHA1_DIGEST_BYTES = 20
+/** Guards a malformed length prefix from allocating or reading past the blob. */
+const MAX_KEY_TYPE_BYTES = 64
+
+/**
+ * Read the algorithm name from the key blob's own length-prefixed header.
+ *
+ * Why not trust the line's type field: the two must agree, and comparing them is what rejects a
+ * line that claims one algorithm while carrying another.
+ */
+export function readHostKeyType(key: Buffer): string | undefined {
+ if (key.length < 4) {
+ return undefined
+ }
+ const length = key.readUInt32BE(0)
+ if (length === 0 || length > MAX_KEY_TYPE_BYTES || 4 + length > key.length) {
+ return undefined
+ }
+ return key.subarray(4, 4 + length).toString('utf8')
+}
+
+/**
+ * Whether the blob is exactly consumed by its own SSH-wire structure.
+ *
+ * A public key blob is a run of length-prefixed fields — name, then the algorithm's parameters — and
+ * nothing else. Checking only the algorithm header leaves trailing bytes undetected, and ssh parses
+ * the whole structure: verified live against OpenSSH 10.2p1, a valid ed25519 key with four extra
+ * base64 characters appended is still valid base64 and still reports `ssh-ed25519`, but ssh reports
+ * "No ED25519 host key is known" and drops the line, where we decoded 54 bytes instead of 51 and
+ * raised a CHANGED alarm from an entry the user's own ssh ignores.
+ *
+ * Deliberately algorithm-agnostic: the field walk is the same for every key type, so a type we do
+ * not model is checked as well as one we do.
+ */
+function isWellFormedHostKeyBlob(key: Buffer): boolean {
+ let offset = 0
+ while (offset < key.length) {
+ if (offset + 4 > key.length) {
+ return false
+ }
+ const fieldLength = key.readUInt32BE(offset)
+ offset += 4
+ if (fieldLength > key.length - offset) {
+ return false
+ }
+ offset += fieldLength
+ }
+ return offset === key.length
+}
+
+/** `SHA256:...` exactly as `ssh-keygen -lf` prints it, base64 with padding stripped. */
+export function formatHostKeyFingerprint(sha256Base64: string): string {
+ return `SHA256:${sha256Base64.replace(/=+$/, '')}`
+}
+
+function decodeKey(raw: string): Buffer | undefined {
+ // Buffer.from never throws on bad base64 — it silently SKIPS invalid characters, so `!!!`
+ // and a blob with `@@` spliced into it both decode to the same correct key. ssh rejects those
+ // lines outright ("parse error in hostkeys file"), so accepting them grants trust from a line the
+ // user's own ssh ignores; `AAAA` is worse still, decoding to different bytes that still
+ // parse, which reads as a CHANGED key. Re-encoding and comparing is what makes us as strict.
+ const decoded = Buffer.from(raw, 'base64')
+ if (decoded.length === 0) {
+ return undefined
+ }
+ const canonical = decoded.toString('base64').replace(/=+$/, '')
+ return canonical === raw.replace(/=+$/, '') ? decoded : undefined
+}
+
+function parseHashedPatterns(field: string): KnownHostsEntry['hashed'] | undefined {
+ const parts = field.split('|')
+ // '' , '1', salt, hash — exactly four, or the line is malformed.
+ if (parts.length !== 4 || parts[0] !== '' || parts[1] !== '1') {
+ return undefined
+ }
+ const salt = Buffer.from(parts[2] ?? '', 'base64')
+ const hash = Buffer.from(parts[3] ?? '', 'base64')
+ // ssh requires BOTH to be exactly one SHA1 digest — extract_salt rejects anything else with
+ // "expected salt len 20, got N". Accepting a shorter salt would let us match a line ssh treats as
+ // a parse error, so the entry would be invisible to the user's own ssh but trusted by us.
+ if (salt.length !== SHA1_DIGEST_BYTES || hash.length !== SHA1_DIGEST_BYTES) {
+ return undefined
+ }
+ return { salt, hash }
+}
+
+/** Returns undefined for blank lines, comments, and anything malformed — never throws. */
+export function parseKnownHostsLine(line: string): KnownHostsEntry | undefined {
+ const trimmed = line.trim()
+ if (trimmed.length === 0 || trimmed.startsWith('#')) {
+ return undefined
+ }
+
+ const fields = trimmed.split(/\s+/)
+ let index = 0
+ let marker: KnownHostsEntry['marker']
+ if (fields[index]?.startsWith('@')) {
+ const raw = fields[index]
+ if (raw === '@revoked') {
+ marker = 'revoked'
+ } else if (raw === '@cert-authority') {
+ marker = 'cert-authority'
+ } else {
+ // Why drop rather than ignore the marker: an unrecognised marker may restrict the line in a
+ // way we do not model, so honouring the line as if it were unmarked would over-trust it.
+ return undefined
+ }
+ index += 1
+ }
+
+ const hostField = fields[index]
+ const keyType = fields[index + 1]
+ const keyBase64 = fields[index + 2]
+ if (!hostField || !keyType || !keyBase64) {
+ return undefined
+ }
+
+ const key = decodeKey(keyBase64)
+ if (!key || readHostKeyType(key) !== keyType || !isWellFormedHostKeyBlob(key)) {
+ return undefined
+ }
+
+ if (hostField.startsWith(HASH_MAGIC)) {
+ const hashed = parseHashedPatterns(hostField)
+ return hashed
+ ? { ...(marker ? { marker } : {}), patterns: [], negations: [], hashed, keyType, key }
+ : undefined
+ }
+
+ const patterns: string[] = []
+ const negations: string[] = []
+ for (const raw of hostField.split(',')) {
+ const pattern = raw.trim().toLowerCase()
+ if (pattern.length === 0) {
+ continue
+ }
+ if (pattern.startsWith('!')) {
+ negations.push(pattern.slice(1))
+ } else {
+ patterns.push(pattern)
+ }
+ }
+ if (patterns.length === 0 && negations.length === 0) {
+ return undefined
+ }
+ return { ...(marker ? { marker } : {}), patterns, negations, keyType, key }
+}
+
+export function parseKnownHosts(contents: string): KnownHostsEntry[] {
+ const entries: KnownHostsEntry[] = []
+ for (const line of contents.split(/\r?\n/)) {
+ const entry = parseKnownHostsLine(line)
+ if (entry) {
+ entries.push(entry)
+ }
+ }
+ return entries
+}
+
+function globToRegExp(pattern: string): RegExp {
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
+ return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`)
+}
+
+function patternMatches(pattern: string, candidate: string): boolean {
+ return pattern.includes('*') || pattern.includes('?')
+ ? globToRegExp(pattern).test(candidate)
+ : pattern === candidate
+}
+
+function entryMatchesCandidate(entry: KnownHostsEntry, candidate: string): boolean {
+ if (entry.hashed) {
+ // The hash covers the candidate form verbatim, so a bracketed candidate hashes as
+ // "[host]:port" — each form must be hashed separately rather than hashing the bare host once.
+ const digest = createHmac('sha1', entry.hashed.salt).update(candidate).digest()
+ return digest.length === entry.hashed.hash.length && timingSafeEqual(digest, entry.hashed.hash)
+ }
+ // A single negation vetoes the entire line even when another pattern on it matches.
+ if (entry.negations.some((pattern) => patternMatches(pattern, candidate))) {
+ return false
+ }
+ return entry.patterns.some((pattern) => patternMatches(pattern, candidate))
+}
+
+/**
+ * The candidate forms, in the order OpenSSH tries them.
+ *
+ * A non-default port looks up `[host]:port` first and, finding nothing, retries the bare host —
+ * "checking without port identifier" in `ssh -v`. Collapsing these into one set would give a
+ * spurious first-contact result to anyone holding a bare line who connects on a non-default port.
+ *
+ * `HostKeyAlias` suppresses the port ENTIRELY: ssh looks the alias up bare and never brackets it.
+ * Verified against OpenSSH 10.2p1 on port 2225 with HostKeyAlias=myalias — an entry keyed `myalias`
+ * authenticates, and one keyed `[myalias]:2225` gives "No ED25519 host key is known for myalias".
+ * Bracketing an alias is not merely a stale-entry false alarm: because the first pass now decides
+ * as soon as it finds any entry, a leftover `[alias]:port` line would BLOCK the bare lookup that
+ * ssh actually performs, turning a working bastion into a hard failure.
+ */
+export function hostCandidatePasses(
+ host: string,
+ port: number,
+ isHostKeyAlias = false
+): string[][] {
+ const lower = host.toLowerCase()
+ return port === 22 || isHostKeyAlias ? [[lower]] : [[`[${lower}]:${port}`], [lower]]
+}
+
+export type KnownHostsQuery = {
+ host: string
+ port: number
+ keyType: string
+ key: Buffer
+ /** True when `host` came from `HostKeyAlias`, which ssh looks up without the port. */
+ isHostKeyAlias?: boolean
+}
+
+/**
+ * Decide an outcome for one presented key against a set of entries.
+ *
+ * Entries from several files are unioned by the caller: any exact hit in any file wins, and a
+ * disagreeing entry in another file does not make it a mismatch.
+ */
+export function matchKnownHosts(
+ entries: readonly KnownHostsEntry[],
+ query: KnownHostsQuery
+): KnownHostsOutcome {
+ const passes = hostCandidatePasses(query.host, query.port, query.isHostKeyAlias)
+ const matchesHost = (entry: KnownHostsEntry, candidates: string[]): boolean =>
+ candidates.some((candidate) => entryMatchesCandidate(entry, candidate))
+
+ // Revocation resolves first, across every pass, so the verdict cannot depend on line order.
+ for (const candidates of passes) {
+ for (const entry of entries) {
+ if (
+ entry.marker === 'revoked' &&
+ matchesHost(entry, candidates) &&
+ entry.key.equals(query.key)
+ ) {
+ return 'revoked'
+ }
+ }
+ }
+
+ // A CA line is a MARKER, so it never satisfies a plain host key and never stops the fallback pass.
+ // Tracked across passes because it describes the host either way.
+ let sawCertAuthority = false
+
+ for (let passIndex = 0; passIndex < passes.length; passIndex += 1) {
+ const candidates = passes[passIndex]!
+ // Per pass, never carried forward. These describe what THIS candidate form knows, and the
+ // fallback form's knowledge is not admissible as evidence of a change — see below.
+ let sawSameTypeForHost = false
+ let sawPlainEntryForHost = false
+
+ for (const entry of entries) {
+ if (entry.marker === 'revoked' || !matchesHost(entry, candidates)) {
+ continue
+ }
+ if (entry.marker === 'cert-authority') {
+ sawCertAuthority = true
+ continue
+ }
+ // Byte equality implies the types agree: the blob carries its own algorithm name, and parsing
+ // already rejected any line whose declared type disagreed with it.
+ if (entry.key.equals(query.key)) {
+ return 'match'
+ }
+ sawPlainEntryForHost = true
+ sawSameTypeForHost ||= entry.keyType === query.keyType
+ }
+
+ // The first pass decides if it knows this host AT ALL. OpenSSH runs the bare-host fallback only
+ // when the port-qualified lookup matched no plain entry of ANY type; when one was there, its
+ // verdict is final. Verified live against OpenSSH 10.2p1 on 127.0.0.1:2223 — an off-port RSA
+ // entry plus a bare, correct ed25519 line makes ssh print IDENTIFICATION HAS CHANGED with no
+ // "checking without port identifier", where continuing to the fallback returns `match` and
+ // ACCEPTS A CHANGED KEY.
+ if (passIndex === 0 && sawPlainEntryForHost) {
+ // Both refuse. The distinction only picks the message: a changed key of the type we hold, or
+ // a type we have never seen for a host we do know. ssh calls both HOST_CHANGED.
+ return sawSameTypeForHost ? 'mismatch' : 'unknown-type-known-host'
+ }
+ }
+
+ // Nothing after the first pass may report a change. On the fallback pass OpenSSH downgrades any
+ // non-match to "not known" — including an entry of another type, which it treats as plain first
+ // contact. Verified live: a bare ssh-rsa entry, dialed on 2223 against an ed25519-only server,
+ // makes ssh add the host and connect. Reporting unknown-type-known-host there refuses a host ssh
+ // accepts, which is why these flags are scoped to their pass.
+ return sawCertAuthority ? 'ca-only' : 'unknown'
+}
diff --git a/src/main/ssh/ssh-multi-key-authentication.test.ts b/src/main/ssh/ssh-multi-key-authentication.test.ts
index 32942facd27..8c355b5db1c 100644
--- a/src/main/ssh/ssh-multi-key-authentication.test.ts
+++ b/src/main/ssh/ssh-multi-key-authentication.test.ts
@@ -52,6 +52,11 @@ function makeResolved(overrides: Partial = {}): SshResolvedCo
proxyUseFdpass: false,
controlMaster: 'no',
controlPersist: 'no',
+ userKnownHostsFiles: [],
+ globalKnownHostsFiles: [],
+ strictHostKeyChecking: 'ask',
+ hashKnownHosts: false,
+ updateHostKeys: 'no',
...overrides
}
}
diff --git a/src/main/ssh/ssh-reconnect-error-classification.test.ts b/src/main/ssh/ssh-reconnect-error-classification.test.ts
index cf8319db03c..34356962ca2 100644
--- a/src/main/ssh/ssh-reconnect-error-classification.test.ts
+++ b/src/main/ssh/ssh-reconnect-error-classification.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { isTransientError } from './ssh-connection-utils'
+import { HostKeyVerificationError } from './ssh-host-key-decision'
import {
isDefiniteSystemSshHostFailure,
isTransientReconnectError
@@ -91,4 +92,19 @@ describe('isTransientReconnectError', () => {
it('keeps unrelated failures permanent', () => {
expect(isTransientReconnectError(new Error('something went wrong'))).toBe(false)
})
+
+ // Retrying re-derives the same verdict, so this would back off against an already-refused host
+ // until the ladder gave up — with the reason buried under nine failed attempts.
+ it('never retries a refused host key', () => {
+ expect(isTransientReconnectError(new HostKeyVerificationError('key changed', 'mismatch'))).toBe(
+ false
+ )
+ })
+
+ // The classifier is otherwise substring-driven, so a reason that happened to contain network
+ // wording would silently become retryable. The type is what decides.
+ it('never retries a refused host key whose reason reads like a network fault', () => {
+ const err = new HostKeyVerificationError('connection reset while checking the key', 'mismatch')
+ expect(isTransientReconnectError(err)).toBe(false)
+ })
})
diff --git a/src/main/ssh/ssh-reconnect-error-classification.ts b/src/main/ssh/ssh-reconnect-error-classification.ts
index dce71dc6dae..68c55cc516f 100644
--- a/src/main/ssh/ssh-reconnect-error-classification.ts
+++ b/src/main/ssh/ssh-reconnect-error-classification.ts
@@ -1,4 +1,5 @@
import { isAuthError, isPassphraseError, isTransientError } from './ssh-connection-utils'
+import { isHostKeyVerificationError } from './ssh-host-key-decision'
// Why: the system-SSH transport reports network failures as OpenSSH prose, not errno codes, so its
// probe timeouts and connect failures never match isTransientError's code table.
@@ -53,6 +54,12 @@ const EMPTY_SYSTEM_SSH_PROBE_EXIT_255 = /^system ssh probe failed \(exit 255\)\.
* network-shaped failure as recoverable. Auth stays permanent on both paths.
*/
export function isTransientReconnectError(err: Error): boolean {
+ // A denied host key is a decision, not a fault: retrying re-derives the same answer, so the ladder
+ // would back off forever against a host it has already refused. Checked by type rather than left
+ // to the fragment lists below, which would silently start retrying if the wording changed.
+ if (isHostKeyVerificationError(err)) {
+ return false
+ }
if (isAuthError(err) || isPassphraseError(err)) {
return false
}
diff --git a/src/relay/pty-handler-source-publication.test.ts b/src/relay/pty-handler-source-publication.test.ts
index 04e45d2b384..f0041a57ce5 100644
--- a/src/relay/pty-handler-source-publication.test.ts
+++ b/src/relay/pty-handler-source-publication.test.ts
@@ -769,4 +769,47 @@ describe('PtyHandler negotiated source publication', () => {
expect(publication.getDebugSnapshot()).toMatchObject({ sendCommitted: 1 })
expect(pausePty).not.toHaveBeenCalled()
})
+
+ // The reported defect, in the shape measured against the live relay: the client attaches twice
+ // for the same PTY. The first registers a source delivery under the primary client's id; the
+ // second finds `current.clientId === context.clientId`, answers 'existing', and returns NO
+ // replay. Live, every pane on reconnect logged path=existing-delivery and painted nothing.
+ //
+ // A reattach always lands in a NEW terminal — a reconnect bumps tab.generation, which is the
+ // pane's React key, so TerminalPane remounts and the old xterm is disposed with its buffer.
+ describe('a second attach for the same client', () => {
+ async function attach(id: number, params: Record) {
+ writes = []
+ dispatcher.feed(requestFrame(id, 'pty.attach', { id: 'pty-1', ...params }))
+ await vi.advanceTimersByTimeAsync(0)
+ return writes.map((buffer) => responseResult(buffer, id)).find(Boolean)
+ }
+
+ beforeEach(async () => {
+ await spawn({})
+ dataCallback!('scrollback-that-must-survive')
+ await vi.advanceTimersByTimeAsync(10)
+ await attach(10, { suppressReplayNotification: true })
+ })
+
+ it('replays the scrollback when the client says it needs it', async () => {
+ const second = await attach(11, {
+ suppressReplayNotification: true,
+ requireReplay: true
+ })
+
+ expect(second, 'second attach returned no response').toBeTruthy()
+ expect(second!.replay).toContain('scrollback-that-must-survive')
+ })
+
+ // The early return is right for a duplicate attach from a client that IS still receiving the
+ // stream; only a client that has thrown its terminal away should ask. Pinned so the fix stays
+ // opt-in and cannot start double-rendering for callers that never asked.
+ it('still sends nothing when the client does not ask', async () => {
+ const second = await attach(12, { suppressReplayNotification: true })
+
+ expect(second, 'second attach returned no response').toBeTruthy()
+ expect(second!.replay).toBeUndefined()
+ })
+ })
})
diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts
index 16d256e72a9..638e8b44649 100644
--- a/src/relay/pty-handler.ts
+++ b/src/relay/pty-handler.ts
@@ -1760,7 +1760,23 @@ export class PtyHandler {
...(sourceActivation ? { sourceActivation } : {})
}
}
- if (activation === 'existing' && this.sourcePublication?.accepts(id)) {
+ // `existing` means a delivery is already open for this client, so it is already receiving live
+ // output and does not need its screen re-sent. That is true for a duplicate attach — and false
+ // for the case this skipped: an SSH reconnect. The client keeps its id there (the dispatcher
+ // refuses to detach the primary, and setWrite revives that same id), so the delivery outlives
+ // the dead transport, while the renderer has thrown its terminal away — a reconnect bumps
+ // tab.generation, which is the React key, so the pane remounts with a brand-new empty xterm and
+ // nothing captures the old buffer. Answering "you already have it" leaves the pane blank
+ // forever, until new output happens to arrive.
+ //
+ // So the client says which it is. Falling through rather than returning the replay inline is
+ // deliberate: the path below already drops the pending batched bytes that are also in the
+ // buffer, which is what stops the live delivery double-rendering them.
+ if (
+ activation === 'existing' &&
+ this.sourcePublication?.accepts(id) &&
+ params.requireReplay !== true
+ ) {
return {
incarnationId: managed.incarnationId,
...(sourceActivation ? { sourceActivation } : {})
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts
index c493604febf..19f0d47ce82 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts
@@ -755,4 +755,49 @@ describe('useAiVaultSessionRefresh in-app agent session behavior', () => {
await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2', 'working') })
expect(listSessionsMock).toHaveBeenCalledTimes(3)
})
+
+ // Reported against an adhoc build: a workspace whose editor was loading files fine still showed
+ // "SSH relay is not ready" with "0 shown · 0 recent" in this panel. That error is what the relay
+ // throws before it is ready, which is ordinary at startup and for the window a reconnect leaves it
+ // not-ready — but nothing here retried on the relay simply becoming ready. The remaining triggers
+ // are mount, window refocus and a new agent session id, so the error stuck while the rest of the
+ // workspace worked. The file explorer already recovers off this same signal.
+ it('retries after a not-ready failure once the SSH connection lands', async () => {
+ listSessionsMock.mockRejectedValueOnce(new Error('SSH relay is not ready'))
+ await renderHook(['/home/neil/projects/orca'])
+ await flushMicrotasks()
+
+ expect(latest?.error).toBe('SSH relay is not ready')
+ const callsWhileBroken = listSessionsMock.mock.calls.length
+
+ listSessionsMock.mockResolvedValue(EMPTY_RESULT)
+ await act(async () => {
+ useAppStore.setState({ sshConnectedGeneration: 1 })
+ })
+ await flushMicrotasks()
+
+ expect(
+ listSessionsMock.mock.calls.length,
+ 'the panel never retried after SSH became ready'
+ ).toBeGreaterThan(callsWhileBroken)
+ expect(latest?.error).toBeNull()
+ })
+
+ it('does not rescan on a connection bump when the last listing succeeded', async () => {
+ // Gated on a prior error so a local workspace, or one that already listed fine, does not rescan
+ // every time some unrelated host connects.
+ listSessionsMock.mockResolvedValue(EMPTY_RESULT)
+ await renderHook(['/home/neil/projects/orca'])
+ await flushMicrotasks()
+
+ expect(latest?.error).toBeNull()
+ const callsWhileHealthy = listSessionsMock.mock.calls.length
+
+ await act(async () => {
+ useAppStore.setState({ sshConnectedGeneration: 1 })
+ })
+ await flushMicrotasks()
+
+ expect(listSessionsMock.mock.calls.length).toBe(callsWhileHealthy)
+ })
})
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
index d6bf0bb120c..0c64607853e 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
@@ -267,6 +267,27 @@ export function useAiVaultSessionRefresh(
void refresh({ force: false, reuseLoadedDepth: true })
}, [executionHostScope, refresh, scanScopeKey])
+ // Why: this panel can query the relay before it is ready — at startup, and again for the window
+ // in which a reconnect leaves the session not-ready — and the query throws 'SSH relay is not
+ // ready'. Nothing else here retries: the remaining triggers are mount, window refocus and a new
+ // agent session id, so a user whose workspace is otherwise working sits on that error
+ // indefinitely. The file explorer already recovers this way for the same reason
+ // (use-file-explorer-tree-load-effects.ts); this panel simply never did.
+ //
+ // Gated on a prior error so a local workspace, or one that already listed fine, does not rescan
+ // every time some other host connects.
+ const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
+ const sshGenerationRef = useRef(sshConnectedGeneration)
+ useEffect(() => {
+ if (sshConnectedGeneration <= sshGenerationRef.current) {
+ return
+ }
+ sshGenerationRef.current = sshConnectedGeneration
+ if (error !== null) {
+ void refresh({ background: true, force: false })
+ }
+ }, [sshConnectedGeneration, error, refresh])
+
// Refocus checks the shared host cache without forcing another transcript scan.
useEffect(() => {
const onRefocus = (): void => {
diff --git a/src/renderer/src/components/settings/SshTargetCard.test.tsx b/src/renderer/src/components/settings/SshTargetCard.test.tsx
new file mode 100644
index 00000000000..6bcdfd45a1e
--- /dev/null
+++ b/src/renderer/src/components/settings/SshTargetCard.test.tsx
@@ -0,0 +1,84 @@
+// @vitest-environment happy-dom
+
+import { act } from 'react'
+import { createRoot } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { SshTargetCard } from './SshTargetCard'
+import { TooltipProvider } from '../ui/tooltip'
+import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
+
+afterEach(() => {
+ document.body.innerHTML = ''
+})
+
+const target: SshTarget = {
+ id: 'target-1',
+ label: 'build-01',
+ host: 'build-01.internal',
+ port: 22,
+ username: 'deploy'
+}
+
+/** The real thing a host key mismatch produces: the remedy is the last clause. */
+const HOST_KEY_ERROR =
+ 'Host key verification failed for build-01.internal. The key does not match the entry in your known_hosts file. ssh and git will refuse this host too. Run: ssh-keygen -R build-01.internal'
+
+async function renderCard(state: SshConnectionState | undefined): Promise {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ await act(async () => {
+ createRoot(container).render(
+
+
+
+ )
+ })
+ return container
+}
+
+const errorState = (error: string): SshConnectionState => ({
+ targetId: 'target-1',
+ status: 'error',
+ error,
+ reconnectAttempt: 0,
+ supportsFolderDownload: false
+})
+
+describe('the connection error on an SSH target card', () => {
+ it('shows the whole message, remedy included', async () => {
+ const container = await renderCard(errorState(HOST_KEY_ERROR))
+
+ expect(container.textContent).toContain('ssh-keygen -R build-01.internal')
+ })
+
+ // This is the regression that made the careful wording pointless: `truncate` clamps to one line
+ // with an ellipsis and there is no title attribute, so the remedy was unreachable even on hover.
+ it('does not clamp it to a single line', async () => {
+ const container = await renderCard(errorState(HOST_KEY_ERROR))
+ const paragraph = [...container.querySelectorAll('p')].find((node) =>
+ node.textContent?.includes('ssh-keygen')
+ )
+
+ expect(paragraph).toBeDefined()
+ expect(paragraph?.className).not.toContain('truncate')
+ // A long hostname has no break opportunity, so wrapping alone would still overflow the column.
+ expect(paragraph?.className).toContain('[overflow-wrap:anywhere]')
+ })
+
+ it('renders nothing when there is no error', async () => {
+ const container = await renderCard(undefined)
+
+ expect(container.textContent).not.toContain('Host key verification failed')
+ })
+})
diff --git a/src/renderer/src/components/settings/SshTargetCard.tsx b/src/renderer/src/components/settings/SshTargetCard.tsx
index 6a99f0fe648..bf9043b68ec 100644
--- a/src/renderer/src/components/settings/SshTargetCard.tsx
+++ b/src/renderer/src/components/settings/SshTargetCard.tsx
@@ -301,8 +301,10 @@ export function SshTargetCard({
{target.identityFile ? ` \u2022 ${target.identityFile}` : ''}
{` \u2022 ${terminalPersistence}`}
+ {/* Why not truncate: host key failures put the remedy (`ssh-keygen -R `) at the end,
+ and a one-line clamp with no tooltip made it unreachable even on hover. */}
{state?.error ? (
- {state.error}
+ {state.error}
) : null}
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index ffcf0de83fd..e611e090321 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -348,6 +348,7 @@ function TerminalPane(
const {
nativeChatTranscriptIsLocalReadable,
sshReconnectEnvironmentId,
+ sshReconnectError,
sshReconnectStatus,
sshReconnectTargetId,
sshReconnectTargetLabel,
@@ -3045,6 +3046,7 @@ function TerminalPane(
targetId={sshReconnectTargetId}
targetLabel={sshReconnectTargetLabel}
status={sshReconnectStatus}
+ error={sshReconnectError}
targetRemoved={sshReconnectTargetRemoved}
worktreeId={worktreeId}
sshOwnerEnvironmentId={sshReconnectEnvironmentId}
diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx
index 2b7b438de87..c7386abd1b5 100644
--- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx
@@ -95,6 +95,55 @@ describe('TerminalSshReconnectOverlay', () => {
expect(connect).toHaveBeenCalledWith({ targetId: 'ssh-target-1' })
})
+ // The canned "Connect again to continue" sentence is the same for a timeout and for a refused
+ // host key. Only the detail says which, and for a host key it carries the only remedy the user
+ // will see anywhere in the terminal.
+ it('shows the failure detail beneath the status sentence', () => {
+ installSshConnect(vi.fn())
+
+ render(
+
+ )
+
+ // Both, not either: the sentence says what to do, the detail says what happened.
+ expect(screen.getByText(/The SSH connection to devbox failed/)).toBeInTheDocument()
+ expect(screen.getByText(/ssh-keygen -R devbox/)).toBeInTheDocument()
+ })
+
+ it('shows nothing extra when there is no detail', () => {
+ installSshConnect(vi.fn())
+
+ render(
+
+ )
+
+ expect(screen.getByText(/The SSH connection to devbox failed/)).toBeInTheDocument()
+ expect(screen.queryByText(/ssh-keygen/)).not.toBeInTheDocument()
+ })
+
+ // A removed target already explains itself and can never reconnect; a stale connection error
+ // underneath would contradict that.
+ it('suppresses the detail for a removed target', () => {
+ installSshConnect(vi.fn())
+
+ render(
+
+ )
+
+ expect(screen.queryByText(/Host key verification failed/)).not.toBeInTheDocument()
+ })
+
it('shows an in-flight state while the SSH target is reconnecting', () => {
const connect = vi.fn().mockResolvedValue(undefined)
installSshConnect(connect)
diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
index 1af99063216..dfdfc9e7630 100644
--- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
@@ -24,6 +24,10 @@ type TerminalSshReconnectOverlayProps = {
targetId: string
targetLabel: string
status: SshConnectionStatus
+ // The failure detail behind the status. Shown beneath the canned sentence rather than instead of
+ // it, because the sentence says what to do and this says what happened — a host key rejection
+ // names the remedy here and nowhere else in the terminal.
+ error?: string | null
// The SSH target was removed entirely — reconnect is impossible, so offer to
// remove the workspace instead of a Connect button that can only fail.
targetRemoved?: boolean
@@ -75,6 +79,7 @@ export function TerminalSshReconnectOverlay({
targetId,
targetLabel,
status,
+ error = null,
targetRemoved = false,
worktreeId,
sshOwnerEnvironmentId = null
@@ -186,6 +191,13 @@ export function TerminalSshReconnectOverlay({
)
: messageForStatus(status, targetLabel)}
+ {/* Why not truncated: a host key failure ends in `ssh-keygen -R `, and a removed
+ target already explains itself above. */}
+ {!targetRemoved && error ? (
+
+ {error}
+
+ ) : null}
{targetRemoved ? (
{
+ const pending = state.directSshPaneRetryByTabId?.[deps.tabId]
+ return (
+ pending?.authority.targetId === connectionId &&
+ pending.tabGeneration === (tab?.generation ?? 0)
+ )
+ })()
const pendingSpawnKey = directSshRetryAttempt
? JSON.stringify([cacheKey, directSshRetryAttempt.attemptId])
: cacheKey
@@ -8231,6 +8244,16 @@ export function connectPanePty(
const revealFollowsTerminalPark =
mountFollowsTerminalPark &&
(connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId))
+ // An SSH reconnect remounts the pane (tab.generation is its React key), so it also paints into
+ // a fresh xterm — but unlike a park it may only use the model for a FULL-SCREEN app. See
+ // sshReconnectPaintsFromModel for why.
+ //
+ // NOT consume-once, unlike mountFollowsTerminalPark: followsDirectSshReconnect is captured per
+ // connectPanePty and never cleared, so this re-arms if one connect reaches handleReattachResult
+ // twice. Bounded by connectStarted and by the emptiness/alt-screen gates rather than by the
+ // read itself. It still reads the PENDING retry rather than directSshRetryAttempt, which also
+ // accepts the live binding and so stays truthy for every later remount of the generation.
+ const reconnectMayUseModel = followsDirectSshReconnect && !revealFollowsTerminalPark
mountFollowsTerminalPark = false
// Why: ordinary parking destroys xterm. Rebuild from the authoritative
// host snapshot before releasing queued live bytes; null falls back to
@@ -8338,10 +8361,36 @@ export function connectPanePty(
// model still holds, but an in-place reattach (network reconnect, wake,
// reload) already has that replay in hand, so probing would only delay its
// paint by the timeout. Memoized, so this is never a second probe.
- const modelSnapshot = revealFollowsTerminalPark
+ // An SSH reconnect may use the model only under sshReconnectPaintsFromModel: the reconnect
+ // replay reaches the renderer without passing through main's model (forwardReattachReplay
+ // and the inline attach replay both bypass onPtyData), so the model is stale by exactly
+ // the outage and the replay is the only witness to it. A park has no such hole, so it
+ // keeps using the model either way.
+ const revealSnapshot = revealFollowsTerminalPark
? (prefetchedParkModelSnapshot ??
(isRemoteRuntimePtyId(ptyId) ? null : await fetchSshMainModelReattachSnapshot()))
: null
+ // Asked before the probe, not after: if the replay shows the app left the alternate
+ // screen, no snapshot can be used no matter what it says, so fetching one only spends the
+ // 750ms timeout to throw the answer away — and spends it inside the coordinator, with live
+ // PTY bytes deferred and the payload still able to be superseded.
+ const replayTransition = lastAlternateScreenTransition(connectResult?.replay)
+ const replayVetoesModel = replayTransition === 'exited'
+ const reconnectSnapshot =
+ !revealSnapshot && reconnectMayUseModel && !replayVetoesModel
+ ? await fetchSshMainModelReattachSnapshot()
+ : null
+ const paintsReconnectFromModel = sshReconnectPaintsFromModel({
+ snapshot: reconnectSnapshot,
+ hasReplay: Boolean(connectResult?.replay),
+ replayTransition,
+ altFrameWouldBeSkipped: shouldSkipAltFrameForWidthMismatch(
+ reconnectSnapshot?.cols,
+ readProposedTerminalCols(pane)
+ )
+ })
+ const modelSnapshot =
+ revealSnapshot ?? (paintsReconnectFromModel ? reconnectSnapshot : null)
if (!isCurrentReattachPayload()) {
return
}
@@ -8370,6 +8419,13 @@ export function connectPanePty(
kittyKeyboardFlags: modelSnapshot.kittyKeyboardFlags,
snapshotSeq: modelSnapshot.seq
})
+ if (paintsReconnectFromModel && connectResult?.replay) {
+ // Why after the snapshot: painting the model discards the replay, but the app's kitty
+ // pushes during the outage exist ONLY there. Snapshot first for the pre-outage
+ // baseline, then the replay layers the outage on top — otherwise Option/Alt chords
+ // encode against a stale flag stack.
+ kittyKeyboardModes.scanReplay(connectResult.replay)
+ }
// Why shared: park+reveal of an alt-screen TUI needs the same
// ?1049l/?1049h rebuild as applyMainBufferSnapshot (main strips
// the ?1049h marker when splitting scrollbackAnsi) — inlined here
diff --git a/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts
index c641332758c..0c2ae088401 100644
--- a/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts
+++ b/src/renderer/src/components/terminal-pane/ssh-reattach-model-restore.ts
@@ -61,7 +61,85 @@ export function decideSshReattachPaintSource(args: {
return contentLength === 0 ? 'relay-replay' : 'main-model-snapshot'
}
-/** Skip the snapshot fetch entirely when the paint could never use it. */
+const ESCAPE = String.fromCharCode(27)
+/** Matches a private-mode set/reset once the leading ESC has been split away. */
+const PRIVATE_MODE_SEQUENCE = /^\[\?([0-9;]*)([hl])/
+/** 47 and 1047 count alongside 1049: older apps still use them. */
+const ALTERNATE_SCREEN_MODES = new Set(['47', '1047', '1049'])
+
+/**
+ * The last alternate-screen transition in a relay replay, or null if it holds none.
+ *
+ * A reconnect replay is the ONLY record of what happened while the client was gone — main's model
+ * never saw those bytes — so it is the authority on which screen the app is on now, however stale
+ * the rest of it is. 47 and 1047 count alongside 1049: older apps still use them, and any of the
+ * three leaving reset means the frame is gone.
+ */
+export function lastAlternateScreenTransition(
+ replay: string | undefined
+): 'entered' | 'exited' | null {
+ if (!replay) {
+ return null
+ }
+ let transition: 'entered' | 'exited' | null = null
+ // Split on ESC rather than matching it. Every private-mode sequence begins immediately after one,
+ // so the two are equivalent — and no-control-regex forbids the escape inside a pattern, which is
+ // worth respecting rather than suppressing when the alternative reads no worse. A tail can begin
+ // mid-escape, so the first chunk is ordinary text and simply fails to match.
+ for (const chunk of replay.split(ESCAPE)) {
+ const match = PRIVATE_MODE_SEQUENCE.exec(chunk)
+ if (match?.[1].split(';').some((mode) => ALTERNATE_SCREEN_MODES.has(mode))) {
+ transition = match[2] === 'h' ? 'entered' : 'exited'
+ }
+ }
+ return transition
+}
+
+/**
+ * Whether an SSH RECONNECT paints main's model instead of the relay tail.
+ *
+ * Only for a full-screen app, and only when the replay does not contradict the model. A tail cannot
+ * rebuild a frame whose start it no longer contains, while a grid can; a scrolling shell is the
+ * opposite, since the tail holds outage output the model never saw and the grid would drop it.
+ *
+ * The `exited` veto is the case this gate exists to refuse: if the app left the alternate screen
+ * while the client was gone, the model still reports alternateScreen because it never consumed
+ * those bytes. Painting it would freeze a frame of an application that no longer exists AND discard
+ * the replay carrying the shell's real output — strictly worse than the fragments this fix targets.
+ */
+export function sshReconnectPaintsFromModel(args: {
+ snapshot: { alternateScreen?: boolean } | null
+ /**
+ * Taken already-computed rather than as the replay itself: the caller has to ask the same question
+ * earlier, to decide whether fetching a snapshot is worth its timeout at all. Scanning again here
+ * meant splitting up to 100 KB per pane twice on every reconnect. `hasReplay` cannot be inferred
+ * from the transition — a replay carrying no mode change and no replay at all are both null.
+ */
+ hasReplay: boolean
+ replayTransition: 'entered' | 'exited' | null
+ altFrameWouldBeSkipped: boolean
+}): boolean {
+ if (!args.snapshot?.alternateScreen) {
+ return false
+ }
+ if (!args.hasReplay) {
+ // Nothing to degrade to, so the vetoes below would only trade a stale frame for a blank one.
+ return true
+ }
+ // The alt frame is dropped for a width mismatch, leaving a cleared screen the app must repaint.
+ // A park could afford that with no tail to lose; here it would mean discarding a usable one.
+ return !args.altFrameWouldBeSkipped && args.replayTransition !== 'exited'
+}
+
+/**
+ * Skip the snapshot fetch entirely when the paint could never use it.
+ *
+ * DELIBERATE that terminalSshViewParking gates the reconnect repaint too, not just parking: it is
+ * the kill switch for painting an SSH pane from main's model at all, and a reconnect does exactly
+ * that from the same probe. Off means every SSH reattach degrades to the relay tail — the behavior
+ * that predates this machinery, which is what an escape hatch should restore. The cost is that a
+ * user who disables parking also loses the full-screen reconnect repaint.
+ */
export function shouldFetchSshReattachModelSnapshot(args: {
ptyId: string
sshParkingEnabled: boolean
diff --git a/src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts b/src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts
new file mode 100644
index 00000000000..8c7b97845c0
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/ssh-reconnect-model-paint-gate.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ lastAlternateScreenTransition,
+ sshReconnectPaintsFromModel
+} from './ssh-reattach-model-restore'
+
+/**
+ * The gate deciding whether an SSH RECONNECT repaints from main's grid or the relay byte tail.
+ *
+ * Worth testing directly rather than through a reattach: the inputs are a stale belief (the model's
+ * alternate-screen flag, which predates the outage) and the only witness to the outage (the replay).
+ * Every interesting case is a disagreement between those two, which is awkward to stage end-to-end
+ * and trivial to state here.
+ */
+const ALT_ON = '\x1b[?1049h'
+const ALT_OFF = '\x1b[?1049l'
+
+describe('lastAlternateScreenTransition', () => {
+ it('reports nothing for a replay with no screen change', () => {
+ expect(lastAlternateScreenTransition('plain output\r\n$ ')).toBeNull()
+ expect(lastAlternateScreenTransition('')).toBeNull()
+ expect(lastAlternateScreenTransition(undefined)).toBeNull()
+ })
+
+ it('reports the LAST transition, not the first', () => {
+ expect(lastAlternateScreenTransition(`${ALT_ON}frame${ALT_OFF}$ `)).toBe('exited')
+ expect(lastAlternateScreenTransition(`${ALT_OFF}$ vim\r\n${ALT_ON}frame`)).toBe('entered')
+ })
+
+ it('counts the legacy 47 and 1047 modes', () => {
+ // Older apps still use these, and any of the three leaving means the frame is gone.
+ expect(lastAlternateScreenTransition('\x1b[?47l')).toBe('exited')
+ expect(lastAlternateScreenTransition('\x1b[?1047h')).toBe('entered')
+ })
+
+ it('finds the mode inside a multi-parameter DECSET', () => {
+ expect(lastAlternateScreenTransition('\x1b[?1049;1006;2004h')).toBe('entered')
+ })
+
+ it('ignores unrelated private modes', () => {
+ // Bracketed paste and cursor visibility must not be read as screen changes.
+ expect(lastAlternateScreenTransition('\x1b[?2004h\x1b[?25l')).toBeNull()
+ // 10491 merely CONTAINS 1049 as a substring.
+ expect(lastAlternateScreenTransition('\x1b[?10491h')).toBeNull()
+ })
+
+ it('still scans a tail that begins mid-escape', () => {
+ // The relay tail is cut at a byte offset, so its first bytes are routinely a partial sequence.
+ expect(lastAlternateScreenTransition(`49;1006h garbage ${ALT_OFF}`)).toBe('exited')
+ })
+})
+
+describe('sshReconnectPaintsFromModel', () => {
+ // Takes the replay as a string and derives what the gate now wants, so these cases still read as
+ // "this is what the host sent" AND still run the real transition parser end to end.
+ const args = (
+ overrides: Partial[0], 'hasReplay'>> & {
+ replay?: string
+ } = {}
+ ) => {
+ // Key presence, not a default parameter: `replay: undefined` is a case under test — "no tail to
+ // degrade to" — and a default would silently turn it back into a replay.
+ const replay = 'replay' in overrides ? overrides.replay : 'some output'
+ return {
+ snapshot: { alternateScreen: true } as { alternateScreen?: boolean } | null,
+ altFrameWouldBeSkipped: false,
+ ...overrides,
+ hasReplay: Boolean(replay),
+ replayTransition: lastAlternateScreenTransition(replay)
+ }
+ }
+
+ it('paints a full-screen app from the grid', () => {
+ // The reported bug: a tail cannot rebuild a frame whose start it no longer contains.
+ expect(sshReconnectPaintsFromModel(args())).toBe(true)
+ })
+
+ it('leaves a scrolling shell on the tail', () => {
+ // Inverted trade: the tail holds outage output the model never saw, and the grid would drop it.
+ expect(sshReconnectPaintsFromModel(args({ snapshot: { alternateScreen: false } }))).toBe(false)
+ expect(sshReconnectPaintsFromModel(args({ snapshot: {} }))).toBe(false)
+ expect(sshReconnectPaintsFromModel(args({ snapshot: null }))).toBe(false)
+ })
+
+ it('refuses the grid when the app LEFT the alternate screen during the outage', () => {
+ // The model never consumed the outage, so it still claims alternateScreen. Painting it would
+ // freeze a frame of an application that no longer exists and discard the shell's real output.
+ expect(
+ sshReconnectPaintsFromModel(args({ replay: `${ALT_OFF}\r\n$ echo done\r\ndone\r\n$ ` }))
+ ).toBe(false)
+ })
+
+ it('keeps the grid when the app re-entered the alternate screen after leaving', () => {
+ expect(sshReconnectPaintsFromModel(args({ replay: `${ALT_OFF}$ vim\r\n${ALT_ON}frame` }))).toBe(
+ true
+ )
+ })
+
+ it('keeps the grid when the replay says nothing about the screen', () => {
+ // The common case: an idle TUI whose tail carries only redraws.
+ expect(sshReconnectPaintsFromModel(args({ replay: 'redraw redraw' }))).toBe(true)
+ })
+
+ it('degrades to the tail when the alt frame would be dropped for a width mismatch', () => {
+ // That guard leaves only a cleared screen for the app to repaint. A park can afford it with no
+ // tail to lose; here it would mean having discarded a usable one for a blank pane.
+ expect(sshReconnectPaintsFromModel(args({ altFrameWouldBeSkipped: true }))).toBe(false)
+ })
+
+ it('still paints the grid when there is no tail to degrade to', () => {
+ // Both vetoes exist to protect a usable replay. With none, they would only trade a stale frame
+ // for a blank one.
+ expect(
+ sshReconnectPaintsFromModel(args({ replay: undefined, altFrameWouldBeSkipped: true }))
+ ).toBe(true)
+ expect(sshReconnectPaintsFromModel(args({ replay: '', altFrameWouldBeSkipped: true }))).toBe(
+ true
+ )
+ })
+
+ it('refuses a normal-buffer model even with no tail', () => {
+ expect(
+ sshReconnectPaintsFromModel(args({ snapshot: { alternateScreen: false }, replay: '' }))
+ ).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts
index 90825f42980..c9614690e4b 100644
--- a/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts
@@ -32,6 +32,7 @@ describe('selectTerminalPaneHostState', () => {
expect(selectTerminalPaneHostState(localState, 'wt-local')).toEqual({
nativeChatTranscriptIsLocalReadable: true,
sshReconnectEnvironmentId: null,
+ sshReconnectError: null,
sshReconnectStatus: null,
sshReconnectTargetId: null,
sshReconnectTargetLabel: '',
@@ -52,6 +53,7 @@ describe('selectTerminalPaneHostState', () => {
expect(selectTerminalPaneHostState(sshState, 'wt-ssh')).toEqual({
nativeChatTranscriptIsLocalReadable: false,
sshReconnectEnvironmentId: null,
+ sshReconnectError: null,
sshReconnectStatus: 'connected',
sshReconnectTargetId: 'ssh-a',
sshReconnectTargetLabel: 'devbox',
@@ -107,6 +109,7 @@ describe('selectTerminalPaneHostState', () => {
expect(selectTerminalPaneHostState(state, 'wt-runtime')).toEqual({
nativeChatTranscriptIsLocalReadable: false,
sshReconnectEnvironmentId: 'env-a',
+ sshReconnectError: null,
sshReconnectStatus: 'disconnected',
sshReconnectTargetId: 'ssh-nested',
sshReconnectTargetLabel: 'build box',
@@ -114,6 +117,32 @@ describe('selectTerminalPaneHostState', () => {
})
})
+ // Without this the terminal overlay shows only a canned sentence, so a host key rejection — whose
+ // remedy is the last clause of the message — is invisible to anyone working in a terminal.
+ it('carries the failure detail alongside the status', () => {
+ const state = makeState({
+ repos: [{ id: 'repo-ssh', connectionId: 'ssh-a' }],
+ sshConnectionStates: new Map([
+ [
+ 'ssh-a',
+ {
+ targetId: 'ssh-a',
+ status: 'error',
+ error: 'Host key verification failed for devbox. Run: ssh-keygen -R devbox',
+ reconnectAttempt: 0
+ }
+ ]
+ ]),
+ sshTargetLabels: new Map([['ssh-a', 'devbox']]),
+ worktreesByRepo: { 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' }] }
+ })
+
+ const host = selectTerminalPaneHostState(state, 'wt-ssh')
+
+ expect(host.sshReconnectStatus).toBe('error')
+ expect(host.sshReconnectError).toContain('ssh-keygen -R devbox')
+ })
+
it('keeps runtime-owned SSH plumbing out of reconnect UI', () => {
const state = makeState({
repos: [{ id: 'repo-ephemeral', connectionId: 'runtime-ssh-vm-a' }],
@@ -125,6 +154,7 @@ describe('selectTerminalPaneHostState', () => {
expect(selectTerminalPaneHostState(state, 'wt-ephemeral')).toEqual({
nativeChatTranscriptIsLocalReadable: true,
sshReconnectEnvironmentId: null,
+ sshReconnectError: null,
sshReconnectStatus: null,
sshReconnectTargetId: null,
sshReconnectTargetLabel: '',
diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts
index 17a7e1a354c..0f42f83bf74 100644
--- a/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts
@@ -3,6 +3,7 @@ import { getConnectionIdFromState } from '@/lib/connection-context'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import {
+ selectRuntimeAwareSshError,
selectRuntimeAwareSshStatus,
selectRuntimeAwareSshTargetLabel,
selectRuntimeAwareSshTargetRemoved
@@ -12,6 +13,8 @@ import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host'
export type TerminalPaneHostState = {
nativeChatTranscriptIsLocalReadable: boolean
sshReconnectEnvironmentId: string | null
+ /** The failure detail behind the status; the overlay shows only a canned sentence without it. */
+ sshReconnectError: string | null
sshReconnectStatus: ReturnType
sshReconnectTargetId: string | null
sshReconnectTargetLabel: string
@@ -31,6 +34,7 @@ export function selectTerminalPaneHostState(
return {
nativeChatTranscriptIsLocalReadable: nativeChatTranscriptIsLocalReadableResult,
sshReconnectEnvironmentId: null,
+ sshReconnectError: null,
sshReconnectStatus: null,
sshReconnectTargetId: null,
sshReconnectTargetLabel: '',
@@ -41,6 +45,11 @@ export function selectTerminalPaneHostState(
return {
nativeChatTranscriptIsLocalReadable: nativeChatTranscriptIsLocalReadableResult,
sshReconnectEnvironmentId,
+ sshReconnectError: selectRuntimeAwareSshError(
+ state,
+ sshReconnectEnvironmentId,
+ sshReconnectTargetId
+ ),
sshReconnectStatus: selectRuntimeAwareSshStatus(
state,
sshReconnectEnvironmentId,
diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts
new file mode 100644
index 00000000000..698c1841229
--- /dev/null
+++ b/src/renderer/src/hooks/remote-workspace-session-merge-local-survival.test.ts
@@ -0,0 +1,340 @@
+import { describe, expect, it } from 'vitest'
+
+import { mergeDirectSshRemoteWorkspaceSession } from './remote-workspace-session-merge'
+import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
+import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
+import type { TerminalTab } from '../../../shared/terminal-tab-types'
+import type { AppState } from '../store/types'
+
+/**
+ * What a reconnect is allowed to do to local state.
+ *
+ * All three cases here were reported from one 60-second manual test: reconnect an SSH workspace and
+ * you land on the home screen, a tab that was running `pnpm install` is gone, and the agent list
+ * shows the same agent twice. They share a cause — the host snapshot is treated as the whole truth
+ * for the reconnecting target, so anything local that the host has not heard about yet is not
+ * merged but erased.
+ *
+ * The host is authoritative for what it knows. It is not authoritative for what it has never been
+ * told, and a snapshot that predates a local change must not be able to delete that change.
+ */
+const WORKTREE = 'repo-1::/home/user/bug-cats'
+const OTHER_WORKTREE = 'repo-1::/home/user/other'
+
+function terminalTab(id: string, overrides: Partial = {}): TerminalTab {
+ return {
+ id,
+ title: id,
+ type: 'terminal',
+ worktreeId: WORKTREE,
+ ...overrides
+ } as TerminalTab
+}
+
+function sessionState(overrides: Partial = {}): WorkspaceSessionState {
+ return {
+ activeRepoId: 'repo-1',
+ activeWorktreeId: WORKTREE,
+ activeWorkspaceKey: worktreeWorkspaceKey(WORKTREE),
+ activeTabId: 'agent',
+ tabsByWorktree: {},
+ terminalLayoutsByTabId: {},
+ activeTabIdByWorktree: {},
+ ...overrides
+ } as WorkspaceSessionState
+}
+
+function merge(
+ current: WorkspaceSessionState,
+ remote: WorkspaceSessionState,
+ liveTabs: AppState['tabsByWorktree'] = {}
+): WorkspaceSessionState {
+ return mergeDirectSshRemoteWorkspaceSession(
+ current,
+ remote,
+ new Set([WORKTREE]),
+ liveTabs,
+ new Set()
+ )
+}
+
+describe('direct-SSH reconnect merge: local state the host has not seen', () => {
+ it('keeps a local tab the host snapshot does not contain', () => {
+ // The reported "setup" tab: created locally, running pnpm install, never uploaded because the
+ // write was suppressed. The host lists only the agent tab.
+ const setup = terminalTab('setup')
+ const agent = terminalTab('agent')
+ const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent, setup] } })
+ const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } })
+
+ const merged = merge(current, remote, { [WORKTREE]: [agent, setup] })
+
+ expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toContain('setup')
+ })
+
+ it('drops a tab closed locally rather than resurrecting it from the snapshot', () => {
+ // The other side of the coin. Closing a tab removes it from local state, so it is absent from
+ // BOTH sides — and the preserve must not reach into the stale payload and bring it back.
+ const agent = terminalTab('agent')
+ const closed = terminalTab('closed')
+ const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } })
+ const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent, closed] } })
+
+ // Live state is the truth about what is open locally: the user closed `closed`.
+ const merged = merge(current, remote, { [WORKTREE]: [agent] })
+
+ // The host still lists it, so it survives here — the host is authoritative for what it knows.
+ // What matters is that the preserve branch invents nothing: the ids come from the two inputs.
+ for (const tab of merged.tabsByWorktree[WORKTREE]) {
+ expect(['agent', 'closed']).toContain(tab.id)
+ }
+ })
+
+ it('keeps a tab another client closed, which is the accepted cost of the rule', () => {
+ // Pinned because it is a deliberate trade, not an oversight. Absence in the snapshot cannot
+ // distinguish "never uploaded" from "closed on another client sharing this host", and the two
+ // outcomes are not symmetric: keeping a tab a moment too long is recoverable, deleting a live
+ // one is not. Closing on THIS client removes it from local state, so the common case never
+ // reaches here.
+ const agent = terminalTab('agent')
+ const closedElsewhere = terminalTab('closed-elsewhere')
+ const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent, closedElsewhere] } })
+ const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } })
+
+ const merged = merge(current, remote, { [WORKTREE]: [agent, closedElsewhere] })
+
+ expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toContain('closed-elsewhere')
+ })
+
+ it('invents no tab that is in neither the host snapshot nor local state', () => {
+ const agent = terminalTab('agent')
+ const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } })
+ const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } })
+
+ const merged = merge(current, remote, { [WORKTREE]: [agent] })
+
+ expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['agent'])
+ })
+
+ it('keeps local tabs when the host snapshot has no entry for the worktree at all', () => {
+ // A first-revision snapshot can omit the worktree entirely. Iterating only the host's keys used
+ // to leave the worktree with no tabs whatsoever.
+ const agent = terminalTab('agent')
+ const setup = terminalTab('setup')
+ const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent, setup] } })
+ const remote = sessionState({ tabsByWorktree: {} })
+
+ const merged = merge(current, remote, { [WORKTREE]: [agent, setup] })
+
+ expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['agent', 'setup'])
+ })
+
+ it('never duplicates a tab that exists on both sides', () => {
+ // The reported double agent entry: one launch must never merge into two rows.
+ const agent = terminalTab('agent')
+ const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } })
+ const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } })
+
+ const merged = merge(current, remote, { [WORKTREE]: [agent] })
+
+ const ids = merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)
+ expect(ids).toEqual([...new Set(ids)])
+ expect(ids.filter((id) => id === 'agent')).toHaveLength(1)
+ })
+
+ it('does not show one agent twice when the host re-lists its session under a new tab id', () => {
+ // The reported duplicate grok. One launch, but the host carries the session under a new tab id
+ // while the local tab still exists under the old one. Preserving both would put the same agent
+ // on screen twice, which is the failure this guard exists for.
+ const localGrok = terminalTab('grok-old')
+ const hostGrok = terminalTab('grok-new')
+ const current = sessionState({
+ tabsByWorktree: { [WORKTREE]: [localGrok] },
+ remoteSessionIdsByTabId: { 'grok-old': 'session-grok' }
+ })
+ const remote = sessionState({
+ tabsByWorktree: { [WORKTREE]: [hostGrok] },
+ remoteSessionIdsByTabId: { 'grok-new': 'session-grok' }
+ })
+
+ const merged = merge(current, remote, { [WORKTREE]: [localGrok] })
+
+ expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['grok-new'])
+ })
+
+ it('still keeps a host-unknown tab that carries no session id', () => {
+ // The session guard must not swallow the setup tab, which has no agent session at all.
+ const setup = terminalTab('setup')
+ const agent = terminalTab('agent')
+ const current = sessionState({
+ tabsByWorktree: { [WORKTREE]: [agent, setup] },
+ remoteSessionIdsByTabId: { agent: 'session-grok' }
+ })
+ const remote = sessionState({
+ tabsByWorktree: { [WORKTREE]: [agent] },
+ remoteSessionIdsByTabId: { agent: 'session-grok' }
+ })
+
+ const merged = merge(current, remote, { [WORKTREE]: [agent, setup] })
+
+ expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['agent', 'setup'])
+ })
+
+ it('keeps the user on their workspace when the host snapshot has no active worktree', () => {
+ // The reported home screen. The host not knowing which workspace is active is not evidence that
+ // none is; nulling it drops the user out of the workspace they are looking at.
+ const current = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } })
+ const remote = sessionState({
+ activeWorktreeId: null,
+ activeWorkspaceKey: null,
+ activeRepoId: null,
+ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] }
+ })
+
+ const merged = merge(current, remote, { [WORKTREE]: [terminalTab('agent')] })
+
+ expect(merged.activeWorktreeId).toBe(WORKTREE)
+ expect(merged.activeWorkspaceKey).toBe(worktreeWorkspaceKey(WORKTREE))
+ })
+
+ it('keeps repo and worktree describing the same workspace when the host names only a repo', () => {
+ // The pair used to split exactly here: the host naming no worktree is precisely when it can
+ // still name a repo, so activeRepoId followed the host while activeWorktreeId stayed local.
+ const current = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } })
+ const remote = sessionState({
+ activeWorktreeId: null,
+ activeWorkspaceKey: null,
+ activeRepoId: 'some-other-repo',
+ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] }
+ })
+
+ const merged = merge(current, remote, { [WORKTREE]: [terminalTab('agent')] })
+
+ expect(merged.activeWorktreeId).toBe(WORKTREE)
+ expect(merged.activeRepoId, 'repo and worktree describe different workspaces').toBe('repo-1')
+ expect(merged.activeWorkspaceKey).toBe(worktreeWorkspaceKey(WORKTREE))
+ })
+
+ it('takes the host repo when it does name a worktree', () => {
+ const current = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } })
+ const remote = sessionState({
+ activeWorktreeId: OTHER_WORKTREE,
+ activeRepoId: 'remote-repo',
+ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] }
+ })
+
+ const merged = merge(current, remote, { [WORKTREE]: [terminalTab('agent')] })
+
+ expect(merged.activeWorktreeId).toBe(OTHER_WORKTREE)
+ expect(merged.activeRepoId).toBe('remote-repo')
+ })
+
+ it('still follows the host when it does name an active worktree', () => {
+ // The preserve above must not freeze the pointer: a host that knows is still authoritative.
+ const current = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] } })
+ const remote = sessionState({
+ activeWorktreeId: OTHER_WORKTREE,
+ activeWorkspaceKey: worktreeWorkspaceKey(OTHER_WORKTREE),
+ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] }
+ })
+
+ const merged = merge(current, remote, { [WORKTREE]: [terminalTab('agent')] })
+
+ expect(merged.activeWorktreeId).toBe(OTHER_WORKTREE)
+ })
+
+ it('leaves a workspace outside the reconnecting target untouched', () => {
+ // Scope guard: reconnecting one host must not move the user out of an unrelated workspace.
+ const current = sessionState({
+ activeWorktreeId: OTHER_WORKTREE,
+ activeWorkspaceKey: worktreeWorkspaceKey(OTHER_WORKTREE),
+ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] }
+ })
+ const remote = sessionState({
+ activeWorktreeId: null,
+ activeWorkspaceKey: null,
+ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] }
+ })
+
+ const merged = merge(current, remote, { [WORKTREE]: [terminalTab('agent')] })
+
+ expect(merged.activeWorktreeId).toBe(OTHER_WORKTREE)
+ })
+
+ it('keeps the local active tab for the target when the host names none', () => {
+ // Same failure one level down: losing the per-worktree active tab reopens the workspace on
+ // whatever happens to be first.
+ const current = sessionState({
+ tabsByWorktree: { [WORKTREE]: [terminalTab('agent'), terminalTab('setup')] },
+ activeTabIdByWorktree: { [WORKTREE]: 'setup' }
+ })
+ const remote = sessionState({
+ tabsByWorktree: { [WORKTREE]: [terminalTab('agent')] },
+ activeTabIdByWorktree: {}
+ })
+
+ const merged = merge(current, remote, {
+ [WORKTREE]: [terminalTab('agent'), terminalTab('setup')]
+ })
+
+ expect(merged.activeTabIdByWorktree?.[WORKTREE]).toBe('setup')
+ })
+})
+
+describe('a tab id local state already holds under two worktrees', () => {
+ it('is emitted once, not preserved into both', () => {
+ // Local-local duplication, which the host-unknown branch did not exclude: it filtered against
+ // ids the HOST knows, never against ids this same merge had already emitted. Two panes then
+ // share one entry in terminalLayoutsByTabId and one in remoteSessionIdsByTabId — so one remote
+ // PTY — and activeTabId can never converge, which strands the active-terminal repair in the
+ // self-retriggering loop active-tab-owner-worktree.ts exists to mitigate (React #185).
+ //
+ // The merge does not create this state. It used to DESTROY it, by deleting every local tab under
+ // a replaced worktree; keeping live panes cost that accidental cure, so it is made explicit.
+ const current = sessionState({
+ tabsByWorktree: {
+ [WORKTREE]: [terminalTab('setup')],
+ [OTHER_WORKTREE]: [terminalTab('setup', { worktreeId: OTHER_WORKTREE })]
+ }
+ })
+ const remote = sessionState({ activeWorktreeId: null, tabsByWorktree: {} })
+
+ const merged = mergeDirectSshRemoteWorkspaceSession(
+ current,
+ remote,
+ new Set([WORKTREE, OTHER_WORKTREE]),
+ current.tabsByWorktree,
+ new Set()
+ )
+
+ const owners = Object.entries(merged.tabsByWorktree)
+ .filter(([, tabs]) => tabs.some((tab) => tab.id === 'setup'))
+ .map(([worktreeId]) => worktreeId)
+ expect(owners, 'one tab id survived under two worktrees').toHaveLength(1)
+ // The survivor is the worktree the user is standing in, which is the owner
+ // resolveActiveTabOwnerWorktreeId prefers — so the merge and the repair agree.
+ expect(owners[0]).toBe(WORKTREE)
+ })
+
+ it('still keeps a host-unknown tab in a worktree that shares no ids', () => {
+ // Guards the obvious over-correction: deduping by id must not swallow distinct tabs.
+ const current = sessionState({
+ tabsByWorktree: {
+ [WORKTREE]: [terminalTab('setup')],
+ [OTHER_WORKTREE]: [terminalTab('build', { worktreeId: OTHER_WORKTREE })]
+ }
+ })
+ const remote = sessionState({ activeWorktreeId: null, tabsByWorktree: {} })
+
+ const merged = mergeDirectSshRemoteWorkspaceSession(
+ current,
+ remote,
+ new Set([WORKTREE, OTHER_WORKTREE]),
+ current.tabsByWorktree,
+ new Set()
+ )
+
+ expect(merged.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['setup'])
+ expect(merged.tabsByWorktree[OTHER_WORKTREE]?.map((tab) => tab.id)).toEqual(['build'])
+ })
+})
diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts
index c5a31073184..4a2b087817f 100644
--- a/src/renderer/src/hooks/remote-workspace-session-merge.ts
+++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts
@@ -28,10 +28,65 @@ export function mergeDirectSshRemoteWorkspaceSession(
.map((tab) => [tab.id, tab])
)
const locallyPreservedTabIds = new Set()
+ const localTabsFor = (worktreeId: string): TerminalTab[] =>
+ liveTabsByWorktree[worktreeId] ?? current.tabsByWorktree[worktreeId] ?? []
+ // Why the union and not just the remote keys: a host snapshot that has never been told about this
+ // worktree carries no entry for it at all, and iterating only its keys would drop every local tab
+ // through the omit below.
+ const mergedWorktreeIds = new Set([
+ ...Object.keys(remote.tabsByWorktree),
+ ...[...replaceWorktreeIds].filter((worktreeId) => localTabsFor(worktreeId).length > 0)
+ ])
+ // The active worktree is walked FIRST so that when one tab id is held locally under two of them,
+ // the copy that survives below is the one the user is looking at. That matches what
+ // resolveActiveTabOwnerWorktreeId already prefers when it has to pick an owner, so the merge and
+ // the repair agree instead of each choosing differently.
+ const orderedWorktreeIds = [...mergedWorktreeIds].sort(
+ (a, b) => Number(b === current.activeWorktreeId) - Number(a === current.activeWorktreeId)
+ )
+ // Every id already emitted by an earlier worktree in THIS merge. Without it the host-unknown
+ // branch below only excludes ids the host knows, so a tab id that local state already holds under
+ // two worktrees is re-added under both — two panes sharing one entry in terminalLayoutsByTabId and
+ // one in remoteSessionIdsByTabId, which means one remote PTY, plus the unconvergeable activeTabId
+ // that active-tab-owner-worktree.ts exists to mitigate (React #185).
+ //
+ // The merge does not create that state; it used to destroy it, by deleting every local tab under a
+ // replaced worktree. Keeping live panes cost that accidental cure, so the guarantee is restored for
+ // exactly the branch below: a LOCALLY-HELD tab is never re-added under a worktree this walk has
+ // already emitted it in.
+ //
+ // Deliberately not phrased as "no id twice". The set is consulted only by the host-unknown filter;
+ // `reconciled` maps the host's own list verbatim, so a snapshot that lists one tab id under two
+ // worktrees still propagates both. That is pre-existing and unchanged — the old code mapped those
+ // same entries — and it is the host's own contradiction rather than one this function introduces.
+ //
+ // Deliberately not stronger than that. A worktree that is neither replaced nor named by the host is
+ // never walked — its tabs pass through from `current` verbatim — so a duplicate straddling that
+ // boundary still survives, and the only place to catch it is after both halves are assembled.
+ // Doing it there was tried and REVERTED: the tie-break has to pick a survivor, and every rule
+ // available at that point is wrong during a worktree-id change, which is the very thing that
+ // produces these duplicates. Preferring the active worktree keeps the OLD id's copy at the moment a
+ // rename lands, because the active worktree has not moved yet — which left the new worktree with no
+ // tabs, so its groups were never created, and remote-workspace-snapshot-duplicate-tab-repair.test
+ // .ts caught it. A duplicate is survivable and already mitigated by active-tab-owner-worktree.ts;
+ // deleting the tabs of the worktree the user is about to land in is not.
+ const emittedTabIds = new Set()
+ const remoteKnownTabIds = new Set(
+ Object.values(remote.tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id))
+ )
+ // Why sessions and not just tab ids: the host can carry the same agent session under a NEW tab id,
+ // and keeping the local tab as well would put one launched agent on the screen twice. The session
+ // id is the identity that survives a tab-id change, so a session the host already lists means the
+ // local tab is a stale alias rather than something the host has never seen.
+ const remoteKnownSessionIds = new Set(
+ Object.entries(remote.remoteSessionIdsByTabId ?? {})
+ .filter(([tabId]) => remoteKnownTabIds.has(tabId))
+ .map(([, sessionId]) => sessionId)
+ )
const tabsByWorktree = Object.fromEntries(
- Object.entries(remote.tabsByWorktree).map(([worktreeId, tabs]) => [
- worktreeId,
- tabs.map((tab) => {
+ orderedWorktreeIds.map((worktreeId) => {
+ const remoteTabs = remote.tabsByWorktree[worktreeId] ?? []
+ const reconciled = remoteTabs.map((tab) => {
const local = currentTabsById.get(tab.id)
if (
!local ||
@@ -44,7 +99,39 @@ export function mergeDirectSshRemoteWorkspaceSession(
locallyPreservedTabIds.add(tab.id)
return preserveNewerLocalTerminalFields(tab, local)
})
- ])
+ for (const tab of reconciled) {
+ emittedTabIds.add(tab.id)
+ }
+ if (!replaceWorktreeIds.has(worktreeId)) {
+ return [worktreeId, reconciled]
+ }
+ // Why: the host is authoritative for what it knows, not for what it has never been told. A tab
+ // created locally whose upload was still pending is absent from the snapshot for the same
+ // reason it is new — deleting it here loses a live pane and the process running in it.
+ //
+ // The trade this accepts: absence cannot distinguish "never uploaded" from "closed by another
+ // client on this host", so a tab closed elsewhere survives here until that client's snapshot
+ // is applied. Closing on THIS client removes it from local state, so the common case is
+ // unaffected. Keeping a tab a moment too long is recoverable — the user closes it — while
+ // deleting a live one is not, and that is the failure this branch exists to prevent.
+ // Why the id is checked against EVERY worktree and not just this one: the same tab id can be
+ // owned by two worktrees while a rename settles, and re-adding it here would keep recreating
+ // the duplicate the repair pass is trying to converge. A host that knows the id anywhere has
+ // been told about the tab, so it is not host-unknown.
+ const hostUnknown = localTabsFor(worktreeId).filter((tab) => {
+ if (remoteKnownTabIds.has(tab.id) || emittedTabIds.has(tab.id)) {
+ return false
+ }
+ const localSessionId = current.remoteSessionIdsByTabId?.[tab.id]
+ return localSessionId == null || !remoteKnownSessionIds.has(localSessionId)
+ })
+ for (const tab of hostUnknown) {
+ // Keeps the tab's layout and remote session id from being swept with the replaced ids below.
+ locallyPreservedTabIds.add(tab.id)
+ emittedTabIds.add(tab.id)
+ }
+ return [worktreeId, [...reconciled, ...hostUnknown]]
+ })
)
const remoteTabIds = new Set(
Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id))
@@ -73,14 +160,36 @@ export function mergeDirectSshRemoteWorkspaceSession(
}
const activeOutsideTarget =
current.activeWorktreeId != null && !replaceWorktreeIds.has(current.activeWorktreeId)
+ // Why this is narrow: a null from the host is not always missing information. A null activeTabId
+ // is a deliberate deselect that arms the duplicate-tab repair, so it is honoured verbatim below.
+ // A null activeWorktreeId is different — it is what a snapshot carries when the host never named
+ // the workspace or its path did not resolve to a local id, and taking that literally drops the
+ // user onto the home screen while their terminals keep running. So it is only overridden when the
+ // workspace they are standing in demonstrably still exists in the merged result.
+ const localActiveWorkspaceSurvives =
+ current.activeWorktreeId != null &&
+ replaceWorktreeIds.has(current.activeWorktreeId) &&
+ (tabsByWorktree[current.activeWorktreeId]?.length ?? 0) > 0
+ const preservedActiveWorktreeId = localActiveWorkspaceSurvives ? current.activeWorktreeId : null
+ // The three active-* fields have to describe ONE workspace, so they are all derived from whichever
+ // worktree wins rather than each choosing a source. Taking the repo from the host while the
+ // worktree came from local state left the pair disagreeing — and precisely in the case the
+ // preservation exists for, since "the host named no worktree" is exactly when it can still name a
+ // repo.
+ const keepsLocalWorkspace =
+ !activeOutsideTarget && remote.activeWorktreeId == null && preservedActiveWorktreeId != null
+ const activeWorktreeId = activeOutsideTarget
+ ? current.activeWorktreeId
+ : (remote.activeWorktreeId ?? preservedActiveWorktreeId)
return {
...current,
- activeRepoId: activeOutsideTarget ? current.activeRepoId : remote.activeRepoId,
- activeWorktreeId: activeOutsideTarget ? current.activeWorktreeId : remote.activeWorktreeId,
+ activeRepoId:
+ activeOutsideTarget || keepsLocalWorkspace ? current.activeRepoId : remote.activeRepoId,
+ activeWorktreeId,
activeWorkspaceKey: activeOutsideTarget
? current.activeWorkspaceKey
- : remote.activeWorktreeId
- ? worktreeWorkspaceKey(remote.activeWorktreeId)
+ : activeWorktreeId
+ ? worktreeWorkspaceKey(activeWorktreeId)
: null,
activeTabId: activeOutsideTarget ? current.activeTabId : remote.activeTabId,
tabsByWorktree: {
@@ -93,7 +202,15 @@ export function mergeDirectSshRemoteWorkspaceSession(
...(remote.activeWorktreeIdsOnShutdown ?? [])
],
activeTabIdByWorktree: {
+ // Why the local entry survives: same rule one level down. A host that names no active tab for
+ // the target reopens the workspace on whatever sorts first, which is not where the user was.
...omitTargetWorktrees(current.activeTabIdByWorktree),
+ ...Object.fromEntries(
+ [...replaceWorktreeIds].flatMap((worktreeId) => {
+ const localActiveTabId = current.activeTabIdByWorktree?.[worktreeId]
+ return localActiveTabId == null ? [] : [[worktreeId, localActiveTabId] as const]
+ })
+ ),
...remote.activeTabIdByWorktree
},
remoteSessionIdsByTabId: {
diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts
new file mode 100644
index 00000000000..77daf42ce37
--- /dev/null
+++ b/src/renderer/src/hooks/remote-workspace-snapshot-local-tab-survival.test.ts
@@ -0,0 +1,209 @@
+/**
+ * Reconnecting an SSH workspace must not delete local state the host has not been told about.
+ *
+ * Reported from a 60-second manual test: reconnect, and a second tab that was running `pnpm install`
+ * is gone while the app drops to the home screen. Both come from the same place — the host snapshot
+ * is applied as the whole truth for the reconnecting target, so a tab created locally but not yet
+ * uploaded has no branch that keeps it, and a snapshot that names no active worktree nulls the one
+ * the user is standing in.
+ *
+ * This drives the real apply path rather than the merge function alone, and it is deterministic: the
+ * end-to-end version of the same scenario only reproduces about one run in three, because the bug
+ * needs the tab to be created inside the debounced upload's suppression window.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import type * as AgentStatusModule from '@/lib/agent-status'
+import type { RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types'
+import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types'
+import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers'
+import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply'
+import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types'
+
+vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/lib/agent-status', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
+})
+
+const TARGET_ID = 'ssh-target-1'
+const PATH = '/srv/proj/bug-cats'
+const WORKTREE_ID = `repoA::${PATH}`
+
+const authority: DirectSshAuthority = {
+ targetId: TARGET_ID,
+ providerEpoch: 'provider-epoch-1' as SshProviderEpoch,
+ connectionGeneration: 1
+}
+
+function token(snapshotRevision: number): DirectSshSnapshotApplyToken {
+ return {
+ authority,
+ catalogRevision: 0,
+ repoFingerprint: 'fp',
+ authorityRequirement: 'required',
+ snapshotRevision,
+ outcome: 'complete'
+ }
+}
+
+function snapshot(
+ revision: number,
+ tabIds: readonly string[],
+ options: { activeWorktreePath?: string | null } = {}
+): RemoteWorkspaceSnapshot {
+ const activeWorktreePath =
+ options.activeWorktreePath === undefined ? PATH : options.activeWorktreePath
+ return {
+ namespace: 'workspace',
+ revision,
+ updatedAt: revision,
+ schemaVersion: 1,
+ session: {
+ activeWorktreePath,
+ activeTabId: tabIds[0] ?? null,
+ tabsByWorktreePath: {
+ [PATH]: tabIds.map((tabId, index) => ({
+ id: tabId,
+ worktreePath: PATH,
+ ptyId: `pty-${tabId}`,
+ title: `Terminal ${index + 1}`,
+ customTitle: null,
+ color: null,
+ sortOrder: index,
+ createdAt: index + 1
+ }))
+ },
+ terminalLayoutsByTabId: {},
+ activeWorktreePathsOnShutdown: [],
+ activeTabIdByWorktreePath: { [PATH]: tabIds[0] ?? null },
+ remoteSessionIdsByTabId: Object.fromEntries(tabIds.map((id) => [id, `pty-${id}`])),
+ lastVisitedAtByWorktreePath: { [PATH]: revision },
+ defaultTerminalTabsAppliedByWorktreePath: { [PATH]: true }
+ }
+ } satisfies RemoteWorkspaceSnapshot
+}
+
+type TestStore = ReturnType
+
+async function applySnapshot(store: TestStore, snap: RemoteWorkspaceSnapshot): Promise {
+ await applyDirectSshRemoteWorkspaceSnapshot({
+ store,
+ snapshot: snap,
+ token: token(snap.revision),
+ arrival: 1,
+ isArrivalCurrent: () => true,
+ isPreparationTokenCurrent: () => true,
+ waitForWorkspaceSessionReady: async () => true,
+ finalizeHydratedTerminals: () => 0
+ })
+}
+
+function seedCatalog(store: TestStore): void {
+ store.setState({
+ worktreesByRepo: {
+ repoA: [
+ makeWorktree({
+ id: WORKTREE_ID,
+ repoId: 'repoA',
+ path: PATH,
+ hostId: `ssh:${TARGET_ID}`
+ } as never)
+ ]
+ },
+ repos: [
+ {
+ id: 'repoA',
+ path: '/srv/proj',
+ displayName: 'Proj',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: TARGET_ID
+ } as never
+ ],
+ reconnectPersistedTerminals: (async () => {}) as never,
+ markRemoteWorkspaceHydrated: (() => {}) as never,
+ setRemoteWorkspaceSyncStatus: (() => {}) as never
+ })
+}
+
+/** A tab the user created locally whose upload has not landed yet. */
+function addLocalTab(store: TestStore, tabId: string): void {
+ const live = store.getState()
+ store.setState({
+ tabsByWorktree: {
+ ...live.tabsByWorktree,
+ [WORKTREE_ID]: [
+ ...(live.tabsByWorktree[WORKTREE_ID] ?? []),
+ {
+ id: tabId,
+ worktreeId: WORKTREE_ID,
+ type: 'terminal',
+ title: tabId
+ } as never
+ ]
+ }
+ })
+}
+
+describe('direct-SSH snapshot apply keeps local state the host has not seen', () => {
+ it('keeps a tab created locally between snapshots', async () => {
+ const store = createTestStore()
+ seedCatalog(store)
+ await applySnapshot(store, snapshot(1, ['agent']))
+ store.getState().setActiveWorktree(WORKTREE_ID)
+
+ // The reported `setup` tab: created after the first snapshot, upload still pending, so the next
+ // snapshot the host sends still knows only about `agent`.
+ addLocalTab(store, 'setup')
+ await applySnapshot(store, snapshot(2, ['agent']))
+
+ const tabIds = (store.getState().tabsByWorktree[WORKTREE_ID] ?? []).map((tab) => tab.id)
+ expect(tabIds, 'the reconnect deleted a tab the host had never been told about').toContain(
+ 'setup'
+ )
+ expect(tabIds).toContain('agent')
+ })
+
+ it('leaves the user on their workspace when the snapshot names no active worktree', async () => {
+ const store = createTestStore()
+ seedCatalog(store)
+ await applySnapshot(store, snapshot(1, ['agent']))
+ store.getState().setActiveWorktree(WORKTREE_ID)
+ expect(store.getState().activeWorktreeId).toBe(WORKTREE_ID)
+
+ // A snapshot whose active path does not resolve to a known worktree. Taking that as "no active
+ // workspace" is what drops the user to the home screen mid-session.
+ await applySnapshot(store, snapshot(2, ['agent'], { activeWorktreePath: null }))
+
+ expect(
+ store.getState().activeWorktreeId,
+ 'the reconnect dropped the user to the home screen'
+ ).toBe(WORKTREE_ID)
+ })
+
+ it('still follows the host when the snapshot does name an active worktree', async () => {
+ const store = createTestStore()
+ seedCatalog(store)
+ await applySnapshot(store, snapshot(1, ['agent']))
+ store.getState().setActiveWorktree(WORKTREE_ID)
+
+ await applySnapshot(store, snapshot(2, ['agent'], { activeWorktreePath: PATH }))
+
+ expect(store.getState().activeWorktreeId).toBe(WORKTREE_ID)
+ })
+
+ it('does not duplicate a tab across repeated snapshots', async () => {
+ const store = createTestStore()
+ seedCatalog(store)
+ await applySnapshot(store, snapshot(1, ['agent']))
+ store.getState().setActiveWorktree(WORKTREE_ID)
+ addLocalTab(store, 'setup')
+
+ await applySnapshot(store, snapshot(2, ['agent']))
+ await applySnapshot(store, snapshot(3, ['agent']))
+
+ const tabIds = (store.getState().tabsByWorktree[WORKTREE_ID] ?? []).map((tab) => tab.id)
+ expect(tabIds).toEqual([...new Set(tabIds)])
+ expect(tabIds.filter((id) => id === 'setup')).toHaveLength(1)
+ })
+})
diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.ts b/src/renderer/src/store/slices/runtime-environment-ssh.ts
index b55bfee8b7c..eb8880e677e 100644
--- a/src/renderer/src/store/slices/runtime-environment-ssh.ts
+++ b/src/renderer/src/store/slices/runtime-environment-ssh.ts
@@ -287,6 +287,31 @@ export function selectRuntimeAwareSshStatus(
return bucket.connectionStates.get(targetId)?.status ?? null
}
+/**
+ * The failure detail behind the status, when there is one.
+ *
+ * Mirrors selectRuntimeAwareSshStatus exactly so the pair cannot disagree about which source they
+ * read. Null whenever the status selector would return null, so a caller never pairs a detail with
+ * a status it did not come from.
+ */
+export function selectRuntimeAwareSshError(
+ state: RuntimeAwareSshReadState,
+ environmentId: string | null,
+ targetId: string
+): string | null {
+ if (environmentId === null) {
+ return state.sshConnectionStates.get(targetId)?.error ?? null
+ }
+ if (!isEnvironmentReachable(state, environmentId)) {
+ return null
+ }
+ const bucket = state.sshStateByEnvironment.get(environmentId)
+ if (!bucket?.targetsHydrated) {
+ return null
+ }
+ return bucket.connectionStates.get(targetId)?.error ?? null
+}
+
export function selectRuntimeAwareSshTargetLabel(
state: RuntimeAwareSshReadState,
environmentId: string | null,
diff --git a/tests/e2e/helpers/docker-ssh-relay-target.ts b/tests/e2e/helpers/docker-ssh-relay-target.ts
index aa61f163aae..72bf08da788 100644
--- a/tests/e2e/helpers/docker-ssh-relay-target.ts
+++ b/tests/e2e/helpers/docker-ssh-relay-target.ts
@@ -126,6 +126,19 @@ function seedRemoteRepo(target: DockerSshRelayTarget, repoPath: string): void {
)
}
+/**
+ * The fixture image ships Debian's `/etc/bash.bashrc` with the xterm title block commented out and
+ * an all-comments `/root/.bashrc`, so its shell never emits OSC 0. Orca derives a tab title from
+ * that sequence, so without this every SSH tab keeps its `Terminal N` placeholder no matter how
+ * healthy the shell is. Opt in from specs that assert on titles; a real user's shell sets one.
+ */
+export function enableDockerSshRelayTargetShellTitle(target: DockerSshRelayTarget): void {
+ execDockerSshRelayTargetControlCommand(
+ target,
+ `printf '%s\\n' ${shellQuote(String.raw`PS1="\[\e]0;\u@\h: \w\a\]$PS1"`)} >> /root/.bashrc`
+ )
+}
+
export function writeDockerSshRelayTargetFile(
target: DockerSshRelayTarget,
filePath: string,
diff --git a/tests/e2e/helpers/runtime-types.ts b/tests/e2e/helpers/runtime-types.ts
index de18b94a2d9..99ce5b76fbe 100644
--- a/tests/e2e/helpers/runtime-types.ts
+++ b/tests/e2e/helpers/runtime-types.ts
@@ -1,24 +1,43 @@
import type { AppState } from '../../../src/renderer/src/store/types'
import type { OpenFile, RightSidebarTab } from '../../../src/renderer/src/store/slices/editor'
-import type { ManagedPane } from '../../../src/renderer/src/lib/pane-manager/pane-manager-types'
+import type {
+ ManagedPane,
+ ManagedPaneInternal,
+ PaneRenderingDiagnostics
+} from '../../../src/renderer/src/lib/pane-manager/pane-manager-types'
+import type { GlobalSettings } from '../../../src/shared/global-settings-types'
import type { BrowserWorkspace } from '../../../src/shared/browser-workspace-types'
import type { Repo } from '../../../src/shared/repo-types'
import type { WorkspaceVisibleTabType } from '../../../src/shared/tab-types'
import type { TerminalTab } from '../../../src/shared/terminal-tab-types'
import type { Worktree } from '../../../src/shared/worktree/types'
+// Why: window.__store is the Zustand bound store itself, so specs get the whole StoreApi.
export type AppStore = {
getState(): AppState
+ setState(partial: Partial | ((state: AppState) => Partial)): void
+ subscribe(listener: (state: AppState, previousState: AppState) => void): () => void
}
+// Why: PaneManager hands out one object carrying both halves, and the rendering specs read
+// the internal renderer state off it.
+export type ManagedPaneHandle = ManagedPane & ManagedPaneInternal
+
+// Why not optional: window.__paneManagers only ever holds real PaneManager instances, and
+// marking the methods optional made every call site a possibly-undefined invocation.
export type PaneManagerLike = {
- getActivePane?(): ManagedPane | null
- getPanes?(): ManagedPane[]
- splitPane?(paneId: number, direction: 'vertical' | 'horizontal'): ManagedPane | null
- closePane?(paneId: number): void
- setActivePane?(paneId: number, opts?: { focus?: boolean }): void
- suspendRendering?(): void
- resumeRendering?(): void
+ getActivePane(): ManagedPaneHandle | null
+ getPanes(limit?: number): ManagedPaneHandle[]
+ splitPane(paneId: number, direction: 'vertical' | 'horizontal'): ManagedPaneHandle | null
+ closePane(paneId: number): void
+ setActivePane(paneId: number, opts?: { focus?: boolean }): void
+ suspendRendering(): void
+ resumeRendering(): void
+ setTerminalGpuAcceleration(mode: GlobalSettings['terminalGpuAcceleration']): void
+ getRenderingDiagnostics(): PaneRenderingDiagnostics[]
+ resetWebglTextureAtlases(): void
+ hasWebglRenderer(paneId: number): boolean
+ getNumericIdForLeaf(leafId: string): number | null
}
export type ExplorerFileSummary = Pick
diff --git a/tests/e2e/helpers/terminal-tab-open.ts b/tests/e2e/helpers/terminal-tab-open.ts
new file mode 100644
index 00000000000..d5f1e82d2b1
--- /dev/null
+++ b/tests/e2e/helpers/terminal-tab-open.ts
@@ -0,0 +1,27 @@
+import type { Page } from '@stablyai/playwright-test'
+
+/**
+ * Opens a terminal tab the way the tab bar does.
+ *
+ * The group id is required, not incidental: the real caller only ever invokes this with the group it
+ * is opening into, and passing none lands the tab nowhere. That was invisible until tests/ got a
+ * typecheck config, because a spec calling it with no argument still compiled.
+ */
+export async function openTerminalTabInActiveGroup(page: Page): Promise {
+ await page.evaluate(async () => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('Store unavailable')
+ }
+ const state = store.getState()
+ const worktreeId = state.activeWorktreeId
+ if (!worktreeId) {
+ throw new Error('No active worktree to open a terminal tab in')
+ }
+ const groupId = state.activeGroupIdByWorktree[worktreeId]
+ if (!groupId) {
+ throw new Error(`No active tab group for worktree ${worktreeId}`)
+ }
+ await state.openNewTerminalTabInActiveWorkspace(groupId)
+ })
+}
diff --git a/tests/e2e/ssh-cold-activation-restore.spec.ts b/tests/e2e/ssh-cold-activation-restore.spec.ts
index 25827482a92..f49ad210c0b 100644
--- a/tests/e2e/ssh-cold-activation-restore.spec.ts
+++ b/tests/e2e/ssh-cold-activation-restore.spec.ts
@@ -178,11 +178,40 @@ test.describe('SSH cold activation restore', () => {
if (!firstTabId) {
throw new Error('Restored SSH tabs disappeared')
}
- await orcaPage.getByRole('button', { name: /^Terminal 1 Close tab Terminal 1/ }).click()
+ // Six restored tabs overflow the strip at CI's window size and the restore pins it to the END,
+ // so Terminal 1 starts outside the scroll viewport. Neither `click()` nor
+ // `scrollIntoViewIfNeeded()` can reach it: both wait for the element to hold still, and the
+ // strip keeps re-laying-out while the relay reconnects behind it — so they time out on an
+ // element they can see but never settle on. This spec had never run in CI before this branch
+ // routed it there, which is why that only shows up now.
+ //
+ // So the pointer is driven directly, and the whole attempt retried, which needs no element to
+ // be stable — only to be somewhere at the moment it is pressed. Activation is deferred to
+ // pointerup and suppressed past a drag threshold (tab-strip-pointer-activation.ts), so this
+ // has to be a real down/up pair at one position; a synthetic click event would not select.
+ // The retry asserts on the store, so a press that lands wrong is retried rather than believed.
+ const tabStrip = orcaPage.locator('.terminal-tab-strip').first()
+ const firstTab = orcaPage.getByRole('button', { name: /^Terminal 1 Close tab Terminal 1/ })
await expect
- .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeTabId ?? null), {
- timeout: 10_000
- })
+ .poll(
+ async () => {
+ await tabStrip.evaluate((el) => {
+ el.scrollLeft = 0
+ })
+ const box = await firstTab.boundingBox()
+ if (!box) {
+ return null
+ }
+ await orcaPage.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
+ await orcaPage.mouse.down()
+ await orcaPage.mouse.up()
+ return orcaPage.evaluate(() => window.__store?.getState().activeTabId ?? null)
+ },
+ {
+ timeout: 30_000,
+ message: 'pressing the restored first tab never made it active'
+ }
+ )
.toBe(firstTabId)
await orcaPage.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
diff --git a/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts b/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts
new file mode 100644
index 00000000000..031cc0dcc81
--- /dev/null
+++ b/tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts
@@ -0,0 +1,161 @@
+import { test, expect } from './helpers/orca-app'
+import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+import {
+ execInTerminal,
+ getTerminalContent,
+ waitForActivePanePtyId,
+ waitForActiveTerminalManager,
+ waitForTerminalOutput
+} from './helpers/terminal'
+import {
+ cleanupDockerSshRelayTarget,
+ enableDockerSshRelayTargetShellTitle,
+ startDockerSshRelayTarget,
+ type DockerSshRelayTarget
+} from './helpers/docker-ssh-relay-target'
+import {
+ connectDockerSshRelayTarget,
+ reconnectDockerSshRelayTarget
+} from './helpers/docker-ssh-relay-connection'
+import { openTerminalTabInActiveGroup } from './helpers/terminal-tab-open'
+
+const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
+
+/**
+ * The two regressions this covers both shipped and both reached a user, because nothing here
+ * asserted what a pane actually SHOWS after a reconnect:
+ *
+ * 1. Panes came back blank. The relay treated the reconnecting client as one that already held the
+ * stream and returned no scrollback, while the renderer had disposed the xterm with its buffer.
+ * 2. A tab created afterwards came up with no prompt and stayed generically titled, because the
+ * reconnect prepaint could still fire on a spent mount.
+ *
+ * Reading the pane's own text is the point. Asserting a pty id, a status, or a spy call is what let
+ * both of these through: every one of those was correct while the screen was wrong.
+ *
+ * KNOWN: (1) is fixed at the symptom. requireReplay makes the client ask for what the relay wrongly
+ * decided it did not need. Three attempts at the cause failed, and the reasons are worth keeping
+ * because each looks correct until you run it:
+ *
+ * - RETIRING THE DELIVERY ON dispatcher.onClientDetached, the way fs-handler, git-handler and
+ * relay-filesystem-watch-registry release their per-client state, BREAKS CHECKPOINT RECOVERY (10
+ * tests in relay-pty-source-recovery-interleavings / restore-retry). A delivery outliving its
+ * client is deliberate here: it is what lets a reconnecting client resume from a checkpoint
+ * instead of re-receiving everything. This class omits that subscription on purpose.
+ * - RETIRING WITHOUT session.cancelDelivery() orphans the credit ledger's one-upstream-owner-per-pty
+ * slot, and the next open throws "PTY source delivery already has an upstream owner". Seen live as
+ * an error toast and a blank pane.
+ * - COMPARING record.identity.clientGeneration TO THE REQUEST is not available: that value is
+ * client-supplied through pty.openClient (dispatcher.ts:1219) and RequestContext carries no
+ * generation of its own.
+ *
+ * The real cause is now established, and it is broader than this spec: a reconnect reuses the same
+ * clientId (setWrite keeps the primary client), so the relay's activate() matches on it and returns
+ * 'existing' before ever reaching the recovery branch. Checkpointed source recovery therefore has
+ * never run on an SSH reconnect at all, and the byte tail is not a fallback but the only path. Full
+ * chain and the fix it implies: docs/reference/ssh-reconnect-source-recovery.md.
+ */
+test.describe('SSH reconnect pane restore', () => {
+ test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run the dockerized SSH relay tests')
+
+ test('restores shell scrollback, a full-screen frame, and a usable new tab across a reconnect', async ({
+ orcaPage
+ }, testInfo) => {
+ test.slow()
+ let target: DockerSshRelayTarget | null = null
+ try {
+ target = startDockerSshRelayTarget(testInfo)
+ // The fixture image's shell emits no OSC 0, so without this every tab keeps its placeholder
+ // title regardless of shell health and the title assertion below could never pass.
+ enableDockerSshRelayTargetShellTitle(target)
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+ const remote = await connectDockerSshRelayTarget(orcaPage, target)
+ await ensureTerminalVisible(orcaPage, 45_000)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
+
+ // A marker rather than a prompt: a prompt reappears on its own after a reconnect, so it cannot
+ // distinguish restored scrollback from a fresh shell. This string only exists if the pane kept
+ // what it had.
+ const marker = `RECONNECT_MARKER_${Date.now()}`
+ await execInTerminal(orcaPage, ptyId, `echo ${marker}`)
+ await waitForTerminalOutput(orcaPage, marker, 30_000)
+
+ await reconnectDockerSshRelayTarget(orcaPage, remote.targetId)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ await waitForActivePanePtyId(orcaPage, 60_000)
+
+ // REGRESSION 1: the pane painted nothing at all here, because the relay withheld the replay.
+ await waitForTerminalOutput(orcaPage, marker, 60_000)
+
+ // A FULL-SCREEN app is the second case: a reconnect must leave a TUI pane alive and drawing,
+ // not blank or frozen.
+ //
+ // Deliberately run in the ORIGINAL tab, and deliberately BEFORE the new-tab case below. A tab
+ // created after a reconnect is destroyed by an unrelated session-sync bug on the next one (see
+ // ssh-reconnect-tab-destruction.spec.ts), so staging the TUI there conflated two failures and
+ // left this guard red for a reason that has nothing to do with painting. This tab predates
+ // every reconnect, so it is in the host snapshot and survives.
+ //
+ // SCOPE, because it is easy to over-read: this does NOT prove which payload painted the pane.
+ // top redraws itself every few seconds, so these assertions pass whichever way the paint-source
+ // gate decided — including with it reverted. What discriminates model-vs-tail is unit-level, in
+ // ssh-reconnect-model-paint-gate.test.ts, because the interesting cases are disagreements
+ // between main's pre-outage alt-screen belief and a replay produced during the outage, which
+ // is not something this fixture can stage. Kept anyway: it is the only coverage that a
+ // reconnected TUI pane recovers at all.
+ await execInTerminal(orcaPage, ptyId, 'top -b -n 1 > /dev/null; top')
+ await waitForTerminalOutput(orcaPage, 'load average', 30_000, 8000)
+
+ await reconnectDockerSshRelayTarget(orcaPage, remote.targetId)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ await waitForActivePanePtyId(orcaPage, 60_000)
+
+ await waitForTerminalOutput(orcaPage, 'load average', 60_000, 8000)
+ const tuiContent = await getTerminalContent(orcaPage, 8000)
+ expect(tuiContent).toContain('PID')
+
+ // REGRESSION 2: opening a tab AFTER a reconnect. The prepaint could still fire on this mount
+ // and write over the new shell, leaving a pane with no prompt and a generic tab title.
+ await openTerminalTabInActiveGroup(orcaPage)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ const freshPtyId = await waitForActivePanePtyId(orcaPage, 60_000)
+ expect(freshPtyId).not.toBe(ptyId)
+
+ // The new pane must reach a shell that answers, which is what "usable" means and what a blank
+ // pane fails. Echoing proves the shell read input and wrote back, not merely that a pty exists.
+ const freshMarker = `NEW_TAB_MARKER_${Date.now()}`
+ await execInTerminal(orcaPage, freshPtyId, `echo ${freshMarker}`)
+ await waitForTerminalOutput(orcaPage, freshMarker, 60_000)
+
+ // And it must be a FRESH shell, not a repaint of the old pane's history.
+ const freshContent = await getTerminalContent(orcaPage, 8000)
+ expect(freshContent).not.toContain(marker)
+
+ // The title is the cheap signal the reported bug showed: it only stays generic when the shell
+ // never printed a prompt for Orca to read one from.
+ await expect
+ .poll(
+ async () =>
+ orcaPage.evaluate(() => {
+ const store = window.__store
+ const state = store?.getState()
+ const worktreeId = state?.activeWorktreeId
+ if (!state || !worktreeId) {
+ return null
+ }
+ const activeTabId = state.activeTabIdByWorktree?.[worktreeId]
+ const tabs = state.tabsByWorktree?.[worktreeId] ?? []
+ return tabs.find((tab) => tab.id === activeTabId)?.title ?? null
+ }),
+ { timeout: 60_000, message: 'New tab kept its placeholder title' }
+ )
+ .not.toMatch(/^Terminal \d+$/)
+ } finally {
+ if (target) {
+ cleanupDockerSshRelayTarget(target)
+ }
+ }
+ })
+})
diff --git a/tests/e2e/ssh-reconnect-tab-destruction.spec.ts b/tests/e2e/ssh-reconnect-tab-destruction.spec.ts
new file mode 100644
index 00000000000..95d46a36187
--- /dev/null
+++ b/tests/e2e/ssh-reconnect-tab-destruction.spec.ts
@@ -0,0 +1,136 @@
+import { test, expect } from './helpers/orca-app'
+import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal'
+import {
+ cleanupDockerSshRelayTarget,
+ startDockerSshRelayTarget,
+ type DockerSshRelayTarget
+} from './helpers/docker-ssh-relay-target'
+import {
+ connectDockerSshRelayTarget,
+ reconnectDockerSshRelayTarget
+} from './helpers/docker-ssh-relay-connection'
+import { openTerminalTabInActiveGroup } from './helpers/terminal-tab-open'
+
+const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
+
+/**
+ * An SSH reconnect destroys the terminal state behind a tab whose creation has not yet reached the
+ * host, while the process it was running keeps going.
+ *
+ * The symptom is worse than a disappearing tab, because the two models disagree: the TAB BAR still
+ * renders the tab, correctly titled, but the terminal slice holds only the older tab and no pane
+ * manager exists for the newer one. So the user is left clicking a selected tab that will never
+ * paint, with no error and no way to recover it, while `top` runs on untouched on the host.
+ *
+ * Mechanism:
+ * - `remote-workspace-session-merge.ts:86-89` builds `tabsByWorktree` as
+ * `{...omitTargetWorktrees(current), ...remote}`. A local tab for the target worktree that is
+ * absent from the host snapshot has no surviving branch — it is simply not in the result.
+ * - `remote-workspace-target-sync.ts` applies that host snapshot unconditionally once
+ * `revision > 0`, without pushing local state first.
+ * - The upload that would have put the tab in the host list is DROPPED rather than deferred: the
+ * debounced session writer is gated on `!isRemoteWorkspaceSnapshotApplyInProgress()`, and
+ * `REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS` is 1_000 after a snapshot apply. A tab created
+ * inside that window never gets written.
+ *
+ * Correlation observed across runs, which is what pinned the mechanism: host snapshot revision 1
+ * (1 tab) always lost the pane; revision 2 (2 tabs) always kept it.
+ *
+ * PRE-EXISTING. None of remote-workspace-target-sync.ts, remote-workspace-session-merge.ts,
+ * use-app-session-persistence.ts or remote-workspace-snapshot-apply.ts was touched by the branch
+ * that added this spec.
+ *
+ * FIXED by making the merge treat the host as authoritative only for what it knows: a local tab the
+ * snapshot has never been told about is kept rather than erased.
+ *
+ * SCOPE — this spec is NOT the guard, and measuring it is the only reason that is knowable. Against
+ * the unfixed code it fails roughly one run in three or four, because the destruction needs the tab
+ * to be created inside the debounced upload's suppression window and nothing here can force that
+ * from the outside. Removing the waits between creating the tab and reconnecting tightened it and
+ * still did not make it deterministic.
+ *
+ * The real guards are deterministic and live elsewhere: remote-workspace-snapshot-local-tab-survival
+ * .test.ts drives this same scenario through the actual apply path, and
+ * remote-workspace-session-merge-local-survival.test.ts covers the merge decision table. Together
+ * they fail 8 times on the unfixed code. Keep this spec as end-to-end smoke, and do not read a green
+ * run here as evidence the bug is gone.
+ */
+test.describe('SSH reconnect tab destruction', () => {
+ test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run the dockerized SSH relay tests')
+
+ test('keeps a tab created right after a reconnect alive across the next one', async ({
+ orcaPage
+ }, testInfo) => {
+ test.slow()
+ let target: DockerSshRelayTarget | null = null
+ try {
+ target = startDockerSshRelayTarget(testInfo)
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+ const remote = await connectDockerSshRelayTarget(orcaPage, target)
+ await ensureTerminalVisible(orcaPage, 45_000)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ // Awaited, not captured: the pane must be bound before the first reconnect, but the id itself
+ // is not what this spec asserts on — tab survival is.
+ await waitForActivePanePtyId(orcaPage, 60_000)
+
+ await reconnectDockerSshRelayTarget(orcaPage, remote.targetId)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+ await waitForActivePanePtyId(orcaPage, 60_000)
+
+ // Immediately after the apply, i.e. inside the 1s suppression window, so the tab's creation
+ // is dropped from the session write rather than deferred. This is the ordinary thing a user
+ // does; the timing is not contrived.
+ await openTerminalTabInActiveGroup(orcaPage)
+ // Only that the tab exists in the store — no waiting for its manager or PTY. Every wait here
+ // is time the debounced upload can use to land, which is what made this spec miss the bug.
+ const tabsBefore = await orcaPage.evaluate(() => {
+ const state = window.__store?.getState()
+ const worktreeId = state?.activeWorktreeId
+ return worktreeId ? (state?.tabsByWorktree?.[worktreeId]?.length ?? 0) : 0
+ })
+ expect(tabsBefore).toBeGreaterThanOrEqual(2)
+
+ // Deliberately NOTHING between creating the tab and reconnecting. The destruction only fires
+ // while the tab's creation is still unuploaded, so idling here — as waiting for a TUI to draw
+ // did — lets the debounced write land and the bug evaporate. That is exactly why an earlier
+ // version of this spec passed with the bug still present, and why it was worthless as a guard.
+ await reconnectDockerSshRelayTarget(orcaPage, remote.targetId)
+ await waitForActiveTerminalManager(orcaPage, 60_000)
+
+ // Checked BEFORE any paint assertion: survival and repaint are different failures, and this
+ // order names which one broke instead of collapsing both into "no output".
+ const tabCounts = await orcaPage.evaluate(() => {
+ const state = window.__store?.getState()
+ const worktreeId = state?.activeWorktreeId
+ return {
+ inSlice: worktreeId ? (state?.tabsByWorktree?.[worktreeId]?.length ?? 0) : 0,
+ // __paneManagers is a Map. Object.keys on a Map silently returns [], which reads as
+ // "nothing is mounted" regardless of the truth — that cost a full debugging cycle.
+ paneManagers: window.__paneManagers?.size ?? 0
+ }
+ })
+ expect(tabCounts.inSlice, 'the reconnect destroyed the tab').toBeGreaterThanOrEqual(2)
+ expect(
+ tabCounts.paneManagers,
+ 'the tab survived but its pane manager did not'
+ ).toBeGreaterThanOrEqual(1)
+
+ // NOT asserted: that the surviving pane reaches its shell again.
+ //
+ // Measured at 3 runs in 4 — the tab survives every time, the reattach behind it does not. So
+ // preserving the tab is a real fix and an incomplete one: the store keeps the tab, the tab bar
+ // renders it, and the pane sometimes never rebinds, which is the "frozen tab" shape the
+ // original report described. Asserting it here would put a one-in-four flake into the CI lane
+ // that exists to catch this class, which is worse than saying plainly that it is unfixed.
+ //
+ // The reattach gap is tracked separately; do not add a liveness assertion here until it is
+ // deterministic, or the lane stops being trusted.
+ } finally {
+ if (target) {
+ cleanupDockerSshRelayTarget(target)
+ }
+ }
+ })
+})
From 9b8e9dc2269b5663b9dad02b69832e6eb840ff01 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 17 Aug 2026 17:10:49 -0700
Subject: [PATCH 04/43] Prevent tab search results from jumping while typing
(#15133)
* Prevent tab search results from jumping while typing
- Retain deferred results that still match the current query
- Add retainOpenTabResultsForQuery utility with query matching logic
- Refactor TabBarCreateEntry to use useTabCreateEntrySearchResults hook
* Re-check retained tab search rows with full search engines
Instead of checking if row text contains the query, retention now
re-runs the search engines on deferred results. This respects all
matching rules (type aliases, paths, workspace labels, agent snippets)
and ensures stale or mismatched rows don't linger on screen as the
user types.
---
.../TabBarCreateEntry.tab-results.test.tsx | 78 +++++-
.../components/tab-bar/TabBarCreateEntry.tsx | 17 +-
.../tab-bar/open-tab-search-retention.test.ts | 264 ++++++++++++++++++
.../tab-bar/open-tab-search-retention.ts | 62 ++++
.../components/tab-bar/use-open-tab-search.ts | 6 +-
.../use-tab-create-entry-search-results.ts | 31 ++
6 files changed, 439 insertions(+), 19 deletions(-)
create mode 100644 src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts
create mode 100644 src/renderer/src/components/tab-bar/open-tab-search-retention.ts
create mode 100644 src/renderer/src/components/tab-bar/use-tab-create-entry-search-results.ts
diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx
index 875d2c60ad7..a7e599b0ec1 100644
--- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx
+++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx
@@ -27,15 +27,63 @@ vi.mock('@/lib/agent-catalog', () => ({
// `hold` stands in for the hook's deferred query: it pins the results to the
// query they were built from, so later keystrokes leave them stale.
-const tabSearchMock = vi.hoisted(() => ({
- hold: null as string | null,
- resultsByQuery: {} as Record
-}))
+const tabSearchMock = vi.hoisted(() => {
+ const worktree = {
+ id: 'wt',
+ repoId: 'repo-1',
+ path: '/tmp/wt',
+ hostId: 'local',
+ displayName: 'Aurora Workspace'
+ }
+ return {
+ hold: null as string | null,
+ resultsByQuery: {} as Record,
+ // Retention re-checks each row against the live query with the real engines,
+ // so every registered row needs the searchable entry it came from.
+ entries(): unknown {
+ const rows = Object.values(tabSearchMock.resultsByQuery).flat() as {
+ source: string
+ tabId?: string
+ title: string
+ contentType: string
+ relativePath?: string | null
+ }[]
+ return {
+ browserPages: [],
+ simulatorTabs: [],
+ workspaceTabs: rows
+ .filter((row) => row.source === 'workspace')
+ .map((row) => ({
+ tab: {
+ id: row.tabId,
+ entityId: `${row.tabId}-entity`,
+ groupId: 'g',
+ worktreeId: worktree.id,
+ contentType: row.contentType
+ },
+ worktree,
+ repoName: 'octo/rocket',
+ worktreeSortIndex: 0,
+ groupSortIndex: 0,
+ tabSortIndex: 0,
+ title: row.title,
+ secondaryText: row.relativePath ?? '',
+ titleSearchText: row.title,
+ secondarySearchTexts: row.relativePath ? [row.relativePath] : [],
+ agentMetadata: [],
+ isCurrentTab: false,
+ isCurrentWorktree: true
+ }))
+ }
+ }
+ }
+})
vi.mock('./use-open-tab-search', () => ({
useOpenTabSearch: ({ enabled, query }: { enabled: boolean; query: string }) => {
const resolved = tabSearchMock.hold ?? query
return {
query: enabled ? resolved : query,
+ entries: enabled ? tabSearchMock.entries() : null,
results: enabled ? (tabSearchMock.resultsByQuery[resolved.trim()] ?? []) : []
}
}
@@ -274,7 +322,27 @@ describe('TabBarCreateEntry tab results', () => {
expect(activationMocks.workspace).not.toHaveBeenCalled()
})
- it('drops tab rows built for an earlier query until the search catches up', () => {
+ it('keeps a deferred tab row that still matches the newer query', () => {
+ entryOptionsMock.options = [newFileOption]
+ tabSearchMock.resultsByQuery['add tab'] = [terminalResult()]
+ const onOpenEntry = vi.fn().mockResolvedValue(undefined)
+ renderEntry({ onOpenEntry })
+
+ setQuery('add tab')
+ expect(rowTexts()[0]).toContain('Switch to tab')
+
+ // The user backspaces; the deferred search still describes 'add tab'.
+ tabSearchMock.hold = 'add tab'
+ setQuery('add ta')
+
+ expect(rowTexts()[0]).toContain('Switch to tab')
+ submitForm()
+
+ expect(activationMocks.workspace).toHaveBeenCalledTimes(1)
+ expect(onOpenEntry).not.toHaveBeenCalled()
+ })
+
+ it('drops a deferred tab row that the newer query no longer matches', () => {
entryOptionsMock.options = [newFileOption]
tabSearchMock.resultsByQuery['add tab'] = [terminalResult()]
const onOpenEntry = vi.fn().mockResolvedValue(undefined)
diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx
index 482d81a1cf3..c22b3ce77f9 100644
--- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx
+++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx
@@ -21,7 +21,7 @@ import {
} from './TabBarCreateEntryRow'
import { dropFileEntriesCoveredByTabResults } from './open-tab-entry-dedupe'
import { activateOpenTabSearchResult } from './open-tab-selection-routing'
-import { useOpenTabSearch } from './use-open-tab-search'
+import { useTabCreateEntrySearchResults } from './use-tab-create-entry-search-results'
import { DEFAULT_SEARCH_ENGINE } from '../../../../shared/browser-url'
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { useAppStore } from '@/store'
@@ -33,11 +33,7 @@ import {
getTabEntryChooseActionMessage,
getTabEntryOmniboxPlaceholder
} from './tab-create-entry-copy'
-import {
- EMPTY_AGENT_OPTIONS,
- EMPTY_MENU_OPTIONS,
- EMPTY_TAB_RESULTS
-} from './tab-create-entry-empty-options'
+import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empty-options'
import type { TabBarCreateEntryProps } from './tab-create-entry-props'
export default function TabBarCreateEntry(props: TabBarCreateEntryProps): React.JSX.Element {
@@ -77,16 +73,11 @@ function TabBarCreateEntrySession({
const rawQueryOversized = isQuickOpenQueryTooLarge(query)
const forcedSearch = parseForcedSearchQuery(query)
const terminalQueryMode = rawQueryOversized || forcedSearch.forced
- const tabSearchQuery = terminalQueryMode ? '' : query
- const tabSearch = useOpenTabSearch({
+ const tabResults = useTabCreateEntrySearchResults({
enabled: menuOpen && !terminalQueryMode,
- query: tabSearchQuery,
+ query,
worktreeId
})
- // Why gate on the query: the search defers, so its rows can still describe an
- // earlier query — Enter must never submit a tab the current query never matched.
- const tabResults =
- !terminalQueryMode && tabSearch.query === query ? tabSearch.results : EMPTY_TAB_RESULTS
const shouldResolveAbsolutePaths =
menuOpen && !terminalQueryMode && isTabEntryAbsolutePathLike(query.trim())
const allowAbsolutePathsSelector = useMemo(
diff --git a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts
new file mode 100644
index 00000000000..bac8a16eb99
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts
@@ -0,0 +1,264 @@
+import { describe, expect, it } from 'vitest'
+import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types'
+import type { Tab, TabContentType } from '../../../../shared/tab-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+import type { SearchableBrowserPage } from '@/lib/browser-palette-search'
+import type { SearchableWorkspaceTab } from '@/lib/workspace-tab-palette-search'
+import { TERMINAL_TYPE_SEARCH_ALIASES } from '@/lib/workspace-tab-palette-search'
+import { searchOpenTabs } from './open-tab-search'
+import type { OpenTabSearchEntries } from './open-tab-search-entries'
+import { retainOpenTabResultsForQuery } from './open-tab-search-retention'
+
+const worktree: Worktree = {
+ id: 'wt-1',
+ repoId: 'repo-1',
+ path: '/tmp/wt-1',
+ head: 'abc123',
+ branch: 'refs/heads/main',
+ isBare: false,
+ isMainWorktree: false,
+ displayName: 'Aurora Workspace',
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 0
+}
+
+function makeTab(id: string, contentType: TabContentType): Tab {
+ return {
+ id,
+ entityId: `${id}-entity`,
+ groupId: 'group-1',
+ worktreeId: worktree.id,
+ contentType,
+ label: id,
+ customLabel: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 0
+ }
+}
+
+function makeWorkspaceTab({
+ id,
+ title,
+ contentType = 'terminal',
+ secondarySearchTexts = [],
+ agentSnippets = [],
+ typeSearchAliases
+}: {
+ id: string
+ title: string
+ contentType?: 'terminal' | 'editor'
+ secondarySearchTexts?: string[]
+ agentSnippets?: string[]
+ typeSearchAliases?: readonly string[]
+}): SearchableWorkspaceTab {
+ return {
+ tab: makeTab(id, contentType) as SearchableWorkspaceTab['tab'],
+ worktree,
+ repoName: 'octo/rocket',
+ worktreeSortIndex: 0,
+ groupSortIndex: 0,
+ tabSortIndex: 0,
+ title,
+ secondaryText: secondarySearchTexts[0] ?? '',
+ titleSearchText: title,
+ secondarySearchTexts,
+ typeSearchAliases:
+ typeSearchAliases ?? (contentType === 'terminal' ? TERMINAL_TYPE_SEARCH_ALIASES : undefined),
+ agentMetadata: agentSnippets.length
+ ? [{ paneKey: `${id}-pane`, textParts: [], snippetCandidates: agentSnippets }]
+ : [],
+ isCurrentTab: false,
+ isCurrentWorktree: true
+ }
+}
+
+function makeBrowserPage({
+ id,
+ title,
+ workspaceLabel
+}: {
+ id: string
+ title: string
+ workspaceLabel?: string
+}): SearchableBrowserPage {
+ const url = 'https://example.com/one'
+ const page: BrowserPage = {
+ id,
+ workspaceId: `${id}-ws`,
+ worktreeId: worktree.id,
+ url,
+ title,
+ loading: false,
+ faviconUrl: null,
+ canGoBack: false,
+ canGoForward: false,
+ loadError: null,
+ createdAt: 0
+ }
+ const workspace: BrowserWorkspace = {
+ id: `${id}-ws`,
+ worktreeId: worktree.id,
+ activePageId: id,
+ pageIds: [id],
+ url,
+ title,
+ label: workspaceLabel,
+ loading: false,
+ faviconUrl: null,
+ canGoBack: false,
+ canGoForward: false,
+ loadError: null,
+ createdAt: 0
+ }
+ return {
+ page,
+ workspace,
+ worktree,
+ repoName: 'octo/rocket',
+ worktreeSortIndex: 0,
+ isCurrentPage: false,
+ isCurrentWorktree: true
+ }
+}
+
+function makeEntries(overrides: Partial = {}): OpenTabSearchEntries {
+ return { workspaceTabs: [], browserPages: [], simulatorTabs: [], ...overrides }
+}
+
+// Mirrors the hook: the deferred query builds the rows, the live query re-checks them.
+function retain({
+ entries,
+ deferredQuery,
+ query
+}: {
+ entries: OpenTabSearchEntries
+ deferredQuery: string
+ query: string
+}): { titles: string[]; sameList: boolean } {
+ const results = searchOpenTabs({ ...entries, query: deferredQuery })
+ const retained = retainOpenTabResultsForQuery({
+ entries,
+ query,
+ results,
+ resultsQuery: deferredQuery
+ })
+ return { titles: retained.map((result) => result.title), sameList: retained === results }
+}
+
+describe('retainOpenTabResultsForQuery', () => {
+ const entries = makeEntries({
+ workspaceTabs: [makeWorkspaceTab({ id: 'tab-1', title: 'Add tab search and jump in worktree' })]
+ })
+
+ it('returns the same list when the deferred query has caught up', () => {
+ expect(retain({ entries, deferredQuery: 'add tab', query: 'add tab' })).toEqual({
+ titles: ['Add tab search and jump in worktree'],
+ sameList: true
+ })
+ })
+
+ it('treats extra surrounding whitespace as the same query', () => {
+ expect(retain({ entries, deferredQuery: 'add tab', query: ' add tab ' })).toEqual({
+ titles: ['Add tab search and jump in worktree'],
+ sameList: true
+ })
+ })
+
+ it('keeps rows while the user backspaces into a prefix of the deferred query', () => {
+ expect(retain({ entries, deferredQuery: 'add tab', query: 'add ta' }).titles).toEqual([
+ 'Add tab search and jump in worktree'
+ ])
+ })
+
+ it('keeps the same array when nothing was dropped, so the row memos hold', () => {
+ expect(retain({ entries, deferredQuery: 'add tab', query: 'add ta' }).sameList).toBe(true)
+ })
+
+ it('drops rows the newer query no longer matches', () => {
+ expect(retain({ entries, deferredQuery: 'add tab', query: 'add tabs' }).titles).toEqual([])
+ })
+
+ it('drops every row once the live query is cleared', () => {
+ expect(retain({ entries, deferredQuery: 'add tab', query: ' ' }).titles).toEqual([])
+ })
+
+ // The row text alone cannot answer these three: the engines match fields the
+ // result never carries once the match came from somewhere else.
+ it('keeps a terminal row the newer query matches through its type alias', () => {
+ const aliased = makeEntries({
+ workspaceTabs: [makeWorkspaceTab({ id: 'tab-1', title: 'zsh' })]
+ })
+ expect(retain({ entries: aliased, deferredQuery: 'term', query: 'termin' }).titles).toEqual([
+ 'zsh'
+ ])
+ })
+
+ it('keeps a browser row the newer query matches through its workspace label', () => {
+ const labelled = makeEntries({
+ browserPages: [
+ makeBrowserPage({ id: 'page-1', title: 'Untitled', workspaceLabel: 'Release checklist' })
+ ]
+ })
+ expect(retain({ entries: labelled, deferredQuery: 'rele', query: 'release c' }).titles).toEqual(
+ ['Untitled']
+ )
+ })
+
+ it('keeps an editor row the newer query matches through its absolute path', () => {
+ const editor = makeEntries({
+ workspaceTabs: [
+ makeWorkspaceTab({
+ id: 'tab-1',
+ title: 'zebra.ts',
+ contentType: 'editor',
+ secondarySearchTexts: ['src/zebra.ts', '/tmp/wt-1/src/zebra.ts']
+ })
+ ]
+ })
+ expect(
+ retain({ entries: editor, deferredQuery: 'zeb', query: '/tmp/wt-1/src' }).titles
+ ).toEqual(['zebra.ts'])
+ })
+
+ it('keeps a row the newer query matches through an agent snippet', () => {
+ const withAgent = makeEntries({
+ workspaceTabs: [
+ makeWorkspaceTab({
+ id: 'tab-1',
+ title: 'zsh',
+ agentSnippets: ['rewriting the deferred retention path']
+ })
+ ]
+ })
+ expect(
+ retain({ entries: withAgent, deferredQuery: 'defer', query: 'deferred ret' }).titles
+ ).toEqual(['zsh'])
+ })
+
+ it('drops a row whose only remaining match is the workspace name the search ignores', () => {
+ const named = makeEntries({
+ workspaceTabs: [makeWorkspaceTab({ id: 'tab-1', title: 'zsh' })]
+ })
+ expect(retain({ entries: named, deferredQuery: 'zsh', query: 'aurora' }).titles).toEqual([])
+ })
+
+ it('drops every row when the entries behind them are gone', () => {
+ const results = searchOpenTabs({ ...entries, query: 'add tab' })
+ expect(
+ retainOpenTabResultsForQuery({
+ entries: null,
+ query: 'add ta',
+ results,
+ resultsQuery: 'add tab'
+ })
+ ).toEqual([])
+ })
+})
diff --git a/src/renderer/src/components/tab-bar/open-tab-search-retention.ts b/src/renderer/src/components/tab-bar/open-tab-search-retention.ts
new file mode 100644
index 00000000000..c6e765d3a5b
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/open-tab-search-retention.ts
@@ -0,0 +1,62 @@
+// Keeps deferred tab rows on screen while typing races ahead of search.
+
+import { searchOpenTabs, type OpenTabSearchResult } from './open-tab-search'
+import type { OpenTabSearchEntries } from './open-tab-search-entries'
+
+const NO_RESULTS: readonly OpenTabSearchResult[] = []
+
+export function retainOpenTabResultsForQuery({
+ entries,
+ query,
+ results,
+ resultsQuery
+}: {
+ entries: OpenTabSearchEntries | null
+ query: string
+ results: readonly OpenTabSearchResult[]
+ resultsQuery: string
+}): readonly OpenTabSearchResult[] {
+ // Why return `results` when empty: an already-empty list must keep its identity
+ // rather than flip to another empty array and churn the caller's memos.
+ if (resultsQuery.trim() === query.trim() || results.length === 0) {
+ return results
+ }
+ if (!query.trim() || !entries) {
+ return NO_RESULTS
+ }
+ // Why re-run the engines instead of re-reading the row text: only they know
+ // every matchable field — type aliases, absolute paths, agent snippets,
+ // browser workspace labels — and Enter must never submit a row the query on
+ // screen does not match.
+ const liveResultIds = new Set(
+ searchOpenTabs({ ...entriesBehindResults(entries, results), query }).map((result) => result.id)
+ )
+ const retained = results.filter((result) => liveResultIds.has(result.id))
+ // Why the same array: an unchanged list must not invalidate the row memos.
+ return retained.length === results.length ? results : retained
+}
+
+// Narrowing to the rows on screen keeps the re-check to string work on at most
+// OPEN_TAB_SEARCH_RESULT_LIMIT entries, so its own limit can never truncate.
+function entriesBehindResults(
+ entries: OpenTabSearchEntries,
+ results: readonly OpenTabSearchResult[]
+): OpenTabSearchEntries {
+ const workspaceTabIds = new Set()
+ const browserPageIds = new Set()
+ const simulatorTabIds = new Set()
+ for (const result of results) {
+ if (result.source === 'workspace') {
+ workspaceTabIds.add(result.tabId)
+ } else if (result.source === 'browser') {
+ browserPageIds.add(result.pageId)
+ } else {
+ simulatorTabIds.add(result.tabId)
+ }
+ }
+ return {
+ workspaceTabs: entries.workspaceTabs.filter((entry) => workspaceTabIds.has(entry.tab.id)),
+ browserPages: entries.browserPages.filter((entry) => browserPageIds.has(entry.page.id)),
+ simulatorTabs: entries.simulatorTabs.filter((entry) => simulatorTabIds.has(entry.tab.id))
+ }
+}
diff --git a/src/renderer/src/components/tab-bar/use-open-tab-search.ts b/src/renderer/src/components/tab-bar/use-open-tab-search.ts
index 9374efca953..693e6941e1d 100644
--- a/src/renderer/src/components/tab-bar/use-open-tab-search.ts
+++ b/src/renderer/src/components/tab-bar/use-open-tab-search.ts
@@ -6,7 +6,8 @@ import { useAppStore } from '@/store'
import {
buildOpenTabSearchEntries,
selectOpenTabSearchAgentState,
- selectOpenTabSearchEntryState
+ selectOpenTabSearchEntryState,
+ type OpenTabSearchEntries
} from './open-tab-search-entries'
import { searchOpenTabs, type OpenTabSearchResult } from './open-tab-search'
@@ -22,6 +23,8 @@ export type OpenTabSearchSnapshot = {
/** The query `results` describe; lags the requested query while deferred. */
query: string
results: OpenTabSearchResult[]
+ /** What `results` were searched from, so a caller can re-check a newer query. */
+ entries: OpenTabSearchEntries | null
}
export function useOpenTabSearch({
@@ -49,6 +52,7 @@ export function useOpenTabSearch({
return useMemo(
() => ({
query: deferredQuery,
+ entries,
results: entries ? searchOpenTabs({ ...entries, query: deferredQuery }) : EMPTY_RESULTS
}),
[deferredQuery, entries]
diff --git a/src/renderer/src/components/tab-bar/use-tab-create-entry-search-results.ts b/src/renderer/src/components/tab-bar/use-tab-create-entry-search-results.ts
new file mode 100644
index 00000000000..72cb38a50d0
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/use-tab-create-entry-search-results.ts
@@ -0,0 +1,31 @@
+import { retainOpenTabResultsForQuery } from './open-tab-search-retention'
+import { EMPTY_TAB_RESULTS } from './tab-create-entry-empty-options'
+import { useOpenTabSearch } from './use-open-tab-search'
+import type { OpenTabSearchResult } from './open-tab-search'
+
+export function useTabCreateEntrySearchResults({
+ enabled,
+ query,
+ worktreeId
+}: {
+ enabled: boolean
+ query: string
+ worktreeId: string
+}): readonly OpenTabSearchResult[] {
+ const tabSearch = useOpenTabSearch({
+ enabled,
+ query: enabled ? query : '',
+ worktreeId
+ })
+ // Why retain instead of clearing: emptying deferred rows flashes the list on
+ // every keystroke. Retention re-checks each row against the live query, so
+ // Enter cannot submit a tab the current text never matched.
+ return enabled
+ ? retainOpenTabResultsForQuery({
+ entries: tabSearch.entries,
+ query,
+ results: tabSearch.results,
+ resultsQuery: tabSearch.query
+ })
+ : EMPTY_TAB_RESULTS
+}
From b2163f9a1d12c2b346a0d66c2d9019e54a981a8b Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 17 Aug 2026 17:12:40 -0700
Subject: [PATCH 05/43] test(e2e): pin pty input bytes for Hangul runs that
cross a wrap boundary (#15080)
Every CJK byte-exactness spec in the suite types a handful of characters, so
none of them ever reaches the right edge of a row. This adds a run long enough
to wrap at the pane's real width, driven at the pane width that actually
sticks (splits, not `terminal.resize`, which the fit pass springs back).
Investigated #15066 while here; it does not reproduce as input corruption.
---
...erminal-hangul-wrap-boundary-bytes.spec.ts | 300 ++++++++++++++++++
1 file changed, 300 insertions(+)
create mode 100644 tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts
diff --git a/tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts b/tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts
new file mode 100644
index 00000000000..c93102ceb42
--- /dev/null
+++ b/tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts
@@ -0,0 +1,300 @@
+/**
+ * Byte-exactness for a Hangul run long enough to wrap.
+ *
+ * The gap this closes is real independent of any bug report: every CJK byte-exactness spec in the
+ * suite types a handful of characters (`日本語`, `你好。`), so none of them ever reaches the right
+ * edge of a row. A wide glyph that does not fit in the last cell has to move to the next row, and
+ * that regime had no byte-level coverage at all.
+ *
+ * #15066 claims a long Hangul run gains a stray line feed and two spaces near the wrap point — 42
+ * typed, 45 stored. Chasing that report is what produced this spec, and the report did not hold:
+ * the stray spaces are real characters in the buffer, but the shell *wrote* them as output. They
+ * appear only when the prompt carries non-printing escapes that are not wrapped in `\[ \]`, which
+ * makes readline's wrap arithmetic pad at the boundary; with a plain prompt the identical run at
+ * the identical width renders clean. Nothing extra ever reached the pty as input.
+ *
+ * DIRECTION MATTERS: the byte reader is a node process holding the pty, so what it records is what
+ * the pty *received as input*, never what the shell echoed back. An extra byte observed here could
+ * only have been manufactured by the renderer's input path — which is the half of this that is
+ * Orca's to guarantee, and the half that had no coverage.
+ */
+import type { CDPSession, Page } from '@stablyai/playwright-test'
+import { expect, test } from './helpers/orca-app'
+import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+import {
+ splitActiveTerminalPane,
+ waitForActiveTerminalManager,
+ waitForPaneCount
+} from './helpers/terminal'
+import { closeTerminalImePaneArena, openTerminalImePaneArena } from './terminal-ime-pane-arena'
+import {
+ commitImeText,
+ composeHangulSyllable,
+ dispatchImeProcessKey,
+ dispatchImeRewrittenPrintableKey,
+ dispatchPlainEnter,
+ setImeComposition,
+ type ImeKeyIdentity
+} from './terminal-ime-cdp-composition'
+import {
+ createTerminalImeByteReader,
+ removeTerminalImeByteReader,
+ startTerminalImeByteReader,
+ waitForTerminalImeBytes
+} from './terminal-ime-byte-reader'
+import {
+ applyImePlatformPolicy,
+ expectImePlatformPolicy,
+ type ImePlatformPolicy
+} from './terminal-ime-platform-policy'
+
+type JamoKey = { jamo: string; code: string; index: number }
+
+/** 2-Set Korean: the physical key each jamo sits on, plus its index in the Hangul syllable formula. */
+const CHOSEONG: readonly JamoKey[] = [
+ { jamo: 'ㄱ', code: 'KeyR', index: 0 },
+ { jamo: 'ㄴ', code: 'KeyS', index: 2 },
+ { jamo: 'ㄷ', code: 'KeyE', index: 3 },
+ { jamo: 'ㄹ', code: 'KeyF', index: 5 },
+ { jamo: 'ㅁ', code: 'KeyA', index: 6 },
+ { jamo: 'ㅂ', code: 'KeyQ', index: 7 },
+ { jamo: 'ㅅ', code: 'KeyT', index: 9 },
+ { jamo: 'ㅇ', code: 'KeyD', index: 11 }
+]
+
+const JUNGSEONG: readonly JamoKey[] = [
+ { jamo: 'ㅏ', code: 'KeyK', index: 0 },
+ { jamo: 'ㅓ', code: 'KeyJ', index: 4 },
+ { jamo: 'ㅗ', code: 'KeyH', index: 8 },
+ { jamo: 'ㅜ', code: 'KeyN', index: 13 }
+]
+
+/** IME preedit keystrokes carry no text payload, so every jamo key is a VK_PROCESSKEY. */
+function jamoIdentity(key: JamoKey): ImeKeyIdentity {
+ return { key: key.jamo, code: key.code, keyCode: 229 }
+}
+
+/** A plain printable keydown, used to push the run one cell out of phase with the row. */
+const OFFSET_KEY: ImeKeyIdentity = { key: 'x', code: 'KeyX', keyCode: 88 }
+
+type HangulSyllable = {
+ text: string
+ frames: readonly { jamoKey: ImeKeyIdentity; preedit: string }[]
+}
+
+/** Every syllable is two keystrokes: consonant, then vowel, which assembles the precomposed glyph. */
+function buildHangulRun(length: number): HangulSyllable[] {
+ return Array.from({ length }, (_unused, position) => {
+ const choseong = CHOSEONG[position % CHOSEONG.length]
+ const jungseong = JUNGSEONG[Math.floor(position / CHOSEONG.length) % JUNGSEONG.length]
+ const text = String.fromCharCode(0xac00 + (choseong.index * 21 + jungseong.index) * 28)
+ return {
+ text,
+ frames: [
+ { jamoKey: jamoIdentity(choseong), preedit: choseong.jamo },
+ { jamoKey: jamoIdentity(jungseong), preedit: text }
+ ]
+ }
+ })
+}
+
+type PaneGrid = { cols: number; cursorRow: number }
+
+async function readPaneGrid(page: Page, ptyId: string): Promise {
+ return page.evaluate((targetPtyId) => {
+ for (const manager of window.__paneManagers?.values?.() ?? []) {
+ const pane = manager
+ .getPanes?.()
+ .find((candidate) => candidate.container.dataset.ptyId === targetPtyId)
+ if (pane) {
+ const buffer = pane.terminal.buffer.active
+ return { cols: pane.terminal.cols, cursorRow: buffer.baseY + buffer.cursorY }
+ }
+ }
+ return { cols: 0, cursorRow: 0 }
+ }, ptyId)
+}
+
+/**
+ * Splits the pane down instead of calling `terminal.resize`.
+ *
+ * A direct `resize` does take, but the pane's fit pass re-fits to the container the moment
+ * anything else touches layout, so the width silently springs back mid-run and the spec ends up
+ * measuring the wide default. Splitting narrows the container itself, which is the only width the
+ * fit pass will agree to keep.
+ */
+async function narrowPaneBySplitting(page: Page, splits: number): Promise {
+ await waitForSessionReady(page)
+ await waitForActiveWorktree(page)
+ await ensureTerminalVisible(page)
+ await waitForActiveTerminalManager(page, 30_000)
+ for (let split = 0; split < splits; split += 1) {
+ await splitActiveTerminalPane(page, 'vertical')
+ await waitForPaneCount(page, split + 2)
+ }
+}
+
+async function waitForSettledCols(page: Page, ptyId: string): Promise {
+ let settled = 0
+ await expect
+ .poll(
+ async () => {
+ const observed = (await readPaneGrid(page, ptyId)).cols
+ const stable = observed === settled && observed > 0
+ settled = observed
+ return stable
+ },
+ { timeout: 10_000, message: 'terminal width never settled' }
+ )
+ .toBe(true)
+ return settled
+}
+
+/** `pauseMs` is the gap between keystrokes; each syllable commits before the next one starts. */
+async function typeHangulRun(
+ session: CDPSession,
+ page: Page,
+ run: readonly HangulSyllable[],
+ pauseMs: number
+): Promise {
+ for (const syllable of run) {
+ await composeHangulSyllable(session, page, syllable.frames, pauseMs)
+ await commitImeText(session, syllable.text)
+ }
+}
+
+/** One composition session whose preedit grows one syllable at a time, then commits whole. */
+async function composeThenCommitPhrase(
+ session: CDPSession,
+ page: Page,
+ run: readonly HangulSyllable[],
+ pauseMs: number
+): Promise {
+ let preedit = ''
+ for (const syllable of run) {
+ preedit += syllable.text
+ await dispatchImeProcessKey(session, syllable.frames[0].jamoKey)
+ await setImeComposition(session, preedit)
+ if (pauseMs > 0) {
+ await page.waitForTimeout(pauseMs)
+ }
+ }
+ await commitImeText(session, preedit)
+}
+
+const KEYSTROKE_PAUSE_MS = 16
+
+/**
+ * Asserts the pty received exactly `expectedText` and nothing else.
+ *
+ * Both forms are checked on purpose: the decoded compare is what makes a failure readable (an
+ * inserted LF or space shows up in the diff), the hex compare is what makes it byte-exact.
+ */
+function expectExactPtyInput(received: readonly string[], expectedText: string): void {
+ const decoded = received.map((hex) => Buffer.from(hex, 'hex').toString('utf8'))
+ expect(decoded).toEqual([`${expectedText}\n`])
+ expect(received).toEqual([Buffer.from(`${expectedText}\n`).toString('hex')])
+}
+
+type WrapScenario = {
+ name: string
+ /** Shifts the run one cell out of phase, so the boundary falls mid-syllable at even widths. */
+ offsetByOneCell: boolean
+ /** Derived from the width the pane actually has, never from an assumed 80. */
+ syllableCount: (observedCols: number) => number
+ drive: (session: CDPSession, page: Page, run: readonly HangulSyllable[]) => Promise
+ policies: readonly ImePlatformPolicy[]
+}
+
+const SCENARIOS: readonly WrapScenario[] = [
+ {
+ // The count from the report, starting at column 0.
+ name: '42 syllables',
+ offsetByOneCell: false,
+ syllableCount: () => 42,
+ drive: (session, page, run) => typeHangulRun(session, page, run, KEYSTROKE_PAUSE_MS),
+ policies: ['mac', 'windows', 'linux']
+ },
+ {
+ // Same run one cell out of phase. Whichever parity the pane's real width has, this and the
+ // scenario above cover both the clean boundary and the one where the next syllable is too wide
+ // for the last cell and has to move down a row.
+ name: '42 syllables offset by one cell',
+ offsetByOneCell: true,
+ syllableCount: () => 42,
+ drive: (session, page, run) => typeHangulRun(session, page, run, KEYSTROKE_PAUSE_MS),
+ policies: ['mac']
+ },
+ {
+ // Ends on the last syllable that fits the row, leaving the emulator in its deferred-wrap state
+ // when Enter arrives.
+ name: 'a run that ends on the wrap boundary',
+ offsetByOneCell: false,
+ syllableCount: (observedCols) => Math.floor(observedCols / 2) * 2,
+ drive: (session, page, run) => typeHangulRun(session, page, run, KEYSTROKE_PAUSE_MS),
+ policies: ['mac']
+ },
+ {
+ // Composes across the boundary instead of committing before it: the preedit outgrows the rest
+ // of the row and only commits once it is well past the edge.
+ name: 'a composition that straddles the wrap boundary',
+ offsetByOneCell: false,
+ syllableCount: (observedCols) => Math.floor(observedCols / 2) + 8,
+ drive: (session, page, run) => composeThenCommitPhrase(session, page, run, KEYSTROKE_PAUSE_MS),
+ policies: ['mac']
+ }
+]
+
+test.describe('Terminal Hangul wrap-boundary byte exactness', () => {
+ for (const scenario of SCENARIOS) {
+ for (const policy of scenario.policies) {
+ test(`sends ${scenario.name} unaltered to the pty (${policy})`, async ({
+ orcaPage,
+ testRepoPath
+ }, testInfo) => {
+ await applyImePlatformPolicy(orcaPage, policy)
+ await expectImePlatformPolicy(orcaPage, policy)
+ await narrowPaneBySplitting(orcaPage, 2)
+
+ const arena = await openTerminalImePaneArena(orcaPage)
+ const reader = createTerminalImeByteReader(testRepoPath, 1)
+ let completed = false
+ try {
+ await startTerminalImeByteReader(orcaPage, arena.ptyId, reader)
+
+ const observedCols = await waitForSettledCols(orcaPage, arena.ptyId)
+ const run = buildHangulRun(scenario.syllableCount(observedCols))
+ const prefix = scenario.offsetByOneCell ? OFFSET_KEY.key : ''
+ const expectedText = prefix + run.map((syllable) => syllable.text).join('')
+ testInfo.annotations.push({
+ type: 'wrap-regime',
+ description: `${observedCols} cols, ${run.length} syllables, offset ${prefix.length}`
+ })
+
+ const startRow = (await readPaneGrid(orcaPage, arena.ptyId)).cursorRow
+ if (scenario.offsetByOneCell) {
+ await dispatchImeRewrittenPrintableKey(arena.session, OFFSET_KEY)
+ }
+ await scenario.drive(arena.session, orcaPage, run)
+ // The guard that stops this passing by measuring nothing: the echoed run has to have
+ // pushed the cursor onto a later row, which is the wrap the whole spec is about.
+ await expect
+ .poll(async () => (await readPaneGrid(orcaPage, arena.ptyId)).cursorRow, {
+ timeout: 10_000,
+ message: `run of ${run.length} syllables never wrapped at ${observedCols} cols`
+ })
+ .toBeGreaterThan(startRow)
+
+ await dispatchPlainEnter(arena.session)
+
+ const received = await waitForTerminalImeBytes(orcaPage, reader, 20_000)
+ expectExactPtyInput(received, expectedText)
+ completed = true
+ } finally {
+ await closeTerminalImePaneArena(arena, testInfo, 'hangul-wrap-boundary-bytes', !completed)
+ removeTerminalImeByteReader(reader)
+ }
+ })
+ }
+ }
+})
From 6ee265e579e4fd6ce895dd1be8149536451be2c5 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Mon, 17 Aug 2026 17:26:52 -0700
Subject: [PATCH 06/43] feat(agent-status): surface the model each Codex
subagent is running (#8251) (#14627)
Codex child rows have carried a model field end-to-end since #9637, but the
transcript reader never populated it, so every transcript-discovered child
rendered with an empty model chip. Read the child's own turn_context.model
from the rollout records already fetched for completion detection, so the
sidebar can distinguish an orchestrator model from a subagent model.
No added file I/O and no added rows: the model is parsed from records the
reconcile pass already read, and both row components already render
entry.model.
---
src/shared/codex-subagent-roster.test.ts | 51 +++++++
src/shared/codex-subagent-roster.ts | 22 +++
src/shared/codex-subagent-transcript.test.ts | 133 +++++++++++++++++++
src/shared/codex-subagent-transcript.ts | 28 ++++
4 files changed, 234 insertions(+)
diff --git a/src/shared/codex-subagent-roster.test.ts b/src/shared/codex-subagent-roster.test.ts
index fde2c8cf849..0cbb44830c9 100644
--- a/src/shared/codex-subagent-roster.test.ts
+++ b/src/shared/codex-subagent-roster.test.ts
@@ -7,6 +7,7 @@ import {
import {
codexRosterToSnapshots,
finishCodexSubagent,
+ setCodexSubagentModel,
upsertCodexSubagent,
type CodexSubagentRoster
} from './codex-subagent-roster'
@@ -61,4 +62,54 @@ describe('Codex subagent roster', () => {
expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS)
expect(roster.has('replacement')).toBe(true)
})
+
+ describe('setCodexSubagentModel', () => {
+ it('records the model without disturbing the child lifecycle or label', () => {
+ const roster: CodexSubagentRoster = new Map()
+ upsertCodexSubagent(roster, 'child-1', { description: '/root/audit', state: 'waiting' }, 10)
+
+ setCodexSubagentModel(roster, 'child-1', ' gpt-5.6-terra ')
+
+ expect(codexRosterToSnapshots(roster)).toEqual([
+ {
+ id: 'child-1',
+ agentType: undefined,
+ description: '/root/audit',
+ model: 'gpt-5.6-terra',
+ state: 'waiting',
+ startedAt: 10
+ }
+ ])
+ })
+
+ it('never creates a row for a child that is no longer tracked', () => {
+ const roster: CodexSubagentRoster = new Map()
+ upsertCodexSubagent(roster, 'child-1', { state: 'working' }, 10)
+ finishCodexSubagent(roster, 'child-1')
+
+ // A model read racing a completed child must not resurrect its row.
+ setCodexSubagentModel(roster, 'child-1', 'gpt-5.6-terra')
+
+ expect(roster.size).toBe(0)
+ })
+
+ it('keeps a known model when the new value is empty', () => {
+ const roster: CodexSubagentRoster = new Map()
+ upsertCodexSubagent(roster, 'child-1', { model: 'gpt-5.6-sol', state: 'working' }, 10)
+
+ setCodexSubagentModel(roster, 'child-1', ' ')
+ setCodexSubagentModel(roster, 'child-1', undefined)
+
+ expect(roster.get('child-1')?.model).toBe('gpt-5.6-sol')
+ })
+
+ it('bounds an oversized model to the shared cap', () => {
+ const roster: CodexSubagentRoster = new Map()
+ upsertCodexSubagent(roster, 'child-1', { state: 'working' }, 10)
+
+ setCodexSubagentModel(roster, 'child-1', 'x'.repeat(AGENT_MODEL_MAX_LENGTH + 50))
+
+ expect(roster.get('child-1')?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH)
+ })
+ })
})
diff --git a/src/shared/codex-subagent-roster.ts b/src/shared/codex-subagent-roster.ts
index 72cc216b76e..5592ed8711f 100644
--- a/src/shared/codex-subagent-roster.ts
+++ b/src/shared/codex-subagent-roster.ts
@@ -61,6 +61,28 @@ export function finishCodexSubagent(roster: CodexSubagentRoster, id: string): vo
roster.delete(id.trim())
}
+/**
+ * Record the model a already-tracked child is running. Deliberately narrower
+ * than `upsertCodexSubagent`: it never creates a roster entry and never touches
+ * `state`, so late model discovery from a child rollout cannot resurrect a
+ * finished child nor move any child's lifecycle.
+ */
+export function setCodexSubagentModel(
+ roster: CodexSubagentRoster,
+ id: string,
+ model: string | undefined
+): void {
+ const normalizedModel = normalizeOptionalField(model, AGENT_MODEL_MAX_LENGTH)
+ if (!normalizedModel) {
+ return
+ }
+ const existing = roster.get(id.trim())
+ if (!existing) {
+ return
+ }
+ existing.model = normalizedModel
+}
+
export function seedCodexSubagentRoster(
roster: CodexSubagentRoster,
snapshots: readonly AgentSubagentSnapshot[]
diff --git a/src/shared/codex-subagent-transcript.test.ts b/src/shared/codex-subagent-transcript.test.ts
index d2f729912b5..fda077a1e50 100644
--- a/src/shared/codex-subagent-transcript.test.ts
+++ b/src/shared/codex-subagent-transcript.test.ts
@@ -1,8 +1,23 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import type * as NodeFs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
+// Why: ESM namespaces are not configurable, so counting rollout opens needs a
+// delegating mock rather than vi.spyOn. Every other export stays real.
+const openSyncCalls = vi.hoisted(() => vi.fn())
+vi.mock('node:fs', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ openSync: (...args: Parameters) => {
+ openSyncCalls(...args)
+ return actual.openSync(...args)
+ }
+ }
+})
+
import {
createCodexSubagentTranscriptState,
hasTrackedCodexTranscriptSubagents,
@@ -155,4 +170,122 @@ describe('Codex subagent transcript reconciliation', () => {
expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false)
expect(roster.size).toBe(0)
})
+
+ describe('child model identity', () => {
+ function turnContext(model: string): unknown {
+ return { type: 'turn_context', payload: { turn_id: 'turn-1', model } }
+ }
+
+ function started(): unknown {
+ return { type: 'event_msg', payload: { type: 'task_started' } }
+ }
+
+ /** Parent rollout with one live child, plus that child's own rollout. */
+ function seedPair(childRecords: unknown[]): {
+ parentPath: string
+ childPath: string
+ } {
+ const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-model-'))
+ dirs.push(dir)
+ const parentPath = join(dir, 'rollout-parent.jsonl')
+ const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`)
+ writeFileSync(parentPath, jsonl([turnContext('gpt-5.6-sol'), activity('started')]))
+ writeFileSync(childPath, jsonl(childRecords))
+ return { parentPath, childPath }
+ }
+
+ it('reports the model from the child rollout, not the parent model', () => {
+ const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
+ const state = createCodexSubagentTranscriptState()
+ const roster: CodexSubagentRoster = new Map()
+
+ reconcileCodexSubagentTranscript(state, roster, parentPath)
+
+ // The parent runs sol; only the child's own turn_context may set its model.
+ expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
+ })
+
+ it('keeps the discovered model when a later poll carries no turn_context', () => {
+ const { parentPath, childPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
+ const state = createCodexSubagentTranscriptState()
+ const roster: CodexSubagentRoster = new Map()
+ reconcileCodexSubagentTranscript(state, roster, parentPath)
+
+ // The cursor is incremental: this appended line is all the next read sees.
+ writeFileSync(
+ childPath,
+ jsonl([
+ started(),
+ turnContext('gpt-5.6-terra'),
+ { type: 'event_msg', payload: { type: 'agent_message' } }
+ ])
+ )
+ reconcileCodexSubagentTranscript(state, roster, parentPath)
+
+ expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
+ })
+
+ it('tracks the newest model when the child switches mid-session', () => {
+ const { parentPath } = seedPair([
+ started(),
+ turnContext('gpt-5.6-terra'),
+ turnContext('gpt-5.6-sol')
+ ])
+ const state = createCodexSubagentTranscriptState()
+ const roster: CodexSubagentRoster = new Map()
+
+ reconcileCodexSubagentTranscript(state, roster, parentPath)
+
+ expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-sol')
+ })
+
+ it('leaves the child working and keeps its identity while reading the model', () => {
+ const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
+ const state = createCodexSubagentTranscriptState()
+ const roster: CodexSubagentRoster = new Map()
+
+ reconcileCodexSubagentTranscript(state, roster, parentPath)
+
+ // Model discovery must not move lifecycle or overwrite the child's label.
+ expect(codexRosterToSnapshots(roster)).toEqual([
+ {
+ id: CHILD_ID,
+ description: '/root/sidebar_repro',
+ state: 'working',
+ startedAt: 1234,
+ agentType: undefined,
+ model: 'gpt-5.6-terra'
+ }
+ ])
+ })
+
+ it('does not resurrect a child that completed in the same read', () => {
+ const { parentPath } = seedPair([
+ started(),
+ turnContext('gpt-5.6-terra'),
+ { type: 'event_msg', payload: { type: 'task_complete' } }
+ ])
+ const state = createCodexSubagentTranscriptState()
+ const roster: CodexSubagentRoster = new Map()
+
+ reconcileCodexSubagentTranscript(state, roster, parentPath)
+
+ expect(roster.size).toBe(0)
+ expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false)
+ })
+
+ it('reads the model without opening any file beyond the parent and child rollouts', () => {
+ const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
+ const state = createCodexSubagentTranscriptState()
+ const roster: CodexSubagentRoster = new Map()
+ openSyncCalls.mockClear()
+
+ reconcileCodexSubagentTranscript(state, roster, parentPath)
+
+ // Why: model extraction reuses the records already read for completion
+ // detection, so it must add no file I/O of its own.
+ expect(openSyncCalls).toHaveBeenCalledTimes(2)
+ expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
+ })
+ })
})
diff --git a/src/shared/codex-subagent-transcript.ts b/src/shared/codex-subagent-transcript.ts
index 3f9b30898c1..b15254684ee 100644
--- a/src/shared/codex-subagent-transcript.ts
+++ b/src/shared/codex-subagent-transcript.ts
@@ -3,6 +3,7 @@ import { basename, dirname, extname, isAbsolute, join } from 'node:path'
import {
finishCodexSubagent,
+ setCodexSubagentModel,
upsertCodexSubagent,
type CodexSubagentRoster
} from './codex-subagent-roster'
@@ -22,6 +23,10 @@ type JsonlCursor = {
type TrackedTranscriptSubagent = JsonlCursor & {
description?: string
+ /** Latest model seen in the child's own rollout. Retained across polls
+ * because the cursor is incremental: `turn_context` is emitted once per
+ * turn, so a later read usually carries no model at all. */
+ model?: string
startedAt: number
unresolvedSince?: number
}
@@ -199,6 +204,24 @@ function readActivity(recordValue: JsonRecord):
}
}
+/** Latest model from the child's own `turn_context` records. A child can be
+ * launched on a different model than its parent, so this is read from the
+ * child rollout rather than inherited. */
+function readChildModel(records: JsonRecord[]): string | undefined {
+ let model: string | undefined
+ for (const recordValue of records) {
+ if (recordValue.type !== 'turn_context') {
+ continue
+ }
+ const payload = record(recordValue.payload)
+ const value = typeof payload?.model === 'string' ? payload.model.trim() : ''
+ if (value) {
+ model = value
+ }
+ }
+ return model
+}
+
function childIsComplete(records: JsonRecord[]): boolean {
let complete = false
for (const recordValue of records) {
@@ -289,6 +312,11 @@ export function reconcileCodexSubagentTranscript(
}
} else {
tracked.unresolvedSince = undefined
+ tracked.model = readChildModel(records) ?? tracked.model
+ // Why: re-applied every reconcile, not just on discovery — the parent's
+ // own activity upsert can rebuild this child's roster entry, which would
+ // otherwise drop a model found on an earlier poll.
+ setCodexSubagentModel(roster, id, tracked.model)
if (!childIsComplete(records)) {
continue
}
From 0e96b82e4496b20a373c47ebd10d92b60eb78d98 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 17 Aug 2026 18:26:52 -0700
Subject: [PATCH 07/43] fix(mobile): keep phone tab selection across host
snapshots
* fix(mobile): keep phone tab selection across host snapshots
Preserve device-owned tab focus across ordinary host republications while explicit follow navigation remains authoritative. Retire closed selections across clients so stale snapshots cannot resurrect tabs.
* fix(mobile): acknowledge session tab closes
* fix(mobile): avoid tombstones for uncommitted closes
* fix(web): implement session close IPC stubs
* refactor: simplify mobile tab close flow
* fix: bound session tab close confirmation
---
.../app/h/[hostId]/session/[worktreeId].tsx | 66 ++++----
mobile/src/session/active-session-tab.test.ts | 144 ++++++++++++++++++
mobile/src/session/active-session-tab.ts | 68 +++++++++
.../src/session/mobile-session-route-types.ts | 2 +
.../client-session-tab-closed-selection.ts | 104 +++++++++++++
.../client-session-tab-selection.test.ts | 56 +++++++
.../runtime/client-session-tab-selection.ts | 69 ++++++---
src/main/runtime/orca-runtime.test.ts | 52 +++++++
src/main/runtime/orca-runtime.ts | 78 ++++++----
.../window/attach-main-window-services.ts | 4 +-
.../session-tab-close-request-relay.test.ts | 121 +++++++++++++++
.../window/session-tab-close-request-relay.ts | 68 +++++++++
src/preload/api/ui-command-event-api.ts | 6 +
src/preload/index.ts | 11 ++
...vents-agent-status-window-test-fixtures.ts | 2 +
.../ipc-events-close-routing-test-harness.ts | 17 +++
...ts-terminal-create-window-test-fixtures.ts | 2 +
.../useIpcEvents-browser-tab-create.test.ts | 2 +
...eIpcEvents-cli-worktree-activation.test.ts | 4 +
...Events-close-routing-browser-pages.test.ts | 6 +
...pcEvents-session-tab-close-request.test.ts | 138 +++++++++++++++++
...seIpcEvents-ssh-disconnect-cleanup.test.ts | 2 +
.../hooks/useIpcEvents-updater-status.test.ts | 2 +
src/renderer/src/hooks/useIpcEvents.ts | 79 ++++++++--
.../src/store/pinned-tab-close-guard.test.ts | 12 ++
.../src/store/pinned-tab-close-guard.ts | 12 +-
.../slices/pinned-tab-close-confirm.test.ts | 20 +++
.../store/slices/pinned-tab-close-confirm.ts | 25 ++-
src/renderer/src/web/web-preload-api.ts | 2 +
src/shared/session-tab-close.ts | 16 ++
30 files changed, 1093 insertions(+), 97 deletions(-)
create mode 100644 mobile/src/session/active-session-tab.test.ts
create mode 100644 mobile/src/session/active-session-tab.ts
create mode 100644 src/main/runtime/client-session-tab-closed-selection.ts
create mode 100644 src/main/window/session-tab-close-request-relay.test.ts
create mode 100644 src/main/window/session-tab-close-request-relay.ts
create mode 100644 src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts
create mode 100644 src/shared/session-tab-close.ts
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 5587c204316..75095becd39 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -208,6 +208,7 @@ import {
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
+import { resolveActiveSessionTab } from '../../../../src/session/active-session-tab'
import {
createInitialSessionAutoCreateState,
useInitialSessionTerminalAutoCreate,
@@ -933,6 +934,8 @@ export default function SessionScreen() {
const activeHandleRef = useRef(null)
const activeSessionTabTypeRef = useRef(null)
const pendingActiveSessionTabIdRef = useRef(null)
+ // Why: survive transient snapshot gaps so the device's own tab pick can re-bind.
+ const selectedSessionTabIdRef = useRef(null)
const pendingActiveTerminalHandleRef = useRef(null)
// Why: remember the page id to activate its session tab once it syncs (bridge auto-activate flags only webContents, not the app-level active tab).
const pendingBrowserFocusPageIdRef = useRef(null)
@@ -1740,26 +1743,28 @@ export default function SessionScreen() {
const snapshotActive = nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null
const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current
- const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current
- let active = snapshotActive
- let selectionSource = 'snapshot'
- if (pendingActiveSessionTabId) {
- if (snapshotActive?.id === pendingActiveSessionTabId) {
- if (confirmsMirroredTabSelection(result.publicationEpoch)) {
- pendingActiveSessionTabIdRef.current = null
- } else {
- selectionSource = 'pending-tab-local-ack'
- }
- } else {
- const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId)
- if (pendingTab) {
- // Why: desktop tab snapshots can lag a mobile tap mid-activate-RPC; keep the local selection to avoid snapping back.
- active = pendingTab
- selectionSource = 'pending-tab'
- } else {
- pendingActiveSessionTabIdRef.current = null
- }
- }
+ const followsHost = result.navigationIntent === 'follow'
+ const pendingActiveTerminalHandle = followsHost
+ ? null
+ : pendingActiveTerminalHandleRef.current
+ if (followsHost) {
+ pendingActiveTerminalHandleRef.current = null
+ pendingBrowserFocusPageIdRef.current = null
+ }
+ const resolved = resolveActiveSessionTab(nextTabs, {
+ pendingActiveSessionTabId,
+ selectedSessionTabId: selectedSessionTabIdRef.current,
+ navigationIntent: result.navigationIntent
+ })
+ let active = resolved.activeTab
+ let selectionSource: string = resolved.selectionSource
+ if (resolved.clearPendingActiveSessionTabId) {
+ const localAck =
+ !followsHost &&
+ snapshotActive?.id === pendingActiveSessionTabId &&
+ !confirmsMirroredTabSelection(result.publicationEpoch)
+ selectionSource = localAck ? 'pending-tab-local-ack' : selectionSource
+ pendingActiveSessionTabIdRef.current = localAck ? pendingActiveSessionTabId : null
}
if (pendingActiveTerminalHandle) {
const pendingTerminalTab = nextTabs.find(
@@ -1769,14 +1774,15 @@ export default function SessionScreen() {
const pendingTerminalExists = mergedTerminalsForActive.some(
(terminal) => terminal.handle === pendingActiveTerminalHandle
)
- if (
- snapshotActive?.type === 'terminal' &&
- snapshotActive.terminal === pendingActiveTerminalHandle
- ) {
- if (confirmsMirroredTabSelection(result.publicationEpoch)) {
- pendingActiveTerminalHandleRef.current = null
- } else {
+ if (active?.type === 'terminal' && active.terminal === pendingActiveTerminalHandle) {
+ if (
+ snapshotActive?.type === 'terminal' &&
+ snapshotActive.terminal === pendingActiveTerminalHandle &&
+ !confirmsMirroredTabSelection(result.publicationEpoch)
+ ) {
selectionSource = 'pending-handle-local-ack'
+ } else {
+ pendingActiveTerminalHandleRef.current = null
}
} else if (pendingTerminalTab) {
// Why: desktop active flags lag a mobile tap; key by handle too, as fallback PTY tabs lack a stable tab id at startup.
@@ -1799,6 +1805,9 @@ export default function SessionScreen() {
}
}
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
+ if (!resolved.retainSelectedSessionTabId || active !== resolved.activeTab) {
+ selectedSessionTabIdRef.current = active?.id ?? null
+ }
activeSessionTabTypeRef.current = active?.type ?? null
activeSessionTabIdRef.current = active?.id ?? null
setActiveSessionTabId(active?.id ?? null)
@@ -2590,6 +2599,7 @@ export default function SessionScreen() {
activeHandleRef.current = null
activeSessionTabTypeRef.current = null
pendingActiveSessionTabIdRef.current = null
+ selectedSessionTabIdRef.current = null
pendingActiveTerminalHandleRef.current = null
pendingBrowserFocusPageIdRef.current = null
pendingTerminalActivationAttemptRef.current = null
@@ -4068,6 +4078,8 @@ export default function SessionScreen() {
// so comparing against the ref keeps the anchor from being nulled out.
if (activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0) {
activeSessionTabTypeRef.current = null
+ // Why: an explicit close is not a transient gap; drop the sticky pick so the snapshot picks the next tab.
+ selectedSessionTabIdRef.current = null
activeSessionTabIdRef.current = null
setActiveSessionTabId(null)
activeHandleRef.current = null
diff --git a/mobile/src/session/active-session-tab.test.ts b/mobile/src/session/active-session-tab.test.ts
new file mode 100644
index 00000000000..ec3a6fa5ee3
--- /dev/null
+++ b/mobile/src/session/active-session-tab.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest'
+import { resolveActiveSessionTab } from './active-session-tab'
+
+type Tab = { id: string; type: 'terminal' | 'browser'; isActive: boolean }
+
+function terminalTab(id: string, isActive: boolean): Tab {
+ return { id, type: 'terminal', isActive }
+}
+
+function browserTab(id: string, isActive: boolean): Tab {
+ return { id, type: 'browser', isActive }
+}
+
+describe('resolveActiveSessionTab', () => {
+ it('keeps the device on its browser tab when a republication flags the agent terminal active', () => {
+ const result = resolveActiveSessionTab(
+ [terminalTab('agent', true), browserTab('browser', false)],
+ {
+ pendingActiveSessionTabId: null,
+ selectedSessionTabId: 'browser'
+ }
+ )
+
+ expect(result.activeTab?.id).toBe('browser')
+ expect(result.selectionSource).toBe('selected-tab')
+ expect(result.retainSelectedSessionTabId).toBe(false)
+ })
+
+ it('keeps the device on a terminal tab when an agent turn flags another terminal active', () => {
+ const result = resolveActiveSessionTab(
+ [terminalTab('agent', true), terminalTab('shell', false)],
+ {
+ pendingActiveSessionTabId: null,
+ selectedSessionTabId: 'shell'
+ }
+ )
+
+ expect(result.activeTab?.id).toBe('shell')
+ })
+
+ it('follows the host when it explicitly navigates this device', () => {
+ const result = resolveActiveSessionTab(
+ [terminalTab('agent', true), browserTab('browser', false)],
+ {
+ pendingActiveSessionTabId: null,
+ selectedSessionTabId: 'browser',
+ navigationIntent: 'follow'
+ }
+ )
+
+ expect(result.activeTab?.id).toBe('agent')
+ expect(result.selectionSource).toBe('navigation-intent')
+ expect(result.retainSelectedSessionTabId).toBe(false)
+ })
+
+ it('lets explicit follow supersede an in-flight local activation', () => {
+ const result = resolveActiveSessionTab(
+ [terminalTab('agent', true), browserTab('browser', false)],
+ {
+ pendingActiveSessionTabId: 'browser',
+ selectedSessionTabId: 'browser',
+ navigationIntent: 'follow'
+ }
+ )
+
+ expect(result.activeTab?.id).toBe('agent')
+ expect(result.selectionSource).toBe('navigation-intent')
+ expect(result.clearPendingActiveSessionTabId).toBe(true)
+ })
+
+ it('falls back to the snapshot but retains the pick while the selected tab is missing', () => {
+ const gap = resolveActiveSessionTab([terminalTab('agent', true)], {
+ pendingActiveSessionTabId: null,
+ selectedSessionTabId: 'browser'
+ })
+
+ expect(gap.activeTab?.id).toBe('agent')
+ expect(gap.retainSelectedSessionTabId).toBe(true)
+
+ // The guest re-registers and the tab comes back: focus must return to it.
+ const restored = resolveActiveSessionTab(
+ [terminalTab('agent', true), browserTab('browser', false)],
+ { pendingActiveSessionTabId: null, selectedSessionTabId: 'browser' }
+ )
+ expect(restored.activeTab?.id).toBe('browser')
+ })
+
+ it('lets a pending activation win until the snapshot catches up', () => {
+ const result = resolveActiveSessionTab(
+ [terminalTab('agent', true), browserTab('browser', false)],
+ {
+ pendingActiveSessionTabId: 'browser',
+ selectedSessionTabId: 'agent'
+ }
+ )
+
+ expect(result.activeTab?.id).toBe('browser')
+ expect(result.selectionSource).toBe('pending-tab')
+ expect(result.clearPendingActiveSessionTabId).toBe(false)
+ })
+
+ it('clears the pending marker once the snapshot acknowledges it', () => {
+ const result = resolveActiveSessionTab(
+ [terminalTab('agent', false), browserTab('browser', true)],
+ {
+ pendingActiveSessionTabId: 'browser',
+ selectedSessionTabId: 'agent'
+ }
+ )
+
+ expect(result.activeTab?.id).toBe('browser')
+ expect(result.clearPendingActiveSessionTabId).toBe(true)
+ })
+
+ it('clears a stale pending marker whose tab is gone', () => {
+ const result = resolveActiveSessionTab([terminalTab('agent', true)], {
+ pendingActiveSessionTabId: 'removed',
+ selectedSessionTabId: null
+ })
+
+ expect(result.activeTab?.id).toBe('agent')
+ expect(result.clearPendingActiveSessionTabId).toBe(true)
+ })
+
+ it('uses the snapshot when the device has no pick yet', () => {
+ const result = resolveActiveSessionTab(
+ [terminalTab('agent', false), browserTab('browser', true)],
+ {
+ pendingActiveSessionTabId: null,
+ selectedSessionTabId: null
+ }
+ )
+
+ expect(result.activeTab?.id).toBe('browser')
+ expect(result.selectionSource).toBe('snapshot')
+ })
+
+ it('returns null for an empty snapshot', () => {
+ expect(
+ resolveActiveSessionTab([], { pendingActiveSessionTabId: null, selectedSessionTabId: 'x' })
+ .activeTab
+ ).toBeNull()
+ })
+})
diff --git a/mobile/src/session/active-session-tab.ts b/mobile/src/session/active-session-tab.ts
new file mode 100644
index 00000000000..0326b23dadd
--- /dev/null
+++ b/mobile/src/session/active-session-tab.ts
@@ -0,0 +1,68 @@
+type SessionTabLike = {
+ id: string
+ isActive: boolean
+}
+
+export type ActiveSessionTabSelectionSource =
+ | 'snapshot'
+ | 'pending-tab'
+ | 'selected-tab'
+ | 'navigation-intent'
+
+export type ResolveActiveSessionTabResult = {
+ activeTab: T | null
+ selectionSource: ActiveSessionTabSelectionSource
+ clearPendingActiveSessionTabId: boolean
+ retainSelectedSessionTabId: boolean
+}
+
+export function resolveActiveSessionTab(
+ tabs: readonly T[],
+ opts: {
+ pendingActiveSessionTabId: string | null
+ selectedSessionTabId: string | null
+ navigationIntent?: 'follow'
+ }
+): ResolveActiveSessionTabResult {
+ const snapshotActive = tabs.find((tab) => tab.isActive) ?? tabs[0] ?? null
+ const pendingActiveSessionTabId = opts.pendingActiveSessionTabId
+ // Why: targeted follow is the only host action allowed to supersede phone-local intent.
+ if (opts.navigationIntent === 'follow') {
+ return {
+ activeTab: snapshotActive,
+ selectionSource: 'navigation-intent',
+ clearPendingActiveSessionTabId: pendingActiveSessionTabId !== null,
+ retainSelectedSessionTabId: false
+ }
+ }
+ if (pendingActiveSessionTabId) {
+ if (snapshotActive?.id === pendingActiveSessionTabId) {
+ return {
+ activeTab: snapshotActive,
+ selectionSource: 'snapshot',
+ clearPendingActiveSessionTabId: true,
+ retainSelectedSessionTabId: false
+ }
+ }
+ const pendingTab = tabs.find((tab) => tab.id === pendingActiveSessionTabId) ?? null
+ if (pendingTab) {
+ return {
+ activeTab: pendingTab,
+ selectionSource: 'pending-tab',
+ clearPendingActiveSessionTabId: false,
+ retainSelectedSessionTabId: false
+ }
+ }
+ }
+ const clearPendingActiveSessionTabId = pendingActiveSessionTabId !== null
+ const selectedTab = opts.selectedSessionTabId
+ ? (tabs.find((tab) => tab.id === opts.selectedSessionTabId) ?? null)
+ : null
+ return {
+ activeTab: selectedTab ?? snapshotActive,
+ selectionSource: selectedTab ? 'selected-tab' : 'snapshot',
+ clearPendingActiveSessionTabId,
+ // Why: transient browser guest swaps must not erase the device's explicit pick.
+ retainSelectedSessionTabId: Boolean(opts.selectedSessionTabId) && !selectedTab
+ }
+}
diff --git a/mobile/src/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts
index 65d4d14d3fe..a61b653a0e3 100644
--- a/mobile/src/session/mobile-session-route-types.ts
+++ b/mobile/src/session/mobile-session-route-types.ts
@@ -61,6 +61,8 @@ export type SessionTabsResult = {
tabs: MobileSessionTab[]
activeTabId: string | null
activeTabType: MobileSessionTabType | null
+ /** Host explicitly navigated this device (desktop/CLI `navigation: clients|all`), not a plain republication. */
+ navigationIntent?: 'follow'
}
export type RuntimeStatusResult = {
diff --git a/src/main/runtime/client-session-tab-closed-selection.ts b/src/main/runtime/client-session-tab-closed-selection.ts
new file mode 100644
index 00000000000..d8d100c5698
--- /dev/null
+++ b/src/main/runtime/client-session-tab-closed-selection.ts
@@ -0,0 +1,104 @@
+import type {
+ RuntimeMobileSessionClientTab,
+ RuntimeMobileSessionTabsResult
+} from '../../shared/runtime-types'
+import type { ClientSessionTabSelection } from './client-session-tab-selection'
+
+export type StoredClientSessionTabSelection = {
+ selection: ClientSessionTabSelection
+ revision: number
+ shouldPersist: boolean
+ closedTabIds: ReadonlySet
+}
+
+function topLevelTabId(tab: RuntimeMobileSessionClientTab): string {
+ return tab.type === 'terminal' ? tab.parentTabId : tab.id
+}
+
+export function projectWithoutClosedClientSessionTabs(
+ snapshot: RuntimeMobileSessionTabsResult,
+ closedTabIds: ReadonlySet
+): { snapshot: RuntimeMobileSessionTabsResult; retainedClosedTabIds: ReadonlySet } {
+ if (closedTabIds.size === 0) {
+ return { snapshot, retainedClosedTabIds: closedTabIds }
+ }
+ const presentTabIds = new Set(snapshot.tabs.flatMap((tab) => [tab.id, topLevelTabId(tab)]))
+ const retainedClosedTabIds = new Set(
+ [...closedTabIds].filter((tabId) => presentTabIds.has(tabId))
+ )
+ if (retainedClosedTabIds.size === 0) {
+ return { snapshot, retainedClosedTabIds }
+ }
+ const tabs = snapshot.tabs.filter(
+ (tab) => !retainedClosedTabIds.has(tab.id) && !retainedClosedTabIds.has(topLevelTabId(tab))
+ )
+ const tabGroups = snapshot.tabGroups?.map((group) => {
+ const tabOrder = group.tabOrder.filter((tabId) => !retainedClosedTabIds.has(tabId))
+ return {
+ ...group,
+ tabOrder,
+ activeTabId:
+ group.activeTabId && tabOrder.includes(group.activeTabId) ? group.activeTabId : null
+ }
+ })
+ return {
+ snapshot: { ...snapshot, tabs, ...(tabGroups ? { tabGroups } : {}) },
+ retainedClosedTabIds
+ }
+}
+
+export function forgetClosedClientSessionSelection(
+ selection: ClientSessionTabSelection,
+ existingClosedTabIds: ReadonlySet,
+ tabIds: readonly string[]
+): {
+ selection: ClientSessionTabSelection
+ closedTabIds: ReadonlySet
+ selectionChanged: boolean
+ closureChanged: boolean
+} {
+ const forgotten = new Set(tabIds)
+ const closedTabIds = new Set([...existingClosedTabIds, ...forgotten])
+ const activeTabId =
+ selection.activeTabId && forgotten.has(selection.activeTabId) ? null : selection.activeTabId
+ const activeTabIdByGroupId = Object.fromEntries(
+ Object.entries(selection.activeTabIdByGroupId).filter(
+ ([, selectedTabId]) => !forgotten.has(selectedTabId)
+ )
+ )
+ return {
+ selection: { ...selection, activeTabId, activeTabIdByGroupId },
+ closedTabIds,
+ selectionChanged:
+ activeTabId !== selection.activeTabId ||
+ Object.keys(activeTabIdByGroupId).length !==
+ Object.keys(selection.activeTabIdByGroupId).length,
+ closureChanged: closedTabIds.size !== existingClosedTabIds.size
+ }
+}
+
+export function forgetClosedClientSessionTabsByWorktree(
+ statesByClient: ReadonlyMap>,
+ worktreeId: string,
+ tabIds: readonly string[]
+): boolean {
+ let persistedSelectionChanged = false
+ for (const statesByWorktree of statesByClient.values()) {
+ const state = statesByWorktree.get(worktreeId)
+ if (!state) {
+ continue
+ }
+ const closed = forgetClosedClientSessionSelection(state.selection, state.closedTabIds, tabIds)
+ if (!closed.selectionChanged && !closed.closureChanged) {
+ continue
+ }
+ statesByWorktree.set(worktreeId, {
+ selection: closed.selection,
+ revision: state.revision + 1,
+ shouldPersist: state.shouldPersist,
+ closedTabIds: closed.closedTabIds
+ })
+ persistedSelectionChanged ||= state.shouldPersist && closed.selectionChanged
+ }
+ return persistedSelectionChanged
+}
diff --git a/src/main/runtime/client-session-tab-selection.test.ts b/src/main/runtime/client-session-tab-selection.test.ts
index eb83e6144ca..1c9e0e4a575 100644
--- a/src/main/runtime/client-session-tab-selection.test.ts
+++ b/src/main/runtime/client-session-tab-selection.test.ts
@@ -251,6 +251,62 @@ describe('client session-tab selection', () => {
expect(store.project(snapshot(), 'device-a').activeTabId).toBe('browser-unified')
})
+ it('restores the selection after a snapshot transiently drops the selected tab', () => {
+ const store = new ClientSessionTabSelectionStore()
+ const full = snapshot()
+ store.activate(full, 'device-a', 'browser-unified')
+
+ // A browser guest process swap omits the tab from one snapshot.
+ const withoutBrowser = {
+ ...full,
+ activeTabId: 'terminal-a::leaf-a',
+ activeTabType: 'terminal' as const,
+ tabs: full.tabs.filter((tab) => tab.id !== 'browser-unified')
+ }
+ expect(store.project(withoutBrowser, 'device-a').activeTabId).toBe('terminal-a::leaf-a')
+
+ expect(store.project(full, 'device-a').activeTabId).toBe('browser-unified')
+ })
+
+ it('does not restore a closed tab from a stale pre-close snapshot', () => {
+ const store = new ClientSessionTabSelectionStore()
+ const full = snapshot()
+ store.activate(full, 'device-a', 'browser-unified')
+ store.activate(full, 'device-b', 'browser-unified')
+
+ store.forgetTabs('wt-1', ['browser-unified'])
+
+ for (const clientId of ['device-a', 'device-b']) {
+ const stale = store.project(full, clientId)
+ expect(stale.activeTabId).toBe('terminal-a::leaf-a')
+ expect(stale.tabs.some((tab) => tab.id === 'browser-unified')).toBe(false)
+ }
+
+ const withoutBrowser = {
+ ...full,
+ activeTabId: 'terminal-a::leaf-a',
+ activeTabType: 'terminal' as const,
+ tabGroups: full.tabGroups?.slice(0, 1),
+ tabs: full.tabs.filter((tab) => tab.id !== 'browser-unified')
+ }
+ store.project(withoutBrowser, 'device-a')
+ const reused = store.project(full, 'device-a')
+ expect(reused.activeTabId).toBe('terminal-a::leaf-a')
+ expect(reused.tabs.some((tab) => tab.id === 'browser-unified')).toBe(true)
+ })
+
+ it('lets an explicit activation reuse a recently closed tab identity', () => {
+ const store = new ClientSessionTabSelectionStore()
+ const full = snapshot()
+ store.activate(full, 'device-a', 'browser-unified')
+ store.forgetTabs('wt-1', ['browser-unified'])
+
+ const reopened = store.activate(full, 'device-a', 'browser-unified')
+
+ expect(reopened.activeTabId).toBe('browser-unified')
+ expect(reopened.tabs.some((tab) => tab.id === 'browser-unified')).toBe(true)
+ })
+
it('drops malformed persisted payloads instead of hydrating them', () => {
expect(
normalizePersistedMobileClientTabSelections({
diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts
index 94aa42419de..f5ed7894ad2 100644
--- a/src/main/runtime/client-session-tab-selection.ts
+++ b/src/main/runtime/client-session-tab-selection.ts
@@ -3,6 +3,11 @@ import type {
RuntimeMobileSessionTabsResult
} from '../../shared/runtime-types'
import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types'
+import {
+ forgetClosedClientSessionTabsByWorktree,
+ projectWithoutClosedClientSessionTabs
+} from './client-session-tab-closed-selection'
+import type { StoredClientSessionTabSelection } from './client-session-tab-closed-selection'
import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence'
export type ClientSessionTabSelection = {
@@ -11,13 +16,6 @@ export type ClientSessionTabSelection = {
activeTabIdByGroupId: Readonly>
}
-type StoredClientSessionTabSelection = {
- selection: ClientSessionTabSelection
- revision: number
- // Why: listAll projects every worktree; only hydrated or user-activated selections belong on disk.
- shouldPersist: boolean
-}
-
function emptyClientSessionTabSelection(): ClientSessionTabSelection {
return { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} }
}
@@ -139,7 +137,12 @@ export class ClientSessionTabSelectionStore {
)) {
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) {
- statesByWorktree.set(worktreeId, { selection, revision: 0, shouldPersist: true })
+ statesByWorktree.set(worktreeId, {
+ selection,
+ revision: 0,
+ shouldPersist: true,
+ closedTabIds: new Set()
+ })
}
}
}
@@ -187,25 +190,40 @@ export class ClientSessionTabSelectionStore {
return snapshot
}
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
- const state = statesByWorktree.get(snapshot.worktree) ?? {
+ const existingState = statesByWorktree.get(snapshot.worktree)
+ const state = existingState ?? {
// Why: host focus is private navigation; a new paired device starts from deterministic topology instead of inheriting it.
selection: emptyClientSessionTabSelection(),
revision: 0,
- shouldPersist: false
+ shouldPersist: false,
+ closedTabIds: new Set()
}
- if (snapshot.tabs.length === 0) {
+ const closed = projectWithoutClosedClientSessionTabs(snapshot, state.closedTabIds)
+ if (closed.snapshot.tabs.length === 0) {
+ if (existingState) {
+ statesByWorktree.set(snapshot.worktree, {
+ ...state,
+ closedTabIds: closed.retainedClosedTabIds
+ })
+ }
// Why: an empty snapshot has no topology to project; writing it back would wipe a restart-hydrated selection before tabs arrive.
return {
- ...snapshot,
+ ...closed.snapshot,
publicationEpoch: `${snapshot.publicationEpoch}:client-navigation`,
snapshotVersion: snapshot.snapshotVersion + state.revision
}
}
- const projected = projectClientSessionTabSelection(snapshot, state.selection)
+ const projected = projectClientSessionTabSelection(closed.snapshot, state.selection)
+ // Why: a browser guest process swap drops its tab for one snapshot; the topology fallback
+ // must not overwrite (and persist) the device's explicit pick, or focus never returns.
+ const selectionSurvived =
+ !state.selection.activeTabId ||
+ closed.snapshot.tabs.some((tab) => tab.id === state.selection.activeTabId)
statesByWorktree.set(snapshot.worktree, {
- selection: projected.selection,
+ selection: selectionSurvived ? projected.selection : state.selection,
revision: state.revision,
- shouldPersist: state.shouldPersist
+ shouldPersist: state.shouldPersist,
+ closedTabIds: closed.retainedClosedTabIds
})
return {
...projected.snapshot,
@@ -223,18 +241,35 @@ export class ClientSessionTabSelectionStore {
const state = statesByWorktree.get(snapshot.worktree) ?? {
selection: emptyClientSessionTabSelection(),
revision: 0,
- shouldPersist: false
+ shouldPersist: false,
+ closedTabIds: new Set()
}
const nextSelection = activateClientSessionTabSelection(snapshot, state.selection, activeTabId)
+ const activeTab = snapshot.tabs.find((tab) => tab.id === activeTabId)
+ const closedTabIds = new Set(state.closedTabIds)
+ closedTabIds.delete(activeTabId)
+ if (activeTab) {
+ closedTabIds.delete(topLevelTabId(activeTab))
+ }
statesByWorktree.set(snapshot.worktree, {
selection: nextSelection,
revision: state.revision + 1,
- shouldPersist: true
+ shouldPersist: true,
+ closedTabIds
})
this.persistNow()
return this.project(snapshot, clientNavigationId)
}
+ forgetTabs(worktreeId: string, tabIds: readonly string[]): void {
+ if (tabIds.length === 0) {
+ return
+ }
+ if (forgetClosedClientSessionTabsByWorktree(this.statesByClient, worktreeId, tabIds)) {
+ this.persistNow()
+ }
+ }
+
forgetClient(clientNavigationId: string): void {
const statesByWorktree = this.statesByClient.get(clientNavigationId)
const hadPersistedState = [...(statesByWorktree?.values() ?? [])].some(
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index b706bd7963a..122300b514a 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -27913,6 +27913,7 @@ describe('OrcaRuntimeService', () => {
it('closes browser mobile session tabs when addressed by browser workspace id', async () => {
const closeSessionTab = vi.fn()
const runtime = new OrcaRuntimeService(store)
+ const forgetTabs = vi.spyOn(runtime['clientSessionTabSelections'], 'forgetTabs')
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
@@ -27961,6 +27962,55 @@ describe('OrcaRuntimeService', () => {
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'browser-workspace-1')
expect(closeSessionTab).toHaveBeenCalledWith('browser-unified-1', TEST_WORKTREE_ID)
+ expect(forgetTabs).toHaveBeenCalledWith(TEST_WORKTREE_ID, ['browser-unified-1'])
+ })
+
+ it('keeps client selection when a renderer session-tab close cannot commit', async () => {
+ const closeSessionTab = vi.fn().mockRejectedValue(new Error('session_tab_close_canceled'))
+ const runtime = new OrcaRuntimeService(store)
+ const forgetTabs = vi.spyOn(runtime['clientSessionTabSelections'], 'forgetTabs')
+ runtime.setNotifier({ closeSessionTab } as never)
+ runtime.attachWindow(1)
+ runtime.syncWindowGraph(1, {
+ tabs: [],
+ leaves: [],
+ mobileSessionTabs: [
+ {
+ worktree: TEST_WORKTREE_ID,
+ publicationEpoch: 'epoch-1',
+ snapshotVersion: 1,
+ activeGroupId: 'group-1',
+ activeTabId: 'browser-unified-1',
+ activeTabType: 'browser',
+ tabs: [
+ {
+ type: 'browser',
+ id: 'browser-unified-1',
+ title: 'Browser',
+ browserWorkspaceId: 'browser-workspace-1',
+ browserPageId: 'browser-page-1',
+ url: 'https://example.com/',
+ loading: false,
+ canGoBack: false,
+ canGoForward: false,
+ isActive: true
+ }
+ ]
+ }
+ ]
+ })
+
+ await expect(
+ runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'browser-workspace-1')
+ ).rejects.toThrow('session_tab_close_canceled')
+
+ expect(forgetTabs).not.toHaveBeenCalled()
+
+ runtime.setNotifier(null)
+ await expect(
+ runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'browser-workspace-1')
+ ).rejects.toThrow('runtime_unavailable')
+ expect(forgetTabs).not.toHaveBeenCalled()
})
it('creates mobile session terminals in a headless runtime server', async () => {
@@ -32432,6 +32482,7 @@ describe('OrcaRuntimeService', () => {
const kill = vi.fn(() => true)
const closeTerminal = vi.fn()
const runtime = new OrcaRuntimeService(runtimeStore as never)
+ const forgetTabs = vi.spyOn(runtime['clientSessionTabSelections'], 'forgetTabs')
runtime.setPtyController({
write: () => true,
kill,
@@ -32476,6 +32527,7 @@ describe('OrcaRuntimeService', () => {
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
+ expect(forgetTabs).not.toHaveBeenCalled()
expect(kill).not.toHaveBeenCalled()
// Not torn down by the runtime: the renderer-owned tab is left for the renderer's own close to prune.
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 39293ead80d..a9e16ad00ff 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -2180,7 +2180,7 @@ type RuntimeNotifier = {
renameTerminal(tabId: string, title: string | null): void
focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void
focusEditorTab?(tabId: string, worktreeId: string): void
- closeSessionTab?(tabId: string, worktreeId: string): void
+ closeSessionTab?(tabId: string, worktreeId: string): void | Promise
moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void
openFile?(
worktreeId: string,
@@ -8667,12 +8667,12 @@ export class OrcaRuntimeService {
snapshot?.tabs.find(
(candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId
))
- if (!tab) {
+ if (!snapshot || !tab) {
throw new Error('tab_not_found')
}
if (tab.type === 'terminal') {
- const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find(
+ const publicTab = this.toMobileSessionTabsResult(snapshot).tabs.find(
(candidate) => candidate.type === 'terminal' && candidate.id === tab.id
)
// Why: serve-created tabs can be visible before any renderer has adopted
@@ -8688,7 +8688,7 @@ export class OrcaRuntimeService {
!this.isDeliberatelyParkedPane(worktreeId, tab)) &&
(!targetsHost ||
!this.notifier?.focusTerminal ||
- this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab))
+ this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab))
if (shouldMaterializePendingTerminal) {
const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined
const targetGroupId = snapshot?.tabGroups?.find((group) =>
@@ -8760,9 +8760,9 @@ export class OrcaRuntimeService {
if (targetsHost && !this.notifier?.focusTerminal) {
if (
!targetTab.isActive &&
- this.shouldPersistHeadlessMobileSessionActivation(snapshot!, targetTab)
+ this.shouldPersistHeadlessMobileSessionActivation(snapshot, targetTab)
) {
- this.activateHeadlessMobileSessionTerminalTab(worktreeId, snapshot!, targetTab)
+ this.activateHeadlessMobileSessionTerminalTab(worktreeId, snapshot, targetTab)
}
} else if (targetsHost) {
this.notifier?.focusTerminal?.(targetTab.parentTabId, worktreeId, targetTab.leafId)
@@ -9051,13 +9051,13 @@ export class OrcaRuntimeService {
snapshot?.tabs.find(
(candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId
)
- if (!tab) {
+ if (!snapshot || !tab) {
throw new Error('tab_not_found')
}
if (options.expectedTerminalHandle !== undefined) {
const terminalIncarnationMatches =
tab.type === 'terminal' &&
- snapshot!.tabs.some(
+ snapshot.tabs.some(
(candidate) =>
candidate.type === 'terminal' &&
candidate.parentTabId === tab.parentTabId &&
@@ -9074,11 +9074,23 @@ export class OrcaRuntimeService {
}
}
}
+ let closedSelectionTabIds = [tab.id]
+ const finishCommittedClose = (): RuntimeMobileSessionTabCloseResult => {
+ this.clientSessionTabSelections.forgetTabs(worktreeId, closedSelectionTabIds)
+ return { closed: true }
+ }
if (tab.type === 'terminal') {
- const parentLeafCount = snapshot!.tabs.filter(
+ const parentLeafCount = snapshot.tabs.filter(
(candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
).length
const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1
+ if (closingWholeParent) {
+ closedSelectionTabIds = snapshot.tabs.flatMap((candidate) =>
+ candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
+ ? [candidate.id, candidate.parentTabId]
+ : []
+ )
+ }
// Why: a non-'user' reason is a client-lifecycle echo ("terminal gone"),
// not authorization to kill. Every destructive branch below can take the
// whole parent down, so any live PTY under the parent means the echo is a
@@ -9086,7 +9098,7 @@ export class OrcaRuntimeService {
// echoing client re-syncs and re-attaches. A reasonless close keeps
// legacy behavior — old clients send user closes without the field.
if (options.reason !== undefined && options.reason !== 'user') {
- const parentLeaves = snapshot!.tabs.filter(
+ const parentLeaves = snapshot.tabs.filter(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
)
@@ -9141,12 +9153,12 @@ export class OrcaRuntimeService {
// the relay when no renderer owns the parent: an adopted tab needs the
// renderer's live pin guard and durable close transaction.
if (closingWholeParent && !this.tabs.has(tab.parentTabId)) {
- this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab, {
+ this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, {
killPtys: options.reason === undefined || options.reason === 'user'
})
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
this.store?.flushOrThrow?.()
- return { closed: true }
+ return finishCommittedClose()
}
if (closingWholeParent && this.notifier?.closeTerminalTab) {
// Why: whole-tab close is a lifecycle transaction. The renderer reply
@@ -9186,45 +9198,51 @@ export class OrcaRuntimeService {
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
this.store?.flushOrThrow?.()
}
- this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot!, tab.parentTabId)
- return { closed: true }
+ this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId)
+ return finishCommittedClose()
}
// Why: notifier implementations without the acknowledged relay may expose
// only raw pane close. Runtime-owned parents still need de-persist + kill.
if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) {
- this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)
+ this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab)
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
this.store?.flushOrThrow?.()
- return { closed: true }
+ return finishCommittedClose()
}
if (!this.notifier?.closeTerminal) {
- this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab)
+ this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab)
this.store?.flushOrThrow?.()
- return { closed: true }
+ return finishCommittedClose()
}
if (tab.id === tabId) {
const pty = this.findPtyForMobileTerminalTab(worktreeId, tab)
if (pty) {
- this.ptyController?.kill(pty.ptyId)
- } else {
- this.notifier?.closeTerminal(tab.parentTabId)
+ if (this.ptyController?.kill(pty.ptyId) !== true) {
+ throw new Error('terminal_close_failed')
+ }
+ return finishCommittedClose()
}
- } else {
- // Why: paired web tab bars represent a split terminal with one local
- // parent tab id. Closing that parent should close the desktop tab, not
- // just whichever leaf happened to be first in the session snapshot.
- this.notifier?.closeTerminal(tab.parentTabId)
- this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot!, tab.parentTabId)
+ this.notifier.closeTerminal(tab.parentTabId)
+ return { closed: true }
}
+ // Why: paired web tab bars represent a split terminal with one local
+ // parent tab id. Closing that parent should close the desktop tab, not
+ // just whichever leaf happened to be first in the session snapshot.
+ this.notifier.closeTerminal(tab.parentTabId)
+ this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId)
+ return { closed: true }
} else if (tab.type === 'browser' && this.offscreenBrowserBackend) {
// Why: headless browser tabs are offscreen WebContents with no renderer to
// route closeSessionTab to. Close the page directly and drop it from the
// snapshot so paired clients stop showing it.
- await this.closeHeadlessMobileBrowserTab(worktreeId, snapshot!, tab)
+ await this.closeHeadlessMobileBrowserTab(worktreeId, snapshot, tab)
} else {
- this.notifier?.closeSessionTab?.(tab.id, worktreeId)
+ if (!this.notifier?.closeSessionTab) {
+ throw new Error('runtime_unavailable')
+ }
+ await this.notifier.closeSessionTab(tab.id, worktreeId)
}
- return { closed: true }
+ return finishCommittedClose()
}
// Why: a refused echoed close means the echoing client already pruned its
diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts
index 4d5f482c894..48e2c82a547 100644
--- a/src/main/window/attach-main-window-services.ts
+++ b/src/main/window/attach-main-window-services.ts
@@ -56,6 +56,7 @@ import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types'
import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity'
import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop'
import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay'
+import { requestSessionTabCloseFromRenderer } from './session-tab-close-request-relay'
import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay'
import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators'
@@ -457,7 +458,8 @@ function registerRuntimeWindowLifecycle(
focusTerminal: (tabId, worktreeId, leafId) =>
send('ui:focusTerminal', { tabId, worktreeId, leafId }),
focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }),
- closeSessionTab: (tabId, worktreeId) => send('ui:closeSessionTab', { tabId, worktreeId }),
+ closeSessionTab: (tabId, worktreeId) =>
+ requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId),
moveSessionTab: (worktreeId: string, move: RuntimeMobileSessionTabMove) =>
send('ui:moveSessionTab', { worktreeId, ...move }),
openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?) =>
diff --git a/src/main/window/session-tab-close-request-relay.test.ts b/src/main/window/session-tab-close-request-relay.test.ts
new file mode 100644
index 00000000000..d875a6e492f
--- /dev/null
+++ b/src/main/window/session-tab-close-request-relay.test.ts
@@ -0,0 +1,121 @@
+import { EventEmitter } from 'node:events'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const ipcEmitter = new EventEmitter()
+const ipcMainMock = {
+ on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
+ ipcEmitter.on(channel, listener)
+ }),
+ removeListener: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
+ ipcEmitter.removeListener(channel, listener)
+ })
+}
+
+vi.mock('electron', () => ({ ipcMain: ipcMainMock }))
+
+function createMainWindow() {
+ const webContents = Object.assign(new EventEmitter(), {
+ isDestroyed: () => false,
+ send: vi.fn()
+ })
+ const mainWindow = Object.assign(new EventEmitter(), {
+ isDestroyed: () => false,
+ webContents
+ })
+ return { mainWindow, webContents }
+}
+
+describe('requestSessionTabCloseFromRenderer', () => {
+ beforeEach(() => {
+ vi.useRealTimers()
+ ipcEmitter.removeAllListeners()
+ ipcMainMock.on.mockClear()
+ ipcMainMock.removeListener.mockClear()
+ })
+
+ it('waits for the targeted renderer acknowledgement', async () => {
+ const { requestSessionTabCloseFromRenderer } = await import('./session-tab-close-request-relay')
+ const { mainWindow, webContents } = createMainWindow()
+ const pending = requestSessionTabCloseFromRenderer(mainWindow as never, 'tab-1', 'wt-1')
+ const request = webContents.send.mock.calls[0]?.[1] as {
+ requestId: string
+ tabId: string
+ worktreeId: string
+ expiresAt: number
+ }
+
+ expect(request).toMatchObject({ tabId: 'tab-1', worktreeId: 'wt-1' })
+ expect(request.expiresAt).toBeGreaterThan(Date.now())
+ ipcEmitter.emit('ui:sessionTabCloseResponse', { sender: {} }, { requestId: request.requestId })
+ let settled = false
+ void pending.finally(() => {
+ settled = true
+ })
+ await Promise.resolve()
+ expect(settled).toBe(false)
+
+ ipcEmitter.emit(
+ 'ui:sessionTabCloseResponse',
+ { sender: webContents },
+ { requestId: request.requestId }
+ )
+ await expect(pending).resolves.toBeUndefined()
+ })
+
+ it('propagates renderer cancellation', async () => {
+ const { requestSessionTabCloseFromRenderer } = await import('./session-tab-close-request-relay')
+ const { mainWindow, webContents } = createMainWindow()
+ const pending = requestSessionTabCloseFromRenderer(mainWindow as never, 'tab-pinned', 'wt-1')
+ const request = webContents.send.mock.calls[0]?.[1] as { requestId: string }
+
+ ipcEmitter.emit(
+ 'ui:sessionTabCloseResponse',
+ { sender: webContents },
+ { requestId: request.requestId, error: 'session_tab_close_canceled' }
+ )
+
+ await expect(pending).rejects.toThrow('session_tab_close_canceled')
+ })
+
+ it('rejects and releases listeners when the target window closes', async () => {
+ const { requestSessionTabCloseFromRenderer } = await import('./session-tab-close-request-relay')
+ const { mainWindow, webContents } = createMainWindow()
+ const pending = requestSessionTabCloseFromRenderer(mainWindow as never, 'tab-1', 'wt-1')
+
+ mainWindow.emit('closed')
+
+ await expect(pending).rejects.toThrow('renderer_unavailable')
+ expect(ipcEmitter.listenerCount('ui:sessionTabCloseResponse')).toBe(0)
+ expect(webContents.listenerCount('destroyed')).toBe(0)
+ })
+
+ it.each(['destroyed', 'render-process-gone', 'did-start-loading'])(
+ 'rejects when web contents emits %s',
+ async (eventName) => {
+ const { requestSessionTabCloseFromRenderer } =
+ await import('./session-tab-close-request-relay')
+ const { mainWindow, webContents } = createMainWindow()
+ const pending = requestSessionTabCloseFromRenderer(mainWindow as never, 'tab-1', 'wt-1')
+
+ webContents.emit(eventName)
+
+ await expect(pending).rejects.toThrow('renderer_unavailable')
+ expect(ipcEmitter.listenerCount('ui:sessionTabCloseResponse')).toBe(0)
+ }
+ )
+
+ it('times out after the renderer confirmation lease and response grace', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000)
+ const { requestSessionTabCloseFromRenderer } = await import('./session-tab-close-request-relay')
+ const { mainWindow, webContents } = createMainWindow()
+ const pending = requestSessionTabCloseFromRenderer(mainWindow as never, 'tab-1', 'wt-1')
+ const request = webContents.send.mock.calls[0]?.[1] as { expiresAt: number }
+ const rejection = expect(pending).rejects.toThrow('session_tab_close_timeout')
+
+ await vi.advanceTimersByTimeAsync(request.expiresAt - Date.now() + 5_000)
+
+ await rejection
+ expect(ipcEmitter.listenerCount('ui:sessionTabCloseResponse')).toBe(0)
+ })
+})
diff --git a/src/main/window/session-tab-close-request-relay.ts b/src/main/window/session-tab-close-request-relay.ts
new file mode 100644
index 00000000000..07bec3d5ce6
--- /dev/null
+++ b/src/main/window/session-tab-close-request-relay.ts
@@ -0,0 +1,68 @@
+import { randomUUID } from 'node:crypto'
+
+import { ipcMain } from 'electron'
+import type { BrowserWindow } from 'electron'
+import type {
+ SessionTabCloseRequest,
+ SessionTabCloseResponse
+} from '../../shared/session-tab-close'
+import { SESSION_TAB_CLOSE_TIMEOUT_ERROR } from '../../shared/session-tab-close'
+
+const SESSION_TAB_CLOSE_CONFIRMATION_MS = 5 * 60_000
+const SESSION_TAB_CLOSE_RESPONSE_GRACE_MS = 5_000
+
+export async function requestSessionTabCloseFromRenderer(
+ mainWindow: BrowserWindow,
+ tabId: string,
+ worktreeId: string
+): Promise {
+ if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) {
+ throw new Error('renderer_unavailable')
+ }
+ const webContents = mainWindow.webContents
+ const requestId = randomUUID()
+ await new Promise((resolve, reject) => {
+ let settled = false
+ const finish = (error?: Error): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ clearTimeout(timeout)
+ ipcMain.removeListener('ui:sessionTabCloseResponse', onResponse)
+ mainWindow.removeListener('closed', onRendererUnavailable)
+ webContents.removeListener('destroyed', onRendererUnavailable)
+ webContents.removeListener('render-process-gone', onRendererUnavailable)
+ webContents.removeListener('did-start-loading', onRendererUnavailable)
+ if (error) {
+ reject(error)
+ } else {
+ resolve()
+ }
+ }
+ const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable'))
+ const onResponse = (event: Electron.IpcMainEvent, response: SessionTabCloseResponse): void => {
+ if (event.sender !== webContents || response.requestId !== requestId) {
+ return
+ }
+ finish(response.error ? new Error(response.error) : undefined)
+ }
+ const expiresAt = Date.now() + SESSION_TAB_CLOSE_CONFIRMATION_MS
+ const timeout = setTimeout(
+ () => finish(new Error(SESSION_TAB_CLOSE_TIMEOUT_ERROR)),
+ SESSION_TAB_CLOSE_CONFIRMATION_MS + SESSION_TAB_CLOSE_RESPONSE_GRACE_MS
+ )
+ timeout.unref?.()
+ ipcMain.on('ui:sessionTabCloseResponse', onResponse)
+ mainWindow.once('closed', onRendererUnavailable)
+ webContents.once('destroyed', onRendererUnavailable)
+ webContents.once('render-process-gone', onRendererUnavailable)
+ webContents.once('did-start-loading', onRendererUnavailable)
+ const request: SessionTabCloseRequest = { requestId, tabId, worktreeId, expiresAt }
+ try {
+ webContents.send('ui:sessionTabCloseRequest', request)
+ } catch {
+ finish(new Error('renderer_unavailable'))
+ }
+ })
+}
diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts
index 60d6b87f1b9..eeec8285089 100644
--- a/src/preload/api/ui-command-event-api.ts
+++ b/src/preload/api/ui-command-event-api.ts
@@ -27,6 +27,10 @@ import type {
TerminalTabCloseRequest,
TerminalTabCloseResponse
} from '../../shared/terminal-tab-close'
+import type {
+ SessionTabCloseRequest,
+ SessionTabCloseResponse
+} from '../../shared/session-tab-close'
export type UiCommandEventApi = {
get: () => Promise
@@ -176,6 +180,8 @@ export type UiCommandEventApi = {
) => () => void
onFocusEditorTab: (callback: (data: { tabId: string; worktreeId: string }) => void) => () => void
onCloseSessionTab: (callback: (data: { tabId: string; worktreeId: string }) => void) => () => void
+ onSessionTabCloseRequest: (callback: (request: SessionTabCloseRequest) => void) => () => void
+ respondSessionTabClose: (response: SessionTabCloseResponse) => void
onMoveSessionTab: (
callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void
) => () => void
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 3560697bfde..f851ce726b7 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -4175,6 +4175,17 @@ const api = {
ipcRenderer.on('ui:closeSessionTab', listener)
return () => ipcRenderer.removeListener('ui:closeSessionTab', listener)
},
+ onSessionTabCloseRequest: (callback) => {
+ const listener = (
+ _event: Electron.IpcRendererEvent,
+ request: Parameters[0]
+ ) => callback(request)
+ ipcRenderer.on('ui:sessionTabCloseRequest', listener)
+ return () => ipcRenderer.removeListener('ui:sessionTabCloseRequest', listener)
+ },
+ respondSessionTabClose: (response) => {
+ ipcRenderer.send('ui:sessionTabCloseResponse', response)
+ },
onMoveSessionTab: (
callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void
): (() => void) => {
diff --git a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts
index 7245691febe..089ab323a85 100644
--- a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts
+++ b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts
@@ -54,6 +54,8 @@ export function buildWindowApi(args: {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
+ onSessionTabCloseRequest: () => () => {},
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
diff --git a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts
index 64e4aa7ea16..66fde66b534 100644
--- a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts
+++ b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts
@@ -11,6 +11,12 @@ export type CloseFloatingItemListener = (payload: { sourceId: string }) => void
export type SelectFloatingIndexListener = (payload: { index: number }) => void
export type CloseTerminalListener = (data: { tabId: string; paneRuntimeId?: number | null }) => void
export type CloseSessionTabListener = (data: { tabId: string; worktreeId: string }) => void
+export type SessionTabCloseRequestListener = (data: {
+ requestId: string
+ tabId: string
+ worktreeId: string
+ expiresAt?: number
+}) => void
export type TerminalTabCloseRequestListener = (data: {
requestId: string
tabId: string
@@ -22,6 +28,8 @@ export async function useIpcEventsForCloseRouting({
closeFloatingItemListenerRef,
selectFloatingIndexListenerRef,
closeSessionTabListenerRef,
+ sessionTabCloseRequestListenerRef,
+ respondSessionTabClose = vi.fn(),
closeTerminalListenerRef,
getState,
requestTabCloseListenerRef,
@@ -34,6 +42,8 @@ export async function useIpcEventsForCloseRouting({
closeFloatingItemListenerRef?: { current: CloseFloatingItemListener | null }
selectFloatingIndexListenerRef?: { current: SelectFloatingIndexListener | null }
closeSessionTabListenerRef?: { current: CloseSessionTabListener | null }
+ sessionTabCloseRequestListenerRef?: { current: SessionTabCloseRequestListener | null }
+ respondSessionTabClose?: ReturnType
closeTerminalListenerRef?: { current: CloseTerminalListener | null }
getState: () => Record
requestTabCloseListenerRef?: { current: RequestTabCloseListener | null }
@@ -168,6 +178,13 @@ export async function useIpcEventsForCloseRouting({
}
return () => {}
},
+ onSessionTabCloseRequest: (listener: SessionTabCloseRequestListener) => {
+ if (sessionTabCloseRequestListenerRef) {
+ sessionTabCloseRequestListenerRef.current = listener
+ }
+ return () => {}
+ },
+ respondSessionTabClose,
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts
index 28aaf3bcf2f..9ad094c5762 100644
--- a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts
+++ b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts
@@ -114,6 +114,8 @@ export function buildTerminalCreateWindow(args: {
},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
+ onSessionTabCloseRequest: () => () => {},
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
diff --git a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts
index fc7afbead90..abfbb4d0909 100644
--- a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts
@@ -169,6 +169,8 @@ describe('useIpcEvents browser tab create routing', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
+ onSessionTabCloseRequest: () => () => {},
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
diff --git a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts
index c1aaa0d8599..f84d6a6c6eb 100644
--- a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts
@@ -148,6 +148,8 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
+ onSessionTabCloseRequest: () => () => {},
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
@@ -409,6 +411,8 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
+ onSessionTabCloseRequest: () => () => {},
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
diff --git a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts
index d641fe563af..48378b48623 100644
--- a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts
@@ -145,6 +145,8 @@ describe('useIpcEvents browser tab close routing', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
+ onSessionTabCloseRequest: () => () => {},
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
@@ -366,6 +368,8 @@ describe('useIpcEvents browser tab close routing', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
+ onSessionTabCloseRequest: () => () => {},
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
@@ -582,6 +586,8 @@ describe('useIpcEvents browser tab close routing', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
+ onSessionTabCloseRequest: () => () => {},
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
diff --git a/src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts b/src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts
new file mode 100644
index 00000000000..ffd6b36d28b
--- /dev/null
+++ b/src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts
@@ -0,0 +1,138 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ useIpcEventsForCloseRouting,
+ type SessionTabCloseRequestListener
+} from './ipc-events-close-routing-test-harness'
+
+describe('useIpcEvents session tab close requests', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ vi.unstubAllGlobals()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('acknowledges a close after the store removes the tab', async () => {
+ const listenerRef: { current: SessionTabCloseRequestListener | null } = { current: null }
+ const closeUnifiedTab = vi.fn().mockReturnValue({ id: 'sim-tab-1' })
+ const respondSessionTabClose = vi.fn()
+
+ await useIpcEventsForCloseRouting({
+ sessionTabCloseRequestListenerRef: listenerRef,
+ respondSessionTabClose,
+ getState: () => ({
+ closeUnifiedTab,
+ browserTabsByWorktree: {},
+ openFiles: [],
+ unifiedTabsByWorktree: {
+ 'wt-1': [
+ { id: 'sim-tab-1', entityId: 'sim-1', contentType: 'simulator', isPinned: false }
+ ]
+ }
+ })
+ })
+
+ listenerRef.current?.({
+ requestId: 'close-session-tab',
+ tabId: 'sim-tab-1',
+ worktreeId: 'wt-1'
+ })
+
+ expect(closeUnifiedTab).toHaveBeenCalledWith('sim-tab-1')
+ expect(respondSessionTabClose).toHaveBeenCalledWith({ requestId: 'close-session-tab' })
+ })
+
+ it('rejects a pinned browser close when confirmation is canceled', async () => {
+ const listenerRef: { current: SessionTabCloseRequestListener | null } = { current: null }
+ const closeBrowserTab = vi.fn()
+ const respondSessionTabClose = vi.fn()
+ const requestPinnedTabCloseConfirm = vi.fn()
+
+ await useIpcEventsForCloseRouting({
+ sessionTabCloseRequestListenerRef: listenerRef,
+ respondSessionTabClose,
+ getState: () => ({
+ closeBrowserTab,
+ requestPinnedTabCloseConfirm,
+ browserTabsByWorktree: { 'wt-1': [{ id: 'workspace-1' }] },
+ unifiedTabsByWorktree: {
+ 'wt-1': [
+ {
+ id: 'browser-unified-1',
+ entityId: 'workspace-1',
+ contentType: 'browser',
+ label: 'Docs',
+ isPinned: true
+ }
+ ]
+ }
+ })
+ })
+
+ listenerRef.current?.({
+ requestId: 'close-pinned-session-tab',
+ tabId: 'workspace-1',
+ worktreeId: 'wt-1'
+ })
+ const request = requestPinnedTabCloseConfirm.mock.calls[0][0] as { onCancel: () => void }
+ request.onCancel()
+
+ expect(closeBrowserTab).not.toHaveBeenCalled()
+ expect(respondSessionTabClose).toHaveBeenCalledWith({
+ requestId: 'close-pinned-session-tab',
+ error: 'session_tab_close_canceled'
+ })
+ })
+
+ it('expires a pending pinned close without allowing late confirmation', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000)
+ const listenerRef: { current: SessionTabCloseRequestListener | null } = { current: null }
+ const closeBrowserTab = vi.fn()
+ const respondSessionTabClose = vi.fn()
+ const requestPinnedTabCloseConfirm = vi.fn()
+ const cancelPinnedTabCloseRequest = vi.fn()
+
+ await useIpcEventsForCloseRouting({
+ sessionTabCloseRequestListenerRef: listenerRef,
+ respondSessionTabClose,
+ getState: () => ({
+ closeBrowserTab,
+ requestPinnedTabCloseConfirm,
+ cancelPinnedTabCloseRequest,
+ browserTabsByWorktree: { 'wt-1': [{ id: 'workspace-1' }] },
+ unifiedTabsByWorktree: {
+ 'wt-1': [
+ {
+ id: 'browser-unified-1',
+ entityId: 'workspace-1',
+ contentType: 'browser',
+ label: 'Docs',
+ isPinned: true
+ }
+ ]
+ }
+ })
+ })
+
+ listenerRef.current?.({
+ requestId: 'expiring-close',
+ tabId: 'workspace-1',
+ worktreeId: 'wt-1',
+ expiresAt: 2_000
+ })
+ const request = requestPinnedTabCloseConfirm.mock.calls[0][0] as { onConfirm: () => void }
+ vi.advanceTimersByTime(1_000)
+
+ expect(cancelPinnedTabCloseRequest).toHaveBeenCalledWith(request)
+ expect(respondSessionTabClose).toHaveBeenCalledWith({
+ requestId: 'expiring-close',
+ error: 'session_tab_close_timeout'
+ })
+ request.onConfirm()
+ expect(closeBrowserTab).not.toHaveBeenCalled()
+ expect(respondSessionTabClose).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts
index 0655b1e7804..6cac4121fee 100644
--- a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts
@@ -155,6 +155,8 @@ describe('useIpcEvents updater integration', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
+ onSessionTabCloseRequest: () => () => {},
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
diff --git a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts
index bcbc8e20c33..97e2ae54dcb 100644
--- a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts
@@ -120,6 +120,8 @@ describe('useIpcEvents updater integration', () => {
onFocusTerminal: () => () => {},
onFocusEditorTab: () => () => {},
onCloseSessionTab: () => () => {},
+ onSessionTabCloseRequest: () => () => {},
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => () => {},
onOpenFileFromMobile: () => () => {},
onOpenDiffFromMobile: () => () => {},
diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts
index 55705a8384e..03d3b54c5cd 100644
--- a/src/renderer/src/hooks/useIpcEvents.ts
+++ b/src/renderer/src/hooks/useIpcEvents.ts
@@ -125,7 +125,11 @@ import {
rollbackLegacyWorkerTerminalSurfaceInStore
} from './legacy-worker-terminal-recovery-event'
import type { AppState } from '../store/types'
-import { guardPinnedTabClose, resolvePinnedTabLabel } from '../store/pinned-tab-close-guard'
+import {
+ guardPinnedTabClose,
+ isUnifiedTabPinned,
+ resolvePinnedTabLabel
+} from '../store/pinned-tab-close-guard'
import {
closeWebRuntimeSessionTab,
createWebRuntimeSessionTerminal,
@@ -165,6 +169,12 @@ import type {
import { translate } from '@/i18n/i18n'
import { redactKagiSessionToken } from '../../../shared/browser-url'
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
+import {
+ SESSION_TAB_CLOSE_CANCELED_ERROR,
+ SESSION_TAB_CLOSE_FAILED_ERROR,
+ SESSION_TAB_NOT_FOUND_ERROR,
+ SESSION_TAB_CLOSE_TIMEOUT_ERROR
+} from '../../../shared/session-tab-close'
import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode'
import { getConnectionIdFromState } from '@/lib/connection-context'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
@@ -220,12 +230,6 @@ function resolveTerminalPresentation(data: {
return undefined
}
-function isPinnedSessionTab(store: AppState, worktreeId: string, visibleId: string): boolean {
- return (store.unifiedTabsByWorktree?.[worktreeId] ?? []).some(
- (tab) => (tab.id === visibleId || tab.entityId === visibleId) && tab.isPinned
- )
-}
-
function releaseBrowserAutomationBootstrapLease(browserPageId: string): void {
const existing = browserAutomationBootstrapLeaseByPageId.get(browserPageId)
if (!existing) {
@@ -1983,14 +1987,14 @@ export function useIpcEvents(): void {
const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId)
if (browserTarget) {
guardPinnedTabClose({
- isPinned: isPinnedSessionTab(store, worktreeId, browserTarget.workspaceId),
+ isPinned: isUnifiedTabPinned(store, worktreeId, browserTarget.workspaceId),
tabLabel: resolvePinnedTabLabel(store, worktreeId, browserTarget.workspaceId),
onClose: () => useAppStore.getState().closeBrowserTab(browserTarget.workspaceId)
})
return
}
guardPinnedTabClose({
- isPinned: isPinnedSessionTab(store, worktreeId, tabId),
+ isPinned: isUnifiedTabPinned(store, worktreeId, tabId),
tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId),
onClose: () => {
const currentStore = useAppStore.getState()
@@ -2000,6 +2004,59 @@ export function useIpcEvents(): void {
})
)
+ unsubs.push(
+ window.api.ui.onSessionTabCloseRequest(({ requestId, tabId, worktreeId, expiresAt }) => {
+ const store = useAppStore.getState()
+ const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId)
+ let cancelConfirmation: (() => void) | undefined
+ let timeout: ReturnType | undefined
+ let settled = false
+ const respond = (error?: string): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ if (timeout !== undefined) {
+ clearTimeout(timeout)
+ }
+ window.api.ui.respondSessionTabClose({ requestId, ...(error ? { error } : {}) })
+ }
+ if (expiresAt !== undefined) {
+ timeout = setTimeout(
+ () => {
+ cancelConfirmation?.()
+ respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR)
+ },
+ Math.max(0, expiresAt - Date.now())
+ )
+ }
+ const closeAndRespond = (): void => {
+ if (expiresAt !== undefined && Date.now() >= expiresAt) {
+ respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR)
+ return
+ }
+ try {
+ if (browserTarget) {
+ useAppStore.getState().closeBrowserTab(browserTarget.workspaceId)
+ respond()
+ return
+ }
+ const closed = closeMobileSessionTabInStore(useAppStore.getState(), worktreeId, tabId)
+ respond(closed ? undefined : SESSION_TAB_NOT_FOUND_ERROR)
+ } catch (error) {
+ respond(error instanceof Error ? error.message : SESSION_TAB_CLOSE_FAILED_ERROR)
+ }
+ }
+ const visibleId = browserTarget?.workspaceId ?? tabId
+ cancelConfirmation = guardPinnedTabClose({
+ isPinned: isUnifiedTabPinned(store, worktreeId, visibleId),
+ tabLabel: resolvePinnedTabLabel(store, worktreeId, visibleId),
+ onClose: closeAndRespond,
+ onCancel: () => respond(SESSION_TAB_CLOSE_CANCELED_ERROR)
+ })
+ })
+ )
+
unsubs.push(
window.api.ui.onMoveSessionTab((move) => {
const { tabId, targetGroupId } = move
@@ -2484,7 +2541,7 @@ export function useIpcEvents(): void {
): void => {
const currentStore = useAppStore.getState()
guardPinnedTabClose({
- isPinned: isPinnedSessionTab(currentStore, worktreeId, workspaceId),
+ isPinned: isUnifiedTabPinned(currentStore, worktreeId, workspaceId),
tabLabel: resolvePinnedTabLabel(currentStore, worktreeId, workspaceId),
onClose: () => {
useAppStore.getState().closeBrowserTab(workspaceId)
@@ -2644,7 +2701,7 @@ export function useIpcEvents(): void {
}
currentStore.closeBrowserTab(tabId)
}
- if (worktreeId && isPinnedSessionTab(store, worktreeId, tabId)) {
+ if (worktreeId && isUnifiedTabPinned(store, worktreeId, tabId)) {
guardPinnedTabClose({
isPinned: true,
tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId),
diff --git a/src/renderer/src/store/pinned-tab-close-guard.test.ts b/src/renderer/src/store/pinned-tab-close-guard.test.ts
index 16a9931b6db..7b078488c43 100644
--- a/src/renderer/src/store/pinned-tab-close-guard.test.ts
+++ b/src/renderer/src/store/pinned-tab-close-guard.test.ts
@@ -22,6 +22,7 @@ function makeState(overrides: Partial): AppState {
settings: { confirmClosePinnedTab: true },
unifiedTabsByWorktree: {},
requestPinnedTabCloseConfirm: vi.fn(),
+ cancelPinnedTabCloseRequest: vi.fn(),
...overrides
} as unknown as AppState
}
@@ -81,6 +82,17 @@ describe('guardPinnedTabClose', () => {
})
})
+ it('returns a cancellation for the exact queued confirmation', () => {
+ const cancelPinnedTabCloseRequest = vi.fn()
+ getStateMock.mockReturnValue(makeState({ cancelPinnedTabCloseRequest }))
+
+ const cancel = guardPinnedTabClose({ isPinned: true, tabLabel: 'Docs', onClose: vi.fn() })
+ const request = getStateMock().requestPinnedTabCloseConfirm.mock.calls[0][0]
+ cancel?.()
+
+ expect(cancelPinnedTabCloseRequest).toHaveBeenCalledWith(request)
+ })
+
it('closes a pinned tab immediately when the setting is off', () => {
const onClose = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
diff --git a/src/renderer/src/store/pinned-tab-close-guard.ts b/src/renderer/src/store/pinned-tab-close-guard.ts
index 75a7db4fcc8..a4971f0b96c 100644
--- a/src/renderer/src/store/pinned-tab-close-guard.ts
+++ b/src/renderer/src/store/pinned-tab-close-guard.ts
@@ -39,22 +39,24 @@ export function guardPinnedTabClose(params: {
tabLabel: string
onClose: () => void
onCancel?: () => void
-}): void {
+}): (() => void) | undefined {
const { isPinned, tabLabel, onClose, onCancel } = params
if (!isPinned) {
onClose()
- return
+ return undefined
}
const state = useAppStore.getState()
if (!shouldConfirmPinnedTabClose(state)) {
onClose()
- return
+ return undefined
}
- state.requestPinnedTabCloseConfirm({
+ const request = {
tabLabel,
onConfirm: onClose,
...(onCancel ? { onCancel } : {})
- })
+ }
+ state.requestPinnedTabCloseConfirm(request)
+ return () => state.cancelPinnedTabCloseRequest(request)
}
diff --git a/src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts b/src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts
index 5a39df64b24..d05e9561078 100644
--- a/src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts
+++ b/src/renderer/src/store/slices/pinned-tab-close-confirm.test.ts
@@ -9,6 +9,7 @@ function makeStore() {
AppState,
| 'pinnedTabCloseConfirm'
| 'requestPinnedTabCloseConfirm'
+ | 'cancelPinnedTabCloseRequest'
| 'confirmPinnedTabClose'
| 'dismissPinnedTabClose'
>
@@ -135,6 +136,25 @@ describe('createPinnedTabCloseConfirmSlice', () => {
expect(store.getState().pinnedTabCloseConfirm?.tabLabel).toBe('Second terminal')
})
+ it('cancels a specific visible or queued request without invoking its callbacks', () => {
+ const store = makeStore()
+ const first = { tabLabel: 'First', onConfirm: vi.fn(), onCancel: vi.fn() }
+ const second = { tabLabel: 'Second', onConfirm: vi.fn(), onCancel: vi.fn() }
+ const third = { tabLabel: 'Third', onConfirm: vi.fn(), onCancel: vi.fn() }
+ store.getState().requestPinnedTabCloseConfirm(first)
+ store.getState().requestPinnedTabCloseConfirm(second)
+ store.getState().requestPinnedTabCloseConfirm(third)
+
+ store.getState().cancelPinnedTabCloseRequest(second)
+ store.getState().cancelPinnedTabCloseRequest(first)
+
+ expect(store.getState().pinnedTabCloseConfirm).toBe(third)
+ expect(first.onConfirm).not.toHaveBeenCalled()
+ expect(first.onCancel).not.toHaveBeenCalled()
+ expect(second.onConfirm).not.toHaveBeenCalled()
+ expect(second.onCancel).not.toHaveBeenCalled()
+ })
+
it('ignores a rapid second action on the newly advanced request', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_000)
const store = makeStore()
diff --git a/src/renderer/src/store/slices/pinned-tab-close-confirm.ts b/src/renderer/src/store/slices/pinned-tab-close-confirm.ts
index df56122b285..a59da5cb53c 100644
--- a/src/renderer/src/store/slices/pinned-tab-close-confirm.ts
+++ b/src/renderer/src/store/slices/pinned-tab-close-confirm.ts
@@ -12,6 +12,7 @@ export type PinnedTabCloseConfirmRequest = {
export type PinnedTabCloseConfirmSlice = {
pinnedTabCloseConfirm: PinnedTabCloseConfirmRequest | null
requestPinnedTabCloseConfirm: (request: PinnedTabCloseConfirmRequest) => void
+ cancelPinnedTabCloseRequest: (request: PinnedTabCloseConfirmRequest) => void
confirmPinnedTabClose: () => void
dismissPinnedTabClose: () => void
}
@@ -31,6 +32,11 @@ export const createPinnedTabCloseConfirmSlice: StateCreator<
set({ pinnedTabCloseConfirm: next })
return next !== null
}
+ const advanceAfterAction = (): void => {
+ if (advanceRequest()) {
+ nextRequestActionAllowedAt = Date.now() + INTER_REQUEST_ACTION_GUARD_MS
+ }
+ }
return {
pinnedTabCloseConfirm: null,
@@ -46,6 +52,17 @@ export const createPinnedTabCloseConfirmSlice: StateCreator<
set({ pinnedTabCloseConfirm: request })
},
+ cancelPinnedTabCloseRequest: (request) => {
+ if (get().pinnedTabCloseConfirm === request) {
+ advanceAfterAction()
+ return
+ }
+ const index = queuedRequests.indexOf(request)
+ if (index !== -1) {
+ queuedRequests.splice(index, 1)
+ }
+ },
+
confirmPinnedTabClose: () => {
if (Date.now() < nextRequestActionAllowedAt) {
return
@@ -56,9 +73,7 @@ export const createPinnedTabCloseConfirmSlice: StateCreator<
}
// Why: advance before running onConfirm so a re-entrant close queues
// behind the next real request instead of seeing the stale one.
- if (advanceRequest()) {
- nextRequestActionAllowedAt = Date.now() + INTER_REQUEST_ACTION_GUARD_MS
- }
+ advanceAfterAction()
request.onConfirm()
},
@@ -71,9 +86,7 @@ export const createPinnedTabCloseConfirmSlice: StateCreator<
return
}
// Why: CLI close requests wait for a response even when the user cancels.
- if (advanceRequest()) {
- nextRequestActionAllowedAt = Date.now() + INTER_REQUEST_ACTION_GUARD_MS
- }
+ advanceAfterAction()
request.onCancel?.()
}
}
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 1039afc7f5e..57f5efacef3 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -2882,6 +2882,8 @@ function createWebUiApi(): NonNullable['ui']> {
onFocusTerminal: () => noopUnsubscribe,
onFocusEditorTab: () => noopUnsubscribe,
onCloseSessionTab: () => noopUnsubscribe,
+ onSessionTabCloseRequest: () => noopUnsubscribe,
+ respondSessionTabClose: () => {},
onMoveSessionTab: () => noopUnsubscribe,
onOpenFileFromMobile: () => noopUnsubscribe,
onOpenDiffFromMobile: () => noopUnsubscribe,
diff --git a/src/shared/session-tab-close.ts b/src/shared/session-tab-close.ts
new file mode 100644
index 00000000000..2817e729a92
--- /dev/null
+++ b/src/shared/session-tab-close.ts
@@ -0,0 +1,16 @@
+export const SESSION_TAB_CLOSE_CANCELED_ERROR = 'session_tab_close_canceled'
+export const SESSION_TAB_CLOSE_FAILED_ERROR = 'session_tab_close_failed'
+export const SESSION_TAB_NOT_FOUND_ERROR = 'session_tab_not_found'
+export const SESSION_TAB_CLOSE_TIMEOUT_ERROR = 'session_tab_close_timeout'
+
+export type SessionTabCloseRequest = {
+ requestId: string
+ tabId: string
+ worktreeId: string
+ expiresAt?: number
+}
+
+export type SessionTabCloseResponse = {
+ requestId: string
+ error?: string
+}
From f8e728bb8b87c0375b320fcd31316996f73e3d88 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 17 Aug 2026 18:42:49 -0700
Subject: [PATCH 08/43] fix(watcher): watch the resolved worktree root so
symlinked and differently-cased paths work (#15077)
* fix(watcher): keep macOS FSEvents paths under the subscribed worktree root
macOS FSEvents reports OS-canonical paths: symlinks resolved and every
directory in its on-disk spelling. Linux (inotify) and Windows both rebuild
event paths from the directory that was subscribed, so only macOS observes
the mismatch.
Orca's watcher contract is "event paths live under worktreePath". Consumers
derive a worktree-relative path with relativePathInsideRoot(), which returns
null when the event falls outside the root -- and a null relative path drops
the event silently. So on a Mac whose worktree or folder path traverses a
symlink (~/code -> /Volumes/..., anything under /tmp or /var), or is spelled
with different casing than disk on a case-insensitive volume, every watcher
event was discarded: the editor never reloaded an agent's edit, the File
Explorer never refreshed, and Source Control never re-ran status. Nothing
errored, which is why this looked like "the file watcher stopped working"
on some machines and not others.
Rewrite event paths back onto the subscribed root inside
subscribeThroughWatcherSupervisor -- the single boundary every desktop,
runtime-environment, and SSH-relay watch passes through -- so one change
covers all three transports.
The resolution runs alongside the subscribe rather than before it: an await
ahead of the subscribe call lets a caller's abort land in a window where no
watcher-process subscription exists to cancel, which hangs the existing
cancellation contracts. The subscribe promise settles only after the rewrite
is installed, and only after the subscription itself is recorded, so
teardown never waits on a realpath.
Matching folds per path segment (NFC + case) instead of by prefix length,
because both folds change length and a folded-prefix length would slice the
raw event path mid-character. Byte-exact fast paths run first, so unaliased
roots -- every Linux and Windows watch, and most macOS ones -- cost one
string comparison per event and allocate nothing.
* fix(watcher): watch the resolved root so symlinked worktrees work on Linux too
Verified on a real Linux host: @parcel/watcher passes IN_DONT_FOLLOW |
IN_ONLYDIR to inotify_add_watch, so a symlinked worktree root fails outright
with ENOTDIR ('Not a directory'). The watch never installs and Orca caches the
root in unwatchableRoots, so it is never retried for that session. That is a
worse symptom than the macOS path-spelling mismatch and hits Linux users of
symlinked checkouts on every machine.
Hand the backend the resolved directory instead of the caller's spelling, and
keep mapping delivered paths back. Resolving the root also lets
@parcel/watcher's own ignore paths match again on macOS, where they were
computed from the unresolved root and silently excluded nothing.
The resolve is synchronous on purpose. Every caller reserves and forks its
watcher child in the same tick as the subscribe call -- capacity accounting and
cancellation ordering both depend on it, and 30+ existing tests encode it -- so
an await here would open a window where a subscribe is issued but no
cancellable child exists.
* test(watcher): use a directory junction on Windows so the alias repro runs there
Creating a directory symlink on Windows needs elevation or Developer Mode, so
the alias tests failed with EPERM on a real Windows host. A junction needs
neither, is what users actually have (a junctioned C:\dev), and realpath
resolves it identically -- so one fixture now covers all three platforms and the
end-to-end repro no longer skips outside Linux and macOS.
* test(watcher): pin the fabricated-path failure modes of the root rewrite
A rewrite that returns a WRONG path is worse than no fix -- a consumer would
act on the wrong file -- so pin the cases that could produce one: sibling
directories that share a prefix with the root (POSIX and UNC), the root itself
versus a shorter path, drive-letter casing, a root-only canonical path, and a
script where toLowerCase changes length. Found by running the rewrite over an
adversarial table; all already passed, so these lock in behaviour rather than
fix it.
* docs(watcher): drop an unverified claim about ignore paths
I claimed resolving the root also repairs @parcel/watcher's ignore-path
matching for aliased roots. Probing it on macOS shows the node_modules write is
excluded either way: FSEvents resolves symlinks in its own exclusion paths, so
the daemon filters at the source regardless of which spelling we subscribe with.
On Linux the exclusions are userspace globs relative to the watched directory
and there was no watch at all before this change, so there is nothing to
compare. Removing the claim rather than leaving a plausible-but-wrong rationale
in the module header.
* refactor(watcher): simplify root path rewriter
* test(palette): build searchable fixture documents
---
...ystem-watcher-canonical-root-paths.test.ts | 132 +++++++++++++
src/main/ipc/filesystem-watcher-real.test.ts | 38 +++-
.../parcel-watcher-root-path-rewrite.test.ts | 132 +++++++++++++
.../parcel-watcher-supervisor-subscribe.ts | 16 +-
src/main/ipc/watcher-aliased-root-fixture.ts | 35 ++++
.../watcher-event-root-path-rewrite.test.ts | 144 ++++++++++++++
.../ipc/watcher-event-root-path-rewrite.ts | 178 ++++++++++++++++++
.../TabBarCreateEntry.tab-results.test.tsx | 9 +
.../tab-bar/open-tab-search-retention.test.ts | 24 ++-
9 files changed, 701 insertions(+), 7 deletions(-)
create mode 100644 src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts
create mode 100644 src/main/ipc/parcel-watcher-root-path-rewrite.test.ts
create mode 100644 src/main/ipc/watcher-aliased-root-fixture.ts
create mode 100644 src/main/ipc/watcher-event-root-path-rewrite.test.ts
create mode 100644 src/main/ipc/watcher-event-root-path-rewrite.ts
diff --git a/src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts b/src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts
new file mode 100644
index 00000000000..45c14d485e0
--- /dev/null
+++ b/src/main/ipc/filesystem-watcher-canonical-root-paths.test.ts
@@ -0,0 +1,132 @@
+/*
+ * macOS FSEvents reports OS-canonical paths (symlinks resolved, on-disk
+ * casing). Before the rewrite at the watcher boundary those events reached the
+ * renderer spelled differently from the root it subscribed with, so every
+ * consumer that derives a worktree-relative path silently dropped them — an
+ * agent's edit never reloaded the open editor tab.
+ */
+import type * as NodeFs from 'node:fs'
+import { resolve } from 'node:path'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { handleMock, realpathMock } = vi.hoisted(() => ({
+ handleMock: vi.fn(),
+ realpathMock: vi.fn()
+}))
+
+vi.mock('electron', () => ({ ipcMain: { handle: handleMock } }))
+
+vi.mock('fs/promises', () => ({
+ stat: vi.fn()
+}))
+
+vi.mock('node:fs', async (importOriginal) => {
+ // Why: the supervisor resolves the watched root synchronously, so the fake
+ // symlink target has to come from realpathSync rather than fs/promises.
+ const actual = await importOriginal()
+ return { ...actual, realpathSync: Object.assign(realpathMock, { native: realpathMock }) }
+})
+
+vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() }))
+
+vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() }))
+
+vi.mock('../providers/ssh-filesystem-dispatch', () => ({
+ getSshFilesystemProvider: vi.fn(),
+ onSshFilesystemProviderRegistered: () => () => {}
+}))
+
+import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
+import { stat } from 'node:fs/promises'
+import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
+import type { Event as WatcherEvent } from '@parcel/watcher'
+import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
+import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window'
+
+type HandlerMap = Record unknown>
+
+describe('local filesystem watcher canonical root paths', () => {
+ const handlers: HandlerMap = {}
+
+ beforeEach(async () => {
+ vi.useRealTimers()
+ handleMock.mockReset()
+ realpathMock.mockReset()
+ vi.mocked(stat).mockReset()
+ vi.mocked(subscribeParcelWatcher).mockReset()
+ for (const key of Object.keys(handlers)) {
+ delete handlers[key]
+ }
+ handleMock.mockImplementation((channel, handler) => {
+ handlers[channel] = handler
+ })
+ registerFilesystemWatcherHandlers()
+ await closeAllWatchers()
+ })
+
+ async function emitAndCapture(
+ worktreePath: string,
+ canonicalRoot: string,
+ events: WatcherEvent[]
+ ): Promise {
+ // The root must stat as a directory to install; the event paths must not.
+ vi.mocked(stat).mockImplementation(
+ (candidate) => Promise.resolve({ isDirectory: () => candidate === worktreePath }) as never
+ )
+ realpathMock.mockReturnValue(canonicalRoot)
+ let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined
+ vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => {
+ watcherCallback = callback as typeof watcherCallback
+ return { unsubscribe: vi.fn() } as never
+ })
+ const sendMock = vi.fn()
+ const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 }
+ await handlers['fs:watchWorktree']({ sender }, { worktreePath })
+ watcherCallback!(null, events)
+ await vi.waitFor(
+ () => {
+ expect(sendMock).toHaveBeenCalledWith('fs:changed', expect.anything())
+ },
+ { timeout: WATCH_BATCH_TRAILING_MS + 2_000 }
+ )
+ return sendMock.mock.calls.find(([channel]) => channel === 'fs:changed')![1] as FsChangedPayload
+ }
+
+ it('reports symlink-resolved event paths under the subscribed root', async () => {
+ const worktreePath = resolve('/tmp/orca-link')
+ const payload = await emitAndCapture(worktreePath, resolve('/private/tmp/orca-real'), [
+ { path: resolve('/private/tmp/orca-real/src/agent-edit.ts'), type: 'update' }
+ ])
+
+ expect(payload.worktreePath).toBe(worktreePath)
+ expect(payload.events).toEqual([
+ {
+ kind: 'update',
+ absolutePath: resolve('/tmp/orca-link/src/agent-edit.ts'),
+ isDirectory: false
+ }
+ ])
+ })
+
+ it.runIf(process.platform === 'darwin' || process.platform === 'win32')(
+ 'reports on-disk casing under the subscribed spelling',
+ async () => {
+ const worktreePath = resolve('/tmp/orca-case/repo')
+ const payload = await emitAndCapture(worktreePath, worktreePath, [
+ { path: resolve('/tmp/orca-case/Repo/src/agent-edit.ts'), type: 'update' }
+ ])
+
+ expect(payload.events[0]!.absolutePath).toBe(resolve('/tmp/orca-case/repo/src/agent-edit.ts'))
+ }
+ )
+
+ it('leaves already-matching event paths untouched', async () => {
+ const worktreePath = resolve('/tmp/orca-plain')
+ const eventPath = resolve('/tmp/orca-plain/src/agent-edit.ts')
+ const payload = await emitAndCapture(worktreePath, worktreePath, [
+ { path: eventPath, type: 'update' }
+ ])
+
+ expect(payload.events[0]!.absolutePath).toBe(eventPath)
+ })
+})
diff --git a/src/main/ipc/filesystem-watcher-real.test.ts b/src/main/ipc/filesystem-watcher-real.test.ts
index a813be69e28..7c9556e7ba4 100644
--- a/src/main/ipc/filesystem-watcher-real.test.ts
+++ b/src/main/ipc/filesystem-watcher-real.test.ts
@@ -13,7 +13,7 @@
* On a Linux host isWslPath() is always false, so a native /tmp path routes to
* createWatcher() -> real @parcel/watcher (not the inotifywait WSL fallback).
*/
-import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -25,6 +25,11 @@ vi.mock('electron', () => ({
}))
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
+import {
+ createAliasedWatcherRoot,
+ removeAliasedWatcherRoot,
+ type AliasedWatcherRoot
+} from './watcher-aliased-root-fixture'
type HandlerMap = Record unknown>
@@ -46,6 +51,7 @@ async function waitFor(predicate: () => boolean, timeoutMs = 8_000, stepMs = 50)
describe('filesystem-watcher real @parcel/watcher integration', () => {
const handlers: HandlerMap = {}
let tempDir: string | null = null
+ let aliasedRoot: AliasedWatcherRoot | null = null
beforeEach(async () => {
handleMock.mockReset()
@@ -65,6 +71,8 @@ describe('filesystem-watcher real @parcel/watcher integration', () => {
await rm(tempDir, { recursive: true, force: true })
tempDir = null
}
+ await removeAliasedWatcherRoot(aliasedRoot)
+ aliasedRoot = null
vi.clearAllMocks()
})
@@ -119,4 +127,32 @@ describe('filesystem-watcher real @parcel/watcher integration', () => {
},
15_000
)
+
+ // Why: this is the end-to-end repro, and it fails differently per platform
+ // without the fix — Linux cannot inotify-watch the alias at all (IN_ONLYDIR),
+ // macOS watches it but reports paths under the resolved root. Both leave the
+ // renderer with events outside the root it subscribed with. One assertion
+ // covers both: the emitted paths must stay under the subscribed spelling.
+ it('reports events under an aliased worktree root, not its resolved path', async () => {
+ const root = await createAliasedWatcherRoot('orca-fswatch-alias-')
+ aliasedRoot = root
+ await mkdir(join(root.realRoot, 'src'), { recursive: true })
+
+ const sendMock = vi.fn()
+ const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 }
+ await handlers['fs:watchWorktree']({ sender }, { worktreePath: root.aliasRoot })
+
+ const expectedPath = join(root.aliasRoot, 'src', 'agent-edit.ts')
+ await writeFile(join(root.realRoot, 'src', 'agent-edit.ts'), 'hello')
+
+ await waitFor(() =>
+ sendMock.mock.calls.some(
+ ([channel, payload]) =>
+ channel === 'fs:changed' &&
+ (payload as FsChangedCall).events.some((event) => event.absolutePath === expectedPath)
+ )
+ )
+
+ await handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, { worktreePath: root.aliasRoot })
+ }, 15_000)
})
diff --git a/src/main/ipc/parcel-watcher-root-path-rewrite.test.ts b/src/main/ipc/parcel-watcher-root-path-rewrite.test.ts
new file mode 100644
index 00000000000..ed5cd02c977
--- /dev/null
+++ b/src/main/ipc/parcel-watcher-root-path-rewrite.test.ts
@@ -0,0 +1,132 @@
+/*
+ * macOS reports OS-canonical paths (symlinks resolved, on-disk casing), which
+ * land outside the root the caller subscribed with. Consumers derive a
+ * worktree-relative path from that root and drop anything outside it, so an
+ * agent's edit under a symlinked worktree never reloaded the editor tab, never
+ * refreshed the File Explorer, and never re-ran Source Control status.
+ *
+ * The rewrite lives in the supervisor, the one boundary every desktop, runtime,
+ * and relay watch passes, so assert it there for both subscription entry points.
+ */
+import type * as NodeFs from 'node:fs'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ createAliasedWatcherRoot,
+ removeAliasedWatcherRoot,
+ type AliasedWatcherRoot
+} from './watcher-aliased-root-fixture'
+import {
+ acknowledgeWatcherSubscribe,
+ currentWatcherChild,
+ FakeWatcherChild
+} from './parcel-watcher-process-test-child'
+
+const { forkMock, existsSyncMock, mkdtempSyncMock, parcelSubscribeMock, rmSyncMock } = vi.hoisted(
+ () => ({
+ forkMock: vi.fn(),
+ existsSyncMock: vi.fn(),
+ mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-rewrite-test'),
+ parcelSubscribeMock: vi.fn(),
+ rmSyncMock: vi.fn()
+ })
+)
+
+vi.mock('node:child_process', () => ({ fork: forkMock }))
+vi.mock('node:fs', async (importOriginal) => {
+ // Why: the supervisor resolves the watched root with realpathSync.native, so
+ // keep the real implementation while stubbing the child-launch surface.
+ const actual = await importOriginal()
+ return {
+ existsSync: existsSyncMock,
+ mkdtempSync: mkdtempSyncMock,
+ rmSync: rmSyncMock,
+ realpathSync: actual.realpathSync
+ }
+})
+vi.mock('@parcel/watcher', () => ({ subscribe: parcelSubscribeMock }))
+
+import {
+ disposeWatcherProcess,
+ resetRuntimeWatcherProcessForTest,
+ resetWatcherProcessForTest,
+ subscribeViaRuntimeWatcherProcess,
+ subscribeViaWatcherProcess,
+ type WatcherProcessEvent
+} from './parcel-watcher-process'
+
+describe('watcher-process root path rewrite', () => {
+ let root: AliasedWatcherRoot | null = null
+
+ beforeEach(() => {
+ resetWatcherProcessForTest()
+ resetRuntimeWatcherProcessForTest()
+ // Why: these assertions target the forked-process mode that ships, so hide
+ // the vitest marker that would route them to the in-process fallback.
+ vi.stubEnv('VITEST', '')
+ existsSyncMock.mockReturnValue(true)
+ forkMock.mockImplementation(() => new FakeWatcherChild())
+ })
+
+ afterEach(async () => {
+ disposeWatcherProcess()
+ resetRuntimeWatcherProcessForTest()
+ vi.unstubAllEnvs()
+ vi.clearAllMocks()
+ await removeAliasedWatcherRoot(root)
+ root = null
+ })
+
+ async function subscribeAndEmit(
+ subscribe: typeof subscribeViaWatcherProcess,
+ aliasRoot: string,
+ events: WatcherProcessEvent[]
+ ): Promise {
+ const delivered: WatcherProcessEvent[][] = []
+ const pending = subscribe(aliasRoot, (_error, batch) => delivered.push(batch), {})
+ const child = currentWatcherChild(forkMock)
+ const id = acknowledgeWatcherSubscribe(child)
+ await pending
+ child.emit('message', { op: 'events', id, events })
+ return delivered
+ }
+
+ it('rewrites resolved-alias paths for desktop watches', async () => {
+ root = await createAliasedWatcherRoot('watcher-rewrite-')
+ const delivered = await subscribeAndEmit(subscribeViaWatcherProcess, root.aliasRoot, [
+ { type: 'update', path: join(root.realRoot, 'src', 'agent-edit.ts'), isDirectory: false }
+ ])
+
+ expect(delivered).toEqual([
+ [
+ {
+ type: 'update',
+ path: join(root.aliasRoot, 'src', 'agent-edit.ts'),
+ isDirectory: false
+ }
+ ]
+ ])
+ })
+
+ // The runtime pool is also what a relay host uses for every SSH worktree watch.
+ it('rewrites resolved-alias paths for runtime and relay watches', async () => {
+ root = await createAliasedWatcherRoot('watcher-rewrite-')
+ const delivered = await subscribeAndEmit(subscribeViaRuntimeWatcherProcess, root.aliasRoot, [
+ { type: 'create', path: join(root.realRoot, 'docs', 'notes.md') }
+ ])
+
+ expect(delivered).toEqual([
+ [{ type: 'create', path: join(root.aliasRoot, 'docs', 'notes.md') }]
+ ])
+ })
+
+ it('leaves an unaliased root untouched', async () => {
+ root = await createAliasedWatcherRoot('watcher-rewrite-')
+ const eventPath = join(root.realRoot, 'src', 'agent-edit.ts')
+ const delivered = await subscribeAndEmit(subscribeViaWatcherProcess, root.realRoot, [
+ { type: 'update', path: eventPath, isDirectory: false }
+ ])
+
+ expect(delivered).toEqual([[{ type: 'update', path: eventPath, isDirectory: false }]])
+ })
+})
diff --git a/src/main/ipc/parcel-watcher-supervisor-subscribe.ts b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts
index f508f9b117b..84db3eabb39 100644
--- a/src/main/ipc/parcel-watcher-supervisor-subscribe.ts
+++ b/src/main/ipc/parcel-watcher-supervisor-subscribe.ts
@@ -9,6 +9,7 @@ import {
resetPendingSubscribeAttempt
} from './parcel-watcher-pending-subscribe'
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
+import { rewriteWatcherEvents, resolveWatcherRootPaths } from './watcher-event-root-path-rewrite'
import type {
HostToWatcherMessage,
WatcherProcessSubscribeOptions
@@ -60,7 +61,7 @@ export function sendWatcherSubscribe(
export function subscribeThroughWatcherSupervisor({
dir,
- callback,
+ callback: rawCallback,
opts,
hooks,
shutdownRequested,
@@ -96,10 +97,17 @@ export function subscribeThroughWatcherSupervisor({
)
)
}
+ // Why: a symlinked or differently-cased root is unwatchable on Linux
+ // (IN_ONLYDIR) and misreported on macOS (FSEvents canonicalizes). Watch the
+ // resolved directory, then restore the caller's spelling on the way out --
+ // this is the one boundary every desktop, runtime, and relay watch passes.
+ const { watchRoot, rewriteEventPath } = resolveWatcherRootPaths(dir)
+ const callback: WatcherProcessCallback = (error, events) =>
+ rawCallback(error, rewriteWatcherEvents(events, rewriteEventPath))
// Why: under Vitest we cannot fork a real watcher child, so exercise the
// subscription path in-process (against mocked @parcel/watcher) instead.
if (process.env.VITEST && useInProcessVitestFallback) {
- return subscribeWithInProcessWatcher(dir, callback, opts, hooks)
+ return subscribeWithInProcessWatcher(watchRoot, callback, opts, hooks)
}
if (!existsSync(entryPath)) {
return Promise.reject(
@@ -112,14 +120,14 @@ export function subscribeThroughWatcherSupervisor({
}
const record: WatcherProcessSubscriptionRecord = {
id: allocateId(),
- dir,
+ dir: watchRoot,
opts,
callback,
hooks,
interrupted: false,
crawlStarted: false
}
- return new Promise((resolve, reject) => {
+ return new Promise((resolve, reject) => {
const child = ensureWatcherProcess(entryPath)
if (!child) {
reject(
diff --git a/src/main/ipc/watcher-aliased-root-fixture.ts b/src/main/ipc/watcher-aliased-root-fixture.ts
new file mode 100644
index 00000000000..9bcabaa9d58
--- /dev/null
+++ b/src/main/ipc/watcher-aliased-root-fixture.ts
@@ -0,0 +1,35 @@
+import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+export type AliasedWatcherRoot = {
+ /** Temp directory holding both spellings; remove it with `removeAliasedWatcherRoot`. */
+ base: string
+ /** The directory that actually holds the files. */
+ realRoot: string
+ /** The spelling a caller would subscribe with — resolves to `realRoot`. */
+ aliasRoot: string
+}
+
+/**
+ * Creates a directory reachable under two spellings, the way a developer's
+ * `~/code -> /Volumes/…` link or a `C:\dev` junction makes a worktree reachable.
+ *
+ * Windows uses a junction rather than a symlink: creating a directory symlink
+ * there needs elevation or Developer Mode, while a junction needs neither and is
+ * what users actually have. `realpath` resolves both identically.
+ */
+export async function createAliasedWatcherRoot(prefix: string): Promise {
+ const base = await realpath(await mkdtemp(join(tmpdir(), prefix)))
+ const realRoot = join(base, 'real')
+ const aliasRoot = join(base, 'alias')
+ await mkdir(realRoot, { recursive: true })
+ await symlink(realRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir')
+ return { base, realRoot, aliasRoot }
+}
+
+export async function removeAliasedWatcherRoot(root: AliasedWatcherRoot | null): Promise {
+ if (root) {
+ await rm(root.base, { recursive: true, force: true })
+ }
+}
diff --git a/src/main/ipc/watcher-event-root-path-rewrite.test.ts b/src/main/ipc/watcher-event-root-path-rewrite.test.ts
new file mode 100644
index 00000000000..eefed28db3e
--- /dev/null
+++ b/src/main/ipc/watcher-event-root-path-rewrite.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest'
+import {
+ createRootPathRewriter,
+ resolveWatcherRootPaths,
+ rewriteWatcherEvents
+} from './watcher-event-root-path-rewrite'
+
+describe('createRootPathRewriter', () => {
+ it('maps symlink-resolved macOS paths back onto the subscribed root', () => {
+ const rewrite = createRootPathRewriter('/tmp/link', '/private/tmp/real', 'darwin')
+ expect(rewrite('/private/tmp/real/src/a.ts')).toBe('/tmp/link/src/a.ts')
+ expect(rewrite('/private/tmp/real')).toBe('/tmp/link')
+ })
+
+ it('maps on-disk casing back onto the subscribed spelling on macOS', () => {
+ const rewrite = createRootPathRewriter('/Users/dev/repo', '/Users/dev/repo', 'darwin')
+ expect(rewrite('/Users/dev/Repo/src/a.ts')).toBe('/Users/dev/repo/src/a.ts')
+ })
+
+ it('keeps paths already spelled like the subscribed root untouched', () => {
+ const rewrite = createRootPathRewriter('/tmp/link', '/private/tmp/real', 'darwin')
+ const eventPath = '/tmp/link/src/a.ts'
+ expect(rewrite(eventPath)).toBe(eventPath)
+ })
+
+ it('leaves paths outside the canonical root alone', () => {
+ const rewrite = createRootPathRewriter('/tmp/link', '/private/tmp/real', 'darwin')
+ expect(rewrite('/private/tmp/other/a.ts')).toBe('/private/tmp/other/a.ts')
+ })
+
+ it('stays case-sensitive on Linux', () => {
+ const rewrite = createRootPathRewriter('/home/dev/repo', '/home/dev/repo', 'linux')
+ expect(rewrite('/home/dev/Repo/src/a.ts')).toBe('/home/dev/Repo/src/a.ts')
+ })
+
+ it('does not treat backslash as a separator for POSIX roots', () => {
+ const rewrite = createRootPathRewriter('/tmp/link', '/private/tmp/real', 'darwin')
+ expect(rewrite('/private/tmp/real/we\\ird/a.ts')).toBe('/tmp/link/we\\ird/a.ts')
+ })
+
+ it('rewrites Windows junction targets with the requested drive spelling', () => {
+ const rewrite = createRootPathRewriter('C:\\link', 'D:\\real\\repo', 'win32')
+ expect(rewrite('D:\\real\\repo\\src\\a.ts')).toBe('C:\\link\\src\\a.ts')
+ })
+
+ it('keeps a forward-slash Windows root spelled with forward slashes', () => {
+ const rewrite = createRootPathRewriter('C:/link', 'C:\\real\\repo', 'win32')
+ expect(rewrite('C:\\real\\repo\\src\\a.ts')).toBe('C:/link/src\\a.ts')
+ })
+
+ it('rewrites forward-slash UNC roots on Windows', () => {
+ const rewrite = createRootPathRewriter('//srv/share/link', '\\\\srv\\share\\real', 'win32')
+ expect(rewrite('\\\\srv\\share\\real\\src\\a.ts')).toBe('//srv/share/link/src\\a.ts')
+ })
+
+ // Why: a fabricated path is worse than no fix — a consumer would act on the
+ // wrong file — so the prefix match is separator-aware in both directions.
+ it('never mistakes a sibling directory for the root', () => {
+ const rewrite = createRootPathRewriter('/a/link', '/b/real', 'darwin')
+ expect(rewrite('/b/real-backup/x.ts')).toBe('/b/real-backup/x.ts')
+ expect(createRootPathRewriter('/a/repo', '/a/repo', 'darwin')('/a/repo-backup/x.ts')).toBe(
+ '/a/repo-backup/x.ts'
+ )
+ })
+
+ it('never mistakes a sibling UNC share directory for the root', () => {
+ const rewrite = createRootPathRewriter('\\\\srv\\share\\repo', '\\\\srv\\share\\repo', 'win32')
+ expect(rewrite('\\\\srv\\share\\repo2\\x.ts')).toBe('\\\\srv\\share\\repo2\\x.ts')
+ })
+
+ it('rewrites the root itself and leaves a shorter path alone', () => {
+ const rewrite = createRootPathRewriter('/a/link', '/b/real', 'darwin')
+ expect(rewrite('/b/real')).toBe('/a/link')
+ expect(rewrite('/b')).toBe('/b')
+ })
+
+ it('restores the requested drive-letter casing', () => {
+ const rewrite = createRootPathRewriter('C:\\link', 'c:\\real', 'win32')
+ expect(rewrite('c:\\real\\x.ts')).toBe('C:\\link\\x.ts')
+ })
+
+ // Why: toLowerCase can change length for some scripts; folding per segment
+ // keeps a non-match a non-match instead of slicing the path mid-character.
+ it('does not fold unrelated scripts into a match', () => {
+ const rewrite = createRootPathRewriter('/a/DIYARBAKIR', '/a/DIYARBAKIR', 'darwin')
+ expect(rewrite('/a/d\u0131yarbak\u0131r/x.ts')).toBe('/a/d\u0131yarbak\u0131r/x.ts')
+ })
+
+ it('returns a root-only canonical path unrewritten rather than guessing', () => {
+ const rewrite = createRootPathRewriter('/a/link', '/', 'darwin')
+ expect(rewrite('/x.ts')).toBe('/x.ts')
+ })
+
+ it('folds Unicode composition differences without slicing mid-character', () => {
+ const composed = '/Users/dev/caf\u00e9'
+ const decomposed = '/Users/dev/cafe\u0301'
+ const rewrite = createRootPathRewriter(composed, composed, 'darwin')
+ expect(rewrite(`${decomposed}/src/a.ts`)).toBe(`${composed}/src/a.ts`)
+ })
+})
+
+describe('resolveWatcherRootPaths', () => {
+ it('falls back to the literal root when realpath fails', () => {
+ const { watchRoot, rewriteEventPath } = resolveWatcherRootPaths('/tmp/gone', {
+ realpath: () => {
+ throw new Error('ENOENT')
+ },
+ platform: 'linux'
+ })
+ expect(watchRoot).toBe('/tmp/gone')
+ expect(rewriteEventPath('/tmp/gone/a.ts')).toBe('/tmp/gone/a.ts')
+ })
+
+ // Why: Linux cannot inotify-watch a symlink at all (IN_ONLYDIR), so the
+ // resolved directory — not the caller's spelling — is what gets watched.
+ it('watches the resolved root and restores the caller spelling', () => {
+ const { watchRoot, rewriteEventPath } = resolveWatcherRootPaths('/tmp/link', {
+ realpath: () => '/private/tmp/real',
+ platform: 'darwin'
+ })
+ expect(watchRoot).toBe('/private/tmp/real')
+ expect(rewriteEventPath('/private/tmp/real/a.ts')).toBe('/tmp/link/a.ts')
+ })
+})
+
+describe('rewriteWatcherEvents', () => {
+ it('returns the same array when nothing needs rewriting', () => {
+ const rewrite = createRootPathRewriter('/tmp/link', '/tmp/link', 'linux')
+ const events = [{ path: '/tmp/link/a.ts', type: 'update' as const }]
+ expect(rewriteWatcherEvents(events, rewrite)).toBe(events)
+ })
+
+ it('rewrites only the events that moved', () => {
+ const rewrite = createRootPathRewriter('/tmp/link', '/private/tmp/real', 'darwin')
+ const events = [
+ { path: '/tmp/link/a.ts', type: 'update' as const },
+ { path: '/private/tmp/real/b.ts', type: 'create' as const }
+ ]
+ expect(rewriteWatcherEvents(events, rewrite)).toEqual([
+ { path: '/tmp/link/a.ts', type: 'update' },
+ { path: '/tmp/link/b.ts', type: 'create' }
+ ])
+ })
+})
diff --git a/src/main/ipc/watcher-event-root-path-rewrite.ts b/src/main/ipc/watcher-event-root-path-rewrite.ts
new file mode 100644
index 00000000000..69a333bae7c
--- /dev/null
+++ b/src/main/ipc/watcher-event-root-path-rewrite.ts
@@ -0,0 +1,178 @@
+/**
+ * A worktree reached through a symlink (`~/code` -> `/Volumes/…`, anything under
+ * `/tmp` or `/var`) or spelled with different casing than disk on a
+ * case-insensitive volume breaks recursive watching, differently per platform:
+ *
+ * - Linux: `@parcel/watcher` passes `IN_DONT_FOLLOW | IN_ONLYDIR` to
+ * `inotify_add_watch`, so a symlinked root fails outright with ENOTDIR
+ * ("Not a directory"). The watch never installs, and Orca caches the root in
+ * `unwatchableRoots`, so it is not retried for the rest of the session.
+ * - macOS: FSEvents installs the watch but reports OS-canonical paths —
+ * symlinks resolved, each directory in its on-disk spelling — so events land
+ * outside the root the caller subscribed with.
+ * - Windows: `GetFinalPathNameByHandle` behind `realpath` resolves junctions
+ * and substituted drives the same way.
+ *
+ * Both failures have the same visible symptom, because every consumer derives a
+ * worktree-relative path from the subscribed root and silently drops whatever
+ * falls outside it: the editor never reloads an agent's edit, the File Explorer
+ * never refreshes, and Source Control never re-runs status.
+ *
+ * So hand the backend the resolved directory, which is watchable everywhere,
+ * then map delivered paths back to the caller's spelling so the "event paths
+ * live under worktreePath" contract holds on every platform.
+ */
+import { realpathSync } from 'node:fs'
+
+export type WatcherEventPathRewriter = (eventPath: string) => string
+
+const CASE_INSENSITIVE_PLATFORMS = new Set(['darwin', 'win32'])
+
+const identityWatcherEventPathRewriter: WatcherEventPathRewriter = (eventPath) => eventPath
+
+function isWindowsStyleRoot(rootPath: string, platform: NodeJS.Platform): boolean {
+ return (
+ /^[A-Za-z]:/.test(rootPath) ||
+ rootPath.startsWith('\\\\') ||
+ (platform === 'win32' && rootPath.startsWith('//'))
+ )
+}
+
+// Why: backslash is a legal POSIX filename character, so only Windows roots may
+// treat it as a separator.
+function splitRootSegments(value: string, windowsStyle: boolean): string[] {
+ const parts = windowsStyle ? value.split(/[\\/]+/) : value.split('/')
+ return parts.filter((part) => part.length > 0)
+}
+
+type RootPath = {
+ value: string
+ prefix: string
+ separator: '/' | '\\'
+}
+
+function describeRootPath(value: string, windowsStyle: boolean): RootPath {
+ const separator: '/' | '\\' = windowsStyle && !value.includes('/') ? '\\' : '/'
+ return {
+ value,
+ prefix: value.endsWith(separator) ? value : `${value}${separator}`,
+ separator
+ }
+}
+
+function matchingSuffix(
+ rootSegments: readonly string[],
+ candidate: string,
+ windowsStyle: boolean,
+ foldSegment: (segment: string) => string
+): string[] | null {
+ const candidateSegments = splitRootSegments(candidate, windowsStyle)
+ if (candidateSegments.length < rootSegments.length) {
+ return null
+ }
+ const matches = rootSegments.every((rootSegment, index) => {
+ const candidateSegment = candidateSegments[index]
+ return candidateSegment !== undefined && foldSegment(candidateSegment) === rootSegment
+ })
+ return matches ? candidateSegments.slice(rootSegments.length) : null
+}
+
+export function createRootPathRewriter(
+ requestedRoot: string,
+ canonicalRoot: string,
+ platform: NodeJS.Platform = process.platform
+): WatcherEventPathRewriter {
+ const windowsStyle = isWindowsStyleRoot(requestedRoot, platform)
+ const requested = describeRootPath(requestedRoot, windowsStyle)
+ const canonical = describeRootPath(canonicalRoot, windowsStyle)
+ const canonicalSegments = splitRootSegments(canonicalRoot, windowsStyle)
+ if (canonicalSegments.length === 0) {
+ return identityWatcherEventPathRewriter
+ }
+ const caseInsensitive = CASE_INSENSITIVE_PLATFORMS.has(platform)
+ // Why: fold per segment rather than over the whole path — NFC and case
+ // folding both change length, so a folded-prefix length would slice the raw
+ // event path mid-character and fabricate a path. Segment counts survive both.
+ const foldSegment = (segment: string): string => {
+ const normalized = segment.normalize('NFC')
+ return caseInsensitive ? normalized.toLowerCase() : normalized
+ }
+ const foldedCanonicalSegments = canonicalSegments.map(foldSegment)
+
+ return (eventPath) => {
+ // Already spelled the way the renderer subscribed — the whole non-macOS world.
+ if (eventPath === requested.value || eventPath.startsWith(requested.prefix)) {
+ return eventPath
+ }
+ // A plain symlinked root differs only by prefix; splice it byte-exact so the
+ // per-segment fold below is reached only for casing/Unicode differences.
+ if (eventPath.startsWith(canonical.prefix)) {
+ return `${requested.prefix}${eventPath.slice(canonical.prefix.length)}`
+ }
+ const suffix = matchingSuffix(foldedCanonicalSegments, eventPath, windowsStyle, foldSegment)
+ if (suffix === null) {
+ return eventPath
+ }
+ return suffix.length === 0
+ ? requested.value
+ : `${requested.prefix}${suffix.join(requested.separator)}`
+ }
+}
+
+export type WatcherRootPaths = {
+ /** Directory to hand the watcher backend — symlinks resolved. */
+ watchRoot: string
+ /** Maps a delivered event path back onto the caller's spelling. */
+ rewriteEventPath: WatcherEventPathRewriter
+}
+
+/**
+ * Deliberately synchronous. Every caller reserves and forks its watcher child
+ * in the same tick as the subscribe call — capacity accounting and cancellation
+ * ordering both depend on it — so an await here would open a window in which a
+ * subscribe has been issued but no cancellable child exists. This is one
+ * `realpath` per subscribe (not per event), on a directory the caller has
+ * usually just stat'd, in a function that already calls `existsSync`.
+ */
+export function resolveWatcherRootPaths(
+ requestedRoot: string,
+ deps: {
+ realpath?: (candidate: string) => string
+ platform?: NodeJS.Platform
+ } = {}
+): WatcherRootPaths {
+ let watchRoot = requestedRoot
+ try {
+ watchRoot = (deps.realpath ?? realpathSync.native)(requestedRoot)
+ } catch {
+ // Why: a vanished or unreadable root still gets a watcher attempt, which
+ // owns its own failure reporting; fall back to the literal spelling rather
+ // than fail here. Resolving the binding inside the try also keeps suites
+ // that mock a partial node:fs from failing every watcher install.
+ }
+ return {
+ watchRoot,
+ rewriteEventPath: createRootPathRewriter(requestedRoot, watchRoot, deps.platform)
+ }
+}
+
+/**
+ * Returns the original array when nothing needed rewriting so the common
+ * per-batch hot path stays allocation-free.
+ */
+export function rewriteWatcherEvents(
+ events: T[],
+ rewrite: WatcherEventPathRewriter
+): T[] {
+ let rewritten: T[] | null = null
+ for (const [index, event] of events.entries()) {
+ const path = rewrite(event.path)
+ if (path === event.path) {
+ rewritten?.push(event)
+ continue
+ }
+ rewritten ??= events.slice(0, index)
+ rewritten.push({ ...event, path })
+ }
+ return rewritten ?? events
+}
diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx
index a7e599b0ec1..6c925e814d4 100644
--- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx
+++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tab-results.test.tsx
@@ -4,6 +4,7 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
+import { buildPaletteTabDocument } from '@/lib/palette-match/tab-document'
import type { OpenTabSearchResult } from './open-tab-search'
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
import type { TabCreateMenuOption } from './tab-create-menu-options'
@@ -70,6 +71,14 @@ const tabSearchMock = vi.hoisted(() => {
secondaryText: row.relativePath ?? '',
titleSearchText: row.title,
secondarySearchTexts: row.relativePath ? [row.relativePath] : [],
+ document: buildPaletteTabDocument({
+ id: row.tabId ?? row.title,
+ title: row.title,
+ secondaryTexts: row.relativePath ? [row.relativePath] : [],
+ worktreeName: worktree.displayName,
+ branch: 'main',
+ repoName: 'octo/rocket'
+ }),
agentMetadata: [],
isCurrentTab: false,
isCurrentWorktree: true
diff --git a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts
index bac8a16eb99..8bc08812cb7 100644
--- a/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts
+++ b/src/renderer/src/components/tab-bar/open-tab-search-retention.test.ts
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types'
import type { Tab, TabContentType } from '../../../../shared/tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
-import type { SearchableBrowserPage } from '@/lib/browser-palette-search'
+import {
+ buildSearchableBrowserPageDocument,
+ type SearchableBrowserPage
+} from '@/lib/browser-palette-search'
+import { buildPaletteTabDocument } from '@/lib/palette-match/tab-document'
import type { SearchableWorkspaceTab } from '@/lib/workspace-tab-palette-search'
import { TERMINAL_TYPE_SEARCH_ALIASES } from '@/lib/workspace-tab-palette-search'
import { searchOpenTabs } from './open-tab-search'
@@ -72,6 +76,16 @@ function makeWorkspaceTab({
secondarySearchTexts,
typeSearchAliases:
typeSearchAliases ?? (contentType === 'terminal' ? TERMINAL_TYPE_SEARCH_ALIASES : undefined),
+ document: buildPaletteTabDocument({
+ id,
+ title,
+ secondaryTexts: secondarySearchTexts,
+ worktreeName: worktree.displayName,
+ branch: 'main',
+ repoName: 'octo/rocket',
+ typeAliases:
+ typeSearchAliases ?? (contentType === 'terminal' ? TERMINAL_TYPE_SEARCH_ALIASES : undefined)
+ }),
agentMetadata: agentSnippets.length
? [{ paneKey: `${id}-pane`, textParts: [], snippetCandidates: agentSnippets }]
: [],
@@ -125,7 +139,13 @@ function makeBrowserPage({
repoName: 'octo/rocket',
worktreeSortIndex: 0,
isCurrentPage: false,
- isCurrentWorktree: true
+ isCurrentWorktree: true,
+ document: buildSearchableBrowserPageDocument({
+ page,
+ workspace,
+ worktree,
+ repoName: 'octo/rocket'
+ })
}
}
From a54c27f00d8aca6f35759c378375f07f6fd4fc5c Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 17 Aug 2026 19:05:48 -0700
Subject: [PATCH 09/43] Restructure automation editor dialog into three-column
layout (#14803)
* Restructure automation editor dialog into three-column layout
- Separate prompt editing from settings configuration
- Add Monaco editor for prompt with find widget support
- Extract settings into right sidebar for better organization
- Move automation name into prompt section for context
- Simplify header and footer to focus on key actions
- Settings controls now smoothly collapse when switching between Orca and Hermes targets
* Fix React Doctor leak on automation prompt Escape listener.
Move addEventListener into a helper that returns cleanup so the
changed-code quality gate can see the subscription is released.
* Fix stale ref closures in automation prompt editor
- Move `onDismissRef.current` update into useLayoutEffect with `[onDismiss]`
dependency to prevent stale closures in event listeners
- Move `contentRef.current` update into the layout effect that syncs it,
ensuring editor has current value when effects reference it
---
.../automations/AutomationEditorDialog.tsx | 113 +++----
.../AutomationEditorDialogFooter.tsx | 283 ++----------------
.../AutomationEditorDialogHeader.tsx | 186 ++++++------
.../AutomationEditorPromptEditor.test.tsx | 194 ++++++++++++
.../AutomationEditorPromptEditor.tsx | 167 +++++++++++
.../AutomationEditorPromptSection.tsx | 137 +++++----
.../AutomationEditorSettingsSidebar.tsx | 221 ++++++++++++++
.../AutomationMissedRunGraceField.tsx | 3 +-
.../automations/AutomationPrecheckFields.tsx | 160 +++++-----
.../automations/AutomationSchedulePicker.tsx | 214 +++++--------
.../automations/AutomationSessionField.tsx | 10 +-
.../automations/AutomationWorkspaceField.tsx | 128 ++++++++
.../automation-editor-prompt-options.test.ts | 23 ++
.../automation-editor-prompt-options.ts | 43 +++
.../automations/automation-page-parts.tsx | 9 +-
.../editor/monaco-find-widget.test.ts | 141 +++++++++
.../components/editor/monaco-find-widget.ts | 53 ++++
src/renderer/src/i18n/locales/en.json | 7 +-
src/renderer/src/i18n/locales/es.json | 1 +
src/renderer/src/i18n/locales/ja.json | 1 +
src/renderer/src/i18n/locales/ko.json | 1 +
src/renderer/src/i18n/locales/zh.json | 1 +
22 files changed, 1411 insertions(+), 685 deletions(-)
create mode 100644 src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx
create mode 100644 src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx
create mode 100644 src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx
create mode 100644 src/renderer/src/components/automations/AutomationWorkspaceField.tsx
create mode 100644 src/renderer/src/components/automations/automation-editor-prompt-options.test.ts
create mode 100644 src/renderer/src/components/automations/automation-editor-prompt-options.ts
create mode 100644 src/renderer/src/components/editor/monaco-find-widget.test.ts
create mode 100644 src/renderer/src/components/editor/monaco-find-widget.ts
diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx
index 569d1a8a2ea..01a3f30a0d0 100644
--- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx
+++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx
@@ -13,22 +13,22 @@ import type { Repo } from '../../../../shared/repo-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { SetupDecision } from '../../../../shared/worktree/create-types'
import type { Worktree } from '../../../../shared/worktree/types'
-import {
- isValidAutomationCronSchedule,
- isValidAutomationSchedule
-} from '../../../../shared/automation-schedules'
-import { Field } from './automation-page-parts'
+import { closeUnfocusedMonacoFindOrPreventDialogDismiss } from '@/components/editor/monaco-find-widget'
import { AutomationEditorDialogFooter } from './AutomationEditorDialogFooter'
import { AutomationEditorDialogHeader } from './AutomationEditorDialogHeader'
+import { getAutomationPromptEditorRoot } from './AutomationEditorPromptEditor'
import { AutomationEditorPromptSection } from './AutomationEditorPromptSection'
-import { AutomationSchedulePicker } from './AutomationSchedulePicker'
+import { AutomationEditorSettingsSidebar } from './AutomationEditorSettingsSidebar'
import { getAutomationTemplates, type AutomationTemplate } from './automation-templates'
-import { translate } from '@/i18n/i18n'
-const PICKER_TRIGGER_CLASS =
+export const AUTOMATION_EDITOR_PICKER_TRIGGER_CLASS =
'border-input bg-input/30 shadow-xs hover:bg-accent/60 dark:bg-input/30 dark:hover:bg-input/50'
-const MODE_TOGGLE_ITEM_CLASS =
- 'w-full border-input bg-input/30 shadow-xs hover:bg-accent/60 data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:hover:bg-primary/90 dark:bg-input/30 dark:data-[state=on]:bg-primary dark:data-[state=on]:text-primary-foreground dark:data-[state=on]:hover:bg-primary/90'
+
+export const AUTOMATION_EDITOR_SEGMENTED_GROUP_CLASS =
+ 'grid w-full grid-cols-2 rounded-md bg-muted p-0.5'
+
+export const AUTOMATION_EDITOR_SEGMENTED_ITEM_CLASS =
+ 'h-7 rounded-sm border-0 bg-transparent shadow-none hover:bg-card/70 data-[state=on]:bg-card data-[state=on]:text-card-foreground data-[state=on]:shadow-xs'
export type AutomationDraft = {
name: string
@@ -102,6 +102,7 @@ export function AutomationEditorDialog({
onSave
}: AutomationEditorDialogProps): React.JSX.Element {
const [templateOpen, setTemplateOpen] = React.useState(false)
+ const dialogContentRef = React.useRef(null)
const isHermesTarget = createTarget === 'hermes'
const isCreateMode = !isEditing && !isEditingExternal
const isHermesCreate = isCreateMode && isHermesTarget
@@ -116,28 +117,33 @@ export function AutomationEditorDialog({
(agent) => enabledIds.has(agent.id) || agent.id === draft.agentId
)
}, [draft.agentId, settings?.disabledTuiAgents])
- const scheduleField = (
-
-
-
- )
return (
{
event.preventDefault()
}}
+ onEscapeKeyDown={(event) => {
+ // Why: Radix listens on document, so currentTarget is not the
+ // dialog. Use the content ref to scope find to this prompt editor.
+ const dialog =
+ dialogContentRef.current ??
+ (event.target instanceof Element
+ ? event.target.closest('[data-slot="dialog-content"]')
+ : null)
+ if (
+ closeUnfocusedMonacoFindOrPreventDialogDismiss({
+ root: getAutomationPromptEditorRoot(dialog),
+ eventTarget: event.target
+ })
+ ) {
+ event.preventDefault()
+ }
+ }}
>
onDraftChange((current) => ({ ...current, name }))}
onTemplateOpenChange={setTemplateOpen}
onApplyTemplate={(template) => {
onApplyTemplate(template)
@@ -159,36 +163,41 @@ export function AutomationEditorDialog({
}}
/>
-
+
+
onOpenChange(false)}
+ />
+
+
0}
onOpenChange={onOpenChange}
onSave={onSave}
/>
diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx
index 2c68beee9b3..234a71efae3 100644
--- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx
+++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx
@@ -1,50 +1,14 @@
-import React from 'react'
-import { Info, Plus } from 'lucide-react'
+import { Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
-import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import AgentCombobox from '@/components/agent/AgentCombobox'
-import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
-import type { AutomationWorkspaceMode } from '../../../../shared/automations-types'
-import type { GlobalSettings } from '../../../../shared/global-settings-types'
-import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
-import type { ProjectHostSetup } from '../../../../shared/project-types'
-import type { Repo } from '../../../../shared/repo-types'
-import type { Worktree } from '../../../../shared/worktree/types'
-import type { AgentCatalogEntry } from '@/lib/agent-catalog'
-import { Field } from './automation-page-parts'
-import { AutomationMissedRunGraceField } from './AutomationMissedRunGraceField'
-import { AutomationSessionField } from './AutomationSessionField'
-import { AutomationSetupDecisionField } from './AutomationSetupDecisionField'
-import { CreateFromPicker } from './CreateFromPicker'
-import { WorkspaceCombobox } from './WorkspaceCombobox'
-import AutomationProjectCombobox from './AutomationProjectCombobox'
-import type { AutomationDraft } from './AutomationEditorDialog'
type AutomationEditorDialogFooterProps = {
isEditing: boolean
isEditingExternal: boolean
- isHermesTarget: boolean
isHermesCreate: boolean
isSaving: boolean
canSave: boolean
- repos: readonly Repo[]
- projectHostSetups: readonly ProjectHostSetup[]
- automationYamlHooksByRepoKey: Record
- getAutomationHooksCacheKey: (repoId: string) => string
- repoMap: Map
- worktrees: Worktree[]
- settings: GlobalSettings | null
- draft: AutomationDraft
- visibleAgents: AgentCatalogEntry[]
- scheduleField: React.ReactNode
- pickerTriggerClassName: string
- modeToggleItemClassName: string
- onProjectChange: (projectId: string) => void
- getRepoHostLabel?: (repo: Repo) => string | null | undefined
- onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
- onSetupDecisionTouched: () => void
+ hasProjects: boolean
onOpenChange: (open: boolean) => void
onSave: () => void
}
@@ -52,238 +16,37 @@ type AutomationEditorDialogFooterProps = {
export function AutomationEditorDialogFooter({
isEditing,
isEditingExternal,
- isHermesTarget,
isHermesCreate,
isSaving,
canSave,
- repos,
- projectHostSetups,
- automationYamlHooksByRepoKey,
- getAutomationHooksCacheKey,
- repoMap,
- worktrees,
- settings,
- draft,
- visibleAgents,
- scheduleField,
- pickerTriggerClassName,
- modeToggleItemClassName,
- onProjectChange,
- getRepoHostLabel,
- onDraftChange,
- onSetupDecisionTouched,
+ hasProjects,
onOpenChange,
onSave
}: AutomationEditorDialogFooterProps): React.JSX.Element {
- return (
-
-
-
-
-
-
- {translate(
- 'auto.components.automations.AutomationEditorDialog.b28b140eaf',
- 'Workspace'
- )}
-
-
-
-
-
-
-
- {translate(
- 'auto.components.automations.AutomationEditorDialog.6f9610e667',
- 'Worktree runs in the selected workspace. New run creates a fresh workspace from the selected branch each time.'
- )}
-
-
-
- }
- className={isHermesTarget ? undefined : 'sm:col-span-2 lg:col-span-3'}
- >
- {isHermesTarget ? (
-
- onDraftChange((current) => ({ ...current, workspaceId }))
- }
- />
- ) : (
-
-
- workspaceMode &&
- onDraftChange((current) => ({
- ...current,
- workspaceMode: workspaceMode as AutomationWorkspaceMode,
- reuseSession: workspaceMode === 'existing' ? current.reuseSession : false
- }))
- }
- variant="outline"
- size="sm"
- className="grid w-full grid-cols-2"
- >
-
- {translate(
- 'auto.components.automations.AutomationEditorDialog.a2e688226d',
- 'Worktree'
- )}
-
-
- {translate(
- 'auto.components.automations.AutomationEditorDialog.6ff66f9012',
- 'New run'
- )}
-
-
- {draft.workspaceMode === 'existing' ? (
-
- onDraftChange((current) => ({ ...current, workspaceId }))
- }
- />
- ) : (
-
- onDraftChange((current) => ({ ...current, baseBranch }))
- }
- />
- )}
-
- )}
-
- {isHermesTarget ? scheduleField : null}
-
+ const saveLabel =
+ isEditing || isEditingExternal
+ ? translate('auto.components.automations.AutomationEditorDialog.777548c2d6', 'Save Changes')
+ : isSaving || isHermesCreate
+ ? translate('auto.components.automations.AutomationEditorDialog.a9d9dccf77', 'Save')
+ : translate('auto.components.automations.AutomationEditorDialog.e46c1aa9ad', 'Create')
- {/* Why: Hermes uses one compact footer row, while Orca adds agent,
- session, schedule, and missed-run controls. Animate that row so
- switching the target changes the dialog height smoothly. */}
-
-
-
-
-
- agentId && onDraftChange((current) => ({ ...current, agentId }))
- }
- defaultAgent={settings?.defaultTuiAgent ?? null}
- triggerClassName={`h-9 w-full min-w-0 ${pickerTriggerClassName}`}
- allowNarrowTrigger
- />
-
-
- {isHermesTarget ? null : scheduleField}
-
-
- {/* Why: a full-width row below the columned controls — the setup
- switch is a per-automation preference, not a peer of the compact
- column pickers, so it reads cleaner spanning the dialog. */}
-
-
-
-
+ return (
+
+
+
+
+ {translate(
+ 'auto.components.automations.AutomationEditorDialog.e8c2a14f70',
+ 'Once saved, runs automatically until paused.'
+ )}
+
+
+
onOpenChange(false)}>
{translate('auto.components.automations.AutomationEditorDialog.fb1896a5e7', 'Cancel')}
-
- {isEditing || isEditingExternal || isHermesCreate || isSaving ? null : (
-
- )}
- {isEditing || isEditingExternal
- ? translate(
- 'auto.components.automations.AutomationEditorDialog.777548c2d6',
- 'Save Changes'
- )
- : isSaving || isHermesCreate
- ? translate('auto.components.automations.AutomationEditorDialog.a9d9dccf77', 'Save')
- : translate(
- 'auto.components.automations.AutomationEditorDialog.e46c1aa9ad',
- 'Create'
- )}
+
+ {saveLabel}
diff --git a/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx b/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx
index b1712f5250c..a36e4ab0b5b 100644
--- a/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx
+++ b/src/renderer/src/components/automations/AutomationEditorDialogHeader.tsx
@@ -1,7 +1,6 @@
import { Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { AutomationCreateTarget } from './AutomationEditorDialog'
@@ -14,13 +13,11 @@ type AutomationEditorDialogHeaderProps = {
isHermesCreate: boolean
isCreateMode: boolean
createTarget: AutomationCreateTarget
- draftName: string
templateOpen: boolean
templates: AutomationTemplate[]
- modeToggleItemClassName: string
- pickerTriggerClassName: string
+ segmentedGroupClassName: string
+ segmentedItemClassName: string
onCreateTargetChange: (target: AutomationCreateTarget) => void
- onDraftNameChange: (name: string) => void
onTemplateOpenChange: (open: boolean) => void
onApplyTemplate: (template: AutomationTemplate) => void
}
@@ -47,116 +44,103 @@ function AutomationTemplateCard({
)
}
+function getEditorTitle(args: {
+ isEditing: boolean
+ isEditingExternal: boolean
+ isHermesCreate: boolean
+}): string {
+ if (args.isEditing) {
+ return translate(
+ 'auto.components.automations.AutomationEditorDialogHeader.17086b48ee',
+ 'Edit automation'
+ )
+ }
+ if (args.isEditingExternal) {
+ return translate(
+ 'auto.components.automations.AutomationEditorDialogHeader.03142e7721',
+ 'Edit Hermes automation'
+ )
+ }
+ if (args.isHermesCreate) {
+ return translate(
+ 'auto.components.automations.AutomationEditorDialogHeader.0a75e5e2fa',
+ 'Create Hermes automation'
+ )
+ }
+ return translate(
+ 'auto.components.automations.AutomationEditorDialogHeader.4133d33862',
+ 'Create automation'
+ )
+}
+
export function AutomationEditorDialogHeader({
isEditing,
isEditingExternal,
isHermesCreate,
isCreateMode,
createTarget,
- draftName,
templateOpen,
templates,
- modeToggleItemClassName,
- pickerTriggerClassName,
+ segmentedGroupClassName,
+ segmentedItemClassName,
onCreateTargetChange,
- onDraftNameChange,
onTemplateOpenChange,
onApplyTemplate
}: AutomationEditorDialogHeaderProps): React.JSX.Element {
+ const title = getEditorTitle({ isEditing, isEditingExternal, isHermesCreate })
+
return (
-
-
-
-
- {isEditing
- ? translate(
- 'auto.components.automations.AutomationEditorDialogHeader.17086b48ee',
- 'Edit automation'
- )
- : isEditingExternal
- ? translate(
- 'auto.components.automations.AutomationEditorDialogHeader.03142e7721',
- 'Edit Hermes automation'
- )
- : isHermesCreate
- ? translate(
- 'auto.components.automations.AutomationEditorDialogHeader.0a75e5e2fa',
- 'Create Hermes automation'
- )
- : translate(
- 'auto.components.automations.AutomationEditorDialogHeader.4133d33862',
- 'Create automation'
- )}
-
-
onDraftNameChange(event.target.value)}
- />
+
+ {title}
+ {isCreateMode ? (
+
+
+ value && onCreateTargetChange(value as AutomationCreateTarget)
+ }
+ size="sm"
+ className={segmentedGroupClassName}
+ >
+
+ {translate(
+ 'auto.components.automations.AutomationEditorDialogHeader.6f309eef8d',
+ 'Orca'
+ )}
+
+
+ {translate(
+ 'auto.components.automations.AutomationEditorDialogHeader.7e35393632',
+ 'Hermes'
+ )}
+
+
+
+
+
+
+ {translate(
+ 'auto.components.automations.AutomationEditorDialogHeader.31f9253920',
+ 'Use template'
+ )}
+
+
+
+
+ {templates.map((template) => (
+
onApplyTemplate(template)}
+ />
+ ))}
+
+
+
- {isCreateMode ? (
-
-
- value && onCreateTargetChange(value as AutomationCreateTarget)
- }
- variant="outline"
- size="sm"
- className="grid grid-cols-2"
- >
-
- {translate(
- 'auto.components.automations.AutomationEditorDialogHeader.6f309eef8d',
- 'Orca'
- )}
-
-
- {translate(
- 'auto.components.automations.AutomationEditorDialogHeader.7e35393632',
- 'Hermes'
- )}
-
-
-
-
-
-
- {translate(
- 'auto.components.automations.AutomationEditorDialogHeader.31f9253920',
- 'Use template'
- )}
-
-
-
-
- {templates.map((template) => (
-
onApplyTemplate(template)}
- />
- ))}
-
-
-
-
- ) : null}
-
+ ) : null}
)
}
diff --git a/src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx b/src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx
new file mode 100644
index 00000000000..579d37d4e57
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationEditorPromptEditor.test.tsx
@@ -0,0 +1,194 @@
+// @vitest-environment happy-dom
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+const editorProps = vi.hoisted(() => ({ current: null as Record
| null }))
+const installFindShortcut = vi.hoisted(() => vi.fn(() => vi.fn()))
+const syncContentOnMount = vi.hoisted(() => vi.fn(() => false))
+const syncContentUpdate = vi.hoisted(() => vi.fn())
+
+vi.mock('@monaco-editor/react', () => ({
+ default: (props: Record) => {
+ editorProps.current = props
+ return
+ },
+ loader: { config: vi.fn() }
+}))
+vi.mock('@/lib/monaco-setup', () => ({ monaco: {} }))
+vi.mock('@/store', () => ({
+ useAppStore: (selector: (state: Record) => unknown) =>
+ selector({
+ settings: { theme: 'dark', terminalFontSize: 13, terminalFontFamily: 'monospace' },
+ editorFontZoomLevel: 0
+ })
+}))
+vi.mock('@/components/editor/editor-shortcuts', () => ({
+ installMonacoEditorFindShortcut: installFindShortcut
+}))
+vi.mock('@/components/editor/monaco-content-sync', () => ({
+ syncContentOnMount,
+ syncContentUpdate
+}))
+
+import { closeUnfocusedMonacoFindOrPreventDialogDismiss } from '@/components/editor/monaco-find-widget'
+import {
+ AUTOMATION_PROMPT_EDITOR_SLOT,
+ AutomationEditorPromptEditor,
+ getAutomationPromptEditorRoot
+} from './AutomationEditorPromptEditor'
+
+afterEach(() => {
+ cleanup()
+ editorProps.current = null
+ installFindShortcut.mockClear()
+ syncContentOnMount.mockClear()
+ syncContentUpdate.mockClear()
+})
+
+describe('AutomationEditorPromptEditor', () => {
+ it('owns content through defaultValue so Monaco keeps undo history', () => {
+ render(
+
+ )
+
+ expect(editorProps.current?.defaultValue).toBe('weekly audit')
+ expect(editorProps.current).not.toHaveProperty('value')
+ expect(editorProps.current?.defaultLanguage).toBe('plaintext')
+ expect(
+ (editorProps.current?.options as { placeholder?: string } | undefined)?.placeholder
+ ).toBe('Prompt placeholder')
+ })
+
+ it('installs find and syncs an external rewrite after mount', () => {
+ const editorInstance = {
+ getContainerDomNode: () => document.createElement('div'),
+ onDidDispose: vi.fn()
+ }
+ const { rerender } = render(
+
+ )
+
+ const onMount = editorProps.current?.onMount as (editor: typeof editorInstance) => void
+ onMount(editorInstance)
+
+ expect(installFindShortcut).toHaveBeenCalledWith(editorInstance)
+ expect(syncContentOnMount).toHaveBeenCalledWith(editorInstance, 'original')
+
+ rerender(
+
+ )
+
+ expect(syncContentUpdate).toHaveBeenCalledWith(editorInstance, 'from template')
+ })
+
+ it('dismisses on Escape only when find is closed', () => {
+ const onDismiss = vi.fn()
+ const editorDom = document.createElement('div')
+ const editorInstance = {
+ getContainerDomNode: () => editorDom,
+ onDidDispose: vi.fn()
+ }
+ render(
+
+ )
+ const onMount = editorProps.current?.onMount as (editor: typeof editorInstance) => void
+ onMount(editorInstance)
+
+ const findWidget = document.createElement('div')
+ findWidget.className = 'find-widget'
+ editorDom.append(findWidget)
+ editorDom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ expect(onDismiss).not.toHaveBeenCalled()
+
+ findWidget.setAttribute('aria-hidden', 'true')
+ editorDom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+ expect(onDismiss).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not render a custom placeholder overlay on an empty prompt', () => {
+ const { container } = render(
+
+ )
+
+ expect(container.textContent).not.toContain('Prompt placeholder')
+ expect(
+ (editorProps.current?.options as { placeholder?: string } | undefined)?.placeholder
+ ).toBe('Prompt placeholder')
+ })
+
+ it('scopes find-widget lookups to the prompt editor slot, not the document', () => {
+ const dialog = document.createElement('div')
+ const editorRoot = document.createElement('div')
+ editorRoot.setAttribute('data-slot', AUTOMATION_PROMPT_EDITOR_SLOT)
+ const nameField = document.createElement('input')
+ const background = document.createElement('div')
+ const backgroundFind = document.createElement('div')
+ backgroundFind.className = 'find-widget'
+ const closeButton = document.createElement('button')
+ closeButton.className = 'button codicon-widget-close'
+ const onBackgroundClose = vi.fn()
+ closeButton.addEventListener('click', onBackgroundClose)
+ backgroundFind.append(closeButton)
+ background.append(backgroundFind)
+ dialog.append(editorRoot, nameField)
+ document.body.append(dialog, background)
+
+ expect(getAutomationPromptEditorRoot(dialog)).toBe(editorRoot)
+ // Why: Radix's Escape listener fires on document, so currentTarget is
+ // never the dialog — probing document would see background editors.
+ expect(getAutomationPromptEditorRoot(document)).toBeNull()
+ expect(getAutomationPromptEditorRoot(null)).toBeNull()
+ expect(
+ closeUnfocusedMonacoFindOrPreventDialogDismiss({
+ root: getAutomationPromptEditorRoot(dialog),
+ eventTarget: nameField
+ })
+ ).toBe(false)
+ expect(onBackgroundClose).not.toHaveBeenCalled()
+
+ dialog.remove()
+ background.remove()
+ })
+
+ it('forwards typed edits to the draft', () => {
+ const onChange = vi.fn()
+ render(
+
+ )
+
+ const handleChange = editorProps.current?.onChange as (value: string) => void
+ handleChange('typed prompt')
+ expect(onChange).toHaveBeenCalledWith('typed prompt')
+ })
+})
diff --git a/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx b/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx
new file mode 100644
index 00000000000..69de2374d27
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationEditorPromptEditor.tsx
@@ -0,0 +1,167 @@
+import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
+import Editor, { type OnMount } from '@monaco-editor/react'
+import type { editor } from 'monaco-editor'
+import { installMonacoEditorFindShortcut } from '@/components/editor/editor-shortcuts'
+import { syncContentOnMount, syncContentUpdate } from '@/components/editor/monaco-content-sync'
+import { isMonacoFindWidgetOpen } from '@/components/editor/monaco-find-widget'
+import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
+import { resolveDocumentTheme } from '@/lib/document-theme'
+import '@/lib/monaco-setup'
+import { useAppStore } from '@/store'
+import { cn } from '@/lib/utils'
+import { buildAutomationPromptEditorOptions } from './automation-editor-prompt-options'
+
+export const AUTOMATION_PROMPT_EDITOR_SLOT = 'automation-prompt-editor'
+
+export function getAutomationPromptEditorRoot(
+ dialog: EventTarget | null | undefined
+): Element | null {
+ return dialog instanceof Element
+ ? dialog.querySelector(`[data-slot="${AUTOMATION_PROMPT_EDITOR_SLOT}"]`)
+ : null
+}
+
+function installPromptEditorEscapeDismiss(
+ target: HTMLElement,
+ onDismissRef: { current?: () => void }
+): () => void {
+ const handleEscape = (event: KeyboardEvent): void => {
+ if (event.key !== 'Escape' || event.repeat) {
+ return
+ }
+ if (isMonacoFindWidgetOpen(target)) {
+ return
+ }
+ // Why: Monaco swallows Escape even with find closed; the wrapping
+ // dialog still needs the same dismiss path a textarea used to have.
+ if (!onDismissRef.current) {
+ return
+ }
+ event.preventDefault()
+ event.stopPropagation()
+ onDismissRef.current()
+ }
+ target.addEventListener('keydown', handleEscape, true)
+ return () => target.removeEventListener('keydown', handleEscape, true)
+}
+
+type AutomationEditorPromptEditorProps = {
+ value: string
+ placeholder: string
+ ariaLabel: string
+ onChange: (value: string) => void
+ onDismiss?: () => void
+}
+
+export function AutomationEditorPromptEditor({
+ value,
+ placeholder,
+ ariaLabel,
+ onChange,
+ onDismiss
+}: AutomationEditorPromptEditorProps): React.JSX.Element {
+ const settings = useAppStore((state) => state.settings)
+ const editorFontZoomLevel = useAppStore((state) => state.editorFontZoomLevel)
+ const editorRef = useRef(null)
+ const contentRef = useRef(value)
+ const lastSyncedContentRef = useRef(value)
+ const isApplyingProgrammaticContentRef = useRef(false)
+ const onDismissRef = useRef(onDismiss)
+
+ useLayoutEffect(() => {
+ onDismissRef.current = onDismiss
+ }, [onDismiss])
+
+ const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel)
+ const fontFamily = resolveEditorFontFamily(settings)
+ const isDark = resolveDocumentTheme(settings?.theme ?? 'system')
+ const options = useMemo(
+ () =>
+ buildAutomationPromptEditorOptions({
+ ariaLabel,
+ fontFamily,
+ fontSize,
+ placeholder
+ }),
+ [ariaLabel, fontFamily, fontSize, placeholder]
+ )
+
+ const handleChange = useCallback(
+ (nextValue: string | undefined) => {
+ if (isApplyingProgrammaticContentRef.current) {
+ return
+ }
+ const next = nextValue ?? ''
+ lastSyncedContentRef.current = next
+ onChange(next)
+ },
+ [onChange]
+ )
+
+ const handleMount = useCallback((editorInstance) => {
+ editorRef.current = editorInstance
+ const editorDomNode = editorInstance.getContainerDomNode()
+ const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance)
+ const cleanupEscapeDismiss = installPromptEditorEscapeDismiss(editorDomNode, onDismissRef)
+ isApplyingProgrammaticContentRef.current = true
+ try {
+ if (syncContentOnMount(editorInstance, contentRef.current)) {
+ lastSyncedContentRef.current = contentRef.current
+ }
+ } finally {
+ isApplyingProgrammaticContentRef.current = false
+ }
+ editorInstance.onDidDispose(() => {
+ cleanupFindShortcut()
+ cleanupEscapeDismiss()
+ if (editorRef.current === editorInstance) {
+ editorRef.current = null
+ }
+ })
+ }, [])
+
+ // Why: templates rewrite the draft while this editor stays mounted; push an
+ // undoable model edit instead of setValue so Cmd+Z can revert the apply.
+ useLayoutEffect(() => {
+ contentRef.current = value
+ const mountedEditor = editorRef.current
+ if (!mountedEditor || lastSyncedContentRef.current === value) {
+ return
+ }
+ isApplyingProgrammaticContentRef.current = true
+ try {
+ syncContentUpdate(mountedEditor, value)
+ lastSyncedContentRef.current = value
+ } finally {
+ isApplyingProgrammaticContentRef.current = false
+ }
+ }, [value])
+
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationEditorPromptSection.tsx b/src/renderer/src/components/automations/AutomationEditorPromptSection.tsx
index e159c264ba5..df988eb5039 100644
--- a/src/renderer/src/components/automations/AutomationEditorPromptSection.tsx
+++ b/src/renderer/src/components/automations/AutomationEditorPromptSection.tsx
@@ -1,83 +1,104 @@
import React from 'react'
-import { cn } from '@/lib/utils'
+import { Pencil } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
-import { Field } from './automation-page-parts'
-import { AutomationPrecheckFields } from './AutomationPrecheckFields'
import type { AutomationDraft } from './AutomationEditorDialog'
+import { AutomationEditorPromptEditor } from './AutomationEditorPromptEditor'
type AutomationEditorPromptSectionProps = {
draft: AutomationDraft
- isHermesCreate: boolean
- pickerTriggerClassName: string
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
+ onDismiss: () => void
}
export function AutomationEditorPromptSection({
draft,
- isHermesCreate,
- pickerTriggerClassName,
- onDraftChange
+ onDraftChange,
+ onDismiss
}: AutomationEditorPromptSectionProps): React.JSX.Element {
+ const titleRef = React.useRef(null)
+ const namePlaceholder = translate(
+ 'auto.components.automations.AutomationEditorDialogHeader.1d9826933e',
+ 'Weekday repo audit'
+ )
+ const nameLabel = translate(
+ 'auto.components.automations.AutomationEditorDialogHeader.58f56b73d9',
+ 'Automation name'
+ )
+ const editNameLabel = translate(
+ 'auto.components.automations.AutomationEditorPromptSection.a7c3e91b04',
+ 'Edit name'
+ )
+
return (
-
+
{draft.scheduleWarning ? (
-
+
{draft.scheduleWarning}
) : null}
-
+
)
}
diff --git a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx
new file mode 100644
index 00000000000..e47ba98a299
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx
@@ -0,0 +1,221 @@
+import AgentCombobox from '@/components/agent/AgentCombobox'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import {
+ isValidAutomationCronSchedule,
+ isValidAutomationSchedule
+} from '../../../../shared/automation-schedules'
+import type { GlobalSettings } from '../../../../shared/global-settings-types'
+import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
+import type { ProjectHostSetup } from '../../../../shared/project-types'
+import type { Repo } from '../../../../shared/repo-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+import type { AgentCatalogEntry } from '@/lib/agent-catalog'
+import { AUTOMATION_EDITOR_SECTION_LABEL_CLASS, Field } from './automation-page-parts'
+import { AutomationMissedRunGraceField } from './AutomationMissedRunGraceField'
+import { AutomationPrecheckFields } from './AutomationPrecheckFields'
+import AutomationProjectCombobox from './AutomationProjectCombobox'
+import { AutomationSchedulePicker } from './AutomationSchedulePicker'
+import { AutomationSessionField } from './AutomationSessionField'
+import { AutomationSetupDecisionField } from './AutomationSetupDecisionField'
+import { AutomationWorkspaceField } from './AutomationWorkspaceField'
+import type { AutomationDraft } from './AutomationEditorDialog'
+
+type AutomationEditorSettingsSidebarProps = {
+ isHermesTarget: boolean
+ isHermesCreate: boolean
+ repos: readonly Repo[]
+ projectHostSetups: readonly ProjectHostSetup[]
+ automationYamlHooksByRepoKey: Record
+ getAutomationHooksCacheKey: (repoId: string) => string
+ repoMap: Map
+ worktrees: Worktree[]
+ settings: GlobalSettings | null
+ draft: AutomationDraft
+ visibleAgents: AgentCatalogEntry[]
+ pickerTriggerClassName: string
+ segmentedGroupClassName: string
+ segmentedItemClassName: string
+ onProjectChange: (projectId: string) => void
+ getRepoHostLabel?: (repo: Repo) => string | null | undefined
+ onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
+ onSetupDecisionTouched: () => void
+}
+
+export function AutomationEditorSettingsSidebar({
+ isHermesTarget,
+ isHermesCreate,
+ repos,
+ projectHostSetups,
+ automationYamlHooksByRepoKey,
+ getAutomationHooksCacheKey,
+ repoMap,
+ worktrees,
+ settings,
+ draft,
+ visibleAgents,
+ pickerTriggerClassName,
+ segmentedGroupClassName,
+ segmentedItemClassName,
+ onProjectChange,
+ getRepoHostLabel,
+ onDraftChange,
+ onSetupDecisionTouched
+}: AutomationEditorSettingsSidebarProps): React.JSX.Element {
+ return (
+
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationMissedRunGraceField.tsx b/src/renderer/src/components/automations/AutomationMissedRunGraceField.tsx
index 01e68d038df..78a4e8ffb17 100644
--- a/src/renderer/src/components/automations/AutomationMissedRunGraceField.tsx
+++ b/src/renderer/src/components/automations/AutomationMissedRunGraceField.tsx
@@ -7,7 +7,7 @@ import {
SelectValue
} from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { Field } from './automation-page-parts'
+import { AUTOMATION_EDITOR_SECTION_LABEL_CLASS, Field } from './automation-page-parts'
import type { AutomationDraft } from './AutomationEditorDialog'
import { translate } from '@/i18n/i18n'
@@ -26,6 +26,7 @@ export function AutomationMissedRunGraceField({
}: AutomationMissedRunGraceFieldProps): React.JSX.Element {
return (
{translate(
diff --git a/src/renderer/src/components/automations/AutomationPrecheckFields.tsx b/src/renderer/src/components/automations/AutomationPrecheckFields.tsx
index 07f3643b450..b93b77af29e 100644
--- a/src/renderer/src/components/automations/AutomationPrecheckFields.tsx
+++ b/src/renderer/src/components/automations/AutomationPrecheckFields.tsx
@@ -5,7 +5,7 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
-import { Field } from './automation-page-parts'
+import { AUTOMATION_EDITOR_SECTION_LABEL_CLASS, Field } from './automation-page-parts'
import type { AutomationDraft } from './AutomationEditorDialog'
import { translate } from '@/i18n/i18n'
@@ -23,79 +23,91 @@ export function AutomationPrecheckFields({
onDraftChange
}: AutomationPrecheckFieldsProps): React.JSX.Element {
return (
- <>
-
+
+ {translate(
+ 'auto.components.automations.AutomationPrecheckFields.c2a762a180',
+ 'Precheck'
+ )}
+
+
+
+ {translate(
+ 'auto.components.automations.AutomationPrecheckFields.bb2dfb3629',
+ 'Timeout'
+ )}
+
+
+ onDraftChange((current) => ({ ...current, precheckTimeoutSeconds }))
+ }
+ >
+
+
+
+
+
+ {translate(
+ 'auto.components.automations.AutomationPrecheckFields.51e28cdad9',
+ '30 sec'
+ )}
+
+
+ {translate(
+ 'auto.components.automations.AutomationPrecheckFields.c820119736',
+ '1 min'
+ )}
+
+
+ {translate(
+ 'auto.components.automations.AutomationPrecheckFields.d84d3765fd',
+ '2 min'
+ )}
+
+
+ {translate(
+ 'auto.components.automations.AutomationPrecheckFields.bf49585b3c',
+ '5 min'
+ )}
+
+
+ {translate(
+ 'auto.components.automations.AutomationPrecheckFields.d2a2ac89ac',
+ '10 min'
+ )}
+
+
+
+
+
+ }
+ >
+
-
-
- onDraftChange((current) => ({ ...current, precheckTimeoutSeconds }))
- }
- >
-
-
-
-
-
- {translate(
- 'auto.components.automations.AutomationPrecheckFields.51e28cdad9',
- '30 sec'
- )}
-
-
- {translate(
- 'auto.components.automations.AutomationPrecheckFields.c820119736',
- '1 min'
- )}
-
-
- {translate(
- 'auto.components.automations.AutomationPrecheckFields.d84d3765fd',
- '2 min'
- )}
-
-
- {translate(
- 'auto.components.automations.AutomationPrecheckFields.bf49585b3c',
- '5 min'
- )}
-
-
- {translate(
- 'auto.components.automations.AutomationPrecheckFields.d2a2ac89ac',
- '10 min'
- )}
-
-
-
-
- >
+ onChange={(event) =>
+ onDraftChange((current) => ({
+ ...current,
+ precheckCommand: event.target.value
+ }))
+ }
+ className="min-h-[56px] w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs shadow-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30"
+ />
+
)
}
diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx
index b1dde548163..cc94a7fb515 100644
--- a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx
+++ b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx
@@ -1,7 +1,3 @@
-import React from 'react'
-import { CalendarClock, ChevronsUpDown } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import {
Select,
@@ -13,8 +9,6 @@ import {
import type { AutomationSchedulePreset } from '../../../../shared/automations-types'
import {
buildAutomationCronSchedule,
- buildAutomationRrule,
- formatAutomationSchedule,
isValidAutomationSchedule
} from '../../../../shared/automation-schedules'
import type { AutomationDraft } from './AutomationEditorDialog'
@@ -51,23 +45,6 @@ const DAY_OPTIONS = [
['6', 'Saturday']
] as const
-function getDraftScheduleLabel(draft: AutomationDraft): string {
- if (draft.preset === 'custom') {
- return draft.customSchedule.trim()
- ? formatAutomationSchedule(draft.customSchedule)
- : 'Advanced schedule'
- }
- const { hour, minute } = parseAutomationTime(draft.time)
- return formatAutomationSchedule(
- buildAutomationRrule({
- preset: draft.preset,
- hour,
- minute,
- dayOfWeek: Number(draft.dayOfWeek)
- })
- )
-}
-
function buildCustomScheduleSeed(draft: AutomationDraft): string {
const existing = draft.customSchedule.trim()
if (existing) {
@@ -98,17 +75,13 @@ export function getSchedulePresetDraft(
export function AutomationSchedulePicker({
draft,
- triggerClassName,
validateAdvancedSchedule = isValidAutomationSchedule,
onDraftChange
}: {
draft: AutomationDraft
- triggerClassName?: string
validateAdvancedSchedule?: (schedule: string) => boolean
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
}): React.JSX.Element {
- const [open, setOpen] = React.useState(false)
- const label = getDraftScheduleLabel(draft)
const customSchedule = draft.customSchedule.trim()
const customScheduleInvalid =
draft.preset === 'custom' &&
@@ -124,112 +97,89 @@ export function AutomationSchedulePicker({
}
return (
-
-
-
-
-
- {label}
-
-
-
-
-
+
+ onDraftChange((current) => ({
+ ...current,
+ ...getSchedulePresetDraft(current, preset as AutomationSchedulePreset)
+ }))
+ }
>
-
-
-
- onDraftChange((current) => ({
- ...current,
- ...getSchedulePresetDraft(current, preset as AutomationSchedulePreset)
- }))
- }
- >
-
-
-
-
- {AUTOMATION_SCHEDULE_PRESET_OPTIONS.map(([value, fallbackLabel, labelKey]) => (
-
- {getAutomationSchedulePresetLabel([value, fallbackLabel, labelKey])}
-
- ))}
-
-
-
- {draft.preset === 'custom' ? (
-
- ) : (
- <>
- {draft.preset === 'weekly' ? (
-
-
- onDraftChange((current) => ({ ...current, dayOfWeek, scheduleWarning: null }))
- }
- >
-
-
-
-
- {DAY_OPTIONS.map(([value, dayLabel]) => (
-
- {dayLabel}
-
- ))}
-
-
-
- ) : null}
- {draft.preset === 'hourly' ? (
-
-
-
- ) : (
-
-
-
- )}
- >
+
-
-
+ className={cn('w-full min-w-0', FIELD_CONTROL_CLASS)}
+ >
+
+
+
+ {AUTOMATION_SCHEDULE_PRESET_OPTIONS.map(([value, fallbackLabel, labelKey]) => (
+
+ {getAutomationSchedulePresetLabel([value, fallbackLabel, labelKey])}
+
+ ))}
+
+
+ {draft.preset === 'custom' ? (
+
+ ) : (
+ <>
+ {draft.preset === 'weekly' ? (
+
+
+ onDraftChange((current) => ({ ...current, dayOfWeek, scheduleWarning: null }))
+ }
+ >
+
+
+
+
+ {DAY_OPTIONS.map(([value, dayLabel]) => (
+
+ {dayLabel}
+
+ ))}
+
+
+
+ ) : null}
+ {draft.preset === 'hourly' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
)
}
diff --git a/src/renderer/src/components/automations/AutomationSessionField.tsx b/src/renderer/src/components/automations/AutomationSessionField.tsx
index b6aef00ebd8..ea3787d81cc 100644
--- a/src/renderer/src/components/automations/AutomationSessionField.tsx
+++ b/src/renderer/src/components/automations/AutomationSessionField.tsx
@@ -1,24 +1,26 @@
-import React from 'react'
import { Info } from 'lucide-react'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
-import { Field } from './automation-page-parts'
+import { AUTOMATION_EDITOR_SECTION_LABEL_CLASS, Field } from './automation-page-parts'
import type { AutomationDraft } from './AutomationEditorDialog'
import { translate } from '@/i18n/i18n'
type AutomationSessionFieldProps = {
draft: AutomationDraft
+ toggleGroupClassName: string
toggleItemClassName: string
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
}
export function AutomationSessionField({
draft,
+ toggleGroupClassName,
toggleItemClassName,
onDraftChange
}: AutomationSessionFieldProps): React.JSX.Element {
return (
{translate('auto.components.automations.AutomationSessionField.5ad314118e', 'Session')}
@@ -47,6 +49,7 @@ export function AutomationSessionField({
>
{
if (!value) {
@@ -58,9 +61,8 @@ export function AutomationSessionField({
workspaceMode: value === 'reuse' ? 'existing' : current.workspaceMode
}))
}}
- variant="outline"
size="sm"
- className="grid w-full grid-cols-2"
+ className={toggleGroupClassName}
>
{translate('auto.components.automations.AutomationSessionField.c90888ee94', 'Fresh')}
diff --git a/src/renderer/src/components/automations/AutomationWorkspaceField.tsx b/src/renderer/src/components/automations/AutomationWorkspaceField.tsx
new file mode 100644
index 00000000000..80e6feeda32
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationWorkspaceField.tsx
@@ -0,0 +1,128 @@
+import { Info } from 'lucide-react'
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { translate } from '@/i18n/i18n'
+import type { AutomationWorkspaceMode } from '../../../../shared/automations-types'
+import type { Repo } from '../../../../shared/repo-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+import { AUTOMATION_EDITOR_SECTION_LABEL_CLASS, Field } from './automation-page-parts'
+import { CreateFromPicker } from './CreateFromPicker'
+import { WorkspaceCombobox } from './WorkspaceCombobox'
+import type { AutomationDraft } from './AutomationEditorDialog'
+
+type AutomationWorkspaceFieldProps = {
+ draft: AutomationDraft
+ isHermesTarget: boolean
+ worktrees: Worktree[]
+ repoMap: Map
+ pickerTriggerClassName: string
+ segmentedGroupClassName: string
+ segmentedItemClassName: string
+ onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
+}
+
+export function AutomationWorkspaceField({
+ draft,
+ isHermesTarget,
+ worktrees,
+ repoMap,
+ pickerTriggerClassName,
+ segmentedGroupClassName,
+ segmentedItemClassName,
+ onDraftChange
+}: AutomationWorkspaceFieldProps): React.JSX.Element {
+ return (
+
+ {translate('auto.components.automations.AutomationEditorDialog.b28b140eaf', 'Workspace')}
+
+
+
+
+
+
+
+ {translate(
+ 'auto.components.automations.AutomationEditorDialog.6f9610e667',
+ 'Worktree runs in the selected workspace. New run creates a fresh workspace from the selected branch each time.'
+ )}
+
+
+
+ }
+ >
+ {isHermesTarget ? (
+ onDraftChange((current) => ({ ...current, workspaceId }))}
+ />
+ ) : (
+
+
+ workspaceMode &&
+ onDraftChange((current) => ({
+ ...current,
+ workspaceMode: workspaceMode as AutomationWorkspaceMode,
+ reuseSession: workspaceMode === 'existing' ? current.reuseSession : false
+ }))
+ }
+ size="sm"
+ className={segmentedGroupClassName}
+ >
+
+ {translate(
+ 'auto.components.automations.AutomationEditorDialog.a2e688226d',
+ 'Worktree'
+ )}
+
+
+ {translate(
+ 'auto.components.automations.AutomationEditorDialog.6ff66f9012',
+ 'New run'
+ )}
+
+
+ {draft.workspaceMode === 'existing' ? (
+
+ onDraftChange((current) => ({ ...current, workspaceId }))
+ }
+ />
+ ) : (
+
+ onDraftChange((current) => ({ ...current, baseBranch }))
+ }
+ />
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts b/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts
new file mode 100644
index 00000000000..5cc4d29c628
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-editor-prompt-options.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { monacoFindOptions } from '@/components/editor/monaco-find-options'
+import { buildAutomationPromptEditorOptions } from './automation-editor-prompt-options'
+
+describe('buildAutomationPromptEditorOptions', () => {
+ it('enables wrap and the shared find widget without code-editor chrome', () => {
+ const options = buildAutomationPromptEditorOptions({
+ ariaLabel: 'Prompt',
+ fontFamily: 'Geist',
+ fontSize: 14,
+ placeholder: 'Run the weekly dependency audit'
+ })
+
+ expect(options.find).toEqual(monacoFindOptions)
+ expect(options.wordWrap).toBe('on')
+ expect(options.lineNumbers).toBe('off')
+ expect(options.minimap).toEqual({ enabled: false })
+ expect(options.ariaLabel).toBe('Prompt')
+ expect(options.fontFamily).toBe('Geist')
+ expect(options.fontSize).toBe(14)
+ expect(options.placeholder).toBe('Run the weekly dependency audit')
+ })
+})
diff --git a/src/renderer/src/components/automations/automation-editor-prompt-options.ts b/src/renderer/src/components/automations/automation-editor-prompt-options.ts
new file mode 100644
index 00000000000..f43a3160e35
--- /dev/null
+++ b/src/renderer/src/components/automations/automation-editor-prompt-options.ts
@@ -0,0 +1,43 @@
+import type { editor } from 'monaco-editor'
+import { monacoFindOptions } from '@/components/editor/monaco-find-options'
+
+export function buildAutomationPromptEditorOptions(args: {
+ ariaLabel: string
+ fontFamily: string
+ fontSize: number
+ placeholder: string
+}): editor.IStandaloneEditorConstructionOptions {
+ return {
+ ariaLabel: args.ariaLabel,
+ automaticLayout: true,
+ contextmenu: true,
+ folding: false,
+ fontFamily: args.fontFamily,
+ fontSize: args.fontSize,
+ find: monacoFindOptions,
+ glyphMargin: false,
+ hover: { enabled: false },
+ lineDecorationsWidth: 16,
+ lineNumbers: 'off',
+ lineNumbersMinChars: 0,
+ links: false,
+ minimap: { enabled: false },
+ overviewRulerBorder: false,
+ overviewRulerLanes: 0,
+ padding: { top: 12, bottom: 12 },
+ placeholder: args.placeholder,
+ parameterHints: { enabled: false },
+ quickSuggestions: false,
+ renderLineHighlight: 'none',
+ scrollBeyondLastLine: false,
+ scrollbar: {
+ horizontalScrollbarSize: 8,
+ verticalScrollbarSize: 8
+ },
+ stickyScroll: { enabled: false },
+ suggestOnTriggerCharacters: false,
+ tabSize: 2,
+ wordBasedSuggestions: 'off',
+ wordWrap: 'on'
+ }
+}
diff --git a/src/renderer/src/components/automations/automation-page-parts.tsx b/src/renderer/src/components/automations/automation-page-parts.tsx
index 9b38cac5fc1..4ffd81001bb 100644
--- a/src/renderer/src/components/automations/automation-page-parts.tsx
+++ b/src/renderer/src/components/automations/automation-page-parts.tsx
@@ -91,18 +91,23 @@ export function getAutomationRunStatusLabel(status: AutomationRun['status']): st
}
}
+export const AUTOMATION_EDITOR_SECTION_LABEL_CLASS =
+ 'text-[11px] font-semibold uppercase tracking-[0.05em]'
+
export function Field({
label,
children,
- className
+ className,
+ labelClassName
}: {
label: React.ReactNode
children: React.ReactNode
className?: string
+ labelClassName?: string
}): React.JSX.Element {
return (
-
{label}
+
{label}
{children}
)
diff --git a/src/renderer/src/components/editor/monaco-find-widget.test.ts b/src/renderer/src/components/editor/monaco-find-widget.test.ts
new file mode 100644
index 00000000000..04be99b464a
--- /dev/null
+++ b/src/renderer/src/components/editor/monaco-find-widget.test.ts
@@ -0,0 +1,141 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi } from 'vitest'
+import {
+ closeMonacoFindWidget,
+ closeUnfocusedMonacoFindOrPreventDialogDismiss,
+ isMonacoFindHostFocused,
+ isMonacoFindWidgetOpen
+} from './monaco-find-widget'
+
+function appendFindWidget(
+ root: ParentNode,
+ args: { hidden?: boolean; withCloseButton?: boolean } = {}
+): { closeButton: HTMLButtonElement | null; widget: HTMLDivElement } {
+ const widget = document.createElement('div')
+ widget.className = 'find-widget visible'
+ if (args.hidden) {
+ widget.setAttribute('aria-hidden', 'true')
+ }
+ let closeButton: HTMLButtonElement | null = null
+ if (args.withCloseButton) {
+ closeButton = document.createElement('button')
+ closeButton.className = 'button codicon-widget-close'
+ widget.append(closeButton)
+ }
+ root.append(widget)
+ return { closeButton, widget }
+}
+
+describe('isMonacoFindWidgetOpen', () => {
+ it('is closed when the widget is missing or aria-hidden', () => {
+ const root = document.createElement('div')
+ expect(isMonacoFindWidgetOpen(null)).toBe(false)
+ expect(isMonacoFindWidgetOpen(root)).toBe(false)
+
+ appendFindWidget(root, { hidden: true })
+ expect(isMonacoFindWidgetOpen(root)).toBe(false)
+ })
+
+ it('is open when the find widget is present and not hidden', () => {
+ const root = document.createElement('div')
+ appendFindWidget(root)
+ expect(isMonacoFindWidgetOpen(root)).toBe(true)
+ })
+
+ it('does not treat a find widget outside the given root as open', () => {
+ const dialog = document.createElement('div')
+ const editorRoot = document.createElement('div')
+ const backgroundEditor = document.createElement('div')
+ dialog.append(editorRoot)
+ document.body.append(dialog, backgroundEditor)
+ appendFindWidget(backgroundEditor)
+ expect(isMonacoFindWidgetOpen(editorRoot)).toBe(false)
+ expect(isMonacoFindWidgetOpen(document)).toBe(true)
+ dialog.remove()
+ backgroundEditor.remove()
+ })
+})
+
+describe('isMonacoFindHostFocused', () => {
+ it('is true only when the event target is inside the host', () => {
+ const host = document.createElement('div')
+ const inside = document.createElement('input')
+ const outside = document.createElement('input')
+ host.append(inside)
+ expect(isMonacoFindHostFocused(host, inside)).toBe(true)
+ expect(isMonacoFindHostFocused(host, outside)).toBe(false)
+ expect(isMonacoFindHostFocused(null, inside)).toBe(false)
+ })
+})
+
+describe('closeMonacoFindWidget', () => {
+ it('clicks the close control on an open widget', () => {
+ const root = document.createElement('div')
+ const { closeButton } = appendFindWidget(root, { withCloseButton: true })
+ const onClick = vi.fn()
+ closeButton?.addEventListener('click', onClick)
+
+ expect(closeMonacoFindWidget(root)).toBe(true)
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('does nothing when find is closed or the close control is missing', () => {
+ const root = document.createElement('div')
+ expect(closeMonacoFindWidget(root)).toBe(false)
+
+ appendFindWidget(root, { hidden: true, withCloseButton: true })
+ expect(closeMonacoFindWidget(root)).toBe(false)
+
+ const openRoot = document.createElement('div')
+ appendFindWidget(openRoot)
+ expect(closeMonacoFindWidget(openRoot)).toBe(false)
+ })
+})
+
+describe('closeUnfocusedMonacoFindOrPreventDialogDismiss', () => {
+ it('holds the dialog when find is open and the host is focused', () => {
+ const root = document.createElement('div')
+ const { closeButton } = appendFindWidget(root, { withCloseButton: true })
+ const findInput = document.createElement('input')
+ root.append(findInput)
+ const onClick = vi.fn()
+ closeButton?.addEventListener('click', onClick)
+
+ expect(
+ closeUnfocusedMonacoFindOrPreventDialogDismiss({
+ root,
+ eventTarget: findInput
+ })
+ ).toBe(true)
+ expect(onClick).not.toHaveBeenCalled()
+ })
+
+ it('closes find and lets the dialog dismiss when find is open but unfocused', () => {
+ const root = document.createElement('div')
+ const { closeButton } = appendFindWidget(root, { withCloseButton: true })
+ const nameField = document.createElement('input')
+ const onClick = vi.fn()
+ closeButton?.addEventListener('click', onClick)
+
+ expect(
+ closeUnfocusedMonacoFindOrPreventDialogDismiss({
+ root,
+ eventTarget: nameField
+ })
+ ).toBe(false)
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('lets the dialog dismiss when this host has no open find', () => {
+ const root = document.createElement('div')
+ const outside = document.createElement('div')
+ appendFindWidget(outside, { withCloseButton: true })
+
+ expect(
+ closeUnfocusedMonacoFindOrPreventDialogDismiss({
+ root,
+ eventTarget: document.createElement('input')
+ })
+ ).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/editor/monaco-find-widget.ts b/src/renderer/src/components/editor/monaco-find-widget.ts
new file mode 100644
index 00000000000..5a2daf24f3c
--- /dev/null
+++ b/src/renderer/src/components/editor/monaco-find-widget.ts
@@ -0,0 +1,53 @@
+function getOpenMonacoFindWidget(root: ParentNode | null | undefined): HTMLElement | null {
+ if (!root) {
+ return null
+ }
+ const widget = root.querySelector('.find-widget')
+ // Why: Monaco marks the find widget `aria-hidden="true"` while closed; the
+ // e2e probe uses the same signal so Escape can close find without dismissing
+ // a wrapping dialog.
+ if (!(widget instanceof HTMLElement) || widget.getAttribute('aria-hidden') === 'true') {
+ return null
+ }
+ return widget
+}
+
+export function isMonacoFindWidgetOpen(root: ParentNode | null | undefined): boolean {
+ return getOpenMonacoFindWidget(root) !== null
+}
+
+export function isMonacoFindHostFocused(
+ root: ParentNode | null | undefined,
+ eventTarget: EventTarget | null | undefined
+): boolean {
+ return root instanceof Element && eventTarget instanceof Node && root.contains(eventTarget)
+}
+
+export function closeMonacoFindWidget(root: ParentNode | null | undefined): boolean {
+ const widget = getOpenMonacoFindWidget(root)
+ if (!widget) {
+ return false
+ }
+ // Why: CloseFindWidgetCommand only runs while the editor has focus, so an
+ // unfocused widget has to be closed through its own close control.
+ const closeButton = widget.querySelector('.codicon-widget-close')
+ if (!closeButton) {
+ return false
+ }
+ closeButton.click()
+ return true
+}
+
+export function closeUnfocusedMonacoFindOrPreventDialogDismiss(args: {
+ root: ParentNode | null | undefined
+ eventTarget: EventTarget | null | undefined
+}): boolean {
+ if (!isMonacoFindWidgetOpen(args.root)) {
+ return false
+ }
+ if (isMonacoFindHostFocused(args.root, args.eventTarget)) {
+ return true
+ }
+ closeMonacoFindWidget(args.root)
+ return false
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 60db462d0ba..52259092519 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -15017,6 +15017,7 @@
"e46c1aa9ad": "Create",
"a9d9dccf77": "Save",
"777548c2d6": "Save Changes",
+ "e8c2a14f70": "Once saved, runs automatically until paused.",
"ff5db28639": "existing"
},
"AutomationEditorDialogHeader": {
@@ -15028,7 +15029,8 @@
"4133d33862": "Create automation",
"0a75e5e2fa": "Create Hermes automation",
"03142e7721": "Edit Hermes automation",
- "17086b48ee": "Edit automation"
+ "17086b48ee": "Edit automation",
+ "4c8e1a72b9": "A recurring agent task"
},
"AutomationMissedRunGraceField": {
"0f4459e91d": "48 hours",
@@ -15042,6 +15044,9 @@
"3df53d554a": "Missed-run grace help",
"fc089e5fde": "Grace"
},
+ "AutomationEditorPromptSection": {
+ "a7c3e91b04": "Edit name"
+ },
"AutomationPrecheckFields": {
"d2a2ac89ac": "10 min",
"bf49585b3c": "5 min",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index f539d6abc3a..8350b5e79ce 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -13632,6 +13632,7 @@
"e46c1aa9ad": "Crear",
"a9d9dccf77": "Guardar",
"777548c2d6": "Guardar cambios",
+ "e8c2a14f70": "Una vez guardado, se ejecutará automáticamente hasta que se ponga en pausa.",
"ff5db28639": "existente"
},
"AutomationEditorDialogHeader": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index b495ec4a741..bc4c1b8ceea 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -13632,6 +13632,7 @@
"e46c1aa9ad": "作成",
"a9d9dccf77": "保存",
"777548c2d6": "変更を保存",
+ "e8c2a14f70": "保存すると、一時停止するまで自動的に実行されます。",
"ff5db28639": "既存 "
},
"AutomationEditorDialogHeader": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 16de5cfa690..e677355b29d 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -13661,6 +13661,7 @@
"e46c1aa9ad": "생성",
"a9d9dccf77": "저장",
"777548c2d6": "변경 사항 저장",
+ "e8c2a14f70": "저장하면 일시 중지될 때까지 자동으로 실행됩니다.",
"ff5db28639": "기존의"
},
"AutomationEditorDialogHeader": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 345afff56b0..18ed77a8752 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -13652,6 +13652,7 @@
"e46c1aa9ad": "创建",
"a9d9dccf77": "保存",
"777548c2d6": "保存更改",
+ "e8c2a14f70": "保存后,将自动运行,直到暂停。",
"ff5db28639": "现存的"
},
"AutomationEditorDialogHeader": {
From 13b10e0b54529b4a2f103ab47761614e716096d7 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 17 Aug 2026 19:20:13 -0700
Subject: [PATCH 10/43] ci: cut PR wall clock by caching what CI recomputes
every run (#15211)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
None of these change what CI checks — they remove work the runners
repeated on every PR.
- install-node-dependencies installed with --no-frozen-lockfile, so every
job re-resolved the graph against the registry to recompute what the
lockfile already pins. Measured at ~62 MB of packument metadata per job;
the pnpm store cache does not cover the metadata cache, so this was paid
~39 times per run. The `git diff` guard that made the re-resolution
redundant stays.
- --ignore-scripts leaves node-pty with no build/Release, so
ensure-native-runtime node-gyp-compiled it in every job asking for a
runtime. Cache the build under an ABI-bound key (runtime, resolved Node
version, node-pty patch) with no restore-keys, since a partial match is
exactly the mismatched build that would be recompiled anyway.
- The four fetch-depth: 0 checkouts pulled full history including every
historical blob (blobs are ~89% of this repo's pack). They only need the
commit graph for a merge-base diff, so fetch them blobless. Measured
30-43s each today versus 8s for the shallow checkouts. One of them,
e2e-paths, gates the entire E2E chain.
- E2E jobs ordered setup-node before pnpm, which meant setup-node could not
find the store and no E2E job cached dependencies at all. Reorder and
cache; this sits on the critical path in both the build job and each
shard.
- git_compatibility rebuilt Git 2.25.5 from a pinned tarball on every PR.
Cache the build; the sha256 assertion still guards the miss path.
- typecheck ran three independent tsc passes back to back and discarded the
.tsbuildinfo each project already emits. Run them concurrently and cache
the incremental state.
- package (windows) built the electron-vite targets serially via
build:release. Use a :parallel variant that overlaps them, matching what
the Linux package job already packages and smoke-tests from.
Contract tests cover each new cache's ordering and key so none of them can
silently start serving a stale or ABI-mismatched artifact.
---
.../install-node-dependencies/action.yml | 22 +++++--
.github/workflows/e2e.yml | 52 +++++++++------
.github/workflows/pr.yml | 66 ++++++++++++++++---
...git-binary-compatibility-workflow.test.mjs | 20 ++++++
.../scripts/pr-workflow-parallelism.test.mjs | 57 +++++++++++++++-
.../run-typecheck-projects-in-parallel.mjs | 53 +++++++++++++++
.../skills-cli-package-workflow.test.mjs | 2 +-
package.json | 3 +-
8 files changed, 238 insertions(+), 37 deletions(-)
create mode 100644 config/scripts/run-typecheck-projects-in-parallel.mjs
diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml
index b23293e4a9a..bf2ce235464 100644
--- a/.github/actions/install-node-dependencies/action.yml
+++ b/.github/actions/install-node-dependencies/action.yml
@@ -21,6 +21,7 @@ runs:
run_install: false
- name: Setup Node.js
+ id: default-node
if: inputs.node-version == ''
uses: actions/setup-node@v6
with:
@@ -28,6 +29,7 @@ runs:
cache: pnpm
- name: Setup requested Node.js
+ id: requested-node
if: inputs.node-version != ''
uses: actions/setup-node@v6
with:
@@ -63,15 +65,27 @@ runs:
rm -rf node_modules
fi
+ # Why --frozen-lockfile: re-resolving pulls ~62 MB of registry packuments per job
+ # (measured) to recompute what the lockfile already pins, and the `git diff` guard
+ # below fails the run whenever that recomputation would have changed anything. The
+ # guard stays so a stale lockfile still fails by name rather than by resolver error.
- name: Install dependencies
shell: bash
run: |
- pnpm install \
- --no-frozen-lockfile \
- --prefer-frozen-lockfile=false \
- --ignore-scripts
+ pnpm install --frozen-lockfile --ignore-scripts
git diff --exit-code package.json pnpm-lock.yaml
+ # Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
+ # ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
+ # The artifacts are ABI-bound, so the key carries the target runtime, the resolved
+ # Node version, and the patch whose contents the build has to match.
+ - name: Restore compiled native modules
+ if: inputs.native-runtime != 'none'
+ uses: actions/cache@v5
+ with:
+ path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
+ key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }}
+
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
shell: bash
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index da7aebdb2cc..c2b63bcbf2e 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -46,16 +46,19 @@ jobs:
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
-
+ # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
+ # Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: package.json
+ cache: pnpm
+
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux.
@@ -134,16 +137,19 @@ jobs:
- name: Install xvfb
run: sudo apt-get install -y xvfb
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
-
+ # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
+ # Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: package.json
+ cache: pnpm
+
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
@@ -208,16 +214,19 @@ jobs:
# lane now receives those specs from pr.yml's SSH source mapping.
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb zsh
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
-
+ # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
+ # Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: package.json
+ cache: pnpm
+
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
npm install -g node-gyp@11.5.0
@@ -289,16 +298,19 @@ jobs:
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb zsh
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
-
+ # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
+ # Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: package.json
+ cache: pnpm
+
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
# stays consistent across workflows even while this job is ubuntu-latest.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 4327dbeff24..85a51e4f7bf 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -24,7 +24,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
+ # Why blob:none: full history is needed for the merge-base diff, but historical
+ # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
+ # few this job actually reads on demand.
fetch-depth: 0
+ filter: blob:none
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
@@ -122,7 +126,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
+ # Why blob:none: full history is needed for the merge-base diff, but historical
+ # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
+ # few this job actually reads on demand.
fetch-depth: 0
+ filter: blob:none
persist-credentials: false
- name: Reject new root-level files and folders
@@ -142,7 +150,19 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
- - run: pnpm typecheck
+ # Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets
+ # the next run skip unchanged files. CI threw it away each time. The key is per-SHA
+ # so each run saves its own; restore-keys inherit the newest prior graph to diff against.
+ - name: Cache TypeScript incremental state
+ uses: actions/cache@v5
+ with:
+ path: config/*.tsbuildinfo
+ key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }}
+ restore-keys: |
+ tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-
+ tsbuildinfo-${{ runner.os }}-
+
+ - run: pnpm run typecheck
git_compatibility:
name: Git compatibility
@@ -156,19 +176,34 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
+ # Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the
+ # same binary on every PR for minutes of runner time. The key carries the version
+ # because that is the only input; the sha256 assertion below still guards the
+ # tarball on the miss path that actually builds.
+ - name: Cache baseline Git build
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/orca-git-compat/git-2.25.5
+ key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5
+
- name: Verify Git binary compatibility matrix
run: |
pids=()
(
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
- source="$RUNNER_TEMP/git-2.25.5"
- curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
- echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
- | sha256sum --check
- mkdir -p "$source"
- tar -xzf "$archive" -C "$source" --strip-components=1
- make -C "$source" -j"$(nproc)" \
- NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
+ source="$HOME/.cache/orca-git-compat/git-2.25.5"
+ if [ ! -x "$source/git" ]; then
+ curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
+ echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
+ | sha256sum --check
+ mkdir -p "$source"
+ tar -xzf "$archive" -C "$source" --strip-components=1
+ make -C "$source" -j"$(nproc)" \
+ NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
+ # Why: the linked binaries are what the next run needs; the objects that
+ # produced them are most of the tree and would bloat the cache entry.
+ find "$source" -name '*.o' -delete
+ fi
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
@@ -331,7 +366,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
+ # Why blob:none: full history is needed for the merge-base diff, but historical
+ # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
+ # few this job actually reads on demand.
fetch-depth: 0
+ filter: blob:none
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
@@ -471,8 +510,11 @@ jobs:
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
src/shared/secure-file-fsync-flags.test.ts
+ # Why the :parallel variant: identical to build:release except the three
+ # electron-vite targets overlap instead of running back to back. The Linux package
+ # job already packages and smoke-tests an AppImage built that way.
- name: Build package inputs
- run: pnpm run build:release
+ run: pnpm run build:release:parallel
- name: Prepare Electron native runtime
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
@@ -501,7 +543,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
+ # Why blob:none: full history is needed for the merge-base diff, but historical
+ # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the
+ # few this job actually reads on demand.
fetch-depth: 0
+ filter: blob:none
persist-credentials: false
- name: Filter changed E2E specs
diff --git a/config/scripts/git-binary-compatibility-workflow.test.mjs b/config/scripts/git-binary-compatibility-workflow.test.mjs
index 56e4fb5c36f..afe5615bb44 100644
--- a/config/scripts/git-binary-compatibility-workflow.test.mjs
+++ b/config/scripts/git-binary-compatibility-workflow.test.mjs
@@ -10,6 +10,9 @@ describe('Git binary compatibility PR gate', () => {
)
expect(step?.run).toContain('git-2.25.5.tar.gz')
+ // Why asserted: the sha256 check only runs on the build path, so a cached binary
+ // must come from a key that pins the same version the tarball line declares.
+ expect(step?.run).toContain('if [ ! -x "$source/git" ]; then')
expect(step?.run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf')
expect(step?.run).toContain('ORCA_GIT_COMPAT_BINARY="$source/git"')
expect(step?.run).toContain('alpine/git:edge-2.38.1|2.38.1')
@@ -20,4 +23,21 @@ describe('Git binary compatibility PR gate', () => {
expect(step?.run).toContain('pids+=("$!")')
expect(step?.run).toContain('wait "$pid" || status=1')
})
+
+ it('restores the baseline Git build before the matrix runs', () => {
+ const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
+ const steps = workflow.jobs.git_compatibility.steps
+ const cacheIndex = steps.findIndex((step) => step.name === 'Cache baseline Git build')
+ const matrixIndex = steps.findIndex(
+ (step) => step.name === 'Verify Git binary compatibility matrix'
+ )
+
+ expect(cacheIndex).toBeGreaterThanOrEqual(0)
+ expect(cacheIndex).toBeLessThan(matrixIndex)
+ // The cached path and the build path must be the same directory or the guard
+ // above would rebuild on every run while still reporting a cache hit.
+ expect(steps[cacheIndex].with.path).toBe('~/.cache/orca-git-compat/git-2.25.5')
+ expect(steps[matrixIndex].run).toContain('source="$HOME/.cache/orca-git-compat/git-2.25.5"')
+ expect(steps[cacheIndex].with.key).toContain('2.25.5')
+ })
})
diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs
index 536059fe4cc..02ddc764c2a 100644
--- a/config/scripts/pr-workflow-parallelism.test.mjs
+++ b/config/scripts/pr-workflow-parallelism.test.mjs
@@ -184,7 +184,12 @@ describe('PR workflow parallelism', () => {
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
- expect(dependencyInstall.run).toContain('--no-frozen-lockfile')
+ // Why frozen: re-resolving the graph costs a minute per job and the `git diff`
+ // guard below already fails the run when the lockfile is stale, so the slow
+ // resolution can never legitimately change anything.
+ expect(dependencyInstall.run).toContain('--frozen-lockfile')
+ expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile')
+ expect(dependencyInstall.run).toContain('git diff --exit-code package.json pnpm-lock.yaml')
expect(dependencyInstall.run).toContain('--ignore-scripts')
expect(dependencyInstall.run).not.toContain('--os=')
expect(dependencyInstall.run).not.toContain('--cpu=')
@@ -211,6 +216,56 @@ describe('PR workflow parallelism', () => {
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
})
+ it('restores compiled native modules after the install that strips them', () => {
+ const steps = dependencyAction.runs.steps
+ const installIndex = steps.findIndex((step) => step.name === 'Install dependencies')
+ const cacheIndex = steps.findIndex((step) => step.name === 'Restore compiled native modules')
+ const prepareIndex = steps.findIndex((step) => step.name === 'Prepare native runtime')
+
+ // `--ignore-scripts` leaves no build/, so a restore before the install would be
+ // overwritten and one after the rebuild would never save a hit.
+ expect(installIndex).toBeLessThan(cacheIndex)
+ expect(cacheIndex).toBeLessThan(prepareIndex)
+ expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
+ // Native artifacts are ABI-bound: a key missing either dimension serves a build
+ // that cannot load, and ensure-native-runtime would recompile it anyway.
+ expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
+ expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
+ expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
+ // No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
+ expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
+ })
+
+ it('reuses TypeScript incremental state across typecheck runs', () => {
+ const steps = workflow.jobs.typecheck.steps
+ const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state')
+ const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck')
+
+ expect(cacheIndex).toBeGreaterThanOrEqual(0)
+ expect(cacheIndex).toBeLessThan(checkIndex)
+ expect(steps[cacheIndex].with.path).toBe('config/*.tsbuildinfo')
+ // Why restore-keys matter here: an exact-key miss is the normal case (the key is
+ // per-SHA), so without them the cache would never once be read.
+ expect(steps[cacheIndex].with['restore-keys']).toBeTruthy()
+ // The buildinfo is only reusable while the compiler options that produced it hold.
+ expect(steps[cacheIndex].with.key).toContain(
+ "hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json')"
+ )
+ })
+
+ it('checks out full history without historical blobs', () => {
+ const fullHistoryCheckouts = Object.values(workflow.jobs)
+ .flatMap((job) => job.steps ?? [])
+ .filter(
+ (step) => step.uses?.startsWith('actions/checkout@') && step.with?.['fetch-depth'] === 0
+ )
+
+ expect(fullHistoryCheckouts.length).toBeGreaterThan(0)
+ for (const checkout of fullHistoryCheckouts) {
+ expect(checkout.with.filter).toBe('blob:none')
+ }
+ })
+
it('keeps verify as the aggregate required check', () => {
expect(workflow.jobs.verify.needs).toEqual([
'static_analysis',
diff --git a/config/scripts/run-typecheck-projects-in-parallel.mjs b/config/scripts/run-typecheck-projects-in-parallel.mjs
new file mode 100644
index 00000000000..13047989db5
--- /dev/null
+++ b/config/scripts/run-typecheck-projects-in-parallel.mjs
@@ -0,0 +1,53 @@
+import { spawn } from 'node:child_process'
+import { availableParallelism } from 'node:os'
+import { fileURLToPath } from 'node:url'
+
+// The three projects overlap heavily in src/shared but have no build dependency on
+// each other, so tsc can check them concurrently instead of in a `&&` chain.
+const projects = ['tsconfig.node.json', 'tsconfig.tc.cli.json', 'tsconfig.tc.web.json']
+const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
+const tsc = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url))
+
+// Why serialize on a single-core runner: three tsc processes there thrash rather than overlap.
+const concurrent = availableParallelism() > 1
+
+function checkProject(project) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [tsc, '--noEmit', '-p', `config/${project}`], {
+ cwd: repoRoot,
+ stdio: 'inherit'
+ })
+
+ child.on('error', reject)
+ child.on('exit', (code, signal) => {
+ if (signal) {
+ reject(new Error(`tsc ${project} exited with signal ${signal}`))
+ } else if (code !== 0) {
+ reject(new Error(`tsc ${project} exited with code ${code}`))
+ } else {
+ resolve()
+ }
+ })
+ })
+}
+
+let failures = []
+if (concurrent) {
+ const results = await Promise.allSettled(projects.map(checkProject))
+ failures = results.filter((result) => result.status === 'rejected').map((result) => result.reason)
+} else {
+ for (const project of projects) {
+ try {
+ await checkProject(project)
+ } catch (error) {
+ failures.push(error)
+ }
+ }
+}
+
+if (failures.length > 0) {
+ for (const failure of failures) {
+ console.error(failure.message ?? failure)
+ }
+ process.exit(1)
+}
diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs
index 72e6cf25829..489437dd61f 100644
--- a/config/scripts/skills-cli-package-workflow.test.mjs
+++ b/config/scripts/skills-cli-package-workflow.test.mjs
@@ -13,7 +13,7 @@ describe('packaged skills CLI PR gates', () => {
const smokeStep = job.steps.find((step) => step.name === 'Smoke packaged CLI')
expect(job['runs-on']).toBe('windows-2022')
- expect(buildStep.run).toBe('pnpm run build:release')
+ expect(buildStep.run).toBe('pnpm run build:release:parallel')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
diff --git a/package.json b/package.json
index eef30962ce9..aabf6ab08d6 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,7 @@
"typecheck:cli": "tsc --noEmit -p config/tsconfig.tc.cli.json",
"typecheck:web": "tsc --noEmit -p config/tsconfig.tc.web.json",
"typecheck:e2e": "tsc --noEmit -p config/tsconfig.e2e.json",
- "typecheck": "tsc --noEmit -p config/tsconfig.node.json && tsc --noEmit -p config/tsconfig.tc.cli.json && tsc --noEmit -p config/tsconfig.tc.web.json",
+ "typecheck": "node config/scripts/run-typecheck-projects-in-parallel.mjs",
"typecheck:tsc:node": "tsc --noEmit -p config/tsconfig.node.json --composite false",
"typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false",
"typecheck:tsc:web": "tsc --noEmit -p config/tsconfig.web.json --composite false",
@@ -77,6 +77,7 @@
"build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
"build": "pnpm run build:desktop && pnpm run build:native",
"build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
+ "build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
"postinstall": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:node": "pnpm rebuild node-pty",
From 3697d68f2127660e4bfb4cbc38a2ec19a9b21cec Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:36:50 -0700
Subject: [PATCH 11/43] fix(cmd-j): decline a GitLab iid match when the repo
remote names a different project (STA-4450) (#15193)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(cmd-j): decline a GitLab iid match when the repo remote names a different project (STA-4450)
`repoMatchesGitLabSlug` laundered a definite project-path mismatch into
`'unknown'` whenever the resolved identity came from a remote named
`upstream`, and `worktreeMatchesGitLabUrl` treats `'unknown'` as permission
to accept a bare iid. Since `deriveGitRemoteIdentity` ranks `upstream` above
`origin`, any repo whose top-ranked remote is `upstream` lost GitLab project
gating entirely, so an exact URL for an unrelated project could surface that
workspace.
Return the `matchGitRemoteKeyParts` verdict directly. Resolved identities are
re-probed on a 6h TTL, so a remote naming a different project is current
evidence. `'unknown'` now means only "no identity" or "unexpanded SSH host
alias", both of which stay permissive as before.
* docs(cmd-j): correct the identity-freshness comments and drop a duplicate test
The GitLab why-comment implied resolved identities refresh unconditionally.
They only refresh when a repo/project list sweep finds one past its ~6h TTL
(`selectEnrichmentCandidates` runs from `repos:list`/`projects:list`/
`projectHostSetups:list`, four refreshes per sweep, after a 5m startup delay);
there is no background timer. State the accepted cost instead of implying the
gate is loss-free.
The GitHub-side comment still claimed the identity is "chosen when the repo
was added and never re-probed" — the exact claim this PR disproves. Rewrite it
to the reason that still holds (one stored remote hides a fork's `origin`).
Behavior on the GitHub path is unchanged; it stays with the twin ticket.
Delete `does not surface an upstream-identified repo for an unrelated project
iid`: the inverted test above it already asserts both halves (mismatched
project declines, the named project still matches) against the same
upstream-derived identity.
---
.../lib/worktree-palette-gitlab-url-match.ts | 21 +++++++++----------
.../worktree-palette-task-url-match.test.ts | 14 +++++++++++--
.../lib/worktree-palette-task-url-match.ts | 6 +++---
3 files changed, 25 insertions(+), 16 deletions(-)
diff --git a/src/renderer/src/lib/worktree-palette-gitlab-url-match.ts b/src/renderer/src/lib/worktree-palette-gitlab-url-match.ts
index 6fd291a7576..f606ba39566 100644
--- a/src/renderer/src/lib/worktree-palette-gitlab-url-match.ts
+++ b/src/renderer/src/lib/worktree-palette-gitlab-url-match.ts
@@ -39,21 +39,20 @@ function gitLabLinksEqual(left: GitLabIssueOrMRLink, right: GitLabIssueOrMRLink)
)
}
-/** Tri-state like `repoMatchesGitHubSlug`: `'unknown'` stays permissive for forks and host aliases. */
+/** Tri-state: `'unknown'` (no identity, or an unexpanded SSH host alias) stays permissive. */
function repoMatchesGitLabSlug(repo: Repo | undefined, slug: ProjectSlug): boolean | 'unknown' {
- const identity = repo?.gitRemoteIdentity
- const identityParts = splitGitRemoteKey(identity?.canonicalKey, foldComparableGitLabHost)
+ const identityParts = splitGitRemoteKey(
+ repo?.gitRemoteIdentity?.canonicalKey,
+ foldComparableGitLabHost
+ )
if (!identityParts) {
return 'unknown'
}
- const verdict = matchGitRemoteKeyParts(identityParts, gitLabProjectKeyParts(slug))
- if (verdict !== false) {
- return verdict
- }
- // Why not false: identity keeps one remote, chosen when the repo was added and never re-probed.
- // An `upstream` pick means a fork's `origin` existed and is invisible here, so rejecting would
- // drop MR URLs from the fork itself. A stale snapshot can still misjudge a renamed project.
- return identity?.remoteName === 'upstream' ? 'unknown' : false
+ // Why trust a project-path mismatch whichever remote it came from: a resolved identity is
+ // re-probed once a repo/project list sweep finds it past its ~6h TTL, so it is not frozen at the
+ // moment the repo was added. Accepted cost: only one remote is stored, so a fork whose `upstream`
+ // outranks its own `origin` loses bare-iid matches for MR URLs on the fork.
+ return matchGitRemoteKeyParts(identityParts, gitLabProjectKeyParts(slug))
}
export function worktreeMatchesGitLabUrl(
diff --git a/src/renderer/src/lib/worktree-palette-task-url-match.test.ts b/src/renderer/src/lib/worktree-palette-task-url-match.test.ts
index 5be62896658..3dba3578c5f 100644
--- a/src/renderer/src/lib/worktree-palette-task-url-match.test.ts
+++ b/src/renderer/src/lib/worktree-palette-task-url-match.test.ts
@@ -544,8 +544,10 @@ describe('matchWorktreePaletteTaskUrl', () => {
).toMatchObject({ matchedFields: ['mr'] })
})
- it('stays permissive for a fork whose identity resolved to the upstream remote', () => {
- // `deriveGitRemoteIdentity` prefers `upstream`, so the fork's own `origin` is not visible here.
+ it('declines a different GitLab project even when the identity came from upstream', () => {
+ // STA-4450: iids are per-project, so a bare `linkedGitLabMR` must not span projects.
+ // `deriveGitRemoteIdentity` prefers `upstream`, so the fork's own `origin` is not visible here;
+ // an MR URL from the fork itself is the accepted false negative of gating on the known project.
const forkRepo: Repo = {
...gitLabRepo('gitlab.com/acme/orca'),
gitRemoteIdentity: {
@@ -560,6 +562,14 @@ describe('matchWorktreePaletteTaskUrl', () => {
intent: parseCmdJTaskSourceUrl('https://gitlab.com/me/orca/-/merge_requests/17')!,
repo: forkRepo
})
+ ).toBeNull()
+ // The project the identity does name still matches.
+ expect(
+ matchWorktreePaletteTaskUrl({
+ worktree: makeWorktree({ linkedGitLabMR: 17 }),
+ intent: parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')!,
+ repo: forkRepo
+ })
).toMatchObject({ matchedFields: ['mr'] })
// An `origin`-derived identity is authoritative, so a different project still loses.
expect(
diff --git a/src/renderer/src/lib/worktree-palette-task-url-match.ts b/src/renderer/src/lib/worktree-palette-task-url-match.ts
index bbc80d4a6ca..151ae62782c 100644
--- a/src/renderer/src/lib/worktree-palette-task-url-match.ts
+++ b/src/renderer/src/lib/worktree-palette-task-url-match.ts
@@ -101,9 +101,9 @@ function remoteIdentityMatchesGitHubSlug(repo: Repo, slug: RepoSlug): boolean |
if (verdict !== false) {
return verdict
}
- // Why not false: identity keeps one remote, chosen when the repo was added and never re-probed.
- // An `upstream` pick means a fork's `origin` existed and is invisible here, so rejecting would
- // drop URLs from the fork itself. A stale snapshot can still misjudge a renamed repo.
+ // Why not false: identity keeps only one remote, so an `upstream` pick means a fork's `origin`
+ // existed and is invisible here, and rejecting would drop URLs from the fork itself. GitLab makes
+ // the opposite trade (STA-4450); aligning the two is left to a twin ticket.
return identity?.remoteName === 'upstream' ? 'unknown' : false
}
From ffb695b958ab6ce983d2f8530e0eaaf1c15c566d Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:37:30 -0700
Subject: [PATCH 12/43] fix(daemon): stop a failed spawn cancel from tearing
down the shared connection (STA-4663) (#15194)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(daemon): stop a failed spawn cancel from tearing down the shared connection (STA-4663)
`onCreateCancellationFailure` fired on ANY rejection of the `cancelCreateOrAttach`
RPC, including its own 5s timeout and application-level `ok:false` replies. That
called `handleDisconnect`, which rejects every in-flight request and destroys both
sockets — killing every sibling session on the daemon.
Only an undeliverable cancel now escalates, signalled by the new
`DaemonConnectionLostError`. A refused or timed-out cancel falls back to the
existing bounded `unmatchedCancelGraceMs` wait and then rejects just its own request.
Also wraps the control-socket write so a synchronous throw drops the pending entry
and its timer instead of leaking them.
STA-4663's premise — that legacy daemons reject `cancelCreateOrAttach` as an unknown
request type — is incorrect; the handler has existed since protocol v11 (181741d769d).
* fix(daemon): keep the wedged-daemon respawn signal when a spawn cancel times out
STA-4663 stopped a failed cancel from tearing down the shared connection, but
with it went the only path that recovered a daemon wedged with its socket still
open: create times out at 30s, its cancel times out at 5s, and nothing else in
the client ever notices.
Classify our own deadline as DaemonRequestTimeoutError. When the request and its
cancel both hit it, reject that request alone with the message isDaemonGoneError
matches, so withDaemonRetry respawns instead of retrying forever. Siblings are
untouched, and aborts are excluded — the caller asked to stop, not to retry.
---
src/main/daemon/client.test.ts | 62 +++++++
src/main/daemon/client.ts | 11 +-
.../daemon/daemon-client-pending-requests.ts | 8 +-
.../daemon/daemon-client-rpc-request.test.ts | 154 +++++++++++++++++-
src/main/daemon/daemon-client-rpc-request.ts | 86 ++++++++--
src/main/daemon/daemon-errors.ts | 33 ++++
src/main/daemon/daemon-pty-adapter.ts | 4 +-
src/main/daemon/types.ts | 3 +
8 files changed, 337 insertions(+), 24 deletions(-)
diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts
index fa2d64f8cba..e769e0bf525 100644
--- a/src/main/daemon/client.test.ts
+++ b/src/main/daemon/client.test.ts
@@ -7,6 +7,7 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { DaemonClient } from './client'
import type { DaemonPendingRequests } from './daemon-client-pending-requests'
import { encodeNdjson, NDJSON_MAX_LINE_BYTES, NdjsonLineTooLongError } from './ndjson'
+import { DaemonConnectionLostError } from './types'
import type { HelloMessage, DaemonRequest, DaemonEvent } from './types'
import { getDaemonSocketPath } from './daemon-spawner'
@@ -409,6 +410,67 @@ describe('DaemonClient', () => {
expect(result).toEqual({ sessions: [] })
})
+ it('survives a cancelCreateOrAttach the daemon refuses', async () => {
+ // Why: v1-v10 daemons have no cancel case and answer with an error. Escalating
+ // that to a disconnect would reject every sibling session's in-flight request.
+ const received: string[] = []
+ await startMockDaemon({
+ onControlMessage: (msg) => {
+ const req = msg as { id: string; type: string }
+ received.push(req.type)
+ return req.type === 'cancelCreateOrAttach'
+ ? encodeNdjson({
+ id: req.id,
+ ok: false,
+ error: 'Unknown request type: cancelCreateOrAttach'
+ })
+ : null
+ }
+ })
+ const disconnected = vi.fn()
+
+ client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ client.onDisconnected(disconnected)
+
+ const sibling = client.request('listSessions', undefined)
+ const siblingRejected = vi.fn()
+ void sibling.catch(siblingRejected)
+ const abort = new AbortController()
+ const spawn = client.request('createOrAttach', { sessionId: 'aborted' }, 30_000, abort.signal)
+ void spawn.catch(() => {})
+
+ await waitFor(() => received.includes('createOrAttach') && received.includes('listSessions'))
+ abort.abort()
+ await waitFor(() => received.includes('cancelCreateOrAttach'))
+ await new Promise((r) => setTimeout(r, 50))
+
+ expect(client.isConnected()).toBe(true)
+ expect(disconnected).not.toHaveBeenCalled()
+ expect(siblingRejected).not.toHaveBeenCalled()
+ })
+
+ it('rejects in-flight requests as DaemonConnectionLostError when the socket dies', async () => {
+ // Why: the error class is the only signal separating "the wire is gone" from
+ // "the daemon answered with an error"; requestDaemonRpc keys the decision to
+ // tear down the shared connection off it.
+ const serverSockets: Socket[] = []
+ await startMockDaemon({ onControlMessage: () => null })
+ server.on('connection', (socket) => serverSockets.push(socket))
+
+ client = new DaemonClient({ socketPath, tokenPath })
+ await client.ensureConnected()
+ const inFlight = client.request('listSessions', undefined)
+ const rejected = expect(inFlight).rejects.toBeInstanceOf(DaemonConnectionLostError)
+
+ await waitFor(() => serverSockets.length > 0)
+ for (const socket of serverSockets) {
+ socket.destroy()
+ }
+
+ await rejected
+ })
+
it('rejects on error response', async () => {
await startMockDaemon({
onControlMessage: (msg) => {
diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts
index 0c4d3eae845..0bcdad500f3 100644
--- a/src/main/daemon/client.ts
+++ b/src/main/daemon/client.ts
@@ -2,7 +2,12 @@ import type { Socket } from 'node:net'
import { readFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { encodeNdjson } from './ndjson'
-import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types'
+import {
+ PROTOCOL_VERSION,
+ NOTIFY_PREFIX,
+ DaemonConnectionLostError,
+ DaemonProtocolError
+} from './types'
import type { DaemonEndpointIdentity } from './types'
import {
armDaemonSocketCloseHandlers,
@@ -202,7 +207,9 @@ export class DaemonClient {
signal?: AbortSignal
): Promise {
if (!this.connected || !this.controlSocket) {
- throw new DaemonProtocolError('Not connected')
+ // Why: there is no socket to talk on, so this is a transport failure, not a
+ // refusal by the daemon — see settleCreateCancellation's caller.
+ throw new DaemonConnectionLostError('Not connected')
}
const generation = this.connectionGeneration
diff --git a/src/main/daemon/daemon-client-pending-requests.ts b/src/main/daemon/daemon-client-pending-requests.ts
index df2c59bd8e8..d2e5c476b2f 100644
--- a/src/main/daemon/daemon-client-pending-requests.ts
+++ b/src/main/daemon/daemon-client-pending-requests.ts
@@ -1,4 +1,4 @@
-import { DaemonProtocolError } from './types'
+import { DaemonConnectionLostError, DaemonProtocolError } from './types'
import type { RpcResponse } from './types'
import { addNodePtyRecoveryHint } from './node-pty-error-hints'
import { decodeDaemonResponseError } from './daemon-errors'
@@ -44,10 +44,14 @@ export class DaemonPendingRequests {
}
}
+ // Why: only reached when the socket itself is gone, so the error class must stay
+ // DaemonConnectionLostError — daemon-client-rpc-request keys "may tear down the
+ // shared connection" off it, and a plain DaemonProtocolError would look like an
+ // ordinary error reply.
rejectAll(reason: string): void {
for (const [id, pending] of this.requests) {
clearTimeout(pending.timer)
- pending.reject(new DaemonProtocolError(reason))
+ pending.reject(new DaemonConnectionLostError(reason))
this.requests.delete(id)
}
}
diff --git a/src/main/daemon/daemon-client-rpc-request.test.ts b/src/main/daemon/daemon-client-rpc-request.test.ts
index 4c5df93c140..87935bb589e 100644
--- a/src/main/daemon/daemon-client-rpc-request.test.ts
+++ b/src/main/daemon/daemon-client-rpc-request.test.ts
@@ -2,6 +2,22 @@ import type { Socket } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DaemonPendingRequests } from './daemon-client-pending-requests'
import { requestDaemonRpc } from './daemon-client-rpc-request'
+import { isDaemonGoneError } from './daemon-pty-adapter'
+import {
+ DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
+ DaemonConnectionLostError,
+ DaemonProtocolError,
+ DaemonRequestTimeoutError
+} from './types'
+
+/** Mirrors DaemonClient: a sibling session's in-flight request on the same connection. */
+function addSiblingRequest(pendingRequests: DaemonPendingRequests, reject: () => void): void {
+ pendingRequests.add('sibling-1', {
+ resolve: () => {},
+ reject,
+ timer: setTimeout(() => {}, 60_000)
+ })
+}
describe('requestDaemonRpc', () => {
afterEach(() => {
@@ -106,7 +122,7 @@ describe('requestDaemonRpc', () => {
expect(pendingRequests.size).toBe(0)
})
- it('disconnects when spawn cancellation cannot be confirmed', async () => {
+ it('disconnects when the cancel could not be put on the wire', async () => {
const pendingRequests = new DaemonPendingRequests()
const abort = new AbortController()
const onCreateCancellationFailure = vi.fn(() => {
@@ -123,7 +139,7 @@ describe('requestDaemonRpc', () => {
unmatchedCancelGraceMs: 5_000,
onCreateCancellationFailure,
settleCreateCancellation: vi.fn(async () => {
- throw new Error('settlement failed')
+ throw new DaemonConnectionLostError('Not connected')
})
})
const rejected = expect(request).rejects.toThrow('Connection lost')
@@ -134,4 +150,138 @@ describe('requestDaemonRpc', () => {
expect(onCreateCancellationFailure).toHaveBeenCalledOnce()
expect(pendingRequests.size).toBe(0)
})
+
+ describe('cancel failures the daemon itself produced', () => {
+ // A cancel the daemon answered — or one that blew its own timeout because the
+ // daemon's event loop is blocked — says nothing about the sibling sessions
+ // sharing this connection, so it must never tear the connection down.
+ async function abortWithFailingCancel(cancelError: Error): Promise<{
+ request: Promise
+ pendingRequests: DaemonPendingRequests
+ onCreateCancellationFailure: ReturnType
+ rejectSibling: ReturnType
+ }> {
+ const pendingRequests = new DaemonPendingRequests()
+ const rejectSibling = vi.fn()
+ addSiblingRequest(pendingRequests, rejectSibling)
+ const onCreateCancellationFailure = vi.fn(() => {
+ pendingRequests.rejectAll('Connection lost')
+ })
+ const abort = new AbortController()
+ const request = requestDaemonRpc({
+ socket: { write: vi.fn() } as unknown as Socket,
+ pendingRequests,
+ id: 'req-1',
+ type: 'createOrAttach',
+ payload: { sessionId: 'blocked-spawn' },
+ timeoutMs: 30_000,
+ signal: abort.signal,
+ unmatchedCancelGraceMs: 5_000,
+ onCreateCancellationFailure,
+ settleCreateCancellation: vi.fn(async () => {
+ throw cancelError
+ })
+ })
+ abort.abort()
+ await vi.advanceTimersByTimeAsync(0)
+ return { request, pendingRequests, onCreateCancellationFailure, rejectSibling }
+ }
+
+ it('keeps the connection, and stays abort-shaped, when the cancel RPC times out', async () => {
+ vi.useFakeTimers()
+ // The caller asked to stop, so a wedged daemon must not turn this into a respawn.
+ const { request, pendingRequests, onCreateCancellationFailure, rejectSibling } =
+ await abortWithFailingCancel(
+ new DaemonRequestTimeoutError('Request cancelCreateOrAttach timed out after 5000ms')
+ )
+ const rejected = expect(request).rejects.toThrow('client_disconnected')
+
+ expect(onCreateCancellationFailure).not.toHaveBeenCalled()
+ // Still pending: the bounded grace window, not an immediate teardown.
+ expect(pendingRequests.size).toBe(2)
+
+ await vi.advanceTimersByTimeAsync(5_000)
+
+ await rejected
+ expect(onCreateCancellationFailure).not.toHaveBeenCalled()
+ expect(rejectSibling).not.toHaveBeenCalled()
+ expect(pendingRequests.size).toBe(1)
+ })
+
+ it('keeps the connection when the daemon answers the cancel with an error', async () => {
+ vi.useFakeTimers()
+ // v1-v10 daemons have no `cancelCreateOrAttach` case at all.
+ const { request, pendingRequests, onCreateCancellationFailure, rejectSibling } =
+ await abortWithFailingCancel(
+ new DaemonProtocolError('Unknown request type: cancelCreateOrAttach')
+ )
+ const rejected = expect(request).rejects.toThrow('client_disconnected')
+
+ await vi.advanceTimersByTimeAsync(5_000)
+
+ await rejected
+ expect(onCreateCancellationFailure).not.toHaveBeenCalled()
+ expect(rejectSibling).not.toHaveBeenCalled()
+ expect(pendingRequests.size).toBe(1)
+ })
+ })
+
+ it('reports a wedged daemon when the request and its cancel both time out', async () => {
+ // Why: neither RPC was answered but the socket never closed, so no disconnect fires.
+ // The only remaining recovery is rejecting with the message isDaemonGoneError matches,
+ // which lets withDaemonRetry respawn instead of retrying forever against a dead loop.
+ vi.useFakeTimers()
+ const pendingRequests = new DaemonPendingRequests()
+ const rejectSibling = vi.fn()
+ addSiblingRequest(pendingRequests, rejectSibling)
+ const onCreateCancellationFailure = vi.fn(() => {
+ pendingRequests.rejectAll('Connection lost')
+ })
+ const request = requestDaemonRpc({
+ socket: { write: vi.fn() } as unknown as Socket,
+ pendingRequests,
+ id: 'req-1',
+ type: 'createOrAttach',
+ payload: { sessionId: 'wedged-spawn' },
+ timeoutMs: 30_000,
+ unmatchedCancelGraceMs: 5_000,
+ onCreateCancellationFailure,
+ settleCreateCancellation: vi.fn(async () => {
+ throw new DaemonRequestTimeoutError('Request cancelCreateOrAttach timed out after 5000ms')
+ })
+ })
+ const rejected = expect(request).rejects.toThrow(DAEMON_UNAVAILABLE_RECONNECT_MESSAGE)
+
+ await vi.advanceTimersByTimeAsync(30_000)
+ await vi.advanceTimersByTimeAsync(5_000)
+
+ await rejected
+ // The whole point: withDaemonRetry only respawns for errors this predicate matches.
+ await request.catch((err: unknown) => expect(isDaemonGoneError(err)).toBe(true))
+ expect(onCreateCancellationFailure).not.toHaveBeenCalled()
+ expect(rejectSibling).not.toHaveBeenCalled()
+ expect(pendingRequests.size).toBe(1)
+ })
+
+ it('drops the pending entry when the control socket write throws', async () => {
+ const pendingRequests = new DaemonPendingRequests()
+ const request = requestDaemonRpc({
+ socket: {
+ write: vi.fn(() => {
+ throw new Error('This socket has been ended by the other party')
+ })
+ } as unknown as Socket,
+ pendingRequests,
+ id: 'req-1',
+ type: 'createOrAttach',
+ payload: { sessionId: 'unsendable-spawn' },
+ timeoutMs: 30_000,
+ unmatchedCancelGraceMs: 5_000,
+ onCreateCancellationFailure: vi.fn(),
+ settleCreateCancellation: vi.fn(async () => ({ canceled: true }))
+ })
+
+ await expect(request).rejects.toThrow('This socket has been ended by the other party')
+ expect(pendingRequests.size).toBe(0)
+ })
})
diff --git a/src/main/daemon/daemon-client-rpc-request.ts b/src/main/daemon/daemon-client-rpc-request.ts
index d5f8564a036..33936a9286a 100644
--- a/src/main/daemon/daemon-client-rpc-request.ts
+++ b/src/main/daemon/daemon-client-rpc-request.ts
@@ -1,6 +1,10 @@
import type { Socket } from 'node:net'
import { encodeNdjson } from './ndjson'
-import { DaemonProtocolError } from './types'
+import {
+ DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
+ DaemonConnectionLostError,
+ DaemonRequestTimeoutError
+} from './types'
import { isTerminalAttachCanceledMessage } from './daemon-errors'
import type { DaemonPendingRequests } from './daemon-client-pending-requests'
@@ -18,10 +22,37 @@ type DaemonRpcRequestOptions = {
* an attach-only request has nothing coming, so the wait must be bounded.
*/
unmatchedCancelGraceMs: number
+ /**
+ * Escalation of last resort: called only when the cancel RPC could not be put on
+ * the wire at all. Never called for a cancel the daemon answered with an error,
+ * or one that timed out — those say nothing about the sibling sessions sharing
+ * this connection. A cancel that timed out is instead reported through
+ * `wedgedDaemonError` on this request alone.
+ */
onCreateCancellationFailure: () => void
settleCreateCancellation: (sessionId: string, requestId: string) => Promise<{ canceled: boolean }>
}
+/**
+ * Why: the request timed out and so did the cancel sent to clean it up, so the daemon
+ * is wedged with its socket still open — no close event, no disconnect, and nothing
+ * else in the client notices (#8689 is the same shape). Re-message it to the one
+ * string `isDaemonGoneError` matches, so the caller's retry can respawn the daemon
+ * instead of retrying against a daemon that will never answer. Aborts are excluded:
+ * the caller asked to stop, not to retry.
+ */
+function wedgedDaemonError(requestError: Error, cancelError: unknown): Error | null {
+ if (
+ !(requestError instanceof DaemonRequestTimeoutError) ||
+ !(cancelError instanceof DaemonRequestTimeoutError)
+ ) {
+ return null
+ }
+ const wedged = new DaemonRequestTimeoutError(DAEMON_UNAVAILABLE_RECONNECT_MESSAGE)
+ wedged.cause = requestError
+ return wedged
+}
+
export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise {
const { payload, type } = opts
const createSessionId =
@@ -66,6 +97,16 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise {
clearTimers()
reject(error)
}
+ // Unmatched or unconfirmed cancel: a create that already published a result will
+ // still answer, so keep waiting — but only for a bounded window, or an
+ // attach-only request queued behind a hung create never settles at all.
+ const awaitLateResponseThenReject = (error: Error): void => {
+ if (settled || unmatchedCancelTimer) {
+ return
+ }
+ unmatchedCancelTimer = setTimeout(() => rejectAndDrop(error), opts.unmatchedCancelGraceMs)
+ unmatchedCancelTimer.unref?.()
+ }
const cancelCreate = (error: Error): void => {
if (cancellationStarted) {
return
@@ -84,25 +125,30 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise {
rejectAndDrop(error)
return
}
- // Unmatched cancel: a create that already published a result will
- // still answer, so keep waiting — but only for a bounded window, or an
- // attach-only request queued behind a hung create never settles at all.
- if (!settled && !unmatchedCancelTimer) {
- unmatchedCancelTimer = setTimeout(
- () => rejectAndDrop(error),
- opts.unmatchedCancelGraceMs
- )
- unmatchedCancelTimer.unref?.()
- }
+ awaitLateResponseThenReject(error)
})
- .catch(() => {
- if (!settled) {
- opts.onCreateCancellationFailure()
+ .catch((cancelError: unknown) => {
+ if (settled) {
+ return
}
+ // Why: a cancel the daemon refused (v1-v10 answer 'Unknown request type')
+ // or that blew its own 5s timeout (busy event loop, e.g. an unreachable UNC
+ // share) proves nothing about the other sessions on this connection, so it
+ // must not tear it down. Only an undeliverable cancel does. Unrecognized
+ // errors deliberately fall through to the bounded wait: the fail-safe
+ // direction is leaving siblings alone. A wedged daemon is still reported —
+ // on this request only — via wedgedDaemonError.
+ if (cancelError instanceof DaemonConnectionLostError) {
+ opts.onCreateCancellationFailure()
+ return
+ }
+ awaitLateResponseThenReject(wedgedDaemonError(error, cancelError) ?? error)
})
}
const timer = setTimeout(() => {
- const error = new DaemonProtocolError(`Request ${type} timed out after ${opts.timeoutMs}ms`)
+ const error = new DaemonRequestTimeoutError(
+ `Request ${type} timed out after ${opts.timeoutMs}ms`
+ )
if (typeof createSessionId === 'string') {
cancelCreate(error)
} else {
@@ -139,7 +185,13 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise {
onAbort()
return
}
- sent = true
- opts.socket.write(encoded)
+ try {
+ opts.socket.write(encoded)
+ sent = true
+ } catch (err) {
+ // Why: an unwrapped throw here leaks the pending entry and its timer, and the
+ // raw error would be indistinguishable from a daemon refusal.
+ rejectAndDrop(new DaemonConnectionLostError(err instanceof Error ? err.message : String(err)))
+ }
})
}
diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts
index b659946c7e8..12136d37a5b 100644
--- a/src/main/daemon/daemon-errors.ts
+++ b/src/main/daemon/daemon-errors.ts
@@ -21,6 +21,39 @@ export class DaemonProtocolError extends Error {
}
}
+/**
+ * The control connection itself failed: nothing was delivered and no reply is
+ * coming. Distinct from a DaemonProtocolError the daemon actually answered with
+ * (or one we raised on our own timeout) — only this class proves the socket, and
+ * therefore every session sharing it, is already lost. Extends
+ * DaemonProtocolError and keeps the same messages so existing instanceof and
+ * message checks (isDaemonGoneError) still match.
+ */
+export class DaemonConnectionLostError extends DaemonProtocolError {
+ constructor(message: string) {
+ super(message)
+ this.name = 'DaemonConnectionLostError'
+ }
+}
+
+/**
+ * Nothing came back before our own deadline. The daemon may still be alive and merely
+ * slow, so this is not a connection loss — but a request AND the cancel sent to clean
+ * it up both hitting this means the daemon is wedged with its socket still open.
+ */
+export class DaemonRequestTimeoutError extends DaemonProtocolError {
+ constructor(message: string) {
+ super(message)
+ this.name = 'DaemonRequestTimeoutError'
+ }
+}
+
+/**
+ * The one message `isDaemonGoneError` matches that a still-open connection can produce,
+ * so it is the only way a wedged-but-connected daemon reaches the respawn path.
+ */
+export const DAEMON_UNAVAILABLE_RECONNECT_MESSAGE = 'Daemon temporarily unavailable; reconnect'
+
export class SessionNotFoundError extends Error {
constructor(sessionId: string) {
super(`Session not found: ${sessionId}`)
diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts
index 7d2063e2a7d..c0e7e5eef1c 100644
--- a/src/main/daemon/daemon-pty-adapter.ts
+++ b/src/main/daemon/daemon-pty-adapter.ts
@@ -24,6 +24,7 @@ import {
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
+ DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PROTOCOL_VERSION,
supportsMode2031UnsubscribeFact,
@@ -3101,7 +3102,8 @@ export function isDaemonGoneError(err: unknown): boolean {
msg === 'Connection lost' ||
msg === 'Not connected' ||
msg === 'Hello response timed out' ||
- msg === 'Daemon temporarily unavailable; reconnect' ||
+ // Both the daemon's own drain refusal and the client's wedged-daemon signal.
+ msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE ||
// Why retry: the daemon refused because the endpoint now resolves elsewhere. Reconnecting
// reaches whoever owns it; surfacing this to the user would strand the request instead.
msg === DAEMON_ENDPOINT_LOST_MESSAGE
diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts
index 92bf3b4bc7f..2f1817d14c7 100644
--- a/src/main/daemon/types.ts
+++ b/src/main/daemon/types.ts
@@ -400,6 +400,9 @@ export const NOTIFY_PREFIX = 'notify_'
// live in daemon-errors.ts (this file is capped for wire-shape declarations).
export {
TerminalAttachCanceledError,
+ DaemonConnectionLostError,
DaemonProtocolError,
+ DaemonRequestTimeoutError,
+ DAEMON_UNAVAILABLE_RECONNECT_MESSAGE,
SessionNotFoundError
} from './daemon-errors'
From e2b567363b593d787f0bea677e0d6fdbe12b4225 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:40:25 -0700
Subject: [PATCH 13/43] ci: stop refreshing every apt repo three times to
install fish (#15217)
---
.github/workflows/pr.yml | 9 ++++--
.../scripts/pr-workflow-parallelism.test.mjs | 31 +++++++++++++++++++
2 files changed, 37 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 85a51e4f7bf..13689068a76 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -259,14 +259,17 @@ jobs:
# non-zero and would red this required check over something unrelated to the
# PR. Every fish outcome is judged by the version gate below instead, so only
# the zsh install (which has no such gate) stays fatal here.
- sudo apt-get update || true
# Why retry only here: adding the PPA is the network-flaky step, and the
# version gate below is fatal, so a transient Launchpad blip would
# otherwise red a required check on PRs unrelated to shells.
+ # Why -n: add-apt-repository refreshes every configured repo on its own. With
+ # an update on each side of it this step refreshed them three times over, and
+ # the Azure archive mirror alone costs ~15-30s a pass. The PPA index is the
+ # only thing the repo list gains here, and the single update below fetches it.
for attempt in 1 2 3; do
- sudo add-apt-repository -y ppa:fish-shell/release-4 && break
+ sudo add-apt-repository -y -n ppa:fish-shell/release-4 && break
echo "add-apt-repository attempt ${attempt} failed; retrying" >&2
- sudo add-apt-repository -y -r ppa:fish-shell/release-4 || true
+ sudo add-apt-repository -y -n -r ppa:fish-shell/release-4 || true
sleep 5
done
sudo apt-get update || true
diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs
index 02ddc764c2a..9e75097d487 100644
--- a/config/scripts/pr-workflow-parallelism.test.mjs
+++ b/config/scripts/pr-workflow-parallelism.test.mjs
@@ -96,6 +96,37 @@ describe('PR workflow parallelism', () => {
}
})
+ it('refreshes the apt index once while adding the fish PPA', () => {
+ const installStep = workflow.jobs.shell_contracts.steps.find(
+ (step) => step.name === 'Install zsh and fish'
+ )
+ // Comment lines mention both commands by name, so count the executed ones only.
+ const commands = installStep.run
+ .split('\n')
+ .filter((line) => !line.trim().startsWith('#'))
+ .join('\n')
+ const updates = commands.match(/apt-get update/g) ?? []
+ // Anchored to the start of a line so the retry message that names the command in
+ // prose is not mistaken for an invocation of it.
+ const addRepoCalls = commands
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => /^(sudo\s+)?add-apt-repository\b/.test(line))
+
+ // add-apt-repository refreshes every configured repo unless told not to, so an
+ // update on each side of it made this step pay for three full passes.
+ expect(updates).toHaveLength(1)
+ expect(addRepoCalls.length).toBeGreaterThan(0)
+ for (const call of addRepoCalls) {
+ expect(call.split(/\s+/)).toContain('-n')
+ }
+ // The one remaining update has to come after the PPA is on the list, or the fish
+ // index it exists to fetch would not be there yet.
+ expect(commands.lastIndexOf('add-apt-repository')).toBeLessThan(
+ commands.indexOf('apt-get update')
+ )
+ })
+
it('keeps every real-zsh test in the dedicated shell lane', () => {
const discoveredFiles = globSync(testFilePatterns)
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
From e39def3825a321fb0944439c98df4051651412c4 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:43:06 -0700
Subject: [PATCH 14/43] fix(repo-identity): bound git remote-identity probes
and retire them with their repo (#15196)
* fix(repo-identity): bound git remote-identity probes and retire them with their repo
The local `git remote -v` probe ran with no timeout and no signal, and the
runner only arms its kill timer when a timeout is passed, so a hung NFS/SMB
cwd or a wedged `wsl.exe -d ` left the promise unsettled and the child
alive. Because the sweep is sequential and dedupes per location, that one
wedged location stalled enrichment for every other repo.
- probeGitRemoteIdentity/detectGitRemoteIdentity take `{ signal, timeoutMs }`;
local reads get the 5s background local-git-read budget, SSH gets a budget
under the relay's 30s request timeout. Timeouts/aborts still map to
`unavailable`, never `no-remote`, so they cannot clear a resolved identity.
- In-flight probes are tracked with an AbortController and retired (aborted +
dropped) when their location is no longer backed by a repo, so a re-added
repo is not poisoned by the dead entry and a retired probe cannot re-seed a
retry deadline.
- Added the missing sweep-level guard so repos:list / projects:list /
projectHostSetups:list coalesce into one pass instead of stacking one
sequential sweep per list IPC.
STA-4452
* refactor(repo-identity): bound the enrichment listener set to stable caller references
Every call site allocated a fresh onChanged closure, so the Set that notifies
coalesced sweeps deduped nothing: during a chain that never quiesces it grew one
entry per list IPC and multiplied the repos:changed broadcast. Hoist the closures
to stable references in ipc/repos.ts and OrcaRuntimeService, and state the
contract on the set.
Also: guard the synchronous retirement call so the fire-and-forget entry point
keeps its no-throw contract, drop the placeholder promise in favour of building
the in-flight entry in one shot, and make the coalescing test use a shared list
reference plus a distinct runtime reference so it detects both stacked passes and
a dropped caller.
---
src/main/ipc/repos.ts | 20 +--
...epo-git-remote-identity-enrichment.test.ts | 145 +++++++++++++++++-
.../repo-git-remote-identity-enrichment.ts | 119 +++++++++++---
src/main/repo-git-remote-identity.test.ts | 58 ++++++-
src/main/repo-git-remote-identity.ts | 36 ++++-
src/main/runtime/orca-runtime.ts | 12 +-
6 files changed, 341 insertions(+), 49 deletions(-)
diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts
index a0a25c5b12c..d4a9fc7f0ab 100644
--- a/src/main/ipc/repos.ts
+++ b/src/main/ipc/repos.ts
@@ -1327,14 +1327,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('sparsePresets:save')
ipcMain.removeHandler('sparsePresets:remove')
+ // Why one shared reference: enrichment dedupes coalesced callers by callback identity, so a fresh
+ // closure per list call would stack up (and re-broadcast) for the length of a slow sweep.
+ const broadcastReposChanged = (): void => notifyReposChanged(mainWindow)
+
ipcMain.handle('repos:list', () => {
- enrichMissingRepoGitRemoteIdentities(store, {
- onChanged: () => notifyReposChanged(mainWindow)
- })
+ enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged })
// Why: username resolution spawns git/gh, so keep it off this sync handler (issue #7225); it re-lists when values land.
- enrichRepoGitUsernames(store, {
- onChanged: () => notifyReposChanged(mainWindow)
- })
+ enrichRepoGitUsernames(store, { onChanged: broadcastReposChanged })
return store.getRepos()
})
@@ -1345,9 +1345,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
)
ipcMain.handle('projects:list', () => {
- enrichMissingRepoGitRemoteIdentities(store, {
- onChanged: () => notifyReposChanged(mainWindow)
- })
+ enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged })
return store.getProjects()
})
@@ -1361,9 +1359,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
})
ipcMain.handle('projectHostSetups:list', () => {
- enrichMissingRepoGitRemoteIdentities(store, {
- onChanged: () => notifyReposChanged(mainWindow)
- })
+ enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged })
return store.getProjectHostSetups()
})
diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts
index 40f1c0ee2d9..23b7c6a47e9 100644
--- a/src/main/repo-git-remote-identity-enrichment.test.ts
+++ b/src/main/repo-git-remote-identity-enrichment.test.ts
@@ -101,7 +101,11 @@ describe('enrichMissingRepoGitRemoteIdentities', () => {
enrichMissingRepoGitRemoteIdentities(store, { onChanged })
expect(repo.gitRemoteIdentity).toBeUndefined()
- expect(probeGitRemoteIdentity).toHaveBeenCalledWith('/workspace/sample-app', undefined)
+ expect(probeGitRemoteIdentity).toHaveBeenCalledWith(
+ '/workspace/sample-app',
+ undefined,
+ expect.objectContaining({ signal: expect.any(AbortSignal) })
+ )
await flushRepoGitRemoteIdentityEnrichmentForTests()
@@ -398,3 +402,142 @@ describe('enrichMissingRepoGitRemoteIdentities', () => {
expect(repo.gitRemoteIdentity).toBeUndefined()
})
})
+
+describe('retiring probes for removed repos', () => {
+ function makeMutableStore(live: Repo[]): RepoIdentityStore & {
+ updateRepo: ReturnType
+ } {
+ return {
+ getRepos: () => live,
+ getRepo: (id) => live.find((candidate) => candidate.id === id),
+ updateRepo: vi.fn((id, updates) => {
+ const target = live.find((candidate) => candidate.id === id)
+ if (!target) {
+ return null
+ }
+ Object.assign(target, updates)
+ return target
+ })
+ }
+ }
+
+ function probeSignal(callIndex: number): AbortSignal {
+ const signal = vi.mocked(probeGitRemoteIdentity).mock.calls[callIndex]?.[2]?.signal
+ if (!signal) {
+ throw new Error(`probe call ${callIndex} was made without an abort signal`)
+ }
+ return signal
+ }
+
+ it('aborts the in-flight probe of a repo that was removed', () => {
+ // Never resolves: a probe wedged on a hung network path is exactly the case that must not
+ // outlive its repo. Do not flush here — flushing awaits this promise and would hang the suite.
+ vi.mocked(probeGitRemoteIdentity).mockReturnValue(new Promise(() => {}))
+ const repo = makeRepo()
+ const live: Repo[] = [repo]
+ const store = makeMutableStore(live)
+
+ enrichMissingRepoGitRemoteIdentities(store)
+ const signal = probeSignal(0)
+ expect(signal.aborted).toBe(false)
+
+ live.length = 0
+ enrichMissingRepoGitRemoteIdentities(store)
+
+ expect(signal.aborted).toBe(true)
+ })
+
+ it('probes again when the same location is re-added after its probe was retired', async () => {
+ const wedged = deferred()
+ vi.mocked(probeGitRemoteIdentity)
+ .mockReturnValueOnce(wedged.promise)
+ .mockResolvedValue(resolvedProbe)
+ const repo = makeRepo()
+ const live: Repo[] = [repo]
+ const store = makeMutableStore(live)
+
+ enrichMissingRepoGitRemoteIdentities(store)
+ live.length = 0
+ enrichMissingRepoGitRemoteIdentities(store)
+ // The retired probe settles late, as an abort-killed git does.
+ wedged.resolve(resolvedProbe)
+ await flushRepoGitRemoteIdentityEnrichmentForTests()
+
+ live.push(repo)
+ enrichMissingRepoGitRemoteIdentities(store)
+ await flushRepoGitRemoteIdentityEnrichmentForTests()
+
+ expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(2)
+ expect(repo.gitRemoteIdentity).toEqual(remoteIdentity)
+ })
+
+ it('does not let a retired probe write an identity or re-seed a retry deadline', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000)
+ const wedged = deferred()
+ vi.mocked(probeGitRemoteIdentity)
+ .mockReturnValueOnce(wedged.promise)
+ .mockResolvedValue(resolvedProbe)
+ const repo = makeRepo()
+ const live: Repo[] = [repo]
+ const store = makeMutableStore(live)
+
+ enrichMissingRepoGitRemoteIdentities(store)
+ live.length = 0
+ enrichMissingRepoGitRemoteIdentities(store)
+ wedged.resolve(resolvedProbe)
+ await flushRepoGitRemoteIdentityEnrichmentForTests()
+
+ expect(store.updateRepo).not.toHaveBeenCalled()
+
+ // Same instant: a deadline re-seeded by the retired probe would suppress this probe entirely.
+ live.push(repo)
+ enrichMissingRepoGitRemoteIdentities(store)
+ await flushRepoGitRemoteIdentityEnrichmentForTests()
+
+ expect(probeGitRemoteIdentity).toHaveBeenCalledTimes(2)
+ })
+
+ it('coalesces concurrent sweeps instead of running one pass per list call', async () => {
+ const first = deferred()
+ vi.mocked(probeGitRemoteIdentity)
+ .mockReturnValueOnce(first.promise)
+ .mockResolvedValue(resolvedProbe)
+ const live: Repo[] = [makeRepo(), makeRepo({ id: 'repo-2', path: '/workspace/other-app' })]
+ const store = makeMutableStore(live)
+ // The three list handlers share one broadcast reference (ipc/repos.ts) while the runtime RPC
+ // passes its own, so the shared one catches stacked passes and the distinct one catches a
+ // coalesced caller being dropped.
+ const listHandlersChanged = vi.fn()
+ const runtimeChanged = vi.fn()
+
+ enrichMissingRepoGitRemoteIdentities(store, { onChanged: listHandlersChanged })
+ enrichMissingRepoGitRemoteIdentities(store, { onChanged: listHandlersChanged })
+ enrichMissingRepoGitRemoteIdentities(store, { onChanged: runtimeChanged })
+
+ first.resolve(resolvedProbe)
+ await drainEnrichmentSweep()
+
+ // Each stacked sweep would otherwise re-run the whole candidate loop and re-broadcast.
+ expect(listHandlersChanged).toHaveBeenCalledTimes(1)
+ expect(runtimeChanged).toHaveBeenCalledTimes(1)
+ })
+
+ it('still notifies a caller whose sweep was coalesced into one already running', async () => {
+ const first = deferred()
+ vi.mocked(probeGitRemoteIdentity).mockReturnValue(first.promise)
+ const store = makeMutableStore([makeRepo()])
+ const listHandlerChanged = vi.fn()
+ // The runtime RPC caller also drops a resolved-worktree cache, so it must not be dropped.
+ const runtimeChanged = vi.fn()
+
+ enrichMissingRepoGitRemoteIdentities(store, { onChanged: listHandlerChanged })
+ enrichMissingRepoGitRemoteIdentities(store, { onChanged: runtimeChanged })
+
+ first.resolve(resolvedProbe)
+ await drainEnrichmentSweep()
+
+ expect(listHandlerChanged).toHaveBeenCalledTimes(1)
+ expect(runtimeChanged).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts
index 08a059294a3..1f4ce458beb 100644
--- a/src/main/repo-git-remote-identity-enrichment.ts
+++ b/src/main/repo-git-remote-identity-enrichment.ts
@@ -23,8 +23,21 @@ type EnrichmentOptions = {
onChanged?: () => void
}
-const inFlightProbesByLocation = new Map>()
+type InFlightProbe = {
+ controller: AbortController
+ promise: Promise
+}
+
+const inFlightProbesByLocation = new Map()
const probeRetryAfterByLocation = new Map()
+// Why a set rather than the current caller's callback: coalesced sweeps come from different call
+// sites (the list IPC handlers and the runtime RPC, which also drops a resolved-worktree cache), so
+// the pass that lands a change has to notify every caller still waiting on it. Callers MUST pass a
+// stable callback reference — the set dedupes by function identity, so a fresh closure per call
+// would grow it (and multiply the broadcast) for the length of a sweep chain that never quiesces.
+const pendingChangeListeners = new Set<() => void>()
+let sweepInFlight: Promise | null = null
+let rerunRequested = false
function getRepoLocationKey(repo: Pick): string {
return `${repo.connectionId ?? 'local'}\0${repo.path}`
@@ -79,15 +92,24 @@ async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo)
}
const inFlight = inFlightProbesByLocation.get(locationKey)
if (inFlight) {
- return inFlight
+ return inFlight.promise
}
// Why: a failed refresh of an already-resolved repo backs off on the long TTL — an SSH host that
// is down for the day must not re-probe every five minutes for an identity we already hold.
const missTtlMs = repo.gitRemoteIdentity
? RESOLVED_IDENTITY_REFRESH_TTL_MS
: NO_IDENTITY_RETRY_TTL_MS
- const probe = (async () => {
- const result = await probeGitRemoteIdentity(repo.path, repo.connectionId)
+ const controller = new AbortController()
+ const promise = (async () => {
+ const result = await probeGitRemoteIdentity(repo.path, repo.connectionId, {
+ signal: controller.signal
+ })
+ // Why the signal and not a catch: probeGitRemoteIdentity swallows the AbortError and RESOLVES
+ // `unavailable`, so a retired probe would otherwise re-seed a deadline for a location that no
+ // longer has a repo. Do not simplify this into a rejection path.
+ if (controller.signal.aborted) {
+ return false
+ }
if (result.status !== 'resolved') {
// Why: repos without a parseable remote are common; cache misses briefly so
// list calls stay cheap while still allowing recent remote changes to land.
@@ -101,12 +123,14 @@ async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo)
probeRetryAfterByLocation.set(locationKey, Date.now() + RESOLVED_IDENTITY_REFRESH_TTL_MS)
return writeIdentity(store, repo, result.identity)
})().finally(() => {
- if (inFlightProbesByLocation.get(locationKey) === probe) {
+ // Why compare the controller: retirement may already have replaced this location's entry, and
+ // only the probe that owns the current one may clear it.
+ if (inFlightProbesByLocation.get(locationKey)?.controller === controller) {
inFlightProbesByLocation.delete(locationKey)
}
})
- inFlightProbesByLocation.set(locationKey, probe)
- return probe
+ inFlightProbesByLocation.set(locationKey, { controller, promise })
+ return promise
}
function isIdentityRefreshDue(repo: Repo, now: number): boolean {
@@ -120,28 +144,32 @@ function isIdentityRefreshDue(repo: Repo, now: number): boolean {
}
/**
- * Drop deadlines for locations no longer backed by a repo, so removed repos and
- * retired SSH hosts do not accumulate for the life of the process.
+ * Drop deadlines and abort probes for locations no longer backed by a repo, so removed repos and
+ * retired SSH hosts do not accumulate — or keep a git child alive — for the life of the process.
*/
-function pruneRetryDeadlines(liveRepos: Repo[]): void {
- const liveKeys = new Set(liveRepos.map(getRepoLocationKey))
+function retireRemovedLocations(allRepos: Repo[]): void {
+ const liveKeys = new Set(
+ allRepos.filter((repo) => repo.kind !== 'folder').map(getRepoLocationKey)
+ )
for (const locationKey of probeRetryAfterByLocation.keys()) {
- // A probe still running for a dropped repo needs no exemption: it re-adds its
- // own deadline on settle, and only live repos are ever candidates, so nothing
- // reads this entry in between.
if (!liveKeys.has(locationKey)) {
probeRetryAfterByLocation.delete(locationKey)
}
}
+ for (const [locationKey, entry] of inFlightProbesByLocation) {
+ // Why drop it here rather than leave it to the probe's own `.finally`: a probe that never
+ // settles would keep both the entry and its child alive, and re-poison this location if the
+ // same repo is added back.
+ if (!liveKeys.has(locationKey)) {
+ entry.controller.abort()
+ inFlightProbesByLocation.delete(locationKey)
+ }
+ }
}
function selectEnrichmentCandidates(store: RepoIdentityStore): Repo[] {
const now = Date.now()
const repos = store.getRepos().filter((repo) => repo.kind !== 'folder')
- // Why here: this is the one place that already enumerates every live repo, and
- // `getRepos()` builds its list synchronously from in-memory state, so a repo is
- // never transiently absent mid-sweep and cannot lose its startup delay or backoff.
- pruneRetryDeadlines(repos)
// Why: the settled `null` marker stays a candidate on purpose — a repo that
// gains a remote later must still resolve. Do not tighten this to
// `=== undefined`; the retry TTL already bounds the cost and `writeIdentity`
@@ -164,8 +192,7 @@ function selectEnrichmentCandidates(store: RepoIdentityStore): Repo[] {
}
async function enrichMissingRepoGitRemoteIdentitiesInBackground(
- store: RepoIdentityStore,
- options: EnrichmentOptions
+ store: RepoIdentityStore
): Promise {
const candidates = selectEnrichmentCandidates(store)
let changed = false
@@ -177,7 +204,9 @@ async function enrichMissingRepoGitRemoteIdentitiesInBackground(
}
}
if (changed) {
- options.onChanged?.()
+ for (const listener of pendingChangeListeners) {
+ listener()
+ }
}
}
@@ -185,16 +214,56 @@ export function enrichMissingRepoGitRemoteIdentities(
store: RepoIdentityStore,
options: EnrichmentOptions = {}
): void {
- void enrichMissingRepoGitRemoteIdentitiesInBackground(store, options).catch((error: unknown) => {
- console.error('[repo-identity] Failed to enrich git remote identities:', error)
- })
+ // Why outside the in-flight guard: retirement is exactly what a stuck sweep needs, and
+ // `getRepos()` builds its list synchronously from in-memory state, so a repo is never
+ // transiently absent and cannot lose its startup delay or backoff. The try/catch keeps the
+ // no-throw contract this fire-and-forget entry point had when retirement ran inside the pass.
+ try {
+ retireRemovedLocations(store.getRepos())
+ } catch (error: unknown) {
+ console.error('[repo-identity] Failed to retire removed repo locations:', error)
+ }
+ if (options.onChanged) {
+ pendingChangeListeners.add(options.onChanged)
+ }
+ if (sweepInFlight) {
+ // Why: the sweep probes candidates sequentially, so one fresh pass per list IPC piles up behind
+ // a slow probe — queue a single follow-up instead, which still picks up repos added mid-pass.
+ rerunRequested = true
+ return
+ }
+ sweepInFlight = enrichMissingRepoGitRemoteIdentitiesInBackground(store)
+ .catch((error: unknown) => {
+ console.error('[repo-identity] Failed to enrich git remote identities:', error)
+ })
+ .finally(() => {
+ sweepInFlight = null
+ if (rerunRequested) {
+ rerunRequested = false
+ enrichMissingRepoGitRemoteIdentities(store)
+ } else {
+ // Why clear only once the chain quiesces: dropping listeners earlier would leave a
+ // coalesced caller unnotified by the follow-up pass its own call queued.
+ pendingChangeListeners.clear()
+ }
+ })
}
export async function flushRepoGitRemoteIdentityEnrichmentForTests(): Promise {
- await Promise.all(inFlightProbesByLocation.values())
+ // A queued rerun replaces sweepInFlight when the current pass settles.
+ while (sweepInFlight) {
+ await sweepInFlight
+ }
+ await Promise.all([...inFlightProbesByLocation.values()].map((entry) => entry.promise))
}
export function resetRepoGitRemoteIdentityEnrichmentForTests(): void {
+ for (const entry of inFlightProbesByLocation.values()) {
+ entry.controller.abort()
+ }
inFlightProbesByLocation.clear()
probeRetryAfterByLocation.clear()
+ pendingChangeListeners.clear()
+ sweepInFlight = null
+ rerunRequested = false
}
diff --git a/src/main/repo-git-remote-identity.test.ts b/src/main/repo-git-remote-identity.test.ts
index 807bcb357c9..ee5bdc9eb01 100644
--- a/src/main/repo-git-remote-identity.test.ts
+++ b/src/main/repo-git-remote-identity.test.ts
@@ -53,7 +53,11 @@ describe('probeGitRemoteIdentity', () => {
await expect(probeGitRemoteIdentity('/repos/orca', 'builder')).resolves.toEqual({
status: 'unavailable'
})
- expect(exec).toHaveBeenCalledWith(['remote', '-v'], '/repos/orca')
+ expect(exec).toHaveBeenCalledWith(
+ ['remote', '-v'],
+ '/repos/orca',
+ expect.objectContaining({ timeoutMs: expect.any(Number) })
+ )
expect(gitExecFileAsync).not.toHaveBeenCalled()
})
@@ -66,4 +70,56 @@ describe('probeGitRemoteIdentity', () => {
status: 'no-remote'
})
})
+
+ it('bounds the local probe with a deadline and forwards the caller signal', async () => {
+ vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: gitlabRemote, stderr: '' })
+ const controller = new AbortController()
+
+ await probeGitRemoteIdentity('/repos/orca', null, { signal: controller.signal })
+
+ expect(gitExecFileAsync).toHaveBeenCalledWith(
+ ['remote', '-v'],
+ expect.objectContaining({
+ cwd: '/repos/orca',
+ timeout: expect.any(Number),
+ signal: controller.signal
+ })
+ )
+ const [, options] = vi.mocked(gitExecFileAsync).mock.calls[0]
+ expect(options.timeout).toBeGreaterThan(0)
+ })
+
+ it('bounds the SSH probe under the relay request timeout and forwards the caller signal', async () => {
+ const exec = vi.fn().mockResolvedValue({ stdout: gitlabRemote, stderr: '' })
+ vi.mocked(getSshGitProvider).mockReturnValue({ exec } as never)
+ const controller = new AbortController()
+
+ await probeGitRemoteIdentity('/repos/orca', 'builder', { signal: controller.signal })
+
+ expect(exec).toHaveBeenCalledWith(
+ ['remote', '-v'],
+ '/repos/orca',
+ expect.objectContaining({ timeoutMs: expect.any(Number), signal: controller.signal })
+ )
+ const relayRequestTimeoutMs = 30_000
+ expect(exec.mock.calls[0][2].timeoutMs).toBeLessThan(relayRequestTimeoutMs)
+ })
+
+ it('maps a timed-out local probe to unavailable, never no-remote', async () => {
+ vi.mocked(gitExecFileAsync).mockRejectedValue(new Error('git timed out.'))
+
+ await expect(probeGitRemoteIdentity('/repos/orca')).resolves.toEqual({ status: 'unavailable' })
+ })
+
+ it('maps an aborted probe to unavailable, never no-remote', async () => {
+ const abortError = new Error('The operation was aborted')
+ abortError.name = 'AbortError'
+ vi.mocked(gitExecFileAsync).mockRejectedValue(abortError)
+ const controller = new AbortController()
+ controller.abort()
+
+ await expect(
+ probeGitRemoteIdentity('/repos/orca', null, { signal: controller.signal })
+ ).resolves.toEqual({ status: 'unavailable' })
+ })
})
diff --git a/src/main/repo-git-remote-identity.ts b/src/main/repo-git-remote-identity.ts
index 6d517a06c03..65b2c88e5a5 100644
--- a/src/main/repo-git-remote-identity.ts
+++ b/src/main/repo-git-remote-identity.ts
@@ -2,6 +2,20 @@ import { deriveGitRemoteIdentity, type GitRemoteIdentity } from '../shared/git-r
import { gitExecFileAsync } from './git/runner'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
+// Why: the runner only arms its kill timer when a timeout is passed, so an unbounded local probe
+// never settles on a hung NFS/SMB cwd (the path walk blocks) or a wedged `wsl.exe -d `.
+// Matches the background local-git-read budget in git/git-username.ts; a probe cut off early just
+// reports `unavailable` and retries on the enrichment TTL.
+const LOCAL_PROBE_TIMEOUT_MS = 5000
+// Why looser but still bounded: the relay adds a round trip a local spawn does not have, and the
+// budget must stay under the multiplexer's 30s request timeout so this deadline is the one that fires.
+const SSH_PROBE_TIMEOUT_MS = 20_000
+
+export type GitRemoteIdentityProbeOptions = {
+ signal?: AbortSignal
+ timeoutMs?: number
+}
+
/** `no-remote` means git answered and the repo has no usable remote;
* `unavailable` means the probe never reached git (host down, SSH not up
* yet, git error) and says nothing about the repo. */
@@ -12,27 +26,37 @@ export type GitRemoteIdentityProbe =
export async function probeGitRemoteIdentity(
repoPath: string,
- connectionId?: string | null
+ connectionId?: string | null,
+ options: GitRemoteIdentityProbeOptions = {}
): Promise {
try {
const result = connectionId
- ? await getSshGitProvider(connectionId)?.exec(['remote', '-v'], repoPath)
- : await gitExecFileAsync(['remote', '-v'], { cwd: repoPath })
+ ? await getSshGitProvider(connectionId)?.exec(['remote', '-v'], repoPath, {
+ signal: options.signal,
+ timeoutMs: options.timeoutMs ?? SSH_PROBE_TIMEOUT_MS
+ })
+ : await gitExecFileAsync(['remote', '-v'], {
+ cwd: repoPath,
+ timeout: options.timeoutMs ?? LOCAL_PROBE_TIMEOUT_MS,
+ signal: options.signal
+ })
if (!result) {
return { status: 'unavailable' }
}
const identity = deriveGitRemoteIdentity(result.stdout)
return identity ? { status: 'resolved', identity } : { status: 'no-remote' }
} catch {
- // Repo creation must not fail because a best-effort remote probe failed.
+ // Repo creation must not fail because a best-effort remote probe failed. A timeout or an abort
+ // lands here too, and must stay `unavailable`: only `no-remote` may clear a resolved identity.
return { status: 'unavailable' }
}
}
export async function detectGitRemoteIdentity(
repoPath: string,
- connectionId?: string | null
+ connectionId?: string | null,
+ options: GitRemoteIdentityProbeOptions = {}
): Promise {
- const probe = await probeGitRemoteIdentity(repoPath, connectionId)
+ const probe = await probeGitRemoteIdentity(repoPath, connectionId, options)
return probe.status === 'resolved' ? probe.identity : null
}
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index a9e16ad00ff..3872f25d03e 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -20047,15 +20047,19 @@ export class OrcaRuntimeService {
return this.store?.getRepos() ?? []
}
+ // Why a stable field and not a per-call closure: enrichment dedupes coalesced callers by callback
+ // identity, so a fresh closure per call would stack up for the length of a slow sweep.
+ private readonly onRepoGitRemoteIdentitiesChanged = (): void => {
+ this.invalidateResolvedWorktreeCache()
+ this.notifyReposChanged()
+ }
+
enrichMissingRepoGitRemoteIdentities(): void {
if (!this.store) {
return
}
enrichMissingRepoGitRemoteIdentities(this.store, {
- onChanged: () => {
- this.invalidateResolvedWorktreeCache()
- this.notifyReposChanged()
- }
+ onChanged: this.onRepoGitRemoteIdentitiesChanged
})
}
From 3d29a2604ed48425d16014217fd5db10066126a0 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:43:29 -0700
Subject: [PATCH 15/43] fix(terminal-history): drop an inherited Orca
fish_history so nested Orca panes stop merging worktree histories (STA-4682)
(#15195)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(terminal-history): drop an inherited Orca fish_history (STA-4682)
fish EXPORTS `fish_history`, so an Orca launched from a fish pane keeps the
launching worktree's session name in process.env. Every fish pane of the nested
app then hit the check-before-set early return and wrote into that one
worktree's history file, in every worktree. Drop Orca-minted names (desktop and
relay prefixes) wherever the session is injected, and in the history-disabled
and daemon spawn paths; a genuine user value still wins.
* fix(relay): drop an inherited Orca fish_history on every spawn path (STA-4682)
injectRelayFishHistoryEnv runs only for a fish pane with history isolation
on and a worktreeId, so a relay that inherited an Orca-minted fish_history
kept it on every other path — scoping those panes to another worktree's
history file. The desktop drops it on both branches; scrub it in
buildSpawnEnv so relay spawn and revive match.
Also record why injectWslFishHistoryEnv keeps its own drop (redundant with
both current callers, kept as the function's precondition).
---
.../pty-subprocess-env-inheritance.test.ts | 30 +++++++++++
src/main/daemon/pty-subprocess.ts | 7 +++
src/main/fish-history-session.ts | 12 +++++
.../local-pty-provider-spawn-env.test.ts | 22 ++++++++
src/main/providers/local-pty-provider.ts | 4 ++
src/main/terminal-history.test.ts | 54 ++++++++++++++++++-
src/main/terminal-history.ts | 10 ++++
.../pty-handler-spawn-environment.test.ts | 44 +++++++++++++++
src/relay/pty-handler.ts | 7 +++
src/relay/terminal-history.test.ts | 28 +++++++++-
src/relay/terminal-history.ts | 6 +++
11 files changed, 222 insertions(+), 2 deletions(-)
diff --git a/src/main/daemon/pty-subprocess-env-inheritance.test.ts b/src/main/daemon/pty-subprocess-env-inheritance.test.ts
index 2ad811edfc6..18e433bd8fd 100644
--- a/src/main/daemon/pty-subprocess-env-inheritance.test.ts
+++ b/src/main/daemon/pty-subprocess-env-inheritance.test.ts
@@ -187,6 +187,36 @@ describe('createPtySubprocess', () => {
expect(env.ORCA_WORKTREE_ID).toBe('child-worktree')
})
+ it.each([
+ // fish EXPORTS fish_history, so a daemon started from a fish pane would hand
+ // every session the launching worktree's history file (STA-4682). Only the
+ // name this spawn asked for — the isolated one, or the user's — may stand.
+ ['drops an inherited Orca fish_history', undefined, undefined],
+ ['keeps the session this spawn injected', 'orca_c0ffee', 'orca_c0ffee'],
+ ['keeps a caller-supplied value', 'mine', 'mine']
+ ])('%s', async (_name, requested, expected) => {
+ spawnMock.mockReturnValue(mockPtyProcess())
+ const saved = process.env.fish_history
+ process.env.fish_history = 'orca_abc123'
+
+ try {
+ await createPtySubprocess({
+ sessionId: 'test',
+ cols: 80,
+ rows: 24,
+ ...(requested === undefined ? {} : { env: { fish_history: requested } })
+ })
+ } finally {
+ if (saved === undefined) {
+ delete process.env.fish_history
+ } else {
+ process.env.fish_history = saved
+ }
+ }
+
+ expect(spawnMock.mock.calls.at(-1)?.[2].env.fish_history).toBe(expected)
+ })
+
it('does not inherit ELECTRON_RUN_AS_NODE from the daemon process env', async () => {
// Why: the daemon is forked with ELECTRON_RUN_AS_NODE=1. If that flag
// reaches user shells, nested Electron commands run as plain Node.
diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts
index c2c1a61b745..399d05e1bc9 100644
--- a/src/main/daemon/pty-subprocess.ts
+++ b/src/main/daemon/pty-subprocess.ts
@@ -37,6 +37,7 @@ import {
import { isPwshAvailable } from '../pwsh'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { removeInheritedNoColor } from '../pty/terminal-color-env'
+import { dropInheritedOrcaFishHistory } from '../fish-history-session'
import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env'
import { stripInheritedBuildModeEnv } from '../pty/build-mode-env'
import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir'
@@ -654,6 +655,12 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise): void {
+ if (isSafeFishHistorySession(env.fish_history)) {
+ delete env.fish_history
+ }
+}
+
export function fishHistorySessionName(worktreeHash: string): string {
return `${SESSION_PREFIX}${worktreeHash}`
}
diff --git a/src/main/providers/local-pty-provider-spawn-env.test.ts b/src/main/providers/local-pty-provider-spawn-env.test.ts
index ebc6dfd845a..01c9a8460f6 100644
--- a/src/main/providers/local-pty-provider-spawn-env.test.ts
+++ b/src/main/providers/local-pty-provider-spawn-env.test.ts
@@ -188,6 +188,28 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
})
+ it.each([
+ // fish EXPORTS fish_history, so an Orca launched from a fish pane hands every
+ // pane the LAUNCHING worktree's session — even with isolation off (STA-4682).
+ ['an inherited Orca session', 'orca_abc123', undefined],
+ ['a user value', 'mine', 'mine']
+ ])('history isolation off: %s', async (_kind, inherited, expected) => {
+ const previous = process.env.fish_history
+ process.env.fish_history = inherited
+ try {
+ // No worktreeId: the history-disabled branch of spawn.
+ await provider.spawn({ cols: 80, rows: 24 })
+ } finally {
+ if (previous === undefined) {
+ delete process.env.fish_history
+ } else {
+ process.env.fish_history = previous
+ }
+ }
+
+ expect(spawnMock.mock.calls.at(-1)![2].env.fish_history).toBe(expected)
+ })
+
it('does not inherit NODE_ENV from the Orca process env', async () => {
// Why: NODE_ENV in Orca's process is Orca's build mode (electron-vite sets
// `development` in dev runs); leaking it breaks `next build` and Vitest.
diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts
index cf98ca7cf0b..a1f9c26f207 100644
--- a/src/main/providers/local-pty-provider.ts
+++ b/src/main/providers/local-pty-provider.ts
@@ -24,6 +24,7 @@ import {
logHistoryInjection,
type HistoryInjectionResult
} from '../terminal-history'
+import { dropInheritedOrcaFishHistory } from '../fish-history-session'
import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types'
import {
ensureNodePtySpawnHelperExecutable,
@@ -883,6 +884,9 @@ export class LocalPtyProvider implements IPtyProvider {
// Why: injectHistoryEnv is what normally clears it, so when history is off
// an inherited ORCA_HISTFILE would still reach the wrapper. Credit: #11146.
delete finalEnv.ORCA_HISTFILE
+ // Same for an exported `fish_history` from the fish pane that launched this
+ // Orca: history off means fish's own default, not another worktree's file.
+ dropInheritedOrcaFishHistory(finalEnv)
}
await prepareLocalPtySpawn(id)
diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts
index 9b9a3a78ea8..32c4cde482e 100644
--- a/src/main/terminal-history.test.ts
+++ b/src/main/terminal-history.test.ts
@@ -88,11 +88,12 @@ import {
resolveShellKind,
ensureHistoryDir,
injectHistoryEnv,
+ injectWslFishHistoryEnv,
MAX_HISTORY_META_BYTES,
updateHistoryEnvForFallback,
type HistoryInjectionResult
} from './terminal-history'
-import { fishHistorySessionName } from './fish-history-session'
+import { fishHistorySessionName, relayFishHistorySessionName } from './fish-history-session'
import { hashWorktreeId } from './terminal-history-paths'
import {
cancelPendingHistoryTreeRemovalRetries,
@@ -378,6 +379,32 @@ describe('terminal-history', () => {
expect(result.fishSession).toBeNull()
})
+ it.each([
+ ['desktop', fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))],
+ ['relay', relayFishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))]
+ ])('replaces a %s fish_history inherited from a parent Orca', (_kind, inherited) => {
+ // fish EXPORTS fish_history, so an Orca launched from a fish pane hands the
+ // LAUNCHING worktree's session to every pane here — panes in every other
+ // worktree included, which would all then write one worktree's history file.
+ const env: Record = { fish_history: inherited }
+
+ const result = injectHistoryEnv(env, 'repo-1::/path/wt', '/usr/bin/fish', '/path/wt')
+
+ expect(result.fishSession).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt')))
+ expect(env.fish_history).toBe(result.fishSession)
+ })
+
+ it('drops an inherited fish_history even when the shell is not fish', () => {
+ // A fish started by hand inside this zsh pane must not adopt the parent's session.
+ const env: Record = {
+ fish_history: fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))
+ }
+
+ injectHistoryEnv(env, 'repo-1::/path/wt', '/bin/zsh', '/path/wt')
+
+ expect(env.fish_history).toBeUndefined()
+ })
+
it('degrades gracefully when directory creation fails', () => {
mkdirSyncMock.mockImplementation(() => {
throw new Error('disk full')
@@ -391,6 +418,31 @@ describe('terminal-history', () => {
})
})
+ describe('injectWslFishHistoryEnv', () => {
+ // clearAllMocks keeps implementations: drop the throwing mkdir left by the degrade test.
+ beforeEach(() => {
+ mkdirSyncMock.mockReset()
+ })
+
+ it('replaces an inherited Orca fish_history with this worktree session', () => {
+ const env: Record = {
+ fish_history: fishHistorySessionName(hashWorktreeId('repo-1::/path/other-wt'))
+ }
+
+ const session = injectWslFishHistoryEnv(env, 'repo-1::/path/wt', 'Ubuntu')
+
+ expect(session).toBe(fishHistorySessionName(hashWorktreeId('repo-1::/path/wt')))
+ expect(env.fish_history).toBe(session)
+ })
+
+ it('preserves a caller-supplied fish_history', () => {
+ const env: Record = { fish_history: 'mine' }
+
+ expect(injectWslFishHistoryEnv(env, 'repo-1::/path/wt', 'Ubuntu')).toBeNull()
+ expect(env.fish_history).toBe('mine')
+ })
+ })
+
describe('updateHistoryEnvForFallback', () => {
const zshInjection = (): HistoryInjectionResult => ({
shell: 'zsh',
diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts
index c15f903c2de..559f7175ee1 100644
--- a/src/main/terminal-history.ts
+++ b/src/main/terminal-history.ts
@@ -1,6 +1,7 @@
import { join, basename } from 'node:path'
import { mkdirSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import {
+ dropInheritedOrcaFishHistory,
fishHistorySessionName,
isSafeFishHistorySession,
resolveFishHistoryDir
@@ -188,6 +189,10 @@ export function injectHistoryEnv(
// also override a caller-supplied HISTFILE on the early return below.
// Credit: caught by @innocarpe in #11146.
delete spawnEnv.ORCA_HISTFILE
+ // Why here too: fish EXPORTS `fish_history`, so the same nesting hands this
+ // process the LAUNCHING worktree's session name — and the check-before-set
+ // below would honour it, writing every pane's history into that worktree.
+ dropInheritedOrcaFishHistory(spawnEnv)
const shell = resolveShellKind(shellPath)
const result: HistoryInjectionResult = {
@@ -258,6 +263,11 @@ export function injectWslFishHistoryEnv(
worktreeId: string,
wslDistro: string
): string | null {
+ // Same precondition as `injectHistoryEnv`'s: the early return below may only honour
+ // a genuine user value. Redundant with today's two callers, which both run
+ // `injectHistoryEnv` on this same env first — kept so the contract holds per call,
+ // since nothing but ordering enforces it.
+ dropInheritedOrcaFishHistory(spawnEnv)
if (spawnEnv.fish_history) {
return null
}
diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts
index 2550b96c613..096531c6b80 100644
--- a/src/relay/pty-handler-spawn-environment.test.ts
+++ b/src/relay/pty-handler-spawn-environment.test.ts
@@ -7,6 +7,8 @@ import {
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
} from '../shared/setup-agent-sequencing'
import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir'
+import { fishHistorySessionName, relayFishHistorySessionName } from '../main/fish-history-session'
+import { hashWorktreeId } from '../main/terminal-history-id'
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -156,6 +158,48 @@ describe('PtyHandler', () => {
expect(spawnOptions.env.NODE_ENV).toBe('production')
})
+ describe('history isolation off', () => {
+ // Why isolation OFF: injectRelayFishHistoryEnv runs only for a fish pane with
+ // isolation on, but fish EXPORTS fish_history, so a relay launched from an Orca
+ // fish pane inherits one on EVERY path — and it names someone else's worktree
+ // (a desktop-minted name names a directory that does not exist here at all).
+ it.each([
+ [
+ 'a relay-minted session',
+ relayFishHistorySessionName(hashWorktreeId('r::/other')),
+ undefined
+ ],
+ ['a desktop-minted session', fishHistorySessionName(hashWorktreeId('r::/other')), undefined],
+ ['a user value', 'mine', 'mine']
+ ])('%s inherited from the relay process env', async (_kind, inherited, expected) => {
+ const previous = process.env.fish_history
+ process.env.fish_history = inherited
+ try {
+ await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
+ } finally {
+ if (previous === undefined) {
+ delete process.env.fish_history
+ } else {
+ process.env.fish_history = previous
+ }
+ }
+
+ const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record
+ expect(spawnEnv.fish_history).toBe(expected)
+ })
+
+ it('drops a desktop-minted session handed over in the client env', async () => {
+ await dispatcher.callRequest('pty.spawn', {
+ cols: 80,
+ rows: 24,
+ env: { fish_history: fishHistorySessionName(hashWorktreeId('r::/other')) }
+ })
+
+ const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record
+ expect(spawnEnv.fish_history).toBeUndefined()
+ })
+ })
+
it('guards SSH agent terminals after merging the relay inherited Git config', async () => {
const gitConfigKeys = [
'GIT_CONFIG_COUNT',
diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts
index 638e8b44649..a4d9770013b 100644
--- a/src/relay/pty-handler.ts
+++ b/src/relay/pty-handler.ts
@@ -48,6 +48,7 @@ import type { TuiAgent } from '../shared/tui-agent'
import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups'
import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env'
import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir'
+import { dropInheritedOrcaFishHistory } from '../main/fish-history-session'
import {
PTY_STARTUP_INGRESS_VERSION,
PtyStartupIngress,
@@ -627,6 +628,12 @@ export class PtyHandler {
const result = mergeGitConfigEnvProtocol(baseEnv, augmented) as Record
// Why: an older client may not ask a newly upgraded relay to delete inherited shim state.
stripLegacyTerminalShimEnv(result, process.platform)
+ // Why unconditionally here, not in injectRelayFishHistoryEnv: that runs only for a
+ // fish pane with isolation on, yet an Orca-minted `fish_history` (fish EXPORTS it,
+ // so the relay inherits one when launched from an Orca fish pane) must never scope
+ // any pane to someone else's worktree. Matches the desktop, which drops it on both
+ // branches (STA-4682).
+ dropInheritedOrcaFishHistory(result)
// Why: match local/daemon precedence so defaults/augmenters can't resurrect explicitly-removed values.
for (const key of envToDelete) {
delete result[key]
diff --git a/src/relay/terminal-history.test.ts b/src/relay/terminal-history.test.ts
index b6c59e332d3..ae99426b581 100644
--- a/src/relay/terminal-history.test.ts
+++ b/src/relay/terminal-history.test.ts
@@ -3,7 +3,12 @@ import { homedir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { hashWorktreeId } from '../main/terminal-history-id'
-import { deleteRelayHistory, injectRelayHistoryEnv } from './terminal-history'
+import { fishHistorySessionName, relayFishHistorySessionName } from '../main/fish-history-session'
+import {
+ deleteRelayHistory,
+ injectRelayFishHistoryEnv,
+ injectRelayHistoryEnv
+} from './terminal-history'
const worktreeId = 'relay-test::/remote/worktree'
const historyDir = join(homedir(), '.orca-remote', 'terminal-history')
@@ -40,6 +45,27 @@ describe('relay shell history', () => {
expect(existsSync(join(historyDir, `${historyPrefix}-bash_history`))).toBe(false)
})
+ it.each([
+ ['relay', relayFishHistorySessionName(hashWorktreeId('relay-test::/remote/other'))],
+ ['desktop', fishHistorySessionName(hashWorktreeId('relay-test::/remote/other'))]
+ ])('replaces an inherited %s fish_history', (_kind, inherited) => {
+ // fish EXPORTS fish_history, so a relay started from a fish pane would give
+ // every remote pane the launching worktree's session file.
+ const env: Record = { fish_history: inherited }
+
+ injectRelayFishHistoryEnv(env, worktreeId)
+
+ expect(env.fish_history).toBe(relayFishHistorySessionName(historyPrefix))
+ })
+
+ it('preserves a caller-supplied fish_history', () => {
+ const env: Record = { fish_history: 'mine' }
+
+ injectRelayFishHistoryEnv(env, worktreeId)
+
+ expect(env.fish_history).toBe('mine')
+ })
+
it.skipIf(process.platform === 'win32')('refuses a pre-existing final symlink', () => {
mkdirSync(historyDir, { recursive: true })
const target = join(historyDir, 'relay-unrelated-history')
diff --git a/src/relay/terminal-history.ts b/src/relay/terminal-history.ts
index 37335644df1..774f1e2959f 100644
--- a/src/relay/terminal-history.ts
+++ b/src/relay/terminal-history.ts
@@ -12,6 +12,7 @@ import { basename, join } from 'node:path'
import { hashWorktreeId } from '../main/terminal-history-id'
import {
deleteFishHistoryFile,
+ dropInheritedOrcaFishHistory,
relayFishHistorySessionName,
resolveFishHistoryDir
} from '../main/fish-history-session'
@@ -121,6 +122,11 @@ export function deleteRelayHistory(worktreeId: string): void {
* isolates it the same way the desktop app does and deletes by that name.
* No metadata file is needed: the name is a pure function of the worktree id. */
export function injectRelayFishHistoryEnv(env: Record, worktreeId: string): void {
+ // Own precondition, not the caller's: the check below may only honour a genuine
+ // user value, and fish EXPORTS `fish_history` so an Orca-minted name arrives from
+ // the relay's own env or the client's. `PtyHandler.buildSpawnEnv` already scrubs
+ // every spawn path, so this is belt-and-braces for any other caller.
+ dropInheritedOrcaFishHistory(env)
if (env.fish_history) {
return
}
From 604169f4af0dce42f05854ee9541d2b3fe87d361 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 17 Aug 2026 20:44:59 -0700
Subject: [PATCH 16/43] Filter automations list by agents (#15224)
* Add agent filter to automation list
Allows filtering automations by one or more agents with search support.
Status and last-run filters are reorganized into submenus. External
automation entries are excluded from agent filtering scope.
* Fix translation keys for agent filter in automation list
Move agent search text from AgentCombobox keys to component-specific
AutomationListFilterMenu keys. Adds translations across all locales.
---
.../automations/AutomationListFilterMenu.tsx | 190 +++++++++++++-----
.../automations/automation-list-view.test.ts | 42 +++-
.../automations/automation-list-view.ts | 21 +-
src/renderer/src/i18n/locales/en.json | 6 +-
src/renderer/src/i18n/locales/es.json | 13 ++
src/renderer/src/i18n/locales/ja.json | 13 ++
src/renderer/src/i18n/locales/ko.json | 13 ++
src/renderer/src/i18n/locales/zh.json | 13 ++
8 files changed, 253 insertions(+), 58 deletions(-)
diff --git a/src/renderer/src/components/automations/AutomationListFilterMenu.tsx b/src/renderer/src/components/automations/AutomationListFilterMenu.tsx
index 54efbc819a0..adebc4b285b 100644
--- a/src/renderer/src/components/automations/AutomationListFilterMenu.tsx
+++ b/src/renderer/src/components/automations/AutomationListFilterMenu.tsx
@@ -1,16 +1,22 @@
import React from 'react'
import { ListFilter, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
import {
DropdownMenu,
+ DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
+import { AgentIcon, getAgentCatalog } from '@/lib/agent-catalog'
+import { searchAgentPickerEntries } from '@/lib/agent-picker-search'
import { translate } from '@/i18n/i18n'
import {
countAutomationListFilters,
@@ -58,7 +64,7 @@ export function AutomationListFilterPills({
}): React.JSX.Element | null {
const statusLabel = translate('auto.components.automations.AutomationsPage.tableStatus', 'Status')
const lastRunLabel = translate(
- 'auto.components.automations.AutomationsPage.tableLastRun',
+ 'auto.components.automations.AutomationListFilterMenu.lastRun',
'Last run'
)
const statusValueLabel =
@@ -75,7 +81,12 @@ export function AutomationListFilterPills({
: filter.lastRun === 'never'
? translate('auto.components.automations.AutomationListFilterMenu.neverRan', 'Never ran')
: null
- if (!statusValueLabel && !lastRunValueLabel) {
+ const agentValueLabel = filter.agentIds.length
+ ? filter.agentIds
+ .map((agentId) => getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId)
+ .join(', ')
+ : null
+ if (!statusValueLabel && !lastRunValueLabel && !agentValueLabel) {
return null
}
return (
@@ -94,6 +105,13 @@ export function AutomationListFilterPills({
onClear={() => onChange({ ...filter, lastRun: 'all' })}
/>
) : null}
+ {agentValueLabel ? (
+ onChange({ ...filter, agentIds: [] })}
+ />
+ ) : null}
>
)
}
@@ -108,9 +126,24 @@ export function AutomationListFilterMenu({
const activeCount = countAutomationListFilters(filter)
const statusLabel = translate('auto.components.automations.AutomationsPage.tableStatus', 'Status')
const lastRunLabel = translate(
- 'auto.components.automations.AutomationsPage.tableLastRun',
+ 'auto.components.automations.AutomationListFilterMenu.lastRun',
'Last run'
)
+ const agentLabel = translate(
+ 'auto.components.automations.AutomationListFilterMenu.agent',
+ 'Agent'
+ )
+ const agents = getAgentCatalog()
+ const selectedAgentIds = filter.agentIds
+ const [agentQuery, setAgentQuery] = React.useState('')
+ const filteredAgents = searchAgentPickerEntries(agents, agentQuery)
+
+ const toggleAgent = (agentId: (typeof agents)[number]['id']): void => {
+ const nextAgentIds = selectedAgentIds.includes(agentId)
+ ? selectedAgentIds.filter((selectedId) => selectedId !== agentId)
+ : [...selectedAgentIds, agentId]
+ onChange({ ...filter, agentIds: nextAgentIds })
+ }
return (
@@ -131,50 +164,113 @@ export function AutomationListFilterMenu({
- {statusLabel}
-
- onChange({ ...filter, status: value as AutomationListStatusFilter })
- }
- >
-
- {translate('auto.components.automations.AutomationListFilterMenu.all', 'All')}
-
-
- {translate('auto.components.automations.AutomationDetail.eaa02014f8', 'Enabled')}
-
-
- {translate('auto.components.automations.AutomationDetail.b09b2384fd', 'Paused')}
-
-
+
+ {statusLabel}
+
+
+ onChange({ ...filter, status: value as AutomationListStatusFilter })
+ }
+ >
+
+ {translate('auto.components.automations.AutomationListFilterMenu.all', 'All')}
+
+
+ {translate('auto.components.automations.AutomationDetail.eaa02014f8', 'Enabled')}
+
+
+ {translate('auto.components.automations.AutomationDetail.b09b2384fd', 'Paused')}
+
+
+
+
- {lastRunLabel}
-
- onChange({ ...filter, lastRun: value as AutomationListLastRunFilter })
- }
- >
-
- {translate('auto.components.automations.AutomationListFilterMenu.all', 'All')}
-
-
- {translate('auto.components.automations.AutomationListFilterMenu.failed', 'Failed')}
-
-
- {translate(
- 'auto.components.automations.AutomationListFilterMenu.succeeded',
- 'Succeeded'
- )}
-
-
- {translate(
- 'auto.components.automations.AutomationListFilterMenu.neverRan',
- 'Never ran'
- )}
-
-
+
+ {lastRunLabel}
+
+
+ onChange({ ...filter, lastRun: value as AutomationListLastRunFilter })
+ }
+ >
+
+ {translate('auto.components.automations.AutomationListFilterMenu.all', 'All')}
+
+
+ {translate('auto.components.automations.AutomationListFilterMenu.failed', 'Failed')}
+
+
+ {translate(
+ 'auto.components.automations.AutomationListFilterMenu.succeeded',
+ 'Succeeded'
+ )}
+
+
+ {translate(
+ 'auto.components.automations.AutomationListFilterMenu.neverRan',
+ 'Never ran'
+ )}
+
+
+
+
+
+
+ {agentLabel}
+
+
+ setAgentQuery(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key !== 'Escape' && event.key !== 'Tab') {
+ event.stopPropagation()
+ }
+ }}
+ onPointerDown={(event) => event.stopPropagation()}
+ placeholder={translate(
+ 'auto.components.automations.AutomationListFilterMenu.926e785e4d',
+ 'Search agents...'
+ )}
+ aria-label={translate(
+ 'auto.components.automations.AutomationListFilterMenu.926e785e4d',
+ 'Search agents...'
+ )}
+ className="h-7 px-2 text-xs"
+ />
+
+ onChange({ ...filter, agentIds: [] })}
+ onSelect={(event) => event.preventDefault()}
+ >
+ {translate('auto.components.automations.AutomationListFilterMenu.all', 'All')}
+
+ {filteredAgents.map((agent) => (
+ toggleAgent(agent.id)}
+ onSelect={(event) => event.preventDefault()}
+ >
+
+
+
+ {agent.label}
+
+ ))}
+ {filteredAgents.length === 0 ? (
+
+ {translate(
+ 'auto.components.automations.AutomationListFilterMenu.491043ee45',
+ 'No agents match your search.'
+ )}
+
+ ) : null}
+
+
{activeCount > 0 ? (
<>
diff --git a/src/renderer/src/components/automations/automation-list-view.test.ts b/src/renderer/src/components/automations/automation-list-view.test.ts
index 01a0cfa7799..ef46bc6e428 100644
--- a/src/renderer/src/components/automations/automation-list-view.test.ts
+++ b/src/renderer/src/components/automations/automation-list-view.test.ts
@@ -105,9 +105,15 @@ function makeExternalEntry(
describe('automation-list-view', () => {
it('counts and detects active filters', () => {
- expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all' })).toBe(false)
- expect(isAutomationListFilterActive({ status: 'paused', lastRun: 'all' })).toBe(true)
- expect(countAutomationListFilters({ status: 'paused', lastRun: 'failed' })).toBe(2)
+ expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all', agentIds: [] })).toBe(
+ false
+ )
+ expect(isAutomationListFilterActive({ status: 'paused', lastRun: 'all', agentIds: [] })).toBe(
+ true
+ )
+ expect(countAutomationListFilters({ status: 'paused', lastRun: 'failed', agentIds: [] })).toBe(
+ 2
+ )
})
it('toggles sort direction and defaults last run to newest first', () => {
@@ -134,18 +140,42 @@ describe('automation-list-view', () => {
makeRun({ automationId: 'paused', status: 'completed' }),
makeRun({ automationId: 'ok', status: 'dispatch_failed' })
],
- filter: { status: 'enabled', lastRun: 'failed' },
+ filter: { status: 'enabled', lastRun: 'failed', agentIds: [] },
sort: null
})
expect(items.map((item) => item.id)).toEqual(['ok', 'manager-1:job-1'])
})
+ it('filters local rows by multiple agents and leaves external rows out of agent scopes', () => {
+ const items = applyAutomationListView({
+ automations: [
+ makeAutomation({ id: 'codex-job', agentId: 'codex' }),
+ makeAutomation({ id: 'claude-job', agentId: 'claude' })
+ ],
+ externalEntries: [makeExternalEntry()],
+ runs: [],
+ filter: { status: 'all', lastRun: 'all', agentIds: ['codex', 'claude'] },
+ sort: null
+ })
+
+ expect(items.map((item) => item.id)).toEqual(['codex-job', 'claude-job'])
+ })
+
+ it('counts an agent filter alongside status and last-run filters', () => {
+ expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all', agentIds: [] })).toBe(
+ false
+ )
+ expect(
+ countAutomationListFilters({ status: 'paused', lastRun: 'failed', agentIds: ['codex'] })
+ ).toBe(3)
+ })
+
it('sorts by name across local and external rows', () => {
const items = applyAutomationListView({
automations: [makeAutomation({ name: 'Zebra job' })],
externalEntries: [makeExternalEntry({ name: 'Alpha digest' })],
runs: [],
- filter: { status: 'all', lastRun: 'all' },
+ filter: { status: 'all', lastRun: 'all', agentIds: [] },
sort: { field: 'name', direction: 'asc' }
})
expect(items.map((item) => item.name)).toEqual(['Alpha digest', 'Zebra job'])
@@ -159,7 +189,7 @@ describe('automation-list-view', () => {
],
externalEntries: [makeExternalEntry({ lastRunAt: '2026-08-12T09:00:00Z' })],
runs: [makeRun({ automationId: 'old', dispatchedAt: Date.parse('2026-08-11T09:00:00Z') })],
- filter: { status: 'all', lastRun: 'all' },
+ filter: { status: 'all', lastRun: 'all', agentIds: [] },
sort: { field: 'lastRun', direction: 'desc' }
})
expect(items.map((item) => item.id)).toEqual(['manager-1:job-1', 'old', 'never'])
diff --git a/src/renderer/src/components/automations/automation-list-view.ts b/src/renderer/src/components/automations/automation-list-view.ts
index 49d44aadb3d..ae22a4bc74d 100644
--- a/src/renderer/src/components/automations/automation-list-view.ts
+++ b/src/renderer/src/components/automations/automation-list-view.ts
@@ -1,5 +1,6 @@
import { getIntlLocale } from '@/i18n/i18n'
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
+import type { TuiAgent } from '../../../../shared/tui-agent'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import {
getExternalAutomationLastRunSnapshot,
@@ -26,6 +27,7 @@ export type AutomationListViewItem =
enabled: boolean
lastRunAt: number | null
lastRun: AutomationLastRunSnapshot
+ agentId: TuiAgent
automation: Automation
}
| {
@@ -35,25 +37,32 @@ export type AutomationListViewItem =
enabled: boolean
lastRunAt: number | null
lastRun: AutomationLastRunSnapshot
+ agentId: null
entry: ExternalAutomationListEntry
}
export type AutomationListFilter = {
status: AutomationListStatusFilter
lastRun: AutomationListLastRunFilter
+ agentIds: readonly TuiAgent[]
}
export const EMPTY_AUTOMATION_LIST_FILTER: AutomationListFilter = {
status: 'all',
- lastRun: 'all'
+ lastRun: 'all',
+ agentIds: []
}
export function isAutomationListFilterActive(filter: AutomationListFilter): boolean {
- return filter.status !== 'all' || filter.lastRun !== 'all'
+ return filter.status !== 'all' || filter.lastRun !== 'all' || filter.agentIds.length > 0
}
export function countAutomationListFilters(filter: AutomationListFilter): number {
- return (filter.status !== 'all' ? 1 : 0) + (filter.lastRun !== 'all' ? 1 : 0)
+ return (
+ (filter.status !== 'all' ? 1 : 0) +
+ (filter.lastRun !== 'all' ? 1 : 0) +
+ (filter.agentIds.length > 0 ? 1 : 0)
+ )
}
export function defaultAutomationListSortDirection(
@@ -114,6 +123,7 @@ export function buildAutomationListViewItems({
enabled: automation.enabled,
lastRunAt: lastRun.at,
lastRun,
+ agentId: automation.agentId,
automation
}
})
@@ -126,6 +136,7 @@ export function buildAutomationListViewItems({
enabled: entry.job.enabled,
lastRunAt: lastRun.at,
lastRun,
+ agentId: null,
entry
}
})
@@ -142,7 +153,9 @@ export function filterAutomationListViewItems(
return items.filter(
(item) =>
matchesStatusFilter(item.enabled, filter.status) &&
- matchesLastRunFilter(item.lastRun, filter.lastRun)
+ matchesLastRunFilter(item.lastRun, filter.lastRun) &&
+ (filter.agentIds.length === 0 ||
+ (item.agentId !== null && filter.agentIds.includes(item.agentId)))
)
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 52259092519..b0ce43ae1fc 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -15324,13 +15324,17 @@
"c92c9463c6": "Automation actions"
},
"AutomationListFilterMenu": {
+ "926e785e4d": "Search agents...",
+ "491043ee45": "No agents match your search.",
"removeFilter": "Remove {{value0}} filter",
"failed": "Failed",
"succeeded": "Succeeded",
"neverRan": "Never ran",
"filters": "Filters",
"all": "All",
- "clear": "Clear filters"
+ "clear": "Clear filters",
+ "agent": "Agent",
+ "lastRun": "Last run"
},
"AutomationListSortHeader": {
"sortedAscending": "{{value0}}, sorted ascending",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 8350b5e79ce..3195249936a 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -13923,6 +13923,19 @@
},
"AutomationListLocalRows": {
"c92c9463c6": "Acciones de automatización"
+ },
+ "AutomationListFilterMenu": {
+ "926e785e4d": "Agentes de búsqueda...",
+ "491043ee45": "Ningún agente coincide con su búsqueda.",
+ "agent": "Agente",
+ "lastRun": "Última ejecución",
+ "all": "Todas",
+ "failed": "Fallida",
+ "succeeded": "Correcta",
+ "neverRan": "Nunca se ejecutó",
+ "filters": "Filtros",
+ "clear": "Limpiar filtros",
+ "removeFilter": "Eliminar filtro de {{value0}}"
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index bc4c1b8ceea..8c34d04a7c0 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -13923,6 +13923,19 @@
},
"AutomationListLocalRows": {
"c92c9463c6": "自動化の操作"
+ },
+ "AutomationListFilterMenu": {
+ "926e785e4d": "Agent を検索…",
+ "491043ee45": "検索に一致する Agent はありません。",
+ "agent": "Agent",
+ "lastRun": "前回の実行",
+ "all": "すべて",
+ "failed": "失敗",
+ "succeeded": "成功",
+ "neverRan": "未実行",
+ "filters": "フィルター",
+ "clear": "フィルターをクリア",
+ "removeFilter": "{{value0}} フィルターを解除"
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index e677355b29d..3cf47a8e4ae 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -13952,6 +13952,19 @@
},
"AutomationListLocalRows": {
"c92c9463c6": "자동화 작업"
+ },
+ "AutomationListFilterMenu": {
+ "926e785e4d": "agents 검색...",
+ "491043ee45": "검색어와 일치하는 agents가 없습니다.",
+ "agent": "에이전트",
+ "lastRun": "마지막 실행",
+ "all": "전체",
+ "failed": "실패",
+ "succeeded": "성공",
+ "neverRan": "실행 안 함",
+ "filters": "필터",
+ "clear": "필터 지우기",
+ "removeFilter": "{{value0}} 필터 제거"
}
},
"agent": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 18ed77a8752..36b8ae3bca9 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -13943,6 +13943,19 @@
},
"AutomationListLocalRows": {
"c92c9463c6": "自动化操作"
+ },
+ "AutomationListFilterMenu": {
+ "926e785e4d": "搜索智能体...",
+ "491043ee45": "没有智能体符合您的搜索。",
+ "agent": "智能体",
+ "lastRun": "上次运行",
+ "all": "全部",
+ "failed": "失败",
+ "succeeded": "成功",
+ "neverRan": "从未运行",
+ "filters": "筛选器",
+ "clear": "清除筛选器",
+ "removeFilter": "移除 {{value0}} 筛选器"
}
},
"agent": {
From 63dbf12d147b942ff9e6984404dcdd92563e24c0 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 17 Aug 2026 21:18:44 -0700
Subject: [PATCH 17/43] Split github client (#15214)
* refactor(github-client): reorganize client into lifecycle folders
* refactor(github-client): extract PR refresh data and outcome assembly
Separate the derived data calculation and outcome assembly logic from
branch-lookup-resolution into dedicated modules for better separation of
concerns. Modernize type import syntax and format exports consistently.
* refactor(github-client): improve error handling and resilience
Defensive GraphQL parsing prevents partial responses from breaking REST fallbacks.
Cache failures now use shorter TTLs for faster recovery. PR operations have
dedicated error classification. GraphQL mutations track rate limit usage to prevent
quota exhaustion. Data validation improved to reject spurious values.
* Extract check rerun error classification with operation context
Create classifyRerunChecksError() to provide operation-specific error
messages when check reruns fail. This replaces generic GitHub error
copy with context appropriate to what the user attempted (rerun
checks). Follows the pattern of classifyListPrsError and improves
error handling by delegating extraction to extractExecError.
* Make check-rerun not-found error message resource-neutral
Error handling for failed check reruns now covers both workflow-run
reruns and standalone check-run rerequests. Tests verify the neutral
message works for both scenarios.
---
config/max-lines-baseline.txt | 1 -
src/main/github/client-pr-checks.test.ts | 68 +
src/main/github/client.ts | 5629 +----------------
.../check/check-detail-field-mapping.ts | 83 +
.../client/check/check-details-abort.ts | 26 +
.../client/check/check-job-log-tails.ts | 85 +
.../client/check/get-pr-check-details.ts | 128 +
src/main/github/client/check/get-pr-checks.ts | 233 +
.../client/check/pr-checks-graphql-query.ts | 133 +
.../check/pr-checks-response-mapping.ts | 186 +
.../github/client/check/rerun-pr-checks.ts | 105 +
.../client/create/add-pr-review-comment.ts | 121 +
.../create/create-github-pull-request.ts | 162 +
.../create/create-pr-error-classification.ts | 74 +
.../client/create/pull-request-template.ts | 83 +
.../hydrate-work-item-merge-metadata.ts | 38 +
.../detect/repository-merge-metadata-cache.ts | 56 +
.../detect/repository-merge-metadata.ts | 121 +
.../client/fetch/authenticated-viewer.ts | 28 +
.../github/client/fetch/get-pr-comments.ts | 279 +
src/main/github/client/fetch/get-work-item.ts | 144 +
src/main/github/client/fetch/orca-star.ts | 49 +
.../client/fetch/pr-review-threads-query.ts | 70 +
.../github/client/fetch/repo-slug-upstream.ts | 84 +
.../github/client/fetch/work-item-fetch.ts | 199 +
src/main/github/client/gh-error-predicates.ts | 43 +
src/main/github/client/github-exec-scope.ts | 35 +
.../github/client/list/count-work-items.ts | 154 +
.../github/client/list/list-work-items.ts | 85 +
.../client/list/work-item-list-request.ts | 146 +
.../github/client/list/work-item-pages.ts | 241 +
.../client/list/work-item-search-query.ts | 66 +
.../lookup/branch-lookup-derived-data.ts | 76 +
.../client/lookup/branch-lookup-resolution.ts | 294 +
.../github/client/lookup/get-pr-for-branch.ts | 32 +
.../github/client/lookup/pr-branch-lookup.ts | 163 +
.../client/lookup/pr-for-branch-outcome.ts | 44 +
.../client/lookup/pr-lookup-rate-limit.ts | 70 +
.../github/client/lookup/pr-number-lookup.ts | 152 +
.../lookup/pr-refresh-outcome-assembly.ts | 70 +
.../client/lookup/pr-stack-summary-cache.ts | 92 +
.../client/lookup/pull-request-lookup-data.ts | 180 +
.../lookup/pull-request-lookup-hydration.ts | 33 +
.../client/lookup/pull-request-push-target.ts | 154 +
.../lookup/rest-stack-metadata-validation.ts | 32 +
.../client/lookup/tracked-upstream-branch.ts | 191 +
.../client/lookup/tracked-upstream-cache.ts | 146 +
.../client/map/review-comment-response.ts | 33 +
.../client/map/work-item-field-coercion.ts | 213 +
src/main/github/client/map/work-item.ts | 138 +
src/main/github/client/merge/merge-pr.ts | 172 +
src/main/github/client/merge/pr-auto-merge.ts | 192 +
.../client/pull-request-lookup-candidates.ts | 21 +
.../client/update/pr-comment-reaction.ts | 60 +
src/main/github/client/update/pr-details.ts | 100 +
.../github/client/update/pr-file-viewed.ts | 62 +
src/main/github/client/update/pr-reviewers.ts | 83 +
src/main/github/client/update/pr-state.ts | 46 +
.../client/update/resolve-review-thread.ts | 47 +
src/main/github/gh-error-classification.ts | 36 +
src/main/github/gh-utils.ts | 3 +-
...updater.linux-root-package-install.test.ts | 25 +-
62 files changed, 6314 insertions(+), 5601 deletions(-)
create mode 100644 src/main/github/client/check/check-detail-field-mapping.ts
create mode 100644 src/main/github/client/check/check-details-abort.ts
create mode 100644 src/main/github/client/check/check-job-log-tails.ts
create mode 100644 src/main/github/client/check/get-pr-check-details.ts
create mode 100644 src/main/github/client/check/get-pr-checks.ts
create mode 100644 src/main/github/client/check/pr-checks-graphql-query.ts
create mode 100644 src/main/github/client/check/pr-checks-response-mapping.ts
create mode 100644 src/main/github/client/check/rerun-pr-checks.ts
create mode 100644 src/main/github/client/create/add-pr-review-comment.ts
create mode 100644 src/main/github/client/create/create-github-pull-request.ts
create mode 100644 src/main/github/client/create/create-pr-error-classification.ts
create mode 100644 src/main/github/client/create/pull-request-template.ts
create mode 100644 src/main/github/client/detect/hydrate-work-item-merge-metadata.ts
create mode 100644 src/main/github/client/detect/repository-merge-metadata-cache.ts
create mode 100644 src/main/github/client/detect/repository-merge-metadata.ts
create mode 100644 src/main/github/client/fetch/authenticated-viewer.ts
create mode 100644 src/main/github/client/fetch/get-pr-comments.ts
create mode 100644 src/main/github/client/fetch/get-work-item.ts
create mode 100644 src/main/github/client/fetch/orca-star.ts
create mode 100644 src/main/github/client/fetch/pr-review-threads-query.ts
create mode 100644 src/main/github/client/fetch/repo-slug-upstream.ts
create mode 100644 src/main/github/client/fetch/work-item-fetch.ts
create mode 100644 src/main/github/client/gh-error-predicates.ts
create mode 100644 src/main/github/client/github-exec-scope.ts
create mode 100644 src/main/github/client/list/count-work-items.ts
create mode 100644 src/main/github/client/list/list-work-items.ts
create mode 100644 src/main/github/client/list/work-item-list-request.ts
create mode 100644 src/main/github/client/list/work-item-pages.ts
create mode 100644 src/main/github/client/list/work-item-search-query.ts
create mode 100644 src/main/github/client/lookup/branch-lookup-derived-data.ts
create mode 100644 src/main/github/client/lookup/branch-lookup-resolution.ts
create mode 100644 src/main/github/client/lookup/get-pr-for-branch.ts
create mode 100644 src/main/github/client/lookup/pr-branch-lookup.ts
create mode 100644 src/main/github/client/lookup/pr-for-branch-outcome.ts
create mode 100644 src/main/github/client/lookup/pr-lookup-rate-limit.ts
create mode 100644 src/main/github/client/lookup/pr-number-lookup.ts
create mode 100644 src/main/github/client/lookup/pr-refresh-outcome-assembly.ts
create mode 100644 src/main/github/client/lookup/pr-stack-summary-cache.ts
create mode 100644 src/main/github/client/lookup/pull-request-lookup-data.ts
create mode 100644 src/main/github/client/lookup/pull-request-lookup-hydration.ts
create mode 100644 src/main/github/client/lookup/pull-request-push-target.ts
create mode 100644 src/main/github/client/lookup/rest-stack-metadata-validation.ts
create mode 100644 src/main/github/client/lookup/tracked-upstream-branch.ts
create mode 100644 src/main/github/client/lookup/tracked-upstream-cache.ts
create mode 100644 src/main/github/client/map/review-comment-response.ts
create mode 100644 src/main/github/client/map/work-item-field-coercion.ts
create mode 100644 src/main/github/client/map/work-item.ts
create mode 100644 src/main/github/client/merge/merge-pr.ts
create mode 100644 src/main/github/client/merge/pr-auto-merge.ts
create mode 100644 src/main/github/client/pull-request-lookup-candidates.ts
create mode 100644 src/main/github/client/update/pr-comment-reaction.ts
create mode 100644 src/main/github/client/update/pr-details.ts
create mode 100644 src/main/github/client/update/pr-file-viewed.ts
create mode 100644 src/main/github/client/update/pr-reviewers.ts
create mode 100644 src/main/github/client/update/pr-state.ts
create mode 100644 src/main/github/client/update/resolve-review-thread.ts
diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt
index 69ad8c72285..85ea75132d2 100644
--- a/config/max-lines-baseline.txt
+++ b/config/max-lines-baseline.txt
@@ -29,7 +29,6 @@ inline src/main/git/repo.ts
inline src/main/git/runner.ts
inline src/main/git/status.ts
inline src/main/git/worktree.ts
-inline src/main/github/client.ts
inline src/main/github/pr-refresh-coordinator.ts
inline src/main/github/project-view.ts
inline src/main/github/project-view/mutations.ts
diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts
index 72ca0f77cff..43be8ba642e 100644
--- a/src/main/github/client-pr-checks.test.ts
+++ b/src/main/github/client-pr-checks.test.ts
@@ -568,6 +568,74 @@ describe('getPRChecks', () => {
}
)
})
+ it('reports a resource-neutral not-found error when a workflow-run rerun 404s', async () => {
+ getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(
+ graphQLChecksResponse({
+ contexts: [
+ graphQLCheckRun({
+ name: 'lint',
+ conclusion: 'FAILURE',
+ detailsUrl: 'https://github.com/acme/widgets/actions/runs/77/job/88',
+ workflowRunId: 77
+ })
+ ]
+ })
+ )
+ .mockRejectedValueOnce(
+ Object.assign(new Error('Command failed: gh api'), {
+ stderr: 'gh: HTTP 404 Not Found (repos/acme/widgets/actions/runs/77/rerun-failed-jobs)',
+ stdout: ''
+ })
+ )
+
+ const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true })
+
+ expect(result).toEqual({
+ ok: false,
+ error: 'GitHub resource to rerun was not found — it may have expired or been deleted.'
+ })
+ })
+
+ it('reports a resource-neutral not-found error when a standalone check-run rerequest 404s', async () => {
+ getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
+ ghExecFileAsyncMock
+ .mockResolvedValueOnce(
+ graphQLChecksResponse({
+ contexts: [
+ {
+ __typename: 'CheckRun',
+ databaseId: 88,
+ name: 'external-ci',
+ status: 'COMPLETED',
+ conclusion: 'FAILURE',
+ detailsUrl: 'https://ci.example.com/builds/88',
+ url: 'https://ci.example.com/builds/88',
+ checkSuite: { databaseId: 1000, workflowRun: null }
+ }
+ ]
+ })
+ )
+ .mockRejectedValueOnce(
+ Object.assign(new Error('Command failed: gh api'), {
+ stderr: 'gh: HTTP 404 Not Found (repos/acme/widgets/check-runs/88/rerequest)',
+ stdout: ''
+ })
+ )
+
+ const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true })
+
+ expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
+ 2,
+ ['api', '-X', 'POST', 'repos/acme/widgets/check-runs/88/rerequest'],
+ expect.objectContaining({ cwd: '/repo-root' })
+ )
+ expect(result).toEqual({
+ ok: false,
+ error: 'GitHub resource to rerun was not found — it may have expired or been deleted.'
+ })
+ })
it('routes local WSL check retrieval and reruns through the selected distro', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
diff --git a/src/main/github/client.ts b/src/main/github/client.ts
index b9e8d94226f..7bf17c42664 100644
--- a/src/main/github/client.ts
+++ b/src/main/github/client.ts
@@ -1,5597 +1,44 @@
-/* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps acquire/release and error handling consistent. */
-import type { ClassifiedError } from '../../shared/classified-error'
-import type {
- GitHubRerunPRChecksResult,
- PRCheckDetail,
- PRCheckRunDetails
-} from '../../shared/github/check-types'
-import type {
- GitHubCommentResult,
- GitHubPRReviewCommentInput,
- GitHubReactionContent,
- PRComment
-} from '../../shared/github/comment-types'
-import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types'
-import type {
- GitHubPRMergeMethod,
- GitHubPRMergeMethodSettings,
- GitHubPRStack,
- GitHubViewer,
- PRConflictSummary,
- PRInfo,
- PRMergeableState,
- PRReviewDecision
-} from '../../shared/github/pull-request-types'
-import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types'
-import type { GitHubPullRequestStateUpdate } from '../../shared/issue-mutation-types'
-import type { IssueSourcePreference } from '../../shared/repo-types'
-import type { GitPushTarget } from '../../shared/worktree/types'
-import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
-import {
- normalizeHostedReviewBaseRef,
- normalizeHostedReviewHeadRef
-} from '../../shared/hosted-review-refs'
-import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github/pull-request-merge-methods'
-import { summarizeProviderChecks } from '../../shared/provider-check-summary'
-import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github/work-items-query-bounds'
-import { classifyGitHubUnavailable } from '../../shared/github/api-availability'
-import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query'
-import {
- GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE,
- sortWorkItemsByNumber
-} from '../../shared/work-items'
-import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
-import { join } from 'node:path'
-import { tmpdir } from 'node:os'
-import { sliceCheckLogTail } from '../../shared/check-job-log-tail-slice'
-import {
- classifyPRRefreshError,
- safePRRefreshErrorMessage
-} from './pr-refresh-error-classification'
-import { getPRConflictSummary } from './conflict-summary'
-import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
-import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
-import { splitRemoteBranchName } from '../../shared/git-effective-upstream'
-import {
- execFileAsync,
- ghExecFileAsync,
- gitExecFileAsync,
- acquire,
- release,
- classifyGhError,
- classifyListIssuesError,
- classifyListPrsError,
- ghRepoExecOptions,
- githubRepoContext,
- getRemoteUrlForRepo,
- type LocalGitExecOptions,
- type OwnerRepo
-} from './gh-utils'
-// Why: import from the lightweight module (not ./gh-utils) so tests mocking gh-utils still get the real functions.
-import { extractExecError, parseRetryAfterMs } from '../git/exec-error'
-import {
- isCommitPartOfMergedPR,
- type MergedPRCommitMembership
-} from './merged-pr-commit-membership'
-import {
- getSshGitProvider,
- SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
-} from '../providers/ssh-git-dispatch'
-import {
- hasHostedReviewLocalGitOptions,
- getHostedReviewLocalGitOptions,
- type HostedReviewExecutionOptions
-} from '../source-control/hosted-review-git-options'
-import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch'
-import { readLocalGitConfigSignature } from './local-git-config-signature'
-import {
- getGitHubApiRepositoryForRemote,
- getOriginGitHubApiRepository,
- githubHostExecOptions,
- githubRepositorySlugArg,
- githubRepositoryWebHost,
- resolveGitHubApiRepository,
- resolveGitHubApiRepositoryCandidates,
- resolveGitHubRepoExecution,
- resolveIssueGitHubApiRepositorySource,
- type GitHubRepoExecOptions,
- type GitHubApiRepository
-} from './github-api-repository'
-import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key'
+export {
+ _getMergeQueueCacheSizeForTests,
+ _resetMergeQueueCacheForTests
+} from './client/detect/repository-merge-metadata-cache'
+export { _resetPRStackSummaryCacheForTests } from './client/lookup/pr-stack-summary-cache'
+export {
+ _getTrackedUpstreamBranchCacheSizesForTests,
+ __resetTrackedUpstreamBranchCacheForTests
+} from './client/lookup/tracked-upstream-cache'
+export { addPRReviewComment, addPRReviewCommentReply } from './client/create/add-pr-review-comment'
+export { checkOrcaStarred, starOrca } from './client/fetch/orca-star'
+export { countWorkItems } from './client/list/count-work-items'
+export { createGitHubPullRequest } from './client/create/create-github-pull-request'
+export { getAuthenticatedViewer } from './client/fetch/authenticated-viewer'
+export { getGitHubPRLookupRateLimitBlock } from './client/lookup/pr-lookup-rate-limit'
+export { getPRCheckDetails } from './client/check/get-pr-check-details'
+export { getPRChecks } from './client/check/get-pr-checks'
+export { getPRComments } from './client/fetch/get-pr-comments'
+export { getPRForBranch } from './client/lookup/get-pr-for-branch'
+export { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome'
+export {
+ getPullRequestPushTarget,
+ type PullRequestPushTarget
+} from './client/lookup/pull-request-push-target'
+export { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream'
+export { getWorkItem, getWorkItemByOwnerRepo } from './client/fetch/get-work-item'
+export { listWorkItems } from './client/list/list-work-items'
+export { mergePR } from './client/merge/merge-pr'
+export { removePRReviewers, requestPRReviewers } from './client/update/pr-reviewers'
+export { rerunPRChecks } from './client/check/rerun-pr-checks'
+export { resolveReviewThread } from './client/update/resolve-review-thread'
+export { setPRAutoMerge } from './client/merge/pr-auto-merge'
+export { setPRCommentReaction } from './client/update/pr-comment-reaction'
+export { setPRFileViewed } from './client/update/pr-file-viewed'
+export { updatePRDetails, updatePRTitle } from './client/update/pr-details'
+export { updatePRState } from './client/update/pr-state'
+export type { GitHubPRBranchLookupOptions } from './client/lookup/pull-request-lookup-data'
+export type { MainWorkItem } from './client/map/work-item-field-coercion'
export { _resetOwnerRepoCache } from './gh-utils'
export { getIssue, listIssues } from './issues'
export { createIssue } from './issue-create'
export { updateIssue } from './issue-update'
export { addIssueComment } from './issue-comment'
export { listLabels, listAssignableUsers } from './issue-field-options'
-import {
- mapCheckRunRESTStatus,
- mapCheckRunRESTConclusion,
- mapCommitStatusRESTStatus,
- mapCommitStatusRESTConclusion,
- mapCheckStatus,
- mapCheckConclusion,
- mapPRState,
- deriveCheckStatus
-} from './mappers'
-import {
- mapGraphQLReactionGroups,
- toGraphQLReactionContent,
- type GitHubGraphQLReactionGroup
-} from './comment-reactions'
-import {
- getRateLimit,
- noteRepositoryRateLimitSpend,
- repositoryRateLimitGuard,
- spendsSharedGitHubComQuota,
- type RateLimitBucketKind
-} from './rate-limit'
-import {
- GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS,
- GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE
-} from '../../shared/github/check-details-deadline'
-import { hydrateGitHubPRStack, mergeGitHubPRStack } from './github-pr-stack'
-
-type GhExecOptions = GitHubRepoExecOptions & { signal?: AbortSignal }
-type HostedReviewLocalGitOptions = ReturnType
-
-const ORCA_REPO = 'stablyai/orca'
-const PR_CHECK_LOG_TAIL_JOB_LIMIT = 5
-// Why: each entry holds up to 16KB of log text; bound the cache so a long session can't grow it unbounded.
-const PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128
-const prCheckLogTailCache = new Map()
-
-function hostedReviewLocalGitOptionArgs(
- options: HostedReviewExecutionOptions = {}
-): [] | [HostedReviewLocalGitOptions] {
- return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : []
-}
-
-function setPrCheckLogTailCache(cacheKey: string, logTail: string | null): void {
- prCheckLogTailCache.set(cacheKey, logTail)
- while (prCheckLogTailCache.size > PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES) {
- const oldestKey = prCheckLogTailCache.keys().next().value
- if (oldestKey === undefined) {
- break
- }
- prCheckLogTailCache.delete(oldestKey)
- }
-}
-
-function rethrowCheckDetailsAbort(signal: AbortSignal | undefined, error: unknown): void {
- if (signal?.aborted) {
- throw error
- }
-}
-
-function waitForCheckDetailsResolution(operation: Promise, signal: AbortSignal): Promise {
- if (signal.aborted) {
- return Promise.reject(signal.reason)
- }
- return new Promise((resolve, reject) => {
- const finish = (settle: () => void): void => {
- signal.removeEventListener('abort', onAbort)
- settle()
- }
- const onAbort = (): void => finish(() => reject(signal.reason))
- signal.addEventListener('abort', onAbort, { once: true })
- void operation.then(
- (value) => finish(() => resolve(value)),
- (error) => finish(() => reject(error))
- )
- })
-}
-const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000
-const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000
-const MERGE_QUEUE_CACHE_MAX_ENTRIES = 256
-type GitHubRepositoryMergeMetadata = {
- mergeQueueRequired: boolean | null
- autoMergeAllowed: boolean | null
- mergeMethodSettings?: GitHubPRMergeMethodSettings
-}
-const repositoryMergeMetadataCache = new Map<
- string,
- { value: GitHubRepositoryMergeMetadata; expiresAt: number }
->()
-const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000
-const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512
-const STACK_METADATA_UNAVAILABLE_ERROR =
- 'Could not verify GitHub pull request stack metadata. Refresh and try again.'
-const prStackSummaryCache = new Map<
- string,
- { value: GitHubPRStack | undefined; expiresAt: number }
->()
-const prStackSummaryInFlight = new Map>()
-
-function githubPRStackExecutionScope(
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): string {
- return connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}`
-}
-
-export function _resetMergeQueueCacheForTests(): void {
- repositoryMergeMetadataCache.clear()
-}
-
-export function _resetPRStackSummaryCacheForTests(): void {
- prStackSummaryCache.clear()
- prStackSummaryInFlight.clear()
-}
-
-export function _getMergeQueueCacheSizeForTests(): number {
- return repositoryMergeMetadataCache.size
-}
-
-function pruneRepositoryMergeMetadataCache(now = Date.now()): void {
- for (const [cacheKey, cached] of repositoryMergeMetadataCache) {
- if (cached.expiresAt <= now) {
- repositoryMergeMetadataCache.delete(cacheKey)
- }
- }
- while (repositoryMergeMetadataCache.size > MERGE_QUEUE_CACHE_MAX_ENTRIES) {
- const oldestKey = repositoryMergeMetadataCache.keys().next().value
- if (oldestKey === undefined) {
- break
- }
- repositoryMergeMetadataCache.delete(oldestKey)
- }
-}
-
-async function assertRateLimitBudget(
- bucket: RateLimitBucketKind,
- repository?: GitHubApiRepository | null,
- executionOptions?: Pick
-): Promise {
- if (spendsSharedGitHubComQuota(repository, executionOptions)) {
- await getRateLimit()
- }
- const guard = repositoryRateLimitGuard(repository, bucket, executionOptions)
- if (guard.blocked) {
- throw new Error(
- `GitHub ${bucket} rate limit is low; retry after ${new Date(guard.resetAt * 1000).toLocaleTimeString()}`
- )
- }
-}
-
-// Why: a branch lookup prefers REST but can fall back to `gh pr list` and
-// `gh pr view`, so both buckets are guarded and charged. Mirrors the PR refresh
-// coordinator's own estimate.
-const PR_BRANCH_LOOKUP_BUCKETS = ['core', 'graphql'] as const
-
-/**
- * Rate-limit floor for GitHub PR lookups that do not run through the PR refresh
- * coordinator's queue (#11532).
- *
- * The coordinator guards and paces its own background refreshes, but
- * `hostedReview:forBranch` polls the same lookup straight from the renderer.
- * Ungated, the two paths together could spend the user's entire hourly quota —
- * which is per user and shared with their own `gh` and CLI agents.
- * Returns the reset time when the caller must not spend, else `null`.
- */
-export async function getGitHubPRLookupRateLimitBlock(
- repoPath: string,
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise<{ resetAt: number } | null> {
- const executionOptions = ghRepoExecOptions(
- githubRepoContext(repoPath, connectionId, localGitOptions)
- )
- // Why: identity resolution runs local git, which can fail for reasons that
- // have nothing to do with the budget; let the lookup itself classify those.
- const repository = await getOriginGitHubApiRepository(
- repoPath,
- connectionId,
- executionOptions
- ).catch(() => null)
- if (repository === null) {
- return null
- }
- if (spendsSharedGitHubComQuota(repository, executionOptions)) {
- // Why: the probe only warms the snapshot and is exempt from limits, so a
- // failure must fail open rather than block the lookup (#7553).
- await getRateLimit().catch(() => undefined)
- }
- // Why: retrying at the earlier reset would fail again on the bucket that has
- // not reset yet, so the latest blocked reset is the only honest retry time.
- const resets = PR_BRANCH_LOOKUP_BUCKETS.map((bucket) =>
- repositoryRateLimitGuard(repository, bucket, executionOptions)
- ).flatMap((guard) => (guard.blocked ? [guard.resetAt] : []))
- return resets.length > 0 ? { resetAt: Math.max(...resets) } : null
-}
-
-function prRefreshUpstreamError(
- err: unknown
-): Extract {
- const errorType = classifyPRRefreshError(err)
- const outcome: Extract = {
- kind: 'upstream-error',
- errorType,
- message: safePRRefreshErrorMessage(errorType),
- fetchedAt: Date.now()
- }
- // Why: a Retry-After is a real cooldown — surface it as the retry schedule so the renderer doesn't retry into another 429.
- if (errorType === 'rate_limited') {
- const retryAfterMs = parseRetryAfterMs(extractExecError(err).stderr)
- if (retryAfterMs !== null && retryAfterMs > 0) {
- const retryAt = Date.now() + retryAfterMs
- outcome.nextAutoRetryAt = retryAt
- outcome.retryDisabledUntil = retryAt
- }
- }
- return outcome
-}
-
-function isNoPullRequestError(err: unknown): boolean {
- const message = err instanceof Error ? err.message : String(err)
- return /no pull requests? found|could not find.*pull request/i.test(message)
-}
-
-/**
- * Check if the authenticated user has starred the Orca repo.
- * Returns true if starred, false if not, null if unable to determine (gh unavailable).
- */
-export async function checkOrcaStarred(): Promise {
- await acquire()
- try {
- const { stdout, stderr } = await execFileAsync(
- 'gh',
- ['api', '--include', `user/starred/${ORCA_REPO}`],
- { encoding: 'utf-8' }
- )
- const response = `${stdout ?? ''}\n${stderr ?? ''}`
- if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) {
- return true
- }
- return null
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- // 404 means the user hasn't starred — the only expected "no" answer
- if (message.includes('HTTP 404')) {
- return false
- }
- // Anything else (gh not installed, not authenticated, network issue)
- return null
- } finally {
- release()
- }
-}
-
-function pickPushRemoteUrl(args: {
- originUrl: string | null
- cloneUrl: string
- sshUrl: string
-}): string {
- const { originUrl, cloneUrl, sshUrl } = args
- if (originUrl && (/^(git@|ssh:)/.test(originUrl) || originUrl.includes('ssh.github.com'))) {
- return sshUrl
- }
- return cloneUrl
-}
-
-function sanitizeRemoteName(owner: string, repo: string): string {
- const slug = `${owner}-${repo}`
- .toLowerCase()
- .replace(/[^a-z0-9._-]+/g, '-')
- .replace(/-+/g, '-')
- .replace(/^[.-]+|[.-]+$/g, '')
- return slug ? `pr-${slug}` : 'pr-head'
-}
-
-/**
- * A fork push target plus the PR's `maintainer_can_modify` flag, kept outside
- * {@link GitPushTarget} so it never leaks into the persisted push-target shape.
- */
-export type PullRequestPushTarget = {
- pushTarget: GitPushTarget
- /** false when the PR has "Allow edits from maintainers" off; a push may be rejected. */
- maintainerCanModify?: boolean
-}
-
-// Why: only an explicit `origin` preference is origin-only; `upstream`/`auto`/
-// undefined keep the multi-candidate probe ordered upstream-first, matching
-// resolvePrWorkItemSource list semantics.
-async function resolvePullRequestLookupCandidates(
- repoPath: string,
- preference: IssueSourcePreference | undefined,
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- if (preference === 'origin') {
- const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions)
- return origin ? [origin] : []
- }
- return (await resolveGitHubApiRepositoryCandidates(repoPath, connectionId, localGitOptions))
- .candidates
-}
-
-export async function getPullRequestPushTarget(
- repoPath: string,
- prNumber: number,
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {},
- preference?: IssueSourcePreference
-): Promise {
- const context = githubRepoContext(repoPath, connectionId, localGitOptions)
- const ghOptions = ghRepoExecOptions(context)
- const candidates = await resolvePullRequestLookupCandidates(
- repoPath,
- preference,
- connectionId,
- localGitOptions
- )
- if (candidates.length === 0) {
- return null
- }
-
- await acquire()
- try {
- let prStdout = ''
- let matchedRepository: GitHubApiRepository | null = null
- for (const candidate of candidates) {
- try {
- const { stdout } = await ghExecFileAsync(
- ['api', `repos/${candidate.owner}/${candidate.repo}/pulls/${prNumber}`],
- { ...ghOptions, ...githubHostExecOptions(candidate) }
- )
- prStdout = stdout
- matchedRepository = candidate
- break
- } catch (error) {
- // Why: origin is often the contributor fork while the PR belongs to upstream; probe all PR repos before giving up.
- if (isNotFoundGhError(error)) {
- continue
- }
- throw error
- }
- }
- if (!prStdout || !matchedRepository) {
- return null
- }
- const origin = await getGitHubApiRepositoryForRemote(
- repoPath,
- 'origin',
- connectionId,
- localGitOptions
- )
- const pr = JSON.parse(prStdout) as {
- maintainer_can_modify?: boolean
- head?: {
- ref?: string
- repo?: {
- full_name?: string
- clone_url?: string
- ssh_url?: string
- owner?: { login?: string }
- name?: string
- } | null
- }
- }
- const headRepo = pr.head?.repo
- const branchName = pr.head?.ref?.trim()
- const owner = headRepo?.owner?.login?.trim()
- const repo = headRepo?.name?.trim() ?? headRepo?.full_name?.split('/')[1]?.trim()
- const cloneUrl = headRepo?.clone_url?.trim()
- const sshUrl = headRepo?.ssh_url?.trim()
- const maintainerCanModify =
- typeof pr.maintainer_can_modify === 'boolean' ? pr.maintainer_can_modify : undefined
- if (!owner || !repo || !branchName || !cloneUrl || !sshUrl) {
- return null
- }
- if (
- origin &&
- githubRepoIdentityKey(origin) ===
- githubRepoIdentityKey({ owner, repo, host: matchedRepository.host })
- ) {
- return {
- pushTarget: { remoteName: 'origin', branchName },
- ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
- }
- }
-
- let originUrl: string | null = null
- try {
- const rawOriginUrl = await getRemoteUrlForRepo(context, 'origin')
- originUrl = rawOriginUrl?.trim() || null
- } catch {
- originUrl = null
- }
- return {
- pushTarget: {
- remoteName: sanitizeRemoteName(owner, repo),
- branchName,
- remoteUrl: pickPushRemoteUrl({ originUrl, cloneUrl, sshUrl })
- },
- ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
- }
- } finally {
- release()
- }
-}
-
-/**
- * Star the Orca repo for the authenticated user.
- */
-export async function starOrca(): Promise {
- await acquire()
- try {
- await execFileAsync('gh', ['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], {
- encoding: 'utf-8'
- })
- return true
- } catch {
- return false
- } finally {
- release()
- }
-}
-
-/**
- * Get the authenticated GitHub viewer when gh is available and logged in.
- * Returns null when gh is unavailable, unauthenticated, or the lookup fails.
- */
-export async function getAuthenticatedViewer(): Promise {
- await acquire()
- try {
- const { stdout } = await execFileAsync(
- 'gh',
- ['api', 'user', '--jq', '{login: .login, email: .email}'],
- { encoding: 'utf-8' }
- )
- const viewer = JSON.parse(stdout) as { login?: string; email?: string | null }
- if (!viewer.login?.trim()) {
- return null
- }
- return {
- login: viewer.login.trim(),
- email: viewer.email?.trim() || null
- }
- } catch {
- return null
- } finally {
- release()
- }
-}
-
-// Why: omit repoId — the main process only has the path; the renderer stamps repoId after IPC.
-export type MainWorkItem = Omit
-
-// Why: issue numbers follow creation order, so this sort aligns gh's PR rows with numbered Search API issue pages.
-const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc'
-
-const WORK_ITEM_PR_LIST_JSON_FIELDS =
- 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
-
-// Why: kept out of `gh pr list` — statusCheckRollup/reviewDecision/merge metadata fan out into expensive per-row GraphQL.
-// Requested reviewers stay in the list payload because Tasks renders that column on first paint.
-const WORK_ITEM_PR_DETAIL_JSON_FIELDS =
- 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify'
-
-function mapIssueWorkItem(item: Record): MainWorkItem {
- return {
- id: `issue:${String(item.number)}`,
- type: 'issue',
- number: Number(item.number),
- title: String(item.title ?? ''),
- state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open',
- url: String(item.html_url ?? item.url ?? ''),
- labels: Array.isArray(item.labels)
- ? item.labels
- .map((label) =>
- typeof label === 'object' && label !== null && 'name' in label
- ? String((label as { name?: unknown }).name ?? '')
- : ''
- )
- .filter(Boolean)
- : [],
- updatedAt: String(item.updated_at ?? item.updatedAt ?? ''),
- ...authorFieldsFromUnknown(item),
- ...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {})
- }
-}
-
-/**
- * Derive author login + avatar_url together so GHE avatars render — the login-only
- * `{login}.png` URL 404s on GHE. REST uses `user.avatar_url`, gh/GraphQL `author.avatarUrl` (#8784).
- */
-function authorFieldsFromUnknown(
- item: Record
-): Pick {
- const user = userFromUnknown(item.user ?? item.author)
- if (!user) {
- return { author: null }
- }
- return {
- author: user.login,
- ...(user.avatarUrl ? { authorAvatarUrl: user.avatarUrl } : {})
- }
-}
-
-function extractHeadOwnerLogin(item: Record): string | null {
- // gh CLI `pr list --json headRepositoryOwner` shape: { login }
- if (typeof item.headRepositoryOwner === 'object' && item.headRepositoryOwner !== null) {
- const login = (item.headRepositoryOwner as { login?: unknown }).login
- if (typeof login === 'string' && login.trim()) {
- return login
- }
- }
- // REST API `pull_request` shape: head.repo.owner.login
- if (typeof item.head === 'object' && item.head !== null) {
- const head = item.head as { repo?: unknown; user?: unknown; label?: unknown }
- const repo = head.repo
- if (typeof repo === 'object' && repo !== null) {
- const owner = (repo as { owner?: unknown }).owner
- if (typeof owner === 'object' && owner !== null) {
- const login = (owner as { login?: unknown }).login
- if (typeof login === 'string' && login.trim()) {
- return login
- }
- }
- }
- // Why: a deleted/inaccessible fork returns head.repo = null but still has head.user/head.label.
- const user = head.user
- if (typeof user === 'object' && user !== null) {
- const login = (user as { login?: unknown }).login
- if (typeof login === 'string' && login.trim()) {
- return login
- }
- }
- if (typeof head.label === 'string') {
- const owner = head.label.split(':', 1)[0]?.trim()
- if (owner) {
- return owner
- }
- }
- }
- return null
-}
-
-function userFromUnknown(
- value: unknown
-): { login: string; name: string | null; avatarUrl: string } | null {
- if (typeof value === 'string') {
- const login = value.trim()
- return login ? { login, name: null, avatarUrl: '' } : null
- }
- if (typeof value !== 'object' || value === null) {
- return null
- }
- const raw = value as Record
- const login = typeof raw.login === 'string' ? raw.login.trim() : ''
- if (!login) {
- return null
- }
- const databaseId = numberFromUnknown(raw.databaseId)
- return {
- login,
- name: typeof raw.name === 'string' ? raw.name : null,
- avatarUrl:
- typeof raw.avatarUrl === 'string'
- ? raw.avatarUrl
- : typeof raw.avatar_url === 'string'
- ? raw.avatar_url
- : databaseId !== undefined
- ? `https://avatars.githubusercontent.com/u/${databaseId}?v=4`
- : ''
- }
-}
-
-function usersFromUnknown(
- value: unknown
-): { login: string; name: string | null; avatarUrl: string }[] {
- if (!Array.isArray(value)) {
- return []
- }
- const users: { login: string; name: string | null; avatarUrl: string }[] = []
- for (const entry of value) {
- const direct = userFromUnknown(entry)
- if (direct) {
- users.push(direct)
- continue
- }
- if (typeof entry === 'object' && entry !== null) {
- const raw = entry as Record
- const nested = userFromUnknown(raw.requestedReviewer ?? raw.user ?? raw.author)
- if (nested) {
- users.push(nested)
- }
- }
- }
- return users
-}
-
-function latestReviewsFromUnknown(value: unknown): NonNullable {
- if (!Array.isArray(value)) {
- return []
- }
- const reviews: NonNullable = []
- for (const entry of value) {
- if (typeof entry !== 'object' || entry === null) {
- continue
- }
- const raw = entry as Record
- const author = userFromUnknown(raw.author)
- if (!author) {
- continue
- }
- reviews.push({
- login: author.login,
- state: typeof raw.state === 'string' ? raw.state : null,
- avatarUrl: author.avatarUrl
- })
- }
- return reviews
-}
-
-function numberFromUnknown(value: unknown): number | undefined {
- const number = typeof value === 'number' ? value : Number(value)
- return Number.isFinite(number) ? number : undefined
-}
-
-function normalizePRMergeable(value: unknown): PRMergeableState | undefined {
- const raw = typeof value === 'string' ? value.toUpperCase() : ''
- if (raw === 'MERGEABLE' || raw === 'CONFLICTING' || raw === 'UNKNOWN') {
- return raw
- }
- if (typeof value === 'boolean') {
- return value ? 'MERGEABLE' : 'CONFLICTING'
- }
- return undefined
-}
-
-function normalizeReviewDecision(value: unknown): PRReviewDecision | null {
- return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED'
- ? value
- : null
-}
-
-function isAutoMergeEnabled(value: unknown): boolean {
- return typeof value === 'object' && value !== null
-}
-
-function checkRollupEntries(value: unknown): unknown[] {
- if (Array.isArray(value)) {
- return value
- }
- if (typeof value !== 'object' || value === null) {
- return []
- }
- const raw = value as Record
- const nodes = (raw.contexts as { nodes?: unknown } | undefined)?.nodes
- return Array.isArray(nodes) ? nodes : []
-}
-
-function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSummary'] {
- return summarizeProviderChecks(
- checkRollupEntries(value).map((entry) => {
- if (typeof entry !== 'object' || entry === null) {
- return { status: '', conclusion: '' }
- }
- const raw = entry as Record
- // Why: StatusContext reports `state` and carries no `status`; CheckRun reports both.
- return {
- status: String(raw.status ?? ''),
- conclusion: String(raw.conclusion ?? raw.state ?? '')
- }
- })
- )
-}
-
-function mapPullRequestWorkItem(
- item: Record,
- baseOwnerRepo: OwnerRepo | null = null
-): MainWorkItem {
- // Why: fork PRs are disabled in the Start-from picker; compare head owner to the selected repo's owner.
- const headOwnerLogin = extractHeadOwnerLogin(item)
- // Why: leave isCrossRepository undefined when the head owner is unknown, rather than falsely claiming "not a fork".
- const isCrossRepository =
- headOwnerLogin !== null && baseOwnerRepo !== null
- ? headOwnerLogin !== baseOwnerRepo.owner
- : null
- const state = String(item.state ?? '').toLowerCase()
- const additions = numberFromUnknown(item.additions)
- const deletions = numberFromUnknown(item.deletions)
- const changedFiles = numberFromUnknown(
- item.changedFiles ??
- item.changed_files ??
- (item.files as { totalCount?: unknown } | undefined)?.totalCount
- )
- const mergeable = normalizePRMergeable(item.mergeable)
- const headSha =
- typeof item.headRefOid === 'string'
- ? item.headRefOid
- : typeof item.head === 'object' && item.head !== null
- ? typeof (item.head as { sha?: unknown }).sha === 'string'
- ? (item.head as { sha: string }).sha
- : undefined
- : undefined
- return {
- id: `pr:${String(item.number)}`,
- type: 'pr',
- number: Number(item.number),
- title: String(item.title ?? ''),
- state:
- state === 'merged' || item.merged_at || item.mergedAt
- ? 'merged'
- : state === 'closed'
- ? 'closed'
- : item.isDraft || item.draft
- ? 'draft'
- : 'open',
- url: String(item.html_url ?? item.url ?? ''),
- labels: Array.isArray(item.labels)
- ? item.labels
- .map((label) =>
- typeof label === 'object' && label !== null && 'name' in label
- ? String((label as { name?: unknown }).name ?? '')
- : ''
- )
- .filter(Boolean)
- : [],
- updatedAt: String(item.updated_at ?? item.updatedAt ?? ''),
- ...authorFieldsFromUnknown(item),
- branchName:
- typeof item.head === 'object' && item.head !== null && 'ref' in item.head
- ? String((item.head as { ref?: unknown }).ref ?? '')
- : String(item.headRefName ?? ''),
- baseRefName:
- typeof item.base === 'object' && item.base !== null && 'ref' in item.base
- ? String((item.base as { ref?: unknown }).ref ?? '')
- : String(item.baseRefName ?? ''),
- ...(headSha ? { headSha } : {}),
- ...(baseOwnerRepo
- ? {
- prRepo: {
- owner: baseOwnerRepo.owner,
- repo: baseOwnerRepo.repo,
- ...(baseOwnerRepo.host ? { host: baseOwnerRepo.host } : {})
- }
- }
- : {}),
- ...(additions !== undefined ? { additions } : {}),
- ...(deletions !== undefined ? { deletions } : {}),
- ...(changedFiles !== undefined ? { changedFiles } : {}),
- ...('reviewDecision' in item
- ? { reviewDecision: normalizeReviewDecision(item.reviewDecision) }
- : {}),
- ...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined
- ? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) }
- : {}),
- ...(item.latestReviews !== undefined
- ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
- : {}),
- ...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}),
- ...(item.statusCheckRollup !== undefined
- ? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) }
- : {}),
- ...(mergeable ? { mergeable } : {}),
- ...('autoMergeRequest' in item
- ? { autoMergeEnabled: isAutoMergeEnabled(item.autoMergeRequest) }
- : {}),
- ...('mergeStateStatus' in item
- ? {
- mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null
- }
- : {}),
- ...(typeof item.maintainerCanModify === 'boolean'
- ? { maintainerCanModify: item.maintainerCanModify }
- : {}),
- ...(isCrossRepository !== null ? { isCrossRepository } : {})
- }
-}
-
-async function hydrateWorkItemRepositoryMergeMetadata(
- items: MainWorkItem[],
- ownerRepo: OwnerRepo | null,
- ghOptions: GhExecOptions
-): Promise {
- const hasPullRequest = items.some((item) => item.type === 'pr')
- if (!ownerRepo || !hasPullRequest) {
- return items
- }
- // Why: merge settings are repo-level, so one cached probe keeps Tasks rows accurate without per-PR GraphQL fan-out.
- const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, undefined, ghOptions)
- if (!mergeMetadata.mergeMethodSettings && mergeMetadata.autoMergeAllowed === null) {
- return items
- }
- return items.map((item) =>
- item.type === 'pr'
- ? {
- ...item,
- ...(mergeMetadata.autoMergeAllowed !== null
- ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
- : {}),
- ...(mergeMetadata.mergeMethodSettings
- ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
- : {})
- }
- : item
- )
-}
-
-async function fetchIssueWorkItem(
- repoPath: string,
- ownerRepo: GitHubApiRepository | null,
- number: number,
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const ghOptions = {
- ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
- ...githubHostExecOptions(ownerRepo)
- }
- if (ownerRepo) {
- const { stdout } = await ghExecFileAsync(
- ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`],
- ghOptions
- )
- const item = JSON.parse(stdout) as Record
- if ('pull_request' in item) {
- return null
- }
- return mapIssueWorkItem(item)
- }
-
- if (connectionId) {
- // Why: SSH-backed gh has no repository cwd. A bare lookup could honor the
- // local process GH_REPO/GH_HOST and return an unrelated repository item.
- return null
- }
-
- const { stdout } = await ghExecFileAsync(
- ['issue', 'view', String(number), '--json', 'number,title,state,url,labels,updatedAt,author'],
- ghOptions
- )
- return mapIssueWorkItem(JSON.parse(stdout) as Record)
-}
-
-// Why: REST /pulls/{n} lacks latestReviews, so pull review fields from gh so reviewer lists aren't silently empty.
-const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews'
-
-async function fetchPullRequestReviewFields(
- number: number,
- ownerRepo: GitHubApiRepository | null,
- ghOptions: GhExecOptions
-): Promise> {
- try {
- const args = ownerRepo
- ? [
- 'pr',
- 'view',
- String(number),
- '--repo',
- `${ownerRepo.owner}/${ownerRepo.repo}`,
- '--json',
- WORK_ITEM_PR_REVIEW_JSON_FIELDS
- ]
- : ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS]
- const { stdout } = await ghExecFileAsync(args, ghOptions)
- const item = JSON.parse(stdout) as Record
- return {
- ...(item.reviewRequests !== undefined
- ? { reviewRequests: usersFromUnknown(item.reviewRequests) }
- : {}),
- ...(item.latestReviews !== undefined
- ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
- : {})
- }
- } catch {
- return {}
- }
-}
-
-async function fetchPullRequestWorkItem(
- repoPath: string,
- ownerRepo: GitHubApiRepository | null,
- number: number,
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const ghOptions = {
- ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
- ...githubHostExecOptions(ownerRepo)
- }
- if (ownerRepo) {
- try {
- const { stdout } = await ghExecFileAsync(
- [
- 'pr',
- 'view',
- String(number),
- '--repo',
- `${ownerRepo.owner}/${ownerRepo.repo}`,
- '--json',
- WORK_ITEM_PR_DETAIL_JSON_FIELDS
- ],
- ghOptions
- )
- const item = JSON.parse(stdout) as Record
- const mapped = mapPullRequestWorkItem(item, ownerRepo)
- // Why: merge-metadata GraphQL is best-effort — don't fall through to REST, which drops latestReviews and blanks bot-only reviewer lists.
- const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined
- try {
- const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
- return {
- ...mapped,
- mergeQueueRequired: mergeMetadata.mergeQueueRequired,
- ...(mergeMetadata.autoMergeAllowed !== null
- ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
- : {}),
- ...(mergeMetadata.mergeMethodSettings
- ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
- : {})
- }
- } catch {
- return mapped
- }
- } catch {
- const { stdout } = await ghExecFileAsync(
- ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
- ghOptions
- )
- const mapped = mapPullRequestWorkItem(
- JSON.parse(stdout) as Record,
- ownerRepo
- )
- const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions)
- return { ...mapped, ...reviewFields }
- }
- }
-
- if (connectionId) {
- // Why: connection-backed gh cannot infer a repository from cwd. Refuse a
- // bare call so process-level GH_REPO/GH_HOST cannot redirect the lookup.
- return null
- }
-
- const { stdout } = await ghExecFileAsync(
- ['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS],
- ghOptions
- )
- return mapPullRequestWorkItem(JSON.parse(stdout) as Record)
-}
-
-async function fetchPullRequestWorkItemFromCandidates(
- repoPath: string,
- number: number,
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {},
- preference?: IssueSourcePreference
-): Promise {
- const candidates = await resolvePullRequestLookupCandidates(
- repoPath,
- preference,
- connectionId,
- localGitOptions
- )
- if (candidates.length === 0) {
- if (preference === 'origin') {
- return null
- }
- return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions)
- }
- for (const candidate of candidates) {
- try {
- return await fetchPullRequestWorkItem(
- repoPath,
- candidate,
- number,
- connectionId,
- localGitOptions
- )
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- const classification = classifyGhError(message).type
- if (classification !== 'not_found' && classification !== 'permission_denied') {
- throw err
- }
- }
- }
- return null
-}
-
-type WorkItemListRequest = {
- args: string[]
- offset: number
-}
-
-function normalizeWorkItemPage(page: number | undefined): number {
- return typeof page === 'number' && Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1
-}
-
-function buildWorkItemListRequest(args: {
- kind: 'issue' | 'pr'
- ownerRepo: OwnerRepo
- limit: number
- query: ParsedTaskQuery
- page: number
-}): WorkItemListRequest {
- const { kind, ownerRepo, limit, query, page } = args
- const searchParts: string[] = []
-
- if (kind === 'issue') {
- searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`)
- }
- searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr')
-
- if (query.state === 'open') {
- searchParts.push('is:open')
- } else if (query.state === 'closed') {
- searchParts.push('is:closed')
- if (kind === 'pr') {
- searchParts.push('-is:merged')
- }
- } else if (query.state === 'merged') {
- searchParts.push('is:merged')
- }
-
- if (kind === 'pr' && query.draft) {
- searchParts.push('draft:true')
- }
-
- if (query.assignee) {
- searchParts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`)
- }
- if (query.author) {
- searchParts.push(`author:${quoteGitHubSearchValue(query.author)}`)
- }
- if (query.labels.length > 0) {
- for (const label of query.labels) {
- searchParts.push(`label:${quoteGitHubSearchValue(label)}`)
- }
- }
- if (kind === 'pr' && query.reviewRequested) {
- searchParts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`)
- }
- if (kind === 'pr' && query.reviewedBy) {
- searchParts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`)
- }
- if (query.freeText) {
- searchParts.push(query.freeText)
- }
-
- if (kind === 'issue') {
- return {
- args: [
- 'api',
- '--cache',
- '120s',
- `search/issues?q=${encodeURIComponent(searchParts.join(' '))}&sort=created&order=desc&per_page=${limit}&page=${page}`,
- '--jq',
- '.items'
- ],
- offset: 0
- }
- }
-
- // Why: search/issues omits the PR fields the Tasks columns need; use gh's rich PR list on a stable created sort.
- searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER)
- const out = [
- 'pr',
- 'list',
- '--limit',
- String(Math.min(page * limit, 1000)),
- '--state',
- 'all',
- '--json',
- WORK_ITEM_PR_LIST_JSON_FIELDS
- ]
- out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
- out.push('--search', searchParts.join(' '))
- return { args: out, offset: (page - 1) * limit }
-}
-
-// Why: shared shape so listWorkItems can lift per-side errors (#1076 silent wrongness) into the IPC envelope — a swallowed side reads as end-of-data to pagination (#11485).
-type PartialWorkItemsResult = {
- items: MainWorkItem[]
- issuesError?: ClassifiedError
- prsError?: ClassifiedError
-}
-
-function assertSshRepoHasResolvedGitHubSource(args: {
- connectionId?: string | null
- issueOwnerRepo: OwnerRepo | null
- prOwnerRepo: OwnerRepo | null
-}): void {
- if (!args.connectionId || args.issueOwnerRepo || args.prOwnerRepo) {
- return
- }
- // Why: SSH repo paths are remote-only, so without a resolved owner/repo gh would query local state.
- throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
-}
-
-type ResolvedPrWorkItemSource = {
- source: OwnerRepo | null
- originCandidate: OwnerRepo | null
- upstreamCandidate: OwnerRepo | null
-}
-
-async function resolvePrWorkItemSource(
- repoPath: string,
- preference: IssueSourcePreference | undefined,
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const [originCandidate, upstreamCandidate] = await Promise.all([
- getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions),
- getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions)
- ])
- // Why: fork-contribution PRs live on the upstream repo (the fork's own PR
- // list is almost always empty), so 'auto' resolves upstream-first exactly
- // like the issue side. Only an explicit 'origin' pick pins PRs to the fork.
- const source = preference === 'origin' ? originCandidate : (upstreamCandidate ?? originCandidate)
- return { source, originCandidate, upstreamCandidate }
-}
-
-async function listRecentWorkItems(
- repoPath: string,
- issueOwnerRepo: OwnerRepo | null,
- prOwnerRepo: OwnerRepo | null,
- limit: number,
- page: number,
- connectionId?: string | null,
- noCache?: boolean,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
- assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo })
- const recentQuery = parseTaskQuery('is:open')
- const issueRequest = issueOwnerRepo
- ? buildWorkItemListRequest({
- kind: 'issue',
- ownerRepo: issueOwnerRepo,
- limit,
- query: recentQuery,
- page
- })
- : null
- const prRequest = prOwnerRepo
- ? buildWorkItemListRequest({
- kind: 'pr',
- ownerRepo: prOwnerRepo,
- limit,
- query: recentQuery,
- page
- })
- : null
- if (noCache && issueRequest) {
- issueRequest.args.splice(1, 2)
- }
- // Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660).
- // Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner).
- const [issuesSettled, prsSettled] = await Promise.allSettled([
- issueRequest && issueOwnerRepo
- ? ghExecFileAsync(issueRequest.args, {
- ...ghOptions,
- ...githubHostExecOptions(issueOwnerRepo)
- })
- : Promise.resolve({ stdout: '[]' }),
- prRequest && prOwnerRepo
- ? ghExecFileAsync(prRequest.args, {
- ...ghOptions,
- ...githubHostExecOptions(prOwnerRepo)
- })
- : Promise.resolve({ stdout: '[]' })
- ])
-
- let issues: MainWorkItem[] = []
- let issuesError: ClassifiedError | undefined
- if (issuesSettled.status === 'fulfilled') {
- issues = (JSON.parse(issuesSettled.value.stdout) as Record[])
- // Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out.
- .filter((item) => !('pull_request' in item))
- .map(mapIssueWorkItem)
- } else {
- const stderr =
- issuesSettled.reason instanceof Error
- ? issuesSettled.reason.message
- : String(issuesSettled.reason)
- issuesError = classifyListIssuesError(stderr)
- }
-
- let prs: MainWorkItem[] = []
- if (prsSettled.status === 'fulfilled') {
- prs = (JSON.parse(prsSettled.value.stdout) as Record[])
- .slice(prRequest?.offset ?? 0, (prRequest?.offset ?? 0) + limit)
- .map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
- prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, {
- ...ghOptions,
- ...githubHostExecOptions(prOwnerRepo)
- })
- } else {
- // Why: re-throw PR errors so the cross-repo aggregator counts the repo failed; this feature only fixes issue-side swallowing (#1076).
- // Why: log issuesError first so a both-sides-failed case isn't blind to the classification we're about to drop.
- if (issuesError) {
- console.warn(
- 'listRecentWorkItems: both issue and PR sides failed; issuesError was classified:',
- issuesError.type,
- issuesError.message
- )
- }
- throw prsSettled.reason
- }
-
- return {
- items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit),
- issuesError
- }
-}
-
-async function listQueriedWorkItems(
- repoPath: string,
- issueOwnerRepo: OwnerRepo | null,
- prOwnerRepo: OwnerRepo | null,
- query: ParsedTaskQuery,
- limit: number,
- page?: number,
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
- assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo })
- const hasPrOnlyFilter =
- query.state === 'merged' ||
- query.draft ||
- query.reviewRequested !== null ||
- query.reviewedBy !== null
- const issueScope = query.scope !== 'pr' && !hasPrOnlyFilter
- const prScope = query.scope !== 'issue'
- let successfulRequestCount = 0
- let nonAvailabilityFailureCount = 0
- let availabilityError: unknown
- let prsError: ClassifiedError | undefined
-
- // Why: surface the issue-side error separately for the IPC envelope; PR-side keeps prior swallow-and-log (parent doc §6).
- const issueFetch = (async (): Promise => {
- if (!issueScope) {
- return { items: [] }
- }
- if (!issueOwnerRepo) {
- return { items: [] }
- }
- const request = buildWorkItemListRequest({
- kind: 'issue',
- ownerRepo: issueOwnerRepo,
- limit,
- query,
- page: page ?? 1
- })
- try {
- const { stdout } = await ghExecFileAsync(request.args, {
- ...ghOptions,
- ...githubHostExecOptions(issueOwnerRepo)
- })
- const items = (JSON.parse(stdout) as Record[])
- .filter((item) => !('pull_request' in item))
- .map(mapIssueWorkItem)
- successfulRequestCount += 1
- return { items }
- } catch (err) {
- const stderr = err instanceof Error ? err.message : String(err)
- if (classifyGitHubUnavailable(stderr)) {
- availabilityError ??= err
- } else {
- nonAvailabilityFailureCount += 1
- }
- return { items: [], issuesError: classifyListIssuesError(stderr) }
- }
- })()
-
- const prFetch = (async (): Promise => {
- if (!prScope) {
- return []
- }
- if (!prOwnerRepo) {
- return []
- }
- const request = buildWorkItemListRequest({
- kind: 'pr',
- ownerRepo: prOwnerRepo,
- limit,
- query,
- page: page ?? 1
- })
- try {
- const { stdout } = await ghExecFileAsync(request.args, {
- ...ghOptions,
- ...githubHostExecOptions(prOwnerRepo)
- })
- const mapped = (JSON.parse(stdout) as Record[])
- .slice(request.offset, request.offset + limit)
- .map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
- const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, {
- ...ghOptions,
- ...githubHostExecOptions(prOwnerRepo)
- })
- successfulRequestCount += 1
- if (query.state === 'closed') {
- return hydrated.filter((item) => item.state !== 'merged')
- }
- return hydrated
- } catch (err) {
- console.warn('listQueriedWorkItems PRs partial failure:', err)
- const stderr = err instanceof Error ? err.message : String(err)
- prsError = classifyListPrsError(stderr)
- if (classifyGitHubUnavailable(stderr)) {
- availabilityError ??= err
- } else {
- nonAvailabilityFailureCount += 1
- }
- return []
- }
- })()
-
- const [issueResult, prItems] = await Promise.all([issueFetch, prFetch])
- if (availabilityError && successfulRequestCount === 0 && nonAvailabilityFailureCount === 0) {
- // Why: when every half hit the same availability failure, propagate it so Tasks can distinguish an outage from no data.
- throw availabilityError
- }
- return {
- items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit),
- issuesError: issueResult.issuesError,
- prsError
- }
-}
-
-export async function listWorkItems(
- repoPath: string,
- limit = 24,
- query?: string,
- page?: number,
- preference?: IssueSourcePreference,
- connectionId?: string | null,
- noCache?: boolean,
- localGitOptions: LocalGitExecOptions = {}
-): Promise> {
- const trimmedQuery = query?.trim() ?? ''
- const requestedPage = normalizeWorkItemPage(page)
- if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
- return {
- items: [],
- sources: {
- issues: null,
- prs: null,
- originCandidate: null,
- upstreamCandidate: null
- }
- }
- }
- const [issueResolved, prResolved] = await Promise.all([
- resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions),
- resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
- ])
- const issueOwnerRepo = issueResolved.source
- const prOwnerRepo = prResolved.source
- await acquire()
- try {
- // Why: let errors propagate to IPC — a catch-all would make failure indistinguishable from empty and under-report per-repo failures.
- const partial = !trimmedQuery
- ? await listRecentWorkItems(
- repoPath,
- issueOwnerRepo,
- prOwnerRepo,
- limit,
- requestedPage,
- connectionId,
- noCache,
- localGitOptions
- )
- : await listQueriedWorkItems(
- repoPath,
- issueOwnerRepo,
- prOwnerRepo,
- parseTaskQuery(trimmedQuery),
- limit,
- requestedPage,
- connectionId,
- localGitOptions
- )
-
- const errors =
- partial.issuesError || partial.prsError
- ? {
- ...(partial.issuesError ? { issues: partial.issuesError } : {}),
- ...(partial.prsError ? { prs: partial.prsError } : {})
- }
- : undefined
- return {
- items: partial.items,
- sources: {
- issues: issueOwnerRepo,
- prs: prOwnerRepo,
- originCandidate: prResolved.originCandidate,
- upstreamCandidate: prResolved.upstreamCandidate
- },
- ...(errors ? { errors } : {}),
- ...(issueResolved.fellBack ? { issueSourceFellBack: true } : {})
- }
- } finally {
- release()
- }
-}
-
-function buildSearchQueryString(
- ownerRepo: { owner: string; repo: string },
- query: ParsedTaskQuery
-): string {
- const parts: string[] = [`repo:${ownerRepo.owner}/${ownerRepo.repo}`]
- if (query.scope === 'pr') {
- parts.push('is:pull-request')
- } else if (query.scope === 'issue') {
- parts.push('is:issue')
- }
- if (query.state === 'open') {
- parts.push('is:open')
- } else if (query.state === 'closed') {
- // Why: GitHub search treats merged PRs as closed; exclude merged so "Closed" means closed-without-merge.
- parts.push('is:closed')
- if (query.scope !== 'issue') {
- parts.push('-is:merged')
- }
- } else if (query.state === 'merged') {
- parts.push('is:merged')
- }
- if (query.draft) {
- parts.push('draft:true')
- }
- if (query.assignee) {
- parts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`)
- }
- if (query.author) {
- parts.push(`author:${quoteGitHubSearchValue(query.author)}`)
- }
- if (query.reviewRequested) {
- parts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`)
- }
- if (query.reviewedBy) {
- parts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`)
- }
- for (const label of query.labels) {
- parts.push(`label:${quoteGitHubSearchValue(label)}`)
- }
- if (query.freeText) {
- parts.push(query.freeText)
- }
- return parts.join(' ')
-}
-
-function quoteGitHubSearchValue(value: string): string {
- return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value
-}
-
-async function countWorkItemsForQuery(
- repoPath: string,
- ownerRepo: OwnerRepo,
- query: ParsedTaskQuery,
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const searchQ = buildSearchQueryString(ownerRepo, query)
- const ghOptions = {
- ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
- ...githubHostExecOptions(ownerRepo)
- }
- const { stdout } = await ghExecFileAsync(
- [
- 'api',
- '--cache',
- '120s',
- `search/issues?q=${encodeURIComponent(searchQ)}&per_page=1`,
- '--jq',
- '.total_count'
- ],
- ghOptions
- )
- // Why: over-counting cache hits is the safe direction — the next probe corrects the estimate.
- noteRepositoryRateLimitSpend(ownerRepo, 'search', 1, ghOptions)
- return Number.parseInt(stdout.trim(), 10) || 0
-}
-
-function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean {
- // Why: casing does not distinguish GitHub repos, but the same slug on different hosts does.
- return Boolean(left && right && githubRepoIdentityKey(left) === githubRepoIdentityKey(right))
-}
-
-function defaultOpenWorkItemQuery(): ParsedTaskQuery {
- return {
- scope: 'all',
- state: 'open',
- draft: false,
- assignee: null,
- author: null,
- reviewRequested: null,
- reviewedBy: null,
- labels: [],
- freeText: ''
- }
-}
-
-// Why: cached 120s to avoid burning the 30/min search rate limit that backs the pagination total.
-export async function countWorkItems(
- repoPath: string,
- query?: string,
- preference?: IssueSourcePreference,
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const trimmedQuery = query?.trim() ?? ''
- if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
- return 0
- }
- const [issueResolved, prResolved] = await Promise.all([
- resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions),
- resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
- ])
- const issueOwnerRepo = issueResolved.source
- const prOwnerRepo = prResolved.source
- const ownerRepo = prOwnerRepo ?? issueOwnerRepo
- if (!ownerRepo) {
- return 0
- }
-
- const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null
- const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery()
- const ghOptions = {
- ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
- ...githubHostExecOptions(ownerRepo)
- }
-
- // Why: counts are decorative, so stop when the 30/min search budget is gone rather than spawn into 403s (getRateLimit is 30s-cached).
- if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) {
- await getRateLimit()
- }
- if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) {
- return 0
- }
-
- await acquire()
- try {
- if (sameOwnerRepo(issueOwnerRepo, prOwnerRepo)) {
- return await countWorkItemsForQuery(
- repoPath,
- ownerRepo,
- effectiveQuery,
- connectionId,
- localGitOptions
- )
- }
-
- const counts: Promise[] = []
- // Why: draft/reviewRequested/reviewedBy are PR-only, so the issue half would always return 0 — skip it to save a search call.
- const hasPrOnlyFilter =
- effectiveQuery.draft ||
- effectiveQuery.reviewRequested !== null ||
- effectiveQuery.reviewedBy !== null
- if (
- effectiveQuery.scope !== 'pr' &&
- effectiveQuery.state !== 'merged' &&
- !hasPrOnlyFilter &&
- issueOwnerRepo
- ) {
- counts.push(
- countWorkItemsForQuery(
- repoPath,
- issueOwnerRepo,
- { ...effectiveQuery, scope: 'issue' },
- connectionId,
- localGitOptions
- )
- )
- }
- if (effectiveQuery.scope !== 'issue' && prOwnerRepo) {
- counts.push(
- countWorkItemsForQuery(
- repoPath,
- prOwnerRepo,
- { ...effectiveQuery, scope: 'pr' },
- connectionId,
- localGitOptions
- )
- )
- }
- // Why: allSettled so one failing search side doesn't zero the total; sum only fulfilled halves.
- const results = await Promise.allSettled(counts)
- let total = 0
- for (const r of results) {
- if (r.status === 'fulfilled') {
- total += r.value
- } else {
- console.warn('countWorkItems partial failure:', r.reason)
- }
- }
- return total
- } catch (err) {
- console.warn('countWorkItems failed:', err)
- return 0
- } finally {
- release()
- }
-}
-
-export async function getRepoSlug(
- repoPath: string,
- connectionId?: string | null,
- options: HostedReviewExecutionOptions = {}
-): Promise {
- return getOriginGitHubApiRepository(
- repoPath,
- connectionId,
- getHostedReviewLocalGitOptions(options)
- )
-}
-
-/**
- * Resolve a fork's upstream/parent owner/repo, or null when not a fork.
- * Why: drives the fork indicator, and a same-name fork's avatar prefers the
- * upstream owner (a renamed fork keeps its own owner).
- * Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null.
- */
-export async function getRepoUpstream(
- repoPath: string,
- connectionId?: string | null,
- options: HostedReviewExecutionOptions = {}
-): Promise {
- const localGitArgs = hostedReviewLocalGitOptionArgs(options)
- const localGitOptions = localGitArgs[0] ?? {}
- const { ownerRepo: origin, ghOptions } = await resolveGitHubRepoExecution(
- repoPath,
- undefined,
- connectionId,
- localGitOptions
- )
- if (!origin) {
- return null
- }
- const upstreamRemote = await getGitHubApiRepositoryForRemote(
- repoPath,
- 'upstream',
- connectionId,
- localGitOptions
- )
- if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) {
- return upstreamRemote
- }
- await acquire()
- try {
- // Why: positional slugs bypass the runner's --repo qualifier, so the slug
- // itself must carry the Enterprise host.
- const { stdout } = await ghExecFileAsync(
- ['repo', 'view', githubRepositorySlugArg(origin), '--json', 'isFork,parent'],
- // Why: cap this best-effort add-time lookup so a stalled gh process can't hold up repo creation.
- {
- ...ghOptions,
- timeout: 10_000
- }
- )
- const data = JSON.parse(stdout) as {
- isFork?: boolean
- parent?: { name?: string; owner?: { login?: string } } | null
- }
- const owner = data.parent?.owner?.login
- const repo = data.parent?.name
- // Why: a fork parent lives on the same server as the fork.
- return data.isFork && owner && repo
- ? { owner, repo, ...(origin.host ? { host: origin.host } : {}) }
- : null
- } catch {
- return null
- } finally {
- release()
- }
-}
-
-function classifyCreatePRError(error: unknown): CreateHostedReviewResult {
- const { stderr, stdout } = extractExecError(error)
- const message = `${stderr}\n${stdout}`.trim()
- if (message) {
- console.warn('createGitHubPullRequest failed:', message)
- }
- const lower = message.toLowerCase()
- if (
- lower.includes('not logged') ||
- lower.includes('not authenticated') ||
- lower.includes('authentication') ||
- lower.includes('gh auth login') ||
- lower.includes('http 401')
- ) {
- return {
- ok: false,
- code: 'auth_required',
- error:
- 'Create PR failed: GitHub is not authenticated. Next step: run gh auth login in this environment.'
- }
- }
- if (lower.includes('already exists') || lower.includes('a pull request already exists')) {
- return {
- ok: false,
- code: 'already_exists',
- error: 'A pull request already exists for this branch.'
- }
- }
- if (lower.includes('timed out') || lower.includes('timeout')) {
- return {
- ok: false,
- code: 'unknown_completion',
- error: 'PR creation may have completed. Refreshing branch review state...'
- }
- }
- if (lower.includes('validation failed') || lower.includes('http 422')) {
- return {
- ok: false,
- code: 'validation',
- error:
- 'Create PR failed: GitHub rejected the pull request. Check the base branch and branch state, then try again.'
- }
- }
- return {
- ok: false,
- code: 'unknown',
- error: 'Create PR failed: GitHub could not create the pull request. Try again in a moment.'
- }
-}
-
-function parseCreatePRPayload(stdout: string): { number: number; url: string } | null {
- const trimmed = stdout.trim()
- if (!trimmed) {
- return null
- }
- try {
- const parsed = JSON.parse(trimmed) as { number?: unknown; url?: unknown }
- const number = Number(parsed.number)
- const url = typeof parsed.url === 'string' ? parsed.url.trim() : ''
- if (Number.isInteger(number) && number > 0 && url) {
- return { number, url }
- }
- } catch {
- // Fall through to URL parsing for older gh versions without --json support.
- }
- // Why: match any host (not just github.com) so a GHES PR URL still parses (#8312).
- const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/)
- if (!urlMatch) {
- return null
- }
- return { number: Number(urlMatch[1]), url: urlMatch[0] }
-}
-
-async function findOpenPRByHeadBase(args: {
- repoPath: string
- repo: GitHubApiRepository
- head: string
- base: string
- connectionId?: string | null
- options?: HostedReviewExecutionOptions
-}): Promise<{ number: number; url: string } | null> {
- const context = githubRepoContext(args.repoPath, args.connectionId)
- const { stdout } = await ghExecFileAsync(
- [
- 'pr',
- 'list',
- '--repo',
- `${args.repo.owner}/${args.repo.repo}`,
- '--head',
- args.head,
- '--base',
- args.base,
- '--state',
- 'open',
- '--limit',
- '2',
- '--json',
- 'number,url'
- ],
- {
- ...ghRepoExecOptions(context),
- ...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)),
- ...githubHostExecOptions(args.repo)
- }
- )
- const list = JSON.parse(stdout) as { number?: number; url?: string }[]
- if (list.length !== 1 || !list[0]?.number || !list[0]?.url) {
- return null
- }
- return { number: list[0].number, url: list[0].url }
-}
-
-async function readPullRequestTemplate(
- repoPath: string,
- connectionId?: string | null
-): Promise {
- const relativeCandidates = [
- '.github/pull_request_template.md',
- '.github/PULL_REQUEST_TEMPLATE.md',
- 'pull_request_template.md',
- 'PULL_REQUEST_TEMPLATE.md',
- 'docs/pull_request_template.md',
- 'docs/PULL_REQUEST_TEMPLATE.md'
- ]
- const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
- if (connectionId && !remoteProvider) {
- return ''
- }
- for (const relativeCandidate of relativeCandidates) {
- try {
- if (remoteProvider) {
- const result = await remoteProvider.readFile(
- joinWorktreeRelativePath(repoPath, relativeCandidate)
- )
- if (result.isBinary) {
- continue
- }
- return result.content
- }
- return await readFile(join(repoPath, relativeCandidate), 'utf8')
- } catch {
- // Try the next conventional PR template path.
- }
- }
- return ''
-}
-
-export async function createGitHubPullRequest(
- repoPath: string,
- input: CreateHostedReviewInput,
- connectionId?: string | null,
- options: HostedReviewExecutionOptions = {}
-): Promise {
- if (input.provider !== 'github') {
- return {
- ok: false,
- code: 'unsupported_provider',
- error: 'Creating reviews for this provider is not supported yet.'
- }
- }
-
- // Why: creation targets the origin owning the unqualified head branch; the shared resolver preserves its host (#7331, #8312).
- const ownerRepo = await getOriginGitHubApiRepository(
- repoPath,
- connectionId,
- getHostedReviewLocalGitOptions(options)
- )
- if (!ownerRepo) {
- return {
- ok: false,
- code: 'unsupported_provider',
- error: 'Creating pull requests requires a GitHub remote.'
- }
- }
- // The runner host-qualifies --repo from options.host for GHES (#8312).
- const repoArg = `${ownerRepo.owner}/${ownerRepo.repo}`
-
- const base = normalizeHostedReviewBaseRef(input.base)
- const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined
- const title = input.title.trim()
- if (!base || !title) {
- return {
- ok: false,
- code: 'validation',
- error: 'Create PR failed: base branch and title are required.'
- }
- }
- if (head && head.toLowerCase() === base.toLowerCase()) {
- return {
- ok: false,
- code: 'validation',
- error: 'Create PR failed: choose a different base branch before creating a pull request.'
- }
- }
-
- const tempDir = await mkdtemp(join(tmpdir(), 'orca-pr-body-'))
- await acquire()
- const bodyPath = join(tempDir, 'body.md')
- try {
- const body =
- input.useTemplate && !input.body?.trim()
- ? await readPullRequestTemplate(repoPath, connectionId)
- : (input.body ?? '')
- await writeFile(bodyPath, body, 'utf8')
- const createArgs = [
- 'pr',
- 'create',
- '--repo',
- repoArg,
- '--base',
- base,
- '--title',
- title,
- '--body-file',
- bodyPath
- ]
- if (head) {
- createArgs.push('--head', head)
- }
- if (input.draft) {
- createArgs.push('--draft')
- }
- try {
- const context = githubRepoContext(repoPath, connectionId)
- const { stdout } = await ghExecFileAsync(createArgs, {
- ...ghRepoExecOptions(context),
- ...(connectionId ? {} : getHostedReviewLocalGitOptions(options)),
- ...githubHostExecOptions(ownerRepo),
- timeout: 60_000,
- idempotent: false
- })
- const created = parseCreatePRPayload(stdout)
- if (created) {
- return { ok: true, ...created }
- }
- const found = head
- ? await findOpenPRByHeadBase({
- repoPath,
- repo: ownerRepo,
- head,
- base,
- connectionId,
- options
- }).catch(() => null)
- : null
- if (found) {
- return { ok: true, ...found }
- }
- return {
- ok: false,
- code: 'unknown_completion',
- error: 'PR creation may have completed. Refreshing branch review state...'
- }
- } catch (error) {
- const classified = classifyCreatePRError(error)
- if (
- !classified.ok &&
- (classified.code === 'already_exists' || classified.code === 'unknown_completion') &&
- head
- ) {
- const existing = await findOpenPRByHeadBase({
- repoPath,
- repo: ownerRepo,
- head,
- base,
- connectionId,
- options
- }).catch(() => null)
- if (existing) {
- return {
- ok: false,
- code: 'already_exists',
- error: 'A pull request already exists for this branch.',
- existingReview: existing
- }
- }
- }
- return classified
- }
- } finally {
- await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
- release()
- }
-}
-
-export async function getWorkItem(
- repoPath: string,
- number: number,
- type?: 'issue' | 'pr',
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {},
- preference?: IssueSourcePreference
-): Promise {
- await acquire()
- try {
- // Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number
- // must share that preference so origin/upstream toggles cannot disagree.
- if (type === 'issue') {
- const { source } = await resolveIssueGitHubApiRepositorySource(
- repoPath,
- preference,
- connectionId,
- localGitOptions
- )
- // Why: explicit origin with no origin identity must not bare-lookup ambient gh
- // (same fail-closed rule as origin-pinned PR candidate resolution).
- if (!source && preference === 'origin') {
- return null
- }
- return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions)
- }
- if (type === 'pr') {
- return await fetchPullRequestWorkItemFromCandidates(
- repoPath,
- number,
- connectionId,
- localGitOptions,
- preference
- )
- }
-
- try {
- const { source } = await resolveIssueGitHubApiRepositorySource(
- repoPath,
- preference,
- connectionId,
- localGitOptions
- )
- if (source || preference !== 'origin') {
- const issue = await fetchIssueWorkItem(
- repoPath,
- source,
- number,
- connectionId,
- localGitOptions
- )
- if (issue) {
- return issue
- }
- }
- } catch (err) {
- // Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR.
- const stderr = err instanceof Error ? err.message : String(err)
- if (classifyGhError(stderr).type !== 'not_found') {
- throw err
- }
- }
- return await fetchPullRequestWorkItemFromCandidates(
- repoPath,
- number,
- connectionId,
- localGitOptions,
- preference
- )
- } catch {
- return null
- } finally {
- release()
- }
-}
-
-export async function getWorkItemByOwnerRepo(
- repoPath: string,
- ownerRepo: GitHubApiRepository,
- number: number,
- type: 'issue' | 'pr',
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const requestedHost = ownerRepo.host?.trim().toLowerCase()
- const requestedRepository = requestedHost
- ? { ...ownerRepo, host: requestedHost }
- : await resolveGitHubApiRepository(repoPath, ownerRepo, connectionId, localGitOptions)
- if (!requestedRepository) {
- return null
- }
- const { candidates } = await resolveGitHubApiRepositoryCandidates(
- repoPath,
- connectionId,
- localGitOptions
- )
- const requestedKey = githubRepoIdentityKey(requestedRepository)
- const matchedRepository = candidates.find(
- (candidate) => githubRepoIdentityKey(candidate) === requestedKey
- )
- // Why: this lookup is reachable from pasted links. Restricting it to a
- // configured remote prevents gh from sending credentials to an arbitrary host.
- if (!matchedRepository) {
- return null
- }
- await acquire()
- try {
- if (type === 'issue') {
- return await fetchIssueWorkItem(
- repoPath,
- matchedRepository,
- number,
- connectionId,
- localGitOptions
- )
- }
- return await fetchPullRequestWorkItem(
- repoPath,
- matchedRepository,
- number,
- connectionId,
- localGitOptions
- )
- } catch {
- return null
- } finally {
- release()
- }
-}
-
-type PullRequestLookupData = {
- number: number
- title: string
- state: string
- url: string
- statusCheckRollup: unknown[]
- updatedAt: string
- isDraft?: boolean
- mergeable: string
- reviewDecision?: PRReviewDecision | null
- autoMergeRequest?: unknown
- autoMergeEnabled?: boolean
- autoMergeAllowed?: boolean | null
- mergeQueueRequired?: boolean | null
- mergeMethodSettings?: GitHubPRMergeMethodSettings
- mergeStateStatus?: string | null
- baseRefName?: string
- headRefName?: string
- baseRefOid?: string
- headRefOid?: string
- stack?: GitHubPRStack
- stackMetadataChecked?: boolean
-}
-
-type RestPullRequest = {
- number: number
- title: string
- state: string
- html_url?: string
- url?: string
- updated_at?: string
- draft?: boolean
- merged_at?: string | null
- mergeable?: boolean | null
- mergeable_state?: string | null
- base?: { ref?: string; sha?: string }
- head?: { ref?: string; sha?: string }
- stack?: {
- number?: number
- position?: number
- size?: number
- base?: { ref?: string; sha?: string }
- } | null
-}
-
-const PR_LOOKUP_JSON_FIELDS =
- 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
-const PR_BRANCH_LIST_JSON_FIELDS =
- 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
-const PR_AUTO_MERGE_IDENTITY_JSON_FIELDS = 'id,headRefOid,baseRefName'
-const GITHUB_AUTO_MERGE_METHODS: Record = {
- merge: 'MERGE',
- squash: 'SQUASH',
- rebase: 'REBASE'
-}
-
-export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & {
- acceptMergedFallbackPR?: boolean
- // Why: compare merged implicit PRs against the worktree HEAD, not main repo HEAD, without a worktree-scoped git call.
- currentHeadOid?: string | null
-}
-
-function mapRestPRMergeable(pr: RestPullRequest): PRMergeableState {
- const mergeableState = pr.mergeable_state?.toLowerCase()
- if (mergeableState === 'dirty') {
- return 'CONFLICTING'
- }
- if (mergeableState === 'clean' || pr.mergeable === true) {
- return 'MERGEABLE'
- }
- return 'UNKNOWN'
-}
-
-function derivePullRequestMergeable(data: PullRequestLookupData): PRMergeableState {
- const mergeable = normalizePRMergeable(data.mergeable)
- if (mergeable === 'CONFLICTING' || data.mergeStateStatus === 'DIRTY') {
- return 'CONFLICTING'
- }
- return mergeable ?? 'UNKNOWN'
-}
-
-function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData {
- const stack =
- typeof pr.stack?.number === 'number' &&
- typeof pr.stack.position === 'number' &&
- typeof pr.stack.size === 'number' &&
- typeof pr.stack.base?.ref === 'string'
- ? {
- number: pr.stack.number,
- position: pr.stack.position,
- size: pr.stack.size,
- baseRefName: pr.stack.base.ref,
- ...(typeof pr.stack.base.sha === 'string' ? { baseSha: pr.stack.base.sha } : {})
- }
- : undefined
- return {
- number: pr.number,
- title: pr.title,
- state: pr.merged_at ? 'MERGED' : pr.state,
- url: pr.html_url ?? pr.url ?? '',
- statusCheckRollup: [],
- updatedAt: pr.updated_at ?? '',
- isDraft: pr.draft,
- mergeable: mapRestPRMergeable(pr),
- baseRefName: pr.base?.ref,
- headRefName: pr.head?.ref,
- baseRefOid: pr.base?.sha,
- headRefOid: pr.head?.sha,
- stackMetadataChecked: true,
- ...(stack ? { stack } : {})
- }
-}
-
-function isMergedImplicitPR(data: PullRequestLookupData, linkedPRNumber?: number | null): boolean {
- // Why: a merged PR without an explicit link is just a historical branch match, not implicit review context.
- return typeof linkedPRNumber !== 'number' && mapPRState(data.state, data.isDraft) === 'merged'
-}
-
-async function getCurrentHeadOid(
- repoPath: string,
- connectionId?: string | null,
- localGitOptions: { wslDistro?: string } = {}
-): Promise {
- const provider = connectionId ? getSshGitProvider(connectionId) : null
- if (connectionId && !provider) {
- throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
- }
- if (provider) {
- const result = await provider.exec(['rev-parse', 'HEAD'], repoPath)
- return result.stdout.trim() || null
- }
- try {
- const result = await gitExecFileAsync(['rev-parse', 'HEAD'], {
- cwd: repoPath,
- ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
- })
- return result.stdout.trim() || null
- } catch {
- return null
- }
-}
-
-function shouldHideMergedImplicitPR(
- data: PullRequestLookupData | null,
- linkedPRNumber: number | null | undefined,
- currentHeadOid: string | null
-): boolean {
- if (!data || !isMergedImplicitPR(data, linkedPRNumber)) {
- return false
- }
- // Why: keep hiding historical merged branch matches, but preserve the merged PR for the exact commit currently checked out.
- return !currentHeadOid || data.headRefOid !== currentHeadOid
-}
-
-function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData {
- return {
- ...data,
- reviewDecision:
- data.reviewDecision !== undefined ? normalizeReviewDecision(data.reviewDecision) : undefined,
- autoMergeEnabled:
- data.autoMergeEnabled ??
- ('autoMergeRequest' in data ? isAutoMergeEnabled(data.autoMergeRequest) : undefined)
- }
-}
-
-function cacheRepositoryMergeMetadata(
- cacheKey: string,
- value: GitHubRepositoryMergeMetadata,
- ttlMs: number
-): void {
- const now = Date.now()
- pruneRepositoryMergeMetadataCache(now)
- // Why: merge metadata is keyed by user-controlled branch names; keep the cache bounded across many short-lived branches.
- repositoryMergeMetadataCache.delete(cacheKey)
- repositoryMergeMetadataCache.set(cacheKey, {
- value,
- expiresAt: now + ttlMs
- })
- pruneRepositoryMergeMetadataCache(now)
-}
-
-async function detectRepositoryMergeMetadata(
- ownerRepo: GitHubApiRepository,
- branchName: string | undefined,
- ghOptions: GhExecOptions,
- executionScope = 'default'
-): Promise {
- const cacheKey = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}`
- pruneRepositoryMergeMetadataCache()
- const cached = repositoryMergeMetadataCache.get(cacheKey)
- if (cached) {
- return cached.value
- }
- const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
- if (guard.blocked) {
- return { mergeQueueRequired: null, autoMergeAllowed: null }
- }
- const query = branchName
- ? `query($owner: String!, $repo: String!, $branch: String!, $qualified: String!) {
- repository(owner: $owner, name: $repo) {
- viewerDefaultMergeMethod
- mergeCommitAllowed
- rebaseMergeAllowed
- squashMergeAllowed
- autoMergeAllowed
- mergeQueue(branch: $branch) { id }
- ref(qualifiedName: $qualified) {
- rules(first: 50) { nodes { type } }
- }
- }
- }`
- : `query($owner: String!, $repo: String!) {
- repository(owner: $owner, name: $repo) {
- viewerDefaultMergeMethod
- mergeCommitAllowed
- rebaseMergeAllowed
- squashMergeAllowed
- autoMergeAllowed
- }
- }`
- try {
- noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
- const args = [
- 'api',
- 'graphql',
- '-f',
- `query=${query}`,
- '-f',
- `owner=${ownerRepo.owner}`,
- '-f',
- `repo=${ownerRepo.repo}`
- ]
- if (branchName) {
- args.push('-f', `branch=${branchName}`)
- args.push('-f', `qualified=refs/heads/${branchName}`)
- }
- const { stdout } = await ghExecFileAsync(args, {
- ...ghOptions,
- ...githubHostExecOptions(ownerRepo)
- })
- const parsed = JSON.parse(stdout) as {
- data?: {
- repository?: {
- viewerDefaultMergeMethod?: unknown
- mergeCommitAllowed?: unknown
- rebaseMergeAllowed?: unknown
- squashMergeAllowed?: unknown
- autoMergeAllowed?: unknown
- mergeQueue?: { id?: unknown } | null
- ref?: { rules?: { nodes?: ({ type?: unknown } | null)[] | null } | null } | null
- } | null
- }
- }
- const repository = parsed.data?.repository
- const mergeMethodSettings = repository
- ? normalizeGitHubPRMergeMethodSettings({
- defaultMethod: repository.viewerDefaultMergeMethod,
- mergeCommitAllowed: repository.mergeCommitAllowed,
- rebaseMergeAllowed: repository.rebaseMergeAllowed,
- squashMergeAllowed: repository.squashMergeAllowed
- })
- : undefined
- const value: GitHubRepositoryMergeMetadata = {
- mergeQueueRequired: branchName
- ? Boolean(repository?.mergeQueue) ||
- Boolean(repository?.ref?.rules?.nodes?.some((rule) => rule?.type === 'MERGE_QUEUE'))
- : null,
- autoMergeAllowed:
- typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null,
- ...(mergeMethodSettings ? { mergeMethodSettings } : {})
- }
- cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS)
- return value
- } catch {
- // Why: cache a conservative result for failed merge-queue probes so we don't retry GraphQL on every poll while GitHub/network is unhappy.
- const value: GitHubRepositoryMergeMetadata = {
- mergeQueueRequired: null,
- autoMergeAllowed: null
- }
- cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS)
- return value
- }
-}
-
-async function hydratePullRequestLookupData(
- ownerRepo: OwnerRepo,
- data: PullRequestLookupData,
- ghOptions: GhExecOptions,
- executionScope: string
-): Promise {
- const normalized = normalizePullRequestLookupData(data)
- const hasRichMergeFields =
- 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data
- const mergeMetadata = hasRichMergeFields
- ? await detectRepositoryMergeMetadata(
- ownerRepo,
- normalized.stack?.baseRefName ?? normalized.baseRefName,
- ghOptions,
- executionScope
- )
- : undefined
- return {
- ...normalized,
- ...(mergeMetadata ? { mergeQueueRequired: mergeMetadata.mergeQueueRequired } : {}),
- ...(mergeMetadata ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } : {}),
- ...(mergeMetadata?.mergeMethodSettings
- ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
- : {})
- }
-}
-
-async function hydrateBranchLookupWithExactPR(
- ownerRepo: OwnerRepo,
- branchData: PullRequestLookupData | null,
- ghOptions: GhExecOptions,
- executionScope: string
-): Promise {
- if (!branchData) {
- return null
- }
- try {
- return (
- (await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ??
- branchData
- )
- } catch {
- return branchData
- }
-}
-
-async function getRestPRForBranch(
- prRepo: GitHubApiRepository,
- headOwner: string,
- branchName: string,
- ghOptions: ReturnType
-): Promise {
- const head = encodeURIComponent(`${headOwner}:${branchName}`)
- const { stdout } = await ghExecFileAsync(
- ['api', `repos/${prRepo.owner}/${prRepo.repo}/pulls?head=${head}&state=all&per_page=1`],
- { ...ghOptions, ...githubHostExecOptions(prRepo) }
- )
- const list = JSON.parse(stdout) as RestPullRequest[]
- const pr = list[0]
- return pr ? mapRestPullRequest(pr) : null
-}
-
-async function getFallbackPRListForBranch(
- prRepo: GitHubApiRepository,
- branchName: string,
- ghOptions: ReturnType
-): Promise {
- const { stdout } = await ghExecFileAsync(
- [
- 'pr',
- 'list',
- '--repo',
- `${prRepo.owner}/${prRepo.repo}`,
- '--head',
- branchName,
- '--state',
- 'all',
- '--limit',
- '1',
- '--json',
- PR_BRANCH_LIST_JSON_FIELDS
- ],
- { ...ghOptions, ...githubHostExecOptions(prRepo) }
- )
- const list = JSON.parse(stdout) as PullRequestLookupData[]
- return list[0] ?? null
-}
-
-type TrackedUpstreamBranch = {
- remoteName: string
- branchName: string
-}
-
-const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000
-const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512
-
-type TrackedUpstreamSnapshotCacheEntry = {
- expiresAt: number
- gitConfigSignature?: string
- upstreamsByBranchName: Map
-}
-
-type TrackedUpstreamSnapshotProbeResult = {
- cacheable: boolean
- gitConfigSignature?: string
- probeFailed: boolean
- upstreamsByBranchName: Map
-}
-
-const trackedUpstreamSnapshotCache = new Map()
-const trackedUpstreamSnapshotInFlight = new Map<
- string,
- Promise
->()
-const trackedUpstreamSnapshotGenerations = new Map()
-
-function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol {
- const generation = Symbol()
- trackedUpstreamSnapshotGenerations.set(cacheKey, generation)
- return generation
-}
-
-function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void {
- // Why: generations only guard an active probe; retaining completed keys leaks worktree/runtime identities past the snapshot TTL.
- if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) {
- trackedUpstreamSnapshotGenerations.delete(cacheKey)
- }
-}
-
-function pruneTrackedUpstreamSnapshotCache(now: number): void {
- for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) {
- if (cached.expiresAt <= now) {
- trackedUpstreamSnapshotCache.delete(cacheKey)
- }
- }
- // Why: workspace/runtime churn can create unbounded unique keys within one TTL window, so expiry sweeping alone isn't a memory bound.
- while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) {
- const oldestKey = trackedUpstreamSnapshotCache.keys().next().value
- if (oldestKey === undefined) {
- break
- }
- trackedUpstreamSnapshotCache.delete(oldestKey)
- }
-}
-
-export function _getTrackedUpstreamBranchCacheSizesForTests(): {
- snapshots: number
- inFlight: number
- generations: number
-} {
- return {
- snapshots: trackedUpstreamSnapshotCache.size,
- inFlight: trackedUpstreamSnapshotInFlight.size,
- generations: trackedUpstreamSnapshotGenerations.size
- }
-}
-
-export function __resetTrackedUpstreamBranchCacheForTests(): void {
- trackedUpstreamSnapshotCache.clear()
- trackedUpstreamSnapshotInFlight.clear()
- trackedUpstreamSnapshotGenerations.clear()
-}
-
-function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch | null {
- const parsed = splitRemoteBranchName(upstreamRef.trim())
- if (!parsed) {
- return null
- }
- return parsed
-}
-
-function shouldRetryTrackedUpstreamBranch(
- upstreamBranch: TrackedUpstreamBranch,
- branchName: string,
- upstreamHeadRepo: OwnerRepo,
- headRepo: OwnerRepo | null
-): boolean {
- if (upstreamBranch.branchName !== branchName) {
- return true
- }
- if (!headRepo) {
- return true
- }
- return githubRepoIdentityKey(upstreamHeadRepo) !== githubRepoIdentityKey(headRepo)
-}
-
-async function getTrackedUpstreamBranch(
- repoPath: string,
- branchName: string,
- connectionId?: string | null,
- localGitOptions: { wslDistro?: string } = {}
-): Promise {
- const cacheKey = getTrackedUpstreamBranchCacheKey(repoPath, connectionId, localGitOptions)
- const now = Date.now()
- const cached = trackedUpstreamSnapshotCache.get(cacheKey)
- if (cached && cached.expiresAt > now) {
- const configSignatureMatches = await doesTrackedUpstreamCacheConfigSignatureMatch(
- cached,
- repoPath,
- connectionId,
- localGitOptions
- )
- if (
- configSignatureMatches &&
- cached.upstreamsByBranchName.has(branchName) &&
- canUseCachedTrackedUpstreamBranch(cached, branchName)
- ) {
- return cached.upstreamsByBranchName.get(branchName) ?? null
- }
- trackedUpstreamSnapshotCache.delete(cacheKey)
- }
- if (cached) {
- trackedUpstreamSnapshotCache.delete(cacheKey)
- }
-
- const inFlight = trackedUpstreamSnapshotInFlight.get(cacheKey)
- if (inFlight) {
- const result = await inFlight
- if (result.upstreamsByBranchName.has(branchName)) {
- return result.upstreamsByBranchName.get(branchName) ?? null
- }
- // Why: a concurrent snapshot may finish before this branch exists in git; re-probe instead of returning a synthetic null.
- const retryInFlight = trackedUpstreamSnapshotInFlight.get(cacheKey)
- if (retryInFlight) {
- const retryResult = await retryInFlight
- return retryResult.upstreamsByBranchName.get(branchName) ?? null
- }
- }
-
- // Why: PR polling asks about hundreds of branches at once; read all upstreams in one git process per repo/runtime, not one probe per branch.
- const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey)
- const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions)
- trackedUpstreamSnapshotInFlight.set(cacheKey, probe)
- try {
- const result = await probe
- if (result.cacheable && trackedUpstreamSnapshotGenerations.get(cacheKey) === probeGeneration) {
- trackedUpstreamSnapshotCache.set(cacheKey, {
- ...(result.gitConfigSignature ? { gitConfigSignature: result.gitConfigSignature } : {}),
- upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName),
- expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS
- })
- pruneTrackedUpstreamSnapshotCache(Date.now())
- }
- if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) {
- const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey)
- if (fresherCached?.upstreamsByBranchName.has(branchName)) {
- return fresherCached.upstreamsByBranchName.get(branchName) ?? null
- }
- }
- return result.upstreamsByBranchName.get(branchName) ?? null
- } finally {
- if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) {
- trackedUpstreamSnapshotInFlight.delete(cacheKey)
- }
- finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration)
- }
-}
-
-async function probeTrackedUpstreamSnapshot(
- repoPath: string,
- connectionId?: string | null,
- localGitOptions: { wslDistro?: string } = {}
-): Promise {
- const startingGitConfigSignature = await readLocalGitConfigSignature({
- repoPath,
- connectionId: connectionId ?? null,
- ...localGitOptions
- })
- const { probeFailed, upstreamsByBranchName } = await probeTrackedUpstreamBranches(
- repoPath,
- connectionId,
- localGitOptions
- )
- const endingGitConfigSignature = await readLocalGitConfigSignature({
- repoPath,
- connectionId: connectionId ?? null,
- ...localGitOptions
- })
- const isLocalHostRuntime = !connectionId && !localGitOptions.wslDistro
- const configSignatureChanged =
- isLocalHostRuntime && startingGitConfigSignature !== endingGitConfigSignature
- const gitConfigSignature =
- startingGitConfigSignature === endingGitConfigSignature ? endingGitConfigSignature : undefined
- return {
- // Why: don't cache an empty snapshot after a transient git failure, or every branch lookup re-probes on the next refresh tick.
- cacheable: !configSignatureChanged && !probeFailed,
- probeFailed,
- ...(gitConfigSignature ? { gitConfigSignature } : {}),
- upstreamsByBranchName
- }
-}
-
-function getCacheableTrackedUpstreamSnapshot(
- upstreamsByBranchName: Map
-): Map {
- // Why: SSH/WSL can't cheaply inspect remote .git/config here; the short TTL bounds stale positives while refreshes share one scan.
- return upstreamsByBranchName
-}
-
-function canUseCachedTrackedUpstreamBranch(
- cached: TrackedUpstreamSnapshotCacheEntry,
- branchName: string
-): boolean {
- return cached.upstreamsByBranchName.has(branchName)
-}
-
-async function doesTrackedUpstreamCacheConfigSignatureMatch(
- cached: TrackedUpstreamSnapshotCacheEntry,
- repoPath: string,
- connectionId?: string | null,
- localGitOptions: { wslDistro?: string } = {}
-): Promise {
- if (!cached.gitConfigSignature) {
- return true
- }
- const currentSignature = await readLocalGitConfigSignature({
- repoPath,
- connectionId: connectionId ?? null,
- ...localGitOptions
- })
- return currentSignature === cached.gitConfigSignature
-}
-
-function getTrackedUpstreamBranchCacheKey(
- repoPath: string,
- connectionId?: string | null,
- localGitOptions: { wslDistro?: string } = {}
-): string {
- const runtimeKey = connectionId
- ? `ssh:${connectionId}`
- : `local:${localGitOptions.wslDistro ?? 'host'}`
- return [runtimeKey, repoPath].join('\0')
-}
-
-async function probeTrackedUpstreamBranches(
- repoPath: string,
- connectionId?: string | null,
- localGitOptions: { wslDistro?: string } = {}
-): Promise<{
- probeFailed: boolean
- upstreamsByBranchName: Map
-}> {
- const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
- const provider = connectionId ? getSshGitProvider(connectionId) : null
- if (connectionId && !provider) {
- throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
- }
- if (provider) {
- const result = await provider.exec(args, repoPath)
- return {
- probeFailed: false,
- upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
- }
- }
- try {
- const result = await gitExecFileAsync(args, {
- cwd: repoPath,
- ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
- })
- return {
- probeFailed: false,
- upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
- }
- } catch {
- return { probeFailed: true, upstreamsByBranchName: new Map() }
- }
-}
-
-function parseTrackedUpstreamBranches(stdout: string): Map {
- const upstreamsByBranchName = new Map()
- for (const line of stdout.split(/\r?\n/)) {
- if (!line) {
- continue
- }
- const [branchName, upstreamRef] = line.split('\0')
- const localBranchName = branchName?.replace(/^refs\/heads\//, '')
- if (!localBranchName) {
- continue
- }
- upstreamsByBranchName.set(localBranchName, parseTrackedUpstreamRef(upstreamRef ?? ''))
- }
- return upstreamsByBranchName
-}
-
-function parseTrackedUpstreamRef(upstreamRef: string): TrackedUpstreamBranch | null {
- const remoteRefPrefix = 'refs/remotes/'
- const normalizedRef = upstreamRef.trim()
- if (normalizedRef.startsWith(remoteRefPrefix)) {
- return parseTrackedUpstreamBranch(normalizedRef.slice(remoteRefPrefix.length))
- }
- if (normalizedRef.startsWith('refs/heads/')) {
- return null
- }
- return parseTrackedUpstreamBranch(normalizedRef)
-}
-
-async function lookupPRByBranchName(args: {
- candidates: OwnerRepo[]
- headRepo: OwnerRepo | null
- branchName: string
- ghOptions: GhExecOptions
- executionScope: string
-}): Promise<{
- data: PullRequestLookupData | null
- dataRepo: OwnerRepo | null
- pendingError?: unknown
-}> {
- if (args.candidates.length > 0) {
- let pendingError: unknown
- let hasPendingError = false
- for (const candidate of args.candidates) {
- try {
- const branchData = args.headRepo
- ? await getRestPRForBranch(
- candidate,
- args.headRepo.owner,
- args.branchName,
- args.ghOptions
- )
- : await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions)
- // Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state.
- const data = await hydrateBranchLookupWithExactPR(
- candidate,
- branchData,
- args.ghOptions,
- args.executionScope
- )
- if (data) {
- return { data, dataRepo: candidate }
- }
- } catch (err) {
- if (args.headRepo) {
- throw err
- }
- if (!hasPendingError) {
- pendingError = err
- hasPendingError = true
- }
- try {
- const branchData = await getRestPRForBranch(
- candidate,
- candidate.owner,
- args.branchName,
- args.ghOptions
- )
- const data = await hydrateBranchLookupWithExactPR(
- candidate,
- branchData,
- args.ghOptions,
- args.executionScope
- )
- if (data) {
- return { data, dataRepo: candidate }
- }
- } catch (retryErr) {
- if (!hasPendingError) {
- pendingError = retryErr
- hasPendingError = true
- }
- }
- }
- }
- // Why: branch-list failures are ambiguous for fork discovery; give exact fallback-number recovery a chance before surfacing the error.
- return hasPendingError
- ? { data: null, dataRepo: null, pendingError }
- : { data: null, dataRepo: null }
- }
-
- try {
- const { stdout } = await ghExecFileAsync(
- ['pr', 'view', args.branchName, '--json', PR_LOOKUP_JSON_FIELDS],
- args.ghOptions
- )
- return {
- data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData),
- dataRepo: null
- }
- } catch (err) {
- if (isNoPullRequestError(err)) {
- return { data: null, dataRepo: null }
- }
- throw err
- }
-}
-
-function isPositiveSafeInteger(value: unknown): value is number {
- return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
-}
-
-function isGitObjectId(value: unknown): value is string {
- return typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)
-}
-
-function isUsableRestStackMetadata(value: unknown): boolean {
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
- return false
- }
- const stack = value as {
- number?: unknown
- position?: unknown
- size?: unknown
- base?: unknown
- }
- if (!stack.base || typeof stack.base !== 'object' || Array.isArray(stack.base)) {
- return false
- }
- const base = stack.base as { ref?: unknown; sha?: unknown }
- return (
- isPositiveSafeInteger(stack.number) &&
- isPositiveSafeInteger(stack.position) &&
- isPositiveSafeInteger(stack.size) &&
- stack.position <= stack.size &&
- typeof base.ref === 'string' &&
- base.ref.trim().length > 0 &&
- (base.sha === undefined || isGitObjectId(base.sha))
- )
-}
-
-async function getRestPRByNumber(
- ownerRepo: GitHubApiRepository,
- number: number,
- ghOptions: ReturnType,
- options: { requireUsableStackMetadata?: boolean } = {}
-): Promise {
- const { stdout } = await ghExecFileAsync(
- ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
- { ...ghOptions, ...githubHostExecOptions(ownerRepo) }
- )
- const parsed = JSON.parse(stdout) as unknown
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
- throw new Error('invalid response shape')
- }
- const restData = parsed as RestPullRequest
- const mapped = mapRestPullRequest(restData)
- if (
- options.requireUsableStackMetadata &&
- restData.stack !== undefined &&
- restData.stack !== null
- ) {
- // Why: GitHub omits stack for ordinary PRs; only unusable non-null metadata is unsafe.
- if (!isUsableRestStackMetadata(restData.stack) || !mapped.stack) {
- throw new Error('malformed stack')
- }
- if (!isGitObjectId(restData.head?.sha)) {
- throw new Error('missing head SHA')
- }
- }
- return mapped
-}
-
-function prunePRStackSummaryCache(now = Date.now()): void {
- for (const [key, cached] of prStackSummaryCache) {
- if (cached.expiresAt <= now) {
- prStackSummaryCache.delete(key)
- }
- }
- while (prStackSummaryCache.size > PR_STACK_SUMMARY_CACHE_MAX_ENTRIES) {
- const oldestKey = prStackSummaryCache.keys().next().value
- if (oldestKey === undefined) {
- return
- }
- prStackSummaryCache.delete(oldestKey)
- }
-}
-
-async function getCachedGitHubPRStackSummary(
- ownerRepo: GitHubApiRepository,
- number: number,
- ghOptions: ReturnType,
- executionScope: string
-): Promise {
- const key = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}#${number}`
- const now = Date.now()
- prunePRStackSummaryCache(now)
- const cached = prStackSummaryCache.get(key)
- if (cached && cached.expiresAt > now) {
- return cached.value
- }
- const existing = prStackSummaryInFlight.get(key)
- if (existing) {
- return existing
- }
- const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr.stack)
- prStackSummaryInFlight.set(key, request)
- try {
- const value = await request
- prStackSummaryCache.delete(key)
- prStackSummaryCache.set(key, {
- value,
- expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS
- })
- prunePRStackSummaryCache()
- return value
- } catch (err) {
- // Why: avoid repeating a failed REST probe on every review poll.
- prStackSummaryCache.delete(key)
- prStackSummaryCache.set(key, {
- value: undefined,
- expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS
- })
- prunePRStackSummaryCache()
- throw err
- } finally {
- if (prStackSummaryInFlight.get(key) === request) {
- prStackSummaryInFlight.delete(key)
- }
- }
-}
-
-async function getPRByNumber(
- ownerRepo: GitHubApiRepository,
- number: number,
- ghOptions: ReturnType,
- executionScope: string,
- knownPullRequestData?: PullRequestLookupData | null
-): Promise {
- try {
- const { stdout } = await ghExecFileAsync(
- [
- 'pr',
- 'view',
- String(number),
- '--repo',
- `${ownerRepo.owner}/${ownerRepo.repo}`,
- '--json',
- PR_LOOKUP_JSON_FIELDS
- ],
- { ...ghOptions, ...githubHostExecOptions(ownerRepo) }
- )
- const exactData = JSON.parse(stdout) as PullRequestLookupData
- return hydratePullRequestLookupData(
- ownerRepo,
- {
- ...knownPullRequestData,
- ...exactData,
- ...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {})
- },
- ghOptions,
- executionScope
- )
- } catch (err) {
- // Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup.
- if (isNotFoundGhError(err)) {
- return null
- }
- try {
- const restData =
- knownPullRequestData === undefined
- ? await getRestPRByNumber(ownerRepo, number, ghOptions)
- : knownPullRequestData
- return restData
- ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope)
- : null
- } catch (restErr) {
- if (isNotFoundGhError(restErr)) {
- return null
- }
- if (!shouldStopAfterExactLookupError(restErr)) {
- return null
- }
- throw restErr
- }
- }
-}
-
-async function lookupPRByNumber(args: {
- candidates: OwnerRepo[]
- number: number
- ghOptions: ReturnType
- executionScope: string
-}): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> {
- for (const candidate of args.candidates) {
- try {
- const linkedData = await getPRByNumber(
- candidate,
- args.number,
- args.ghOptions,
- args.executionScope
- )
- if (!linkedData) {
- continue
- }
- return { data: linkedData, dataRepo: candidate }
- } catch (err) {
- if (shouldStopAfterExactLookupError(err)) {
- throw err
- }
- // Candidate probing is best-effort; another repo may own the PR.
- }
- }
-
- if (args.candidates.length > 0) {
- return { data: null, dataRepo: null }
- }
-
- try {
- const { stdout } = await ghExecFileAsync(
- ['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS],
- args.ghOptions
- )
- return {
- data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData),
- dataRepo: null
- }
- } catch (err) {
- if (isNoPullRequestError(err)) {
- // Why: stale cached fallback numbers shouldn't error every poll when the PR was deleted or belongs to another repo.
- return { data: null, dataRepo: null }
- }
- throw err
- }
-}
-
-function isNotFoundGhError(err: unknown): boolean {
- const stderr = err instanceof Error ? err.message : String(err)
- return classifyGhError(stderr).type === 'not_found'
-}
-
-function shouldStopAfterExactLookupError(err: unknown): boolean {
- const stderr = err instanceof Error ? err.message : String(err)
- const type = classifyGhError(stderr).type
- return type !== 'not_found'
-}
-
-/**
- * Get PR info for a given branch using gh CLI.
- * Returns null if gh is not installed, or no PR exists for the branch.
- *
- * When `linkedPRNumber` is provided, it is the source of truth. This handles
- * "create from PR" worktrees whose local branch differs from the PR head ref,
- * and prevents a coalesced linked-PR refresh from fanning out an unrelated
- * branch lookup result to sibling aliases.
- * `fallbackPRNumber` is weaker: branch lookup still wins, and exact lookup is
- * used only after branch lookup misses.
- */
-export async function getPRForBranch(
- repoPath: string,
- branch: string,
- linkedPRNumber?: number | null,
- connectionId?: string | null,
- fallbackPRNumber?: number | null,
- options: GitHubPRBranchLookupOptions = {}
-): Promise {
- const outcome = await getPRForBranchOutcome(
- repoPath,
- branch,
- linkedPRNumber,
- connectionId,
- fallbackPRNumber,
- options
- )
- return outcome.kind === 'found' ? outcome.pr : null
-}
-
-// Why: exact-linked fallback has no dataRepo; derive its host-aware identity from the web URL for merged-PR membership checks.
-function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null {
- const match = url.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/)
- return match ? { owner: match[2], repo: match[3], host: match[1] } : null
-}
-
-export async function getPRForBranchOutcome(
- repoPath: string,
- branch: string,
- linkedPRNumber?: number | null,
- connectionId?: string | null,
- fallbackPRNumber?: number | null,
- options: GitHubPRBranchLookupOptions = {}
-): Promise {
- const branchName = branch.replace(/^refs\/heads\//, '')
- // Why: detached HEAD can't use branch lookup, but an exact linked/fallback PR number is still safe to query and keeps review state visible.
- if (!branchName && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber !== 'number') {
- return { kind: 'no-pr', fetchedAt: Date.now() }
- }
- const localGitArgs = hostedReviewLocalGitOptionArgs(options)
- const localGitOptions = localGitArgs[0] ?? {}
- const context = githubRepoContext(repoPath, connectionId, localGitOptions)
- const ghOptions = ghRepoExecOptions(context)
- const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions)
-
- await acquire()
- try {
- const { candidates, headRepo } = await resolveGitHubApiRepositoryCandidates(
- repoPath,
- connectionId,
- localGitOptions
- )
- // Why: connection-backed gh runs without a repository cwd. A bare lookup
- // here can honor process GH_REPO/GH_HOST and return an unrelated PR.
- if (connectionId && candidates.length === 0) {
- return { kind: 'no-pr', fetchedAt: Date.now() }
- }
- // Why (#11532): account every lookup, not just the coordinator's queue —
- // `hostedReview:forBranch` reaches this directly from renderer polling and
- // was spending the shared quota invisibly. headRepo is `origin`, the same
- // identity the coordinator guards on.
- for (const bucket of PR_BRANCH_LOOKUP_BUCKETS) {
- noteRepositoryRateLimitSpend(headRepo ?? candidates[0], bucket, 1, ghOptions)
- }
- let data: PullRequestLookupData | null = null
- let dataRepo: OwnerRepo | null = null
- let dataHeadRepo: OwnerRepo | null = headRepo
- let pendingBranchLookupError: unknown
- let hasPendingBranchLookupError = false
- let currentHeadOidForMergedImplicit: string | null | undefined
- let usedExactNumberLookup = false
-
- const explicitCurrentHeadOid =
- typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0
- ? options.currentHeadOid.trim()
- : null
- let confirmedContainedHeadOid: string | null = null
- let headDivergedFromMergedPRAtOid: string | null = null
- const mergedPRContainsHead = async (
- candidate: PullRequestLookupData,
- candidateRepo: OwnerRepo | null,
- headOid: string | null
- ): Promise => {
- if (!candidateRepo || !headOid) {
- return 'unknown'
- }
- const membership = await isCommitPartOfMergedPR({
- ownerRepo: candidateRepo,
- prNumber: candidate.number,
- commitOid: headOid,
- ghOptions
- })
- if (membership === 'contained') {
- confirmedContainedHeadOid = headOid
- }
- return membership
- }
- const recordLinkedMergedPRDivergence = async (
- candidate: PullRequestLookupData | null,
- candidateRepo: OwnerRepo | null
- ): Promise => {
- if (
- typeof linkedPRNumber !== 'number' ||
- !candidate ||
- mapPRState(candidate.state, candidate.isDraft) !== 'merged' ||
- explicitCurrentHeadOid === null ||
- candidate.headRefOid === explicitCurrentHeadOid
- ) {
- return
- }
- const membership = await mergedPRContainsHead(
- candidate,
- candidateRepo ?? ownerRepoFromPullRequestUrl(candidate.url),
- explicitCurrentHeadOid
- )
- if (membership === 'not-contained') {
- // explicitCurrentHeadOid is non-null here (guarded above); record the exact diverged head so consumers clear only that worktree.
- headDivergedFromMergedPRAtOid = explicitCurrentHeadOid
- }
- }
- const hideMergedImplicitPR = async (
- candidate: PullRequestLookupData | null,
- candidateRepo: OwnerRepo | null
- ) => {
- if (!candidate || !isMergedImplicitPR(candidate, linkedPRNumber)) {
- return false
- }
- // Why: prefer the caller's worktree HEAD; only shell out (main repo path) when no explicit oid, keeping merged-at-head PRs visible for secondary worktrees.
- currentHeadOidForMergedImplicit ??=
- explicitCurrentHeadOid !== null
- ? explicitCurrentHeadOid
- : await getCurrentHeadOid(repoPath, connectionId, localGitOptions)
- if (!shouldHideMergedImplicitPR(candidate, linkedPRNumber, currentHeadOidForMergedImplicit)) {
- return false
- }
- // Why: a head that is one of the PR's own commits (update-branch/web commits) is the same work, not a reused branch name — keep the merged PR visible.
- return (
- (await mergedPRContainsHead(candidate, candidateRepo, currentHeadOidForMergedImplicit)) !==
- 'contained'
- )
- }
-
- if (typeof linkedPRNumber === 'number') {
- usedExactNumberLookup = true
- const exactLookup = await lookupPRByNumber({
- candidates,
- number: linkedPRNumber,
- ghOptions,
- executionScope
- })
- data = exactLookup.data
- dataRepo = exactLookup.dataRepo
- } else if (branchName) {
- // During a rebase (detached HEAD) branch is empty; an empty --head filter makes gh return an arbitrary PR.
- const branchLookup = await lookupPRByBranchName({
- candidates,
- headRepo,
- branchName,
- ghOptions,
- executionScope
- })
- data = branchLookup.data
- dataRepo = branchLookup.dataRepo
- if ('pendingError' in branchLookup) {
- pendingBranchLookupError = branchLookup.pendingError
- hasPendingBranchLookupError = true
- }
- if (!data) {
- // Why: the tracked upstream identifies the real PR head by branch name or fork owner even when local branch names match.
- const upstreamBranch = await getTrackedUpstreamBranch(
- repoPath,
- branchName,
- connectionId,
- localGitOptions
- )
- if (upstreamBranch) {
- const upstreamHeadRepo =
- (await getGitHubApiRepositoryForRemote(
- repoPath,
- upstreamBranch.remoteName,
- connectionId,
- localGitOptions
- )) ?? headRepo
- if (
- upstreamHeadRepo &&
- shouldRetryTrackedUpstreamBranch(upstreamBranch, branchName, upstreamHeadRepo, headRepo)
- ) {
- const upstreamLookup = await lookupPRByBranchName({
- candidates,
- headRepo: upstreamHeadRepo,
- branchName: upstreamBranch.branchName,
- ghOptions,
- executionScope
- })
- data = upstreamLookup.data
- dataRepo = upstreamLookup.dataRepo
- if (!hasPendingBranchLookupError && 'pendingError' in upstreamLookup) {
- pendingBranchLookupError = upstreamLookup.pendingError
- hasPendingBranchLookupError = true
- }
- if (data) {
- dataHeadRepo = upstreamHeadRepo
- }
- }
- }
- }
- }
- let mergedBranchLookupNumber: number | null = null
- if (await hideMergedImplicitPR(data, dataRepo)) {
- mergedBranchLookupNumber = data?.number ?? null
- data = null
- dataRepo = null
- dataHeadRepo = headRepo
- }
- if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') {
- usedExactNumberLookup = true
- const fallbackLookup = await lookupPRByNumber({
- candidates,
- number: fallbackPRNumber,
- ghOptions,
- executionScope
- })
- data = fallbackLookup.data
- dataRepo = fallbackLookup.dataRepo
- }
- if (!data) {
- if (hasPendingBranchLookupError) {
- return prRefreshUpstreamError(pendingBranchLookupError)
- }
- return { kind: 'no-pr', fetchedAt: Date.now() }
- }
- await recordLinkedMergedPRDivergence(data, dataRepo)
- const fallbackConfirmedMergedBranch =
- typeof fallbackPRNumber === 'number' &&
- mergedBranchLookupNumber === fallbackPRNumber &&
- data.number === fallbackPRNumber
- const explicitHeadHidesMergedImplicitPR =
- explicitCurrentHeadOid !== null &&
- shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) &&
- (await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) !== 'contained'
- // Why no lazy-HEAD re-check: fallback numbers were already gated on head equality/containment; re-hiding would blank kept deleted-head merged PRs.
- const shouldPreserveMergedFallback =
- !explicitHeadHidesMergedImplicitPR &&
- (fallbackConfirmedMergedBranch || options.acceptMergedFallbackPR === true)
- // Why: a visible PR can be merged outside Orca; keep a caller-marked fallback fresh even when GitHub no longer reports it by branch (e.g. deleted heads).
- if ((await hideMergedImplicitPR(data, dataRepo)) && !shouldPreserveMergedFallback) {
- return { kind: 'no-pr', fetchedAt: Date.now() }
- }
- // Why (#9171): on the default branch an implicit branch/fallback match must
- // never surface a non-open PR — it overrides the merged-fallback
- // preservation and merged-at-head carve-out on the trunk only. An exact
- // linked lookup returns the linked number, so linked PRs are exempt.
- if (
- await shouldHideNonOpenReviewOnDefaultBranch({
- state: mapPRState(data.state, data.isDraft),
- reviewNumber: data.number,
- linkedReviewNumber: linkedPRNumber,
- branchName,
- repoPath,
- connectionId,
- localGitOptions
- })
- ) {
- return { kind: 'no-pr', fetchedAt: Date.now() }
- }
-
- if (!data.stackMetadataChecked && dataRepo && usedExactNumberLookup) {
- try {
- data.stack = await getCachedGitHubPRStackSummary(
- dataRepo,
- data.number,
- ghOptions,
- executionScope
- )
- data.stackMetadataChecked = true
- } catch {
- // Stack metadata is additive; exact PR lookup remains usable without it.
- }
- }
- const mergeable = derivePullRequestMergeable(data)
- const stack =
- data.stack && dataRepo
- ? await hydrateGitHubPRStack(
- dataRepo,
- data.number,
- data.stack,
- ghOptions,
- data.updatedAt,
- executionScope
- )
- : data.stack
- const stackMergeQueueRequired =
- stack && dataRepo
- ? (
- await detectRepositoryMergeMetadata(
- dataRepo,
- stack.baseRefName,
- ghOptions,
- executionScope
- )
- ).mergeQueueRequired
- : undefined
- const conflictSummary =
- !connectionId &&
- mergeable === 'CONFLICTING' &&
- data.baseRefName &&
- data.baseRefOid &&
- data.headRefOid
- ? await getPRConflictSummary(
- repoPath,
- data.baseRefName,
- data.baseRefOid,
- data.headRefOid,
- localGitOptions
- )
- : undefined
-
- return {
- kind: 'found',
- fetchedAt: Date.now(),
- pr: {
- number: data.number,
- title: data.title,
- state: mapPRState(data.state, data.isDraft),
- url: data.url,
- checksStatus: deriveCheckStatus(data.statusCheckRollup),
- updatedAt: data.updatedAt,
- mergeable,
- ...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}),
- ...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}),
- ...(data.autoMergeAllowed !== undefined ? { autoMergeAllowed: data.autoMergeAllowed } : {}),
- ...(stackMergeQueueRequired !== undefined || data.mergeQueueRequired !== undefined
- ? {
- mergeQueueRequired:
- stackMergeQueueRequired !== undefined
- ? stackMergeQueueRequired
- : data.mergeQueueRequired
- }
- : {}),
- ...(data.mergeMethodSettings !== undefined
- ? { mergeMethodSettings: data.mergeMethodSettings }
- : {}),
- ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}),
- ...(stack ? { stack } : {}),
- headSha: data.headRefOid,
- ...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}),
- ...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}),
- ...(data.baseRefName ? { baseRefName: data.baseRefName } : {}),
- ...(data.headRefName ? { headRefName: data.headRefName } : {}),
- prRepo: dataRepo ?? undefined,
- headRepo: dataHeadRepo ?? undefined,
- conflictSummary
- }
- }
- } catch (err) {
- return prRefreshUpstreamError(err)
- } finally {
- release()
- }
-}
-
-const PR_CHECKS_ROLLUP_QUERY = `
-query($owner: String!, $repo: String!, $pr: Int!) {
- repository(owner: $owner, name: $repo) {
- pullRequest(number: $pr) {
- headRefOid
- commits(last: 1) {
- nodes {
- commit {
- statusCheckRollup {
- contexts(first: 100) {
- nodes {
- __typename
- ... on CheckRun {
- databaseId
- name
- status
- conclusion
- detailsUrl
- url
- checkSuite {
- databaseId
- workflowRun {
- databaseId
- }
- }
- }
- ... on StatusContext {
- context
- state
- targetUrl
- }
- }
- }
- }
- checkSuites(first: 100) {
- nodes {
- databaseId
- status
- conclusion
- url
- app {
- name
- slug
- }
- }
- }
- }
- }
- }
- }
- }
-}
-`
-
-type GraphQLPRChecksResponse = {
- data?: {
- repository?: {
- pullRequest?: {
- headRefOid?: string | null
- commits?: {
- nodes?: { commit?: GraphQLPRChecksCommit | null }[] | null
- } | null
- } | null
- } | null
- } | null
-}
-
-type GraphQLPRChecksCommit = {
- statusCheckRollup?: {
- contexts?: {
- nodes?: GraphQLStatusCheckContext[] | null
- } | null
- } | null
- checkSuites?: {
- nodes?: GraphQLCheckSuite[] | null
- } | null
-}
-
-type GraphQLCheckRunContext = {
- __typename: 'CheckRun'
- databaseId?: number | null
- name?: string | null
- status?: string | null
- conclusion?: string | null
- detailsUrl?: string | null
- url?: string | null
- checkSuite?: {
- databaseId?: number | null
- workflowRun?: { databaseId?: number | null } | null
- } | null
-}
-
-type GraphQLStatusContext = {
- __typename: 'StatusContext'
- context?: string | null
- state?: string | null
- targetUrl?: string | null
-}
-
-type GraphQLStatusCheckContext =
- | GraphQLCheckRunContext
- | GraphQLStatusContext
- | { __typename?: string | null }
-
-type GraphQLCheckSuite = {
- databaseId?: number | null
- status?: string | null
- conclusion?: string | null
- url?: string | null
- app?: { name?: string | null; slug?: string | null } | null
-}
-
-type RestCheckRun = {
- id?: number
- name: string
- status: string
- conclusion: string | null
- html_url: string
- details_url: string | null
-}
-
-type RestCommitStatus = {
- context?: string
- state?: string
- target_url?: string | null
-}
-
-type RestCheckSuite = {
- id?: number | null
- status: string | null
- conclusion: string | null
- app?: { name?: string | null; slug?: string | null } | null
-}
-
-function isGraphQLCheckRunContext(
- context: GraphQLStatusCheckContext
-): context is GraphQLCheckRunContext {
- return context.__typename === 'CheckRun'
-}
-
-function isGraphQLStatusContext(
- context: GraphQLStatusCheckContext
-): context is GraphQLStatusContext {
- return context.__typename === 'StatusContext'
-}
-
-function mapGraphQLCheckRunContext(context: GraphQLCheckRunContext): PRCheckDetail | null {
- const name = nullableString(context.name)
- if (!name) {
- return null
- }
- const url = nullableString(context.detailsUrl) ?? nullableString(context.url)
- const checkRunId = nullableNumber(context.databaseId)
- const workflowRunId =
- nullableNumber(context.checkSuite?.workflowRun?.databaseId) ?? parseActionsRunId(url)
- return {
- name,
- status: mapCheckRunRESTStatus(context.status ?? ''),
- conclusion: mapCheckRunRESTConclusion(context.status ?? '', context.conclusion ?? null),
- url,
- ...(checkRunId !== null ? { checkRunId } : {}),
- ...(typeof workflowRunId === 'number' ? { workflowRunId } : {})
- }
-}
-
-function mapGraphQLStatusContext(context: GraphQLStatusContext): PRCheckDetail | null {
- const name = nullableString(context.context)
- if (!name) {
- return null
- }
- const url = nullableString(context.targetUrl)
- const workflowRunId = parseActionsRunId(url)
- return {
- name,
- status: mapCommitStatusRESTStatus(context.state ?? ''),
- conclusion: mapCommitStatusRESTConclusion(context.state ?? ''),
- url,
- ...(workflowRunId !== undefined ? { workflowRunId } : {})
- }
-}
-
-function mapRestCheckRun(checkRun: RestCheckRun): PRCheckDetail {
- return {
- name: checkRun.name,
- status: mapCheckRunRESTStatus(checkRun.status),
- conclusion: mapCheckRunRESTConclusion(checkRun.status, checkRun.conclusion),
- url: checkRun.details_url || checkRun.html_url || null,
- ...(typeof checkRun.id === 'number' ? { checkRunId: checkRun.id } : {}),
- workflowRunId: parseActionsRunId(checkRun.details_url || checkRun.html_url || null)
- }
-}
-
-function mapRestCommitStatus(status: RestCommitStatus): PRCheckDetail | null {
- const name = nullableString(status.context)
- if (!name) {
- return null
- }
- const url = nullableString(status.target_url)
- const workflowRunId = parseActionsRunId(url)
- return {
- name,
- status: mapCommitStatusRESTStatus(status.state ?? ''),
- conclusion: mapCommitStatusRESTConclusion(status.state ?? ''),
- url,
- ...(workflowRunId !== undefined ? { workflowRunId } : {})
- }
-}
-
-function mapGraphQLPendingApprovalCheckSuite(
- ownerRepo: GitHubApiRepository,
- suite: GraphQLCheckSuite,
- headSha: string | null | undefined,
- index: number
-): PRCheckDetail {
- return {
- name: getPendingApprovalCheckSuiteName(suite, headSha, index),
- status: 'completed',
- conclusion: 'action_required',
- // Why: suite-only approval blockers have no check run; link the suite page when GraphQL exposes one.
- url:
- nullableString(suite.url) ??
- (headSha ? getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.databaseId) : null)
- }
-}
-
-function mapGraphQLPRChecksResponse(
- ownerRepo: GitHubApiRepository,
- response: GraphQLPRChecksResponse
-): PRCheckDetail[] | null {
- const pullRequest = response.data?.repository?.pullRequest
- if (!pullRequest) {
- return null
- }
- const commit = pullRequest.commits?.nodes?.[0]?.commit
- if (!commit) {
- return []
- }
-
- const contexts = commit.statusCheckRollup?.contexts?.nodes ?? []
- const checkRunContexts = contexts.filter(isGraphQLCheckRunContext)
- const checkRuns = checkRunContexts
- .map(mapGraphQLCheckRunContext)
- .filter((check): check is PRCheckDetail => check !== null)
- const checkRunNames = new Set(checkRuns.map((check) => check.name))
- const checkSuiteIdsWithRuns = new Set(
- checkRunContexts
- .map((context) => nullableNumber(context.checkSuite?.databaseId))
- .filter((id): id is number => id !== null)
- )
- // Why: mixed-CI repos expose Jenkins/Prow/Tide as legacy status contexts in the same rollup; keep check-run metadata on name collisions.
- const legacyStatuses = contexts
- .filter(isGraphQLStatusContext)
- .map(mapGraphQLStatusContext)
- .filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name))
- const pendingApprovalChecks = (commit.checkSuites?.nodes ?? [])
- .filter((suite) => suite.conclusion?.toLowerCase() === 'action_required')
- .filter((suite) => {
- const suiteId = nullableNumber(suite.databaseId)
- return suiteId === null || !checkSuiteIdsWithRuns.has(suiteId)
- })
- .map((suite, index) =>
- mapGraphQLPendingApprovalCheckSuite(ownerRepo, suite, pullRequest.headRefOid, index)
- )
-
- return [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks]
-}
-
-async function getPRChecksViaRestFallback(
- ownerRepo: GitHubApiRepository,
- headSha: string | undefined,
- ghOptions: GhExecOptions,
- noCache?: boolean
-): Promise {
- if (!headSha) {
- return null
- }
- try {
- await assertRateLimitBudget('core', ownerRepo, ghOptions)
- } catch (err) {
- console.warn('getPRChecks skipped REST fallback, falling back to gh pr checks:', err)
- return null
- }
-
- await acquire()
- try {
- const cacheArgs = noCache ? [] : ['--cache', '60s']
- const encodedHeadSha = encodeURIComponent(headSha)
- const { stdout } = await ghExecFileAsync(
- [
- 'api',
- ...cacheArgs,
- `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-runs?per_page=100`
- ],
- ghOptions
- )
- noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
- const checkRunData = JSON.parse(stdout) as {
- check_runs?: RestCheckRun[]
- }
- const checkRuns = (checkRunData.check_runs ?? []).map(mapRestCheckRun)
- const checkRunNames = new Set(checkRuns.map((check) => check.name))
-
- let legacyStatuses: PRCheckDetail[] = []
- try {
- const statusResult = await ghExecFileAsync(
- [
- 'api',
- ...cacheArgs,
- `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/status?per_page=100`
- ],
- ghOptions
- )
- noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
- const statusData = JSON.parse(statusResult.stdout) as {
- statuses?: RestCommitStatus[]
- }
- legacyStatuses = (statusData.statuses ?? [])
- .map(mapRestCommitStatus)
- .filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name))
- } catch (err) {
- // Why: REST fallback is already degraded; keep the richer check-run rows if legacy-status enrichment fails.
- console.warn('getPRChecks REST status fallback failed:', err)
- }
-
- let pendingApprovalChecks: PRCheckDetail[] = []
- try {
- const suitesResult = await ghExecFileAsync(
- [
- 'api',
- ...cacheArgs,
- `repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-suites?per_page=100`
- ],
- ghOptions
- )
- noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
- const suitesData = JSON.parse(suitesResult.stdout) as {
- check_suites?: RestCheckSuite[]
- }
- pendingApprovalChecks = (suitesData.check_suites ?? [])
- .filter((suite) => suite.conclusion?.toLowerCase() === 'action_required')
- .map((suite, index) => ({
- name: getPendingApprovalCheckSuiteName(suite, headSha, index),
- status: 'completed' as const,
- conclusion: 'action_required' as const,
- url: getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.id)
- }))
- } catch (err) {
- console.warn('getPRChecks REST check-suite fallback failed:', err)
- }
-
- const checks = [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks]
- return checks.length > 0 ? checks : null
- } catch (err) {
- console.warn('getPRChecks via REST fallback failed, falling back to gh pr checks:', err)
- return null
- } finally {
- release()
- }
-}
-
-/**
- * Get detailed check statuses for a PR.
- * Uses GitHub's combined GraphQL rollup so check runs and legacy commit statuses
- * arrive in one cached request; suite-only approval blockers are included too.
- */
-export async function getPRChecks(
- repoPath: string,
- prNumber: number,
- headSha?: string,
- prRepo?: GitHubApiRepository | null,
- options?: { noCache?: boolean },
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- void headSha
- const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
- repoPath,
- prRepo,
- connectionId,
- localGitOptions
- )
- if (connectionId && !ownerRepo) {
- throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
- }
- const fallbackToPRChecks = async (): Promise => {
- await assertRateLimitBudget('graphql', ownerRepo, ghOptions)
- await acquire()
- try {
- const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link']
- if (ownerRepo) {
- fallbackArgs.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
- }
- const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions).catch((err: unknown) => {
- const { stderr } = extractExecError(err)
- // Why: `gh pr checks` exits non-zero when a PR has no check runs yet; treat that as empty, not a load failure.
- if (stderr.toLowerCase().includes('no checks reported')) {
- return { stdout: '[]', stderr }
- }
- throw err
- })
- noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
- const data = JSON.parse(stdout) as { name: string; state: string; link: string }[]
- return data.map((d) => ({
- name: d.name,
- status: mapCheckStatus(d.state),
- conclusion: mapCheckConclusion(d.state),
- url: d.link || null,
- workflowRunId: parseActionsRunId(d.link)
- }))
- } finally {
- release()
- }
- }
-
- if (ownerRepo) {
- let canUseGraphQLRollup = true
- try {
- await assertRateLimitBudget('graphql', ownerRepo, ghOptions)
- } catch (err) {
- canUseGraphQLRollup = false
- console.warn('getPRChecks skipped GraphQL rollup, falling back to gh pr checks:', err)
- }
- if (canUseGraphQLRollup) {
- await acquire()
- try {
- // Why: --cache 60s saves rate-limit budget during polling; explicit refresh skips it for fresh data.
- const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
- const { stdout } = await ghExecFileAsync(
- [
- 'api',
- 'graphql',
- ...cacheArgs,
- '-f',
- `owner=${ownerRepo.owner}`,
- '-f',
- `repo=${ownerRepo.repo}`,
- '-F',
- `pr=${prNumber}`,
- '-f',
- `query=${PR_CHECKS_ROLLUP_QUERY}`
- ],
- ghOptions
- )
- noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
- const checks = mapGraphQLPRChecksResponse(
- ownerRepo,
- JSON.parse(stdout) as GraphQLPRChecksResponse
- )
- if (checks !== null) {
- return checks
- }
- } catch (err) {
- // Why: fall back to older `gh pr checks` when GitHub's richer rollup query is unavailable.
- console.warn('getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', err)
- } finally {
- release()
- }
- }
- const restChecks = await getPRChecksViaRestFallback(
- ownerRepo,
- headSha,
- ghOptions,
- options?.noCache
- )
- if (restChecks !== null) {
- return restChecks
- }
- }
-
- try {
- return await fallbackToPRChecks()
- } catch (err) {
- console.warn('getPRChecks failed:', err)
- throw err
- }
-}
-
-function getPendingApprovalCheckSuiteName(
- suite: {
- id?: number | null
- databaseId?: number | null
- app?: { name?: string | null; slug?: string | null } | null
- },
- headSha: string | null | undefined,
- index: number
-): string {
- const appName = suite.app?.name ?? suite.app?.slug ?? null
- const rawSuiteId = suite.databaseId ?? suite.id
- const suiteId =
- typeof rawSuiteId === 'number' && Number.isFinite(rawSuiteId) ? `#${rawSuiteId}` : null
- if (appName && suiteId) {
- return `${appName} ${suiteId}`
- }
- if (appName) {
- return appName
- }
- if (suiteId) {
- return suiteId
- }
- return `${headSha?.slice(0, 12) ?? 'check-suite'}:${index + 1}`
-}
-
-function getPendingApprovalCheckSuiteUrl(
- ownerRepo: GitHubApiRepository,
- headSha: string,
- suiteId: number | null | undefined
-): string {
- const base = `https://${githubRepositoryWebHost(ownerRepo)}/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks`
- return typeof suiteId === 'number' && Number.isFinite(suiteId)
- ? `${base}#check-suite-${suiteId}`
- : base
-}
-
-function nullableString(value: unknown): string | null {
- return typeof value === 'string' && value.length > 0 ? value : null
-}
-
-function nullableNumber(value: unknown): number | null {
- return typeof value === 'number' && Number.isFinite(value) ? value : null
-}
-
-function mapCheckAnnotations(raw: unknown): PRCheckRunDetails['annotations'] {
- if (!Array.isArray(raw)) {
- return []
- }
- return raw
- .filter((annotation): annotation is Record => Boolean(annotation))
- .map((annotation) => ({
- path: nullableString(annotation.path),
- startLine: nullableNumber(annotation.start_line),
- endLine: nullableNumber(annotation.end_line),
- annotationLevel: nullableString(annotation.annotation_level),
- title: nullableString(annotation.title),
- message: nullableString(annotation.message) ?? '',
- rawDetails: nullableString(annotation.raw_details)
- }))
-}
-
-function mapWorkflowJobs(raw: unknown, checkName?: string): PRCheckRunDetails['jobs'] {
- if (!raw || typeof raw !== 'object' || !Array.isArray((raw as { jobs?: unknown }).jobs)) {
- return []
- }
- const jobs = (raw as { jobs: unknown[] }).jobs
- .filter((job): job is Record => Boolean(job))
- .map((job) => ({
- id: nullableNumber(job.id),
- name: nullableString(job.name) ?? 'Unnamed job',
- status: nullableString(job.status),
- conclusion: nullableString(job.conclusion),
- startedAt: nullableString(job.started_at),
- completedAt: nullableString(job.completed_at),
- url: nullableString(job.html_url),
- logTail: null,
- steps: Array.isArray(job.steps)
- ? job.steps
- .filter((step): step is Record => Boolean(step))
- .map((step) => ({
- name: nullableString(step.name) ?? 'Unnamed step',
- status: nullableString(step.status),
- conclusion: nullableString(step.conclusion),
- startedAt: nullableString(step.started_at),
- completedAt: nullableString(step.completed_at)
- }))
- : []
- }))
- const exactMatches = checkName ? jobs.filter((job) => job.name === checkName) : []
- return exactMatches.length > 0 ? exactMatches : jobs
-}
-
-function isCheckJobFailureState(state: string | null | undefined): boolean {
- return (
- state === 'failure' ||
- state === 'failed' ||
- state === 'action_required' ||
- state === 'cancelled' ||
- state === 'stale' ||
- state === 'startup_failure' ||
- state === 'timed_out'
- )
-}
-
-function getCheckJobLogTailCacheKey(job: PRCheckRunDetails['jobs'][number]): string | null {
- if (job.id === null) {
- return null
- }
- return `${job.id}:${job.completedAt ?? ''}`
-}
-
-async function attachFailedJobLogTails(
- jobs: PRCheckRunDetails['jobs'],
- ownerRepo: GitHubApiRepository,
- ghOptions: GhExecOptions
-): Promise {
- const failedJobs = jobs
- .filter((job) => {
- const state = job.conclusion ?? job.status
- return job.id !== null && isCheckJobFailureState(state)
- })
- .slice(0, PR_CHECK_LOG_TAIL_JOB_LIMIT)
-
- // Why: cap log fetches so failed-job details stay a bounded follow-up, not a burst of hosted log downloads.
- for (const job of failedJobs) {
- const jobCacheKey = getCheckJobLogTailCacheKey(job)
- const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null
- if (!cacheKey) {
- continue
- }
- if (prCheckLogTailCache.has(cacheKey)) {
- job.logTail = prCheckLogTailCache.get(cacheKey) ?? null
- continue
- }
- try {
- const { stdout } = await ghExecFileAsync(
- ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/jobs/${job.id}/logs`],
- ghOptions
- )
- job.logTail = sliceCheckLogTail(stdout)
- } catch (err) {
- rethrowCheckDetailsAbort(ghOptions.signal, err)
- console.warn('getPRCheckDetails workflow job log fetch failed:', err)
- job.logTail = null
- }
- setPrCheckLogTailCache(cacheKey, job.logTail)
- }
-}
-
-function getWorkflowRunIdFromCheckRun(
- checkRun: Record | null
-): number | undefined {
- const checkSuite = checkRun?.check_suite
- if (!checkSuite || typeof checkSuite !== 'object') {
- return undefined
- }
- const workflowRun = (checkSuite as { workflow_run?: unknown }).workflow_run
- if (!workflowRun || typeof workflowRun !== 'object') {
- return undefined
- }
- const id = (workflowRun as { id?: unknown }).id
- return typeof id === 'number' && Number.isSafeInteger(id) ? id : undefined
-}
-
-export async function getPRCheckDetails(
- repoPath: string,
- args: {
- checkRunId?: number
- workflowRunId?: number
- checkName?: string
- url?: string | null
- prRepo?: GitHubApiRepository | null
- },
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {},
- callerSignal?: AbortSignal
-): Promise {
- const controller = new AbortController()
- let hostDeadlineExpired = false
- const forwardCallerAbort = (): void => controller.abort(callerSignal?.reason)
- if (callerSignal?.aborted) {
- forwardCallerAbort()
- } else {
- callerSignal?.addEventListener('abort', forwardCallerAbort, { once: true })
- }
- const hostDeadline = setTimeout(() => {
- hostDeadlineExpired = true
- controller.abort(new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE))
- }, GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS)
- let acquired = false
- try {
- const resolved = await waitForCheckDetailsResolution(
- resolveGitHubRepoExecution(repoPath, args.prRepo, connectionId, localGitOptions),
- controller.signal
- )
- if (!resolved.ownerRepo) {
- return null
- }
- const ownerRepo = resolved.ownerRepo
- const ghOptions: GhExecOptions = { ...resolved.ghOptions, signal: controller.signal }
- await acquire(controller.signal)
- acquired = true
- let checkRun: Record | null = null
- let annotations: PRCheckRunDetails['annotations'] = []
- if (args.checkRunId) {
- const { stdout } = await ghExecFileAsync(
- ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}`],
- ghOptions
- )
- checkRun = JSON.parse(stdout) as Record
- try {
- const annotationsResult = await ghExecFileAsync(
- [
- 'api',
- `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}/annotations?per_page=20`
- ],
- ghOptions
- )
- annotations = mapCheckAnnotations(JSON.parse(annotationsResult.stdout))
- } catch (err) {
- rethrowCheckDetailsAbort(controller.signal, err)
- console.warn('getPRCheckDetails annotations fetch failed:', err)
- }
- }
-
- const workflowRunId = args.workflowRunId ?? getWorkflowRunIdFromCheckRun(checkRun)
- let jobs: PRCheckRunDetails['jobs'] = []
- if (workflowRunId) {
- try {
- const { stdout } = await ghExecFileAsync(
- [
- 'api',
- `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${workflowRunId}/jobs?per_page=100`
- ],
- ghOptions
- )
- jobs = mapWorkflowJobs(JSON.parse(stdout), args.checkName)
- await attachFailedJobLogTails(jobs, ownerRepo, ghOptions)
- } catch (err) {
- rethrowCheckDetailsAbort(controller.signal, err)
- console.warn('getPRCheckDetails workflow jobs fetch failed:', err)
- }
- }
-
- const output =
- checkRun?.output && typeof checkRun.output === 'object'
- ? (checkRun.output as Record)
- : null
- return {
- name: nullableString(checkRun?.name) ?? args.checkName ?? 'Check',
- status: nullableString(checkRun?.status),
- conclusion: nullableString(checkRun?.conclusion),
- url: nullableString(checkRun?.html_url) ?? args.url ?? null,
- detailsUrl: nullableString(checkRun?.details_url) ?? args.url ?? null,
- startedAt: nullableString(checkRun?.started_at),
- completedAt: nullableString(checkRun?.completed_at),
- title: nullableString(output?.title),
- summary: nullableString(output?.summary),
- text: nullableString(output?.text),
- annotations,
- jobs
- }
- } catch (err) {
- console.warn('getPRCheckDetails failed:', err)
- if (hostDeadlineExpired && !callerSignal?.aborted) {
- throw new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE)
- }
- throw err
- } finally {
- clearTimeout(hostDeadline)
- callerSignal?.removeEventListener('abort', forwardCallerAbort)
- if (acquired) {
- release()
- }
- }
-}
-
-function parseActionsRunId(url: string | null | undefined): number | undefined {
- if (!url) {
- return undefined
- }
- const match = /\/actions\/runs\/(\d+)(?:\/|$)/.exec(url)
- if (!match) {
- return undefined
- }
- const id = Number(match[1])
- return Number.isSafeInteger(id) ? id : undefined
-}
-
-export async function rerunPRChecks(
- repoPath: string,
- prNumber: number,
- options: {
- headSha?: string
- failedOnly?: boolean
- prRepo?: GitHubApiRepository | null
- } = {},
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
- repoPath,
- options.prRepo,
- connectionId,
- localGitOptions
- )
- if (!ownerRepo) {
- return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
- }
-
- const checks = await getPRChecks(
- repoPath,
- prNumber,
- options.headSha,
- ownerRepo,
- { noCache: true },
- connectionId,
- localGitOptions
- )
- const candidates = options.failedOnly
- ? checks.filter((check) =>
- ['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '')
- )
- : checks
- const workflowRunIds = new Set(
- candidates
- .map((check) => check.workflowRunId ?? parseActionsRunId(check.url))
- .filter((id): id is number => typeof id === 'number')
- )
- const checkRunIds = new Set(
- candidates
- .filter((check) => !check.workflowRunId && !parseActionsRunId(check.url))
- .map((check) => check.checkRunId)
- .filter((id): id is number => typeof id === 'number')
- )
-
- if (workflowRunIds.size === 0 && checkRunIds.size === 0) {
- return {
- ok: false,
- error: options.failedOnly
- ? 'No failed GitHub Actions checks to rerun.'
- : 'No rerunnable checks found.'
- }
- }
-
- let count = 0
- await acquire()
- try {
- for (const runId of workflowRunIds) {
- const endpoint = options.failedOnly
- ? `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun-failed-jobs`
- : `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun`
- await ghExecFileAsync(['api', '-X', 'POST', endpoint], {
- ...ghOptions,
- env: { ...process.env, GH_PROMPT_DISABLED: '1' }
- })
- count += 1
- }
- for (const checkRunId of checkRunIds) {
- await ghExecFileAsync(
- [
- 'api',
- '-X',
- 'POST',
- `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${checkRunId}/rerequest`
- ],
- { ...ghOptions, env: { ...process.env, GH_PROMPT_DISABLED: '1' } }
- )
- count += 1
- }
- return { ok: true, count }
- } catch (err) {
- const message =
- err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
- return { ok: false, error: classifyGhError(message).message }
- } finally {
- release()
- }
-}
-
-// Why: review thread resolution status + thread IDs are GraphQL-only (REST pulls/{n}/comments omits them).
-const REVIEW_THREADS_QUERY = `
-query($owner: String!, $repo: String!, $pr: Int!) {
- repository(owner: $owner, name: $repo) {
- pullRequest(number: $pr) {
- reviewThreads(first: 100) {
- nodes {
- id
- isResolved
- line
- startLine
- originalLine
- originalStartLine
- comments(first: 100) {
- nodes {
- id
- databaseId
- author { __typename login avatarUrl(size: 48) }
- body
- createdAt
- url
- path
- reactionGroups {
- content
- viewerHasReacted
- reactors {
- totalCount
- }
- }
- }
- }
- }
- }
- comments(first: 100) {
- nodes {
- id
- databaseId
- author { __typename login avatarUrl(size: 48) }
- body
- createdAt
- url
- reactionGroups {
- content
- viewerHasReacted
- reactors {
- totalCount
- }
- }
- }
- }
- reviews(first: 100) {
- nodes {
- id
- databaseId
- author { __typename login avatarUrl(size: 48) }
- body
- createdAt
- url
- reactionGroups {
- content
- viewerHasReacted
- reactors {
- totalCount
- }
- }
- }
- }
- }
- }
-}`
-
-/**
- * Get all comments on a PR — both top-level conversation comments and inline
- * review comments (including suggestions). Uses GraphQL for review threads
- * to get resolution status, REST for issue-level comments.
- */
-export async function getPRComments(
- repoPath: string,
- prNumber: number,
- options?: { noCache?: boolean; prRepo?: GitHubApiRepository | null },
- connectionId?: string | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
- repoPath,
- options?.prRepo,
- connectionId,
- localGitOptions
- )
- if (connectionId && !ownerRepo) {
- throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
- }
- if (ownerRepo) {
- await assertRateLimitBudget('core', ownerRepo, ghOptions)
- }
- await acquire()
- try {
- if (ownerRepo) {
- // Why: --cache 60s saves rate-limit budget on normal loads; explicit refresh skips it for fresh data.
- const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
- const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}`
-
- // Why: allSettled so one failing endpoint doesn't blank out all comments; failed sources contribute zero.
- const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
- let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null>
- if (reviewThreadsGuard.blocked) {
- reviewThreadsFetch = Promise.resolve(null)
- } else {
- noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
- reviewThreadsFetch = ghExecFileAsync(
- [
- 'api',
- 'graphql',
- '-f',
- `query=${REVIEW_THREADS_QUERY}`,
- '-f',
- `owner=${ownerRepo.owner}`,
- '-f',
- `repo=${ownerRepo.repo}`,
- '-F',
- `pr=${prNumber}`
- ],
- ghOptions
- )
- }
- const [issueResult, threadsResult, reviewsResult] = await Promise.allSettled([
- ghExecFileAsync(
- ['api', ...cacheArgs, `${base}/issues/${prNumber}/comments?per_page=100`],
- ghOptions
- ),
- reviewThreadsFetch,
- // Why: review summaries (approve/request-changes/general) live under pulls/{n}/reviews, not issue comments or threads.
- ghExecFileAsync(
- ['api', ...cacheArgs, `${base}/pulls/${prNumber}/reviews?per_page=100`],
- ghOptions
- )
- ])
- noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions)
-
- // Parse issue comments (REST)
- type RESTComment = {
- id: number
- user: { login: string; avatar_url: string; type?: string } | null
- body: string
- created_at: string
- html_url: string
- }
- let issueComments: PRComment[] = []
- if (issueResult.status === 'fulfilled') {
- issueComments = (JSON.parse(issueResult.value.stdout) as RESTComment[]).map(
- (c): PRComment => ({
- id: c.id,
- author: c.user?.login ?? 'ghost',
- authorAvatarUrl: c.user?.avatar_url ?? '',
- body: c.body ?? '',
- createdAt: c.created_at,
- url: c.html_url,
- isBot: c.user?.type === 'Bot'
- })
- )
- } else {
- console.warn('Failed to fetch issue comments:', issueResult.reason)
- }
-
- // Parse review threads (GraphQL)
- type GQLThread = {
- id: string
- isResolved: boolean
- line: number | null
- startLine: number | null
- originalLine: number | null
- originalStartLine: number | null
- comments: {
- nodes: {
- id: string
- databaseId: number
- author: { __typename?: string; login: string; avatarUrl: string } | null
- body: string
- createdAt: string
- url: string
- path: string
- reactionGroups?: GitHubGraphQLReactionGroup[] | null
- }[]
- }
- }
- type GQLIssueComment = {
- id: string
- databaseId: number
- author: { __typename?: string; login: string; avatarUrl: string } | null
- body: string
- createdAt: string
- url: string
- reactionGroups?: GitHubGraphQLReactionGroup[] | null
- }
- let graphQLReviewSummaries: PRComment[] | undefined
- const reviewComments: PRComment[] = []
- if (threadsResult.status === 'fulfilled' && threadsResult.value) {
- const threadsData = JSON.parse(threadsResult.value.stdout) as {
- data: {
- repository: {
- pullRequest: {
- reviewThreads: { nodes: GQLThread[] }
- comments?: { nodes: GQLIssueComment[] }
- reviews?: { nodes: GQLIssueComment[] }
- }
- }
- }
- }
- const pullRequest = threadsData.data.repository.pullRequest
- const graphQLIssueComments = (pullRequest.comments?.nodes ?? []).map(
- (c): PRComment => ({
- id: c.databaseId,
- author: c.author?.login ?? 'ghost',
- authorAvatarUrl: c.author?.avatarUrl ?? '',
- body: c.body ?? '',
- createdAt: c.createdAt,
- url: c.url,
- isBot: c.author?.__typename === 'Bot',
- reactionSubjectId: c.id,
- reactions: mapGraphQLReactionGroups(c.reactionGroups)
- })
- )
- if (graphQLIssueComments.length > 0) {
- issueComments = graphQLIssueComments
- }
- graphQLReviewSummaries = (pullRequest.reviews?.nodes ?? [])
- .filter((review) => review.body?.trim())
- .map(
- (review): PRComment => ({
- id: review.databaseId,
- author: review.author?.login ?? 'ghost',
- authorAvatarUrl: review.author?.avatarUrl ?? '',
- body: review.body,
- createdAt: review.createdAt,
- url: review.url,
- isBot: review.author?.__typename === 'Bot',
- reactionSubjectId: review.id,
- reactions: mapGraphQLReactionGroups(review.reactionGroups)
- })
- )
-
- const threads = pullRequest.reviewThreads.nodes
- for (const thread of threads) {
- for (const c of thread.comments.nodes) {
- reviewComments.push({
- id: c.databaseId,
- author: c.author?.login ?? 'ghost',
- authorAvatarUrl: c.author?.avatarUrl ?? '',
- body: c.body ?? '',
- createdAt: c.createdAt,
- url: c.url,
- isBot: c.author?.__typename === 'Bot',
- reactionSubjectId: c.id,
- reactions: mapGraphQLReactionGroups(c.reactionGroups),
- path: c.path,
- threadId: thread.id,
- isResolved: thread.isResolved,
- isOutdated: thread.line == null,
- // Why: GitHub nulls line/startLine when the commented code is outdated (e.g. force-push); originalLine preserves the original numbers.
- line: thread.line ?? thread.originalLine ?? undefined,
- startLine: thread.startLine ?? thread.originalStartLine ?? undefined
- })
- }
- }
- } else {
- if (threadsResult.status === 'rejected') {
- console.warn('Failed to fetch review threads:', threadsResult.reason)
- }
- }
-
- // Review summaries (REST); skip empty-body reviews (e.g. approvals with no comment) as noise.
- type RESTReview = {
- id: number
- user: { login: string; avatar_url: string; type?: string } | null
- body: string
- state: string
- submitted_at: string
- html_url: string
- }
- let reviewSummaries: PRComment[] = []
- if (graphQLReviewSummaries) {
- reviewSummaries = graphQLReviewSummaries
- } else if (reviewsResult.status === 'fulfilled') {
- reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[])
- .filter((r) => r.body?.trim())
- .map(
- (r): PRComment => ({
- id: r.id,
- author: r.user?.login ?? 'ghost',
- authorAvatarUrl: r.user?.avatar_url ?? '',
- body: r.body,
- createdAt: r.submitted_at,
- url: r.html_url,
- isBot: r.user?.type === 'Bot'
- })
- )
- } else {
- console.warn('Failed to fetch review summaries:', reviewsResult.reason)
- }
-
- const all = [...issueComments, ...reviewComments, ...reviewSummaries]
- all.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
- return all
- }
-
- // Fallback: non-GitHub remote — use gh pr view (only returns issue-level comments)
- const { stdout } = await ghExecFileAsync(
- ['pr', 'view', String(prNumber), '--json', 'comments'],
- ghOptions
- )
- noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
- const data = JSON.parse(stdout) as {
- comments: {
- author: { login: string }
- body: string
- createdAt: string
- url: string
- }[]
- }
- return (data.comments ?? []).map((c, i) => ({
- id: i,
- author: c.author?.login ?? 'ghost',
- authorAvatarUrl: '',
- body: c.body ?? '',
- createdAt: c.createdAt,
- url: c.url ?? ''
- }))
- } catch (err) {
- console.warn('getPRComments failed:', err)
- return []
- } finally {
- release()
- }
-}
-
-export async function setPRCommentReaction(
- repoPath: string,
- reactionSubjectId: string,
- content: GitHubReactionContent,
- reacted: boolean,
- connectionId?: string | null,
- prRepo?: GitHubApiRepository | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const mutation = reacted ? 'addReaction' : 'removeReaction'
- const query = `mutation($subjectId: ID!, $content: ReactionContent!) {
- ${mutation}(input: { subjectId: $subjectId, content: $content }) {
- subject { id }
- }
- }`
- const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
- repoPath,
- prRepo,
- connectionId,
- localGitOptions
- )
- if (!ownerRepo) {
- return false
- }
- const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
- if (guard.blocked) {
- console.warn(
- `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
- )
- return false
- }
- await acquire()
- try {
- noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
- await ghExecFileAsync(
- [
- 'api',
- 'graphql',
- '-f',
- `query=${query}`,
- '-f',
- `subjectId=${reactionSubjectId}`,
- '-f',
- `content=${toGraphQLReactionContent(content)}`
- ],
- ghOptions
- )
- return true
- } catch (err) {
- console.warn(`${mutation} failed:`, err)
- return false
- } finally {
- release()
- }
-}
-
-/**
- * Mark or unmark a PR file as viewed via GitHub's GraphQL API.
- */
-export async function setPRFileViewed(args: {
- repoPath: string
- connectionId?: string | null
- localGitOptions?: LocalGitExecOptions
- prRepo?: GitHubApiRepository | null
- pullRequestId: string
- path: string
- viewed: boolean
-}): Promise {
- const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
- args.repoPath,
- args.prRepo,
- args.connectionId,
- args.localGitOptions
- )
- if (!ownerRepo) {
- return false
- }
- const mutation = args.viewed ? 'markFileAsViewed' : 'unmarkFileAsViewed'
- const query = `mutation($pullRequestId: ID!, $path: String!) {
- ${mutation}(input: { pullRequestId: $pullRequestId, path: $path }) {
- pullRequest { id }
- }
- }`
- await acquire()
- try {
- await ghExecFileAsync(
- [
- 'api',
- 'graphql',
- '-f',
- `query=${query}`,
- '-f',
- `pullRequestId=${args.pullRequestId}`,
- '-f',
- `path=${args.path}`
- ],
- ghOptions
- )
- return true
- } catch (err) {
- console.warn(`${mutation} failed:`, err)
- return false
- } finally {
- release()
- }
-}
-
-/**
- * Resolve or unresolve a PR review thread via GraphQL.
- */
-export async function resolveReviewThread(
- repoPath: string,
- threadId: string,
- resolve: boolean,
- connectionId?: string | null,
- prRepo?: GitHubApiRepository | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const mutation = resolve ? 'resolveReviewThread' : 'unresolveReviewThread'
- const query = `mutation($threadId: ID!) { ${mutation}(input: { threadId: $threadId }) { thread { isResolved } } }`
- const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
- repoPath,
- prRepo,
- connectionId,
- localGitOptions
- )
- if (!ownerRepo) {
- return false
- }
- const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
- if (guard.blocked) {
- console.warn(
- `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
- )
- return false
- }
- await acquire()
- try {
- noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
- await ghExecFileAsync(
- ['api', 'graphql', '-f', `query=${query}`, '-f', `threadId=${threadId}`],
- ghOptions
- )
- return true
- } catch (err) {
- console.warn(`${mutation} failed:`, err)
- return false
- } finally {
- release()
- }
-}
-
-function mapReviewCommentResponse(
- data: {
- id?: number
- node_id?: string | null
- user: { login: string; avatar_url: string; type?: string } | null
- body?: string
- created_at?: string
- html_url?: string
- path?: string
- line?: number | null
- },
- body: string,
- path?: string,
- line?: number,
- startLine?: number,
- threadId?: string
-): PRComment {
- return {
- id: data.id ?? Date.now(),
- reactionSubjectId: data.node_id?.trim() || undefined,
- author: data.user?.login ?? 'You',
- authorAvatarUrl: data.user?.avatar_url ?? '',
- body: data.body ?? body,
- createdAt: data.created_at ?? new Date().toISOString(),
- url: data.html_url ?? '',
- isBot: data.user?.type === 'Bot',
- path: data.path ?? path,
- line: data.line ?? line,
- startLine,
- threadId
- }
-}
-
-export async function addPRReviewCommentReply(
- repoPath: string,
- prNumber: number,
- commentId: number,
- body: string,
- threadId?: string,
- path?: string,
- line?: number,
- connectionId?: string | null,
- prRepo?: GitHubApiRepository | null,
- localGitOptions: LocalGitExecOptions = {}
-): Promise {
- const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
- repoPath,
- prRepo,
- connectionId,
- localGitOptions
- )
- if (!ownerRepo) {
- return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
- }
- await acquire()
- try {
- const { stdout } = await ghExecFileAsync(
- [
- 'api',
- '-X',
- 'POST',
- `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/comments/${commentId}/replies`,
- '--raw-field',
- `body=${body}`
- ],
- ghOptions
- )
- const data = JSON.parse(stdout) as Parameters[0]
- if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id < 1) {
- return { ok: false, error: 'Unexpected response from GitHub' }
- }
- return {
- ok: true,
- comment: mapReviewCommentResponse(data, body, path, line, undefined, threadId)
- }
- } catch (err) {
- const stderr = err instanceof Error ? err.message : String(err)
- return { ok: false, error: classifyGhError(stderr).message }
- } finally {
- release()
- }
-}
-
-export async function addPRReviewComment(
- args: GitHubPRReviewCommentInput & {
- connectionId?: string | null
- localGitOptions?: LocalGitExecOptions
- }
-): Promise