diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts index 8c3bf4bbc6d..55411a70e8d 100644 --- a/src/main/ipc/ssh-connection-handlers.ts +++ b/src/main/ipc/ssh-connection-handlers.ts @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import type { SshTarget } from '../../shared/ssh-types' +import type { SshTarget, SshTerminateSessionsResult } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' @@ -98,6 +98,9 @@ export function registerSshConnectionHandlers(): void { ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => { invalidateConnectAttempt(args.targetId) + // Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell + // "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue. + let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 } await runTargetLifecycle(args.targetId, async () => { const provider = getSshPtyProvider(args.targetId) const leases = persistedStore!.getSshRemotePtyLeases(args.targetId) @@ -142,6 +145,10 @@ export function registerSshConnectionHandlers(): void { ) ) : [] + if (!provider) { + // Nothing observed these remote shells, so their state is unknown — not "nothing to do". + outcome = { terminated: 0, unverifiable: ptyIds.length } + } const shutdownFailures: string[] = [] for (const [index, result] of shutdownResults.entries()) { const { appPtyId, relayPtyId } = ptyIds[index] @@ -154,6 +161,7 @@ export function registerSshConnectionHandlers(): void { clearProviderPtyState(appPtyId) deletePtyOwnership(appPtyId) persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') + outcome = { ...outcome, terminated: outcome.terminated + 1 } } if (shutdownFailures.length > 0) { // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. @@ -161,6 +169,7 @@ export function registerSshConnectionHandlers(): void { } await teardownSshTargetTransport(args.targetId, (session) => session.disposeAndPersist()) }) + return outcome }) ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => { diff --git a/src/main/ipc/ssh-terminate-sessions.test.ts b/src/main/ipc/ssh-terminate-sessions.test.ts index 77a8638b098..ccdc19bbe34 100644 --- a/src/main/ipc/ssh-terminate-sessions.test.ts +++ b/src/main/ipc/ssh-terminate-sessions.test.ts @@ -95,7 +95,9 @@ describe('SSH IPC handlers', () => { mockPtyProvider.shutdown.mockResolvedValue(undefined) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + await expect( + handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + ).resolves.toEqual({ terminated: 2, unverifiable: 0 }) expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-live', { immediate: true, @@ -168,7 +170,9 @@ describe('SSH IPC handlers', () => { await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' }) }) - it('ssh:terminateSessions cannot reach expired leases without a relay', async () => { + // Issue #12661: an offline sweep tears down local transport only. Reporting plain success would + // read as "the remote shells are gone" when nobody asked the host. + it('ssh:terminateSessions reports expired leases as unverifiable without a relay', async () => { mockStore.getSshRemotePtyLeases.mockReturnValue([ { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' } ]) @@ -177,10 +181,26 @@ describe('SSH IPC handlers', () => { await expect( handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) - ).resolves.toBeUndefined() + ).resolves.toEqual({ terminated: 0, unverifiable: 1 }) expect(mockPtyProvider.shutdown).not.toHaveBeenCalled() + // Still no forced reconnect: an expired lease can name a host that is gone for good (#2626). expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( + 'ssh-1', + 'pty-expired', + 'terminated' + ) + }) + + it('ssh:terminateSessions reports nothing unverifiable when there is nothing to reach', async () => { + mockStore.getSshRemotePtyLeases.mockReturnValue([]) + vi.mocked(getSshPtyProvider).mockReturnValue(undefined) + vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + + await expect( + handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + ).resolves.toEqual({ terminated: 0, unverifiable: 0 }) }) it('ssh:terminateSessions kills expired leases whose remote PTY may still be alive', async () => { diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts index d5cf9b5a5e1..8f1430d8e75 100644 --- a/src/main/providers/ssh-git-worktree-provider.ts +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -2,6 +2,7 @@ import type { GitStatusResult } from '../../shared/git-status-types' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { CapabilityProbeCache } from '../../shared/capability-probe-cache' +import { assertAuthoritativeWorktreeCatalog } from '../../shared/worktree/worktree-catalog-availability' import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' @@ -32,11 +33,14 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { repoPath: string, options?: { signal?: AbortSignal } ): Promise { - return (await this.mux.request( + const response = await this.mux.request( 'git.listWorktrees', { repoPath }, { signal: options?.signal } - )) as GitWorktreeInfo[] + ) + // Why (#14004): relays before this fix answered a failed worktree scan with `[]`. Mixed versions are + // normal, so refuse the shape here too — a Git repo always lists its own checkout. + return assertAuthoritativeWorktreeCatalog(response, repoPath) } async addWorktree( diff --git a/src/main/providers/ssh-worktree-catalog-authority.test.ts b/src/main/providers/ssh-worktree-catalog-authority.test.ts new file mode 100644 index 00000000000..3f11c87dc0f --- /dev/null +++ b/src/main/providers/ssh-worktree-catalog-authority.test.ts @@ -0,0 +1,141 @@ +/** + * Issue #14004: an SSH worktree catalog Orca could not read must never surface as an authoritative + * empty catalog. Covers the whole client-side chain — provider response guard, the repo-level + * listing, and the detected-worktree result whose `authoritative` flag gates renderer terminal + * teardown (`teardownMissingWorktreeTerminalsBestEffort`). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshGitProvider } from './ssh-git-provider' +import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' +import { isWorktreeCatalogUnavailableError } from '../../shared/worktree/worktree-catalog-availability' +import { listRepoWorktrees } from '../repo-worktrees' +import { listDetectedWorktreesForCapturedRepo } from '../ipc/worktrees/listing/detected-provider-listing' +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence/loading-store/store' + +const { getSshGitProviderMock } = vi.hoisted(() => ({ getSshGitProviderMock: vi.fn() })) + +vi.mock('./ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + requireSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: () => 1 +})) + +const CONNECTION_ID = 'conn-1' +const REPO_PATH = '/home/user/repo' +const WORKTREE_PATH = '/home/user/feature' + +const repo: Repo = { + id: 'repo-1', + path: REPO_PATH, + displayName: 'repo', + connectionId: CONNECTION_ID +} as Repo + +const worktreeId = `${repo.id}::${WORKTREE_PATH}` + +function createStore(): Store { + const meta: Record = { + [worktreeId]: { instanceId: 'instance-1' } + } + return { + getRepos: () => [repo], + getRepo: () => repo, + getAllWorktreeMeta: () => meta, + getWorktreeMeta: (id: string) => meta[id], + setWorktreeMeta: vi.fn(), + getAllWorktreeLineage: () => ({}), + getProjectHostSetups: () => [], + getSettings: () => ({}) + } as unknown as Store +} + +describe('SSH worktree catalog authority (#14004)', () => { + let mux: MockMultiplexer + let provider: SshGitProvider + + beforeEach(() => { + mux = createMockMux() + provider = new SshGitProvider(CONNECTION_ID, mux as never) + getSshGitProviderMock.mockReset() + getSshGitProviderMock.mockReturnValue(provider) + }) + + it('refuses an empty relay response instead of publishing an empty catalog', async () => { + // An older relay converted a failed `git worktree list` into `[]`; mixed versions are the normal state. + mux.request.mockResolvedValue([]) + + await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy( + isWorktreeCatalogUnavailableError + ) + }) + + it('refuses a malformed relay response', async () => { + mux.request.mockResolvedValue(undefined) + + await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy( + isWorktreeCatalogUnavailableError + ) + }) + + it('reports an unreachable SSH host as unavailable, not as an empty repo listing', async () => { + getSshGitProviderMock.mockReturnValue(undefined) + + await expect(listRepoWorktrees(repo)).rejects.toSatisfy(isWorktreeCatalogUnavailableError) + }) + + it('does not authorize missing-worktree teardown when the relay listing fails', async () => { + mux.request.mockRejectedValue(new Error('relay request failed')) + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider + ) + + expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' }) + // The persisted workspace survives the failed scan, so the renderer has nothing to reconcile away. + expect( + (result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id) + ).toContain(worktreeId) + }) + + it('does not authorize missing-worktree teardown when the relay answers with an empty list', async () => { + mux.request.mockResolvedValue([]) + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider + ) + + expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' }) + expect( + (result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id) + ).toContain(worktreeId) + }) + + it('republishes an authoritative catalog once the relay answers again', async () => { + mux.request.mockResolvedValue([ + { path: REPO_PATH, head: 'abc123', branch: 'main', isBare: false, isMainWorktree: true }, + { + path: WORKTREE_PATH, + head: 'def456', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider + ) + + expect(result).toMatchObject({ authoritative: true, source: 'git' }) + }) +}) diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index c5e756b9310..b7c6e16f91a 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -4,6 +4,7 @@ import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/wor import { isFolderRepo } from '../shared/repo-kind' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { areWorktreePathsEqual } from './ipc/worktree-logic' +import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability' type LocalRepoWorktreeListOptions = { wslDistro?: string @@ -42,10 +43,15 @@ export async function listRepoWorktrees( } if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) - // Why: runtime worktree resolution can run before SSH providers have - // reattached during startup. Return empty instead of falling back to - // local git against a server path. - return provider ? await provider.listWorktrees(repo.path) : [] + // Why: runtime worktree resolution can run before SSH providers have reattached during startup. + // Never fall back to local git against a server path, and never report the unreachable host as an + // empty catalog (#14004) — callers treat a resolved listing as authoritative. + if (!provider) { + throw new WorktreeCatalogUnavailableError( + `Worktree catalog unavailable for ${repo.path}: SSH connection "${repo.connectionId}" is not connected.` + ) + } + return await provider.listWorktrees(repo.path) } return hasLocalRepoWorktreeListOptions(options) ? await listWorktrees(repo.path, options) diff --git a/src/preload/api/ssh-api.ts b/src/preload/api/ssh-api.ts index e2b9ce9e665..af198ad1080 100644 --- a/src/preload/api/ssh-api.ts +++ b/src/preload/api/ssh-api.ts @@ -9,7 +9,8 @@ import type { SshTarget, SshTargetAddResult, SshTargetCreateInput, - SshTargetUpdateInput + SshTargetUpdateInput, + SshTerminateSessionsResult } from '../../shared/ssh-types' import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' @@ -25,7 +26,7 @@ export type SshApi = { resolveConfigHost: (args: { alias: string }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise - terminateSessions: (args: { targetId: string }) => Promise + terminateSessions: (args: { targetId: string }) => Promise resetRelay: (args: { targetId: string }) => Promise getState: (args: { targetId: string }) => Promise needsPassphrasePrompt: (args: { targetId: string }) => Promise diff --git a/src/relay/git-handler-worktree-list-authority.test.ts b/src/relay/git-handler-worktree-list-authority.test.ts new file mode 100644 index 00000000000..8b8a606dbba --- /dev/null +++ b/src/relay/git-handler-worktree-list-authority.test.ts @@ -0,0 +1,96 @@ +/** + * Issue #14004: a relay-side worktree-list failure must stay a failure across the relay/provider + * boundary. Converting it to `[]` reports an unreadable catalog as an authoritative empty one, and + * downstream reconciliation uses that to authorize missing-worktree teardown. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { + createMockDispatcher, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +type GitSpyTarget = { + git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> +} + +const WORKTREE_LIST_OUTPUT = `worktree /repo +HEAD abc123 +branch refs/heads/main +` + +/** Git <2.36 rejects `worktree list -z` with a usage error, which routes the handler to the fallback lane. */ +function unsupportedZError(): Error { + return Object.assign(new Error('git usage error'), { + code: 129, + stderr: 'usage: git worktree list []\n' + }) +} + +describe('relay worktree-list authority (#14004)', () => { + let dispatcher: MockDispatcher + let handler: GitHandler + + beforeEach(() => { + dispatcher = createMockDispatcher() + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + }) + + it('rejects instead of reporting an empty catalog when the fallback listing fails', async () => { + vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => + args.includes('-z') + ? Promise.reject(unsupportedZError()) + : Promise.reject( + Object.assign(new Error('fatal: not a git repository'), { code: 128, stderr: '' }) + ) + ) + + await expect( + dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + ).rejects.toThrow('not a git repository') + }) + + it('rejects a timed-out fallback listing on a host whose -z support is already known absent', async () => { + const gitSpy = vi + .spyOn(handler as unknown as GitSpyTarget, 'git') + .mockImplementation((args: string[]) => + args.includes('-z') + ? Promise.reject(unsupportedZError()) + : Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) + ) + // Prime the capability cache so the probe is not repeated; later scans go straight to the fallback. + await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + + gitSpy.mockRejectedValue(Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' })) + + await expect( + dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + ).rejects.toThrow('ETIMEDOUT') + expect(gitSpy.mock.calls.at(-1)?.[0]).toEqual(['worktree', 'list', '--porcelain']) + }) + + it('republishes the catalog when a later fallback listing succeeds', async () => { + let failListing = true + vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => { + if (args.includes('-z')) { + return Promise.reject(unsupportedZError()) + } + return failListing + ? Promise.reject(new Error('transient relay failure')) + : Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) + }) + + await expect( + dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + ).rejects.toThrow('transient relay failure') + + failListing = false + const result = (await dispatcher.callRequest('git.listWorktrees', { + repoPath: '/repo' + })) as Record[] + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ path: '/repo', isMainWorktree: true }) + }) +}) diff --git a/src/relay/git-handler-worktree-operations.ts b/src/relay/git-handler-worktree-operations.ts index 6689c178344..72853a342c6 100644 --- a/src/relay/git-handler-worktree-operations.ts +++ b/src/relay/git-handler-worktree-operations.ts @@ -132,19 +132,14 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { }, async () => { // Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety). - try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { - signal: context?.signal - }) - const normalized = await this.normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout) - ) - // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). - return annotatePrunableWorktreesByExistence(normalized) - } catch { - return [] - } + // Why no catch (#14004): swallowing to `[]` would report an unreadable catalog as an authoritative + // empty one, and callers use that to authorize missing-worktree teardown. Let the failure propagate. + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { + signal: context?.signal + }) + const normalized = await this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) + // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). + return annotatePrunableWorktreesByExistence(normalized) }, isUnsupportedWorktreeListZError ) diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index b4b82dd3e41..3424abb4644 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -6,7 +6,10 @@ import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' import { Button } from '../ui/button' import { removeSshTargetWithBestEffortCleanup } from './ssh-target-remove' -import { terminateSshSessionsWithReconnect } from './ssh-session-termination' +import { + describeSshTerminateOutcome, + terminateSshSessionsWithReconnect +} from './ssh-session-termination' import { SshTargetCard } from './SshTargetCard' import { SshTargetDestructiveActions } from './SshTargetDestructiveActions' import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm' @@ -218,10 +221,8 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem const handleTerminateSessions = async (targetId: string): Promise => { try { - await terminateSshSessionsWithReconnect(targetId) - toast.success( - translate('auto.components.settings.SshPane.90e308c98b', 'Remote terminals ended') - ) + const report = describeSshTerminateOutcome(await terminateSshSessionsWithReconnect(targetId)) + toast[report.level](report.message) } catch (err) { toast.error( err instanceof Error diff --git a/src/renderer/src/components/settings/ssh-session-termination.ts b/src/renderer/src/components/settings/ssh-session-termination.ts index 735fa7b27ca..71cb5c80cce 100644 --- a/src/renderer/src/components/settings/ssh-session-termination.ts +++ b/src/renderer/src/components/settings/ssh-session-termination.ts @@ -1,8 +1,12 @@ import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../../../shared/constants' +import type { SshTerminateSessionsResult } from '../../../../shared/ssh-types' +import { translate } from '../../i18n/i18n' -export async function terminateSshSessionsWithReconnect(targetId: string): Promise { +export async function terminateSshSessionsWithReconnect( + targetId: string +): Promise { try { - await window.api.ssh.terminateSessions({ targetId }) + return await window.api.ssh.terminateSessions({ targetId }) } catch (err) { const message = err instanceof Error ? err.message : String(err) if (!message.includes(SSH_TERMINATE_RECONNECT_REQUIRED)) { @@ -11,6 +15,31 @@ export async function terminateSshSessionsWithReconnect(targetId: string): Promi // Why: disconnect is now non-destructive, so preserved remote PTYs may // require a fresh relay attachment before they can be explicitly killed. await window.api.ssh.connect({ targetId }) - await window.api.ssh.terminateSessions({ targetId }) + return await window.api.ssh.terminateSessions({ targetId }) + } +} + +/** + * An offline sweep only tears down local transport, so its remote shells are `unverifiable`, never + * `exited` (docs/reference/ssh-execution-boundary.md). Reporting plain success there would announce + * a kill nobody delivered (issue #12661). + */ +export function describeSshTerminateOutcome(outcome: SshTerminateSessionsResult): { + level: 'success' | 'warning' + message: string +} { + if (outcome.unverifiable > 0) { + return { + level: 'warning', + message: translate( + 'auto.components.settings.SshPane.terminateUnverifiable', + '{{terminals}} remote terminal(s) could not be reached. Reconnect to end them.', + { terminals: outcome.unverifiable } + ) + } + } + return { + level: 'success', + message: translate('auto.components.settings.SshPane.90e308c98b', 'Remote terminals ended') } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8ab61e5ce21..163ba22b978 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8265,7 +8265,8 @@ "4db9afce1c": "Port must be between 1 and 65535", "0e5aa04161": "Host or SSH config alias is required", "f1fc50dad2": "Failed to load SSH targets", - "0cda732f43": "Connection test failed" + "0cda732f43": "Connection test failed", + "terminateUnverifiable": "{{terminals}} remote terminal(s) could not be reached. Reconnect to end them." }, "SshPassphraseDialog": { "d5a234456f": "Cancel", diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index df0c84f262b..a3e4964cb8f 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -144,7 +144,7 @@ export function createSshApi(): NonNullable['ssh']> { return state }, disconnect: () => Promise.resolve(), - terminateSessions: () => Promise.resolve(), + terminateSessions: () => Promise.resolve({ terminated: 0, unverifiable: 0 }), resetRelay: () => Promise.resolve(), getState: async (args) => { if (!requireActiveEnvironmentOrNull()) { diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index cbcce254922..90121007468 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -266,3 +266,13 @@ export type EnrichedDetectedPort = DetectedPort & { advertisedUrl?: string advertisedProtocol?: 'http' | 'https' } + +/** Outcome of `ssh:terminateSessions`. Uses the fixed verdict vocabulary from + * docs/reference/ssh-execution-boundary.md: a host we could not reach yields `unverifiable`, + * never `exited`, so an offline sweep can never be read as a successful remote kill (issue #12661). */ +export type SshTerminateSessionsResult = { + /** Remote PTYs the host acknowledged stopping. */ + terminated: number + /** Leases whose remote shells were never reached because the relay was offline. */ + unverifiable: number +} diff --git a/src/shared/worktree/worktree-catalog-availability.ts b/src/shared/worktree/worktree-catalog-availability.ts new file mode 100644 index 00000000000..ac4744f6734 --- /dev/null +++ b/src/shared/worktree/worktree-catalog-availability.ts @@ -0,0 +1,41 @@ +/** + * "I could not ask" is not "there is nothing there". + * + * A worktree catalog that could not be read must stay distinguishable from one that + * genuinely lists no worktrees, or downstream reconciliation converts a transport or + * Git failure into authoritative emptiness and tears down live state + * (docs/reference/ssh-execution-boundary.md, issue #14004). + */ +export class WorktreeCatalogUnavailableError extends Error { + /** Structural marker: survives JSON-RPC re-wrapping better than `instanceof` across module copies. */ + readonly worktreeCatalogUnavailable = true + + constructor(message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'WorktreeCatalogUnavailableError' + } +} + +export function isWorktreeCatalogUnavailableError(error: unknown): boolean { + return ( + error instanceof WorktreeCatalogUnavailableError || + (typeof error === 'object' && + error !== null && + (error as { worktreeCatalogUnavailable?: unknown }).worktreeCatalogUnavailable === true) + ) +} + +/** + * Git always lists at least the repository's own checkout, so a zero-row listing for a Git repo + * can only mean the scan never produced an answer. Older relays converted worktree-list failures + * into `[]`, so a mixed-version client must reject that shape rather than publish it. + */ +export function assertAuthoritativeWorktreeCatalog(worktrees: unknown, repoPath: string): T[] { + if (!Array.isArray(worktrees) || worktrees.length === 0) { + throw new WorktreeCatalogUnavailableError( + `Worktree catalog unavailable for ${repoPath}: the execution host returned no worktree listing. ` + + 'Treating this as an empty catalog would authorize removing workspaces that still exist.' + ) + } + return worktrees as T[] +}