fix(worktrees): an unknown host home refuses the recursive delete

`resolveWorktreeRemovalHome` answers `{ kind: 'executionHost', homePath: null }`
whenever the relay session has left `activeSessions` or never resolved its host
env — an ordinary disconnect, and the default resolver before the registration
side effect runs at all. `resolveGuardHomePath` then correctly declines to
substitute the client's home, but `isHomeDirectoryRemovalPath` fell through to
path SHAPES only, and shapes do not know `/srv/homes/alice`,
`/export/home/alice` or `D:\Profiles\bob`.

Injected: the host-home fixture with the resolver answering null. Measured:
`removeRuntimeUnregisteredWorktree` called `deletePath('/srv/homes/alice', true)`
— a recursive delete of the host's own home, the exact path the two shipped
tests pin as refused when the resolver does answer.

"Could not ask the host where its home is" is unverifiable, so the two
recursive-delete gates now fail closed on it. `isDangerousWorktreeRemovalPath`
deliberately does not consult it: it also fences the registered
`git worktree remove` path, which must stay usable mid-reconnect.

The stated cost is pinned too: an ordinary orphan is also declined while the
home is unknown. Declining is recoverable — the row survives and the next
connected removal proceeds — and a recursive delete of the wrong directory is
not.
This commit is contained in:
Neil
2026-09-10 17:46:35 -07:00
parent aa98edf35a
commit 81dd2fe590
3 changed files with 59 additions and 1 deletions
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -15,6 +15,7 @@ import {
import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { makeWorktreeMeta, mockKnownFeatureWorktree } from './worktrees-test-fixtures'
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
import { setWorktreeRemovalSshHostHomeResolver } from '../worktree-removal-execution-host-route'
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
@@ -105,6 +106,10 @@ describe('registerWorktreeHandlers', () => {
runtimeStub = setupWorktreeHandlers()
})
afterEach(() => {
setWorktreeRemovalSshHostHomeResolver(() => null)
})
it('reports already-missing unregistered delete paths before teardown, hooks, or git removal', async () => {
mockKnownFeatureWorktree('/workspace/real-feature')
getEffectiveHooksMock.mockReturnValue({
@@ -421,7 +426,10 @@ describe('registerWorktreeHandlers', () => {
}
})
// The recursive-delete gate now requires the execution host to have reported its `$HOME`, so
// this test has to establish it before it can reach the symlink check it is actually about.
it('refuses SSH orphan cleanup when remote .git is a symlink', async () => {
setWorktreeRemovalSshHostHomeResolver(() => '/remote/home/alice')
const repo = {
id: 'repo-ssh-symlink-git',
path: '/remote/repo',
@@ -77,6 +77,34 @@ describe('removeRuntimeUnregisteredWorktree against an SSH host home', () => {
expect(fsProvider.deletePath).not.toHaveBeenCalled()
})
// The resolver answers null whenever the relay session left `activeSessions` or never resolved
// its host env — an ordinary disconnect. That skips the containment check entirely and leaves
// only path SHAPES, which do not know `/srv/homes/alice`. "Could not ask the host where its home
// is" is unverifiable, so the recursive delete has to fail closed.
it('refuses the recursive delete when the host never reported its home', async () => {
setWorktreeRemovalSshHostHomeResolver(() => null)
const fsProvider = provenOrphanFilesystem(HOST_HOME)
await expect(
removeRuntimeUnregisteredWorktree(removalArgs(HOST_HOME, fsProvider))
).rejects.toThrow(`Refusing to delete unregistered worktree path: ${HOST_HOME}`)
expect(fsProvider.deletePath).not.toHaveBeenCalled()
})
// The stated cost of failing closed: an ordinary orphan is also declined until the host answers.
// Declining is recoverable — the row survives and the next connected removal proceeds — while a
// recursive delete of the wrong directory is not.
it('declines an ordinary orphan too while the home is unknown', async () => {
setWorktreeRemovalSshHostHomeResolver(() => null)
const worktreePath = `${HOST_HOME}/workspaces/leftover`
const fsProvider = provenOrphanFilesystem(worktreePath)
await expect(
removeRuntimeUnregisteredWorktree(removalArgs(worktreePath, fsProvider))
).rejects.toThrow(`Refusing to delete unregistered worktree path: ${worktreePath}`)
expect(fsProvider.deletePath).not.toHaveBeenCalled()
})
it('still deletes a proven orphan under that host home', async () => {
setWorktreeRemovalSshHostHomeResolver(() => HOST_HOME)
const worktreePath = `${HOST_HOME}/workspaces/leftover`
+22
View File
@@ -128,6 +128,22 @@ export function assertWorktreeDoesNotContainRegisteredWorktree(
}
}
/**
* Whether the home guard was able to ask the machine that executes the removal.
*
* An execution host with no reported `$HOME` is `unknown`, not safe: the containment check is
* skipped entirely and only path SHAPES remain, and shapes do not know `/srv/homes/alice`,
* `/export/home/alice` or `D:\Profiles\bob`. The resolver answers `null` whenever the relay
* session is gone from `activeSessions` or never resolved its host env, which is an ordinary
* disconnect — and loss of contact is not permission to recursively delete
* (docs/reference/ssh-execution-boundary.md). Only the recursive-delete gates consult this;
* `isDangerousWorktreeRemovalPath` deliberately does not, because it also fences the registered
* `git worktree remove` path, which must stay usable while a session is mid-reconnect.
*/
function homeAuthorityAnswered(home: WorktreeRemovalHomeAuthority): boolean {
return home.kind !== 'executionHost' || Boolean(home.homePath)
}
export async function canSafelyRemoveOrphanedWorktreeDirectory(
worktreePath: string,
repoPath: string,
@@ -135,6 +151,9 @@ export async function canSafelyRemoveOrphanedWorktreeDirectory(
statPath: StatPath = lstat,
readPath: ReadPath = (path) => readFile(path, 'utf8')
): Promise<boolean> {
if (!homeAuthorityAnswered(home)) {
return false
}
if (isDangerousWorktreeRemovalPath(worktreePath, repoPath, home)) {
return false
}
@@ -185,6 +204,9 @@ export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: {
if (!hasCurrentOrcaCreationProvenance(args.meta) && !hasLegacyOrcaCreationEvidence(args.meta)) {
return false
}
if (!homeAuthorityAnswered(args.home)) {
return false
}
if (
isDangerousWorktreeRemovalPath(args.worktreePath, args.repo.path, args.home) ||