From 520c5938c700deab9b59bc5052cd1038a026bee4 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Sat, 5 Sep 2026 20:57:03 +0100 Subject: [PATCH 1/2] fix(workspace): bound linked worktree discovery --- src/main/services/workspace.test.ts | 25 +++++++++++++ src/main/services/workspace.ts | 55 ++++++++++++++++++++++++----- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/main/services/workspace.test.ts b/src/main/services/workspace.test.ts index 720713e2a1..30114013ae 100644 --- a/src/main/services/workspace.test.ts +++ b/src/main/services/workspace.test.ts @@ -26,6 +26,7 @@ import { resolveReadablePath, resolveSshHostForWorkspaceRoot, resolveWorkspacePath, + runOptionalLinkedWorktreeRegistration, scheduleAllowedWorkspaceRootsBootstrap, seedAllowedWorkspaceRoots, setWorkspaceRootForTest, @@ -348,6 +349,30 @@ describe('allowed workspace roots', () => { } }) + it('does not let optional linked-worktree metadata block opening a project', async () => { + const controller = new AbortController() + let aborted = false + const registration = runOptionalLinkedWorktreeRegistration( + (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + aborted = true + reject(new Error('linked-worktree registration aborted')) + }, + { once: true }, + ) + }), + controller.signal, + ) + await Promise.resolve() + controller.abort() + await registration + + assert.equal(aborted, true) + }) + it('tracks SSH project roots by host id and path', async () => { await registerAllowedWorkspaceRoot('/var/www/app', 'dev') await assert.doesNotReject(() => assertAllowedWorkspaceRoot('/var/www/app', 'dev')) diff --git a/src/main/services/workspace.ts b/src/main/services/workspace.ts index 61cbca1724..7f60ca341a 100644 --- a/src/main/services/workspace.ts +++ b/src/main/services/workspace.ts @@ -12,6 +12,7 @@ import { isRecord } from '@shared/unknown-value.ts' const WORKSPACE_KEY = 'workspaceRoot' const PROJECTS_KEY = 'projects' const ACTIVE_PROJECT_KEY = 'activeProjectId' +const LINKED_WORKTREE_REGISTRATION_TIMEOUT_MS = 2_000 const storedWorkspaceRoot = storageGet(WORKSPACE_KEY) let workspaceRoot: string | null = @@ -127,6 +128,36 @@ export async function seedAllowedWorkspaceRoots( } } +/** + * Run best-effort linked-worktree discovery without letting slow or blocked Git + * metadata prevent an otherwise valid project folder from opening. + */ +export async function runOptionalLinkedWorktreeRegistration( + register: (signal: AbortSignal) => Promise, + signal: AbortSignal, +): Promise { + let onAbort: (() => void) | undefined + const aborted = new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + onAbort = (): void => { + resolve() + } + signal.addEventListener('abort', onAbort, { once: true }) + }) + const registration = Promise.resolve() + .then(() => register(signal)) + .catch(() => undefined) + + try { + await Promise.race([registration, aborted]) + } finally { + if (onAbort) signal.removeEventListener('abort', onAbort) + } +} + /** * Register sandbox metadata when an allowed project lives inside an existing * linked Git worktree. The `.git` file points outside the selected project, so @@ -139,12 +170,12 @@ async function registerAllowedLinkedWorktree(root: string): Promise { try { const dotGit = await stat(join(cursor, '.git')) if (dotGit.isFile()) { - try { - await registerInternalWorkspaceRoot(cursor, root) - } catch { - // Gitfiles also represent submodules and may be malformed. Neither - // grants linked-worktree authority; the project remains allowlisted. - } + await runOptionalLinkedWorktreeRegistration(async (signal) => { + await registerInternalWorkspaceRoot(cursor, root, signal) + }, AbortSignal.timeout(LINKED_WORKTREE_REGISTRATION_TIMEOUT_MS)) + // Gitfiles also represent submodules and may be malformed. Neither a + // failed nor a timed-out probe grants linked-worktree authority; the + // project remains allowlisted. return } if (dotGit.isDirectory()) return @@ -211,6 +242,7 @@ export async function assertAllowedWorkspaceRoot(root: string, sshHost?: string) export async function registerInternalWorkspaceRoot( checkoutRoot: string, executionRoot: string = checkoutRoot, + signal?: AbortSignal, ): Promise { const canonicalCheckoutRoot = await canonicalWorkspaceRoot(checkoutRoot, localWorkspaceFs) const canonicalExecutionRoot = await canonicalWorkspaceRoot(executionRoot, localWorkspaceFs) @@ -219,12 +251,14 @@ export async function registerInternalWorkspaceRoot( throw new Error('Internal execution root is outside its linked Git worktree') } const dotGitPath = join(canonicalCheckoutRoot, '.git') - const dotGit = await readFile(dotGitPath, 'utf-8') + const dotGit = await readFile(dotGitPath, { encoding: 'utf-8', signal }) const match = /^gitdir:\s*(.+?)\s*$/i.exec(dotGit.trim()) if (!match?.[1]) throw new Error('Internal workspace root is not a linked Git worktree') const gitDir = realpathSync.native(resolve(canonicalCheckoutRoot, match[1])) - const commonRelative = (await readFile(join(gitDir, 'commondir'), 'utf-8')).trim() + const commonRelative = ( + await readFile(join(gitDir, 'commondir'), { encoding: 'utf-8', signal }) + ).trim() if (!commonRelative) throw new Error('Linked worktree has no common Git directory') const commonGitDir = realpathSync.native(resolve(gitDir, commonRelative)) if (dirname(gitDir) !== join(commonGitDir, 'worktrees')) { @@ -236,7 +270,10 @@ export async function registerInternalWorkspaceRoot( if (!entry.isDirectory() || entry.name === basename(gitDir)) continue try { const siblingGitFile = ( - await readFile(join(dirname(gitDir), entry.name, 'gitdir'), 'utf-8') + await readFile(join(dirname(gitDir), entry.name, 'gitdir'), { + encoding: 'utf-8', + signal, + }) ).trim() if (!siblingGitFile) continue const siblingDotGit = realpathSync.native( From 775e6bbd15f4e7d679ce20e2eae19b210316c436 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Wed, 9 Sep 2026 01:46:46 +0100 Subject: [PATCH 2/2] fix(workspace): discard cancelled worktree discovery --- src/main/services/workspace.test.ts | 40 ++++++++++++++++++++++++++++- src/main/services/workspace.ts | 4 +++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/main/services/workspace.test.ts b/src/main/services/workspace.test.ts index 30114013ae..9912210f34 100644 --- a/src/main/services/workspace.test.ts +++ b/src/main/services/workspace.test.ts @@ -1,4 +1,4 @@ -import { describe, it, beforeEach, afterEach } from 'node:test' +import { describe, it, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { existsSync, @@ -7,6 +7,7 @@ import { realpathSync, symlinkSync, writeFileSync, + type PathLike, } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -23,6 +24,7 @@ import { getProjectRoot, isResolvedPathInsideWorkspace, registerAllowedWorkspaceRoot, + registerInternalWorkspaceRoot, resolveReadablePath, resolveSshHostForWorkspaceRoot, resolveWorkspacePath, @@ -349,6 +351,42 @@ describe('allowed workspace roots', () => { } }) + it('does not publish sandbox metadata after cancellation during sibling discovery', async () => { + const root = mkdtempSync(join(tmpdir(), 'copse-cancel-linked-worktree-')) + const repo = join(root, 'repo') + const worktree = join(root, 'worktree') + const sibling = join(root, 'sibling') + mkdirSync(repo) + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repo }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }) + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repo }) + execFileSync('git', ['commit', '--allow-empty', '-m', 'initial'], { cwd: repo }) + execFileSync('git', ['worktree', 'add', '-q', '-b', 'feature', worktree], { cwd: repo }) + execFileSync('git', ['worktree', 'add', '-q', '-b', 'sibling', sibling], { cwd: repo }) + const controller = new AbortController() + const originalRealpath = realpathSync.native + const siblingGitFile = originalRealpath(join(sibling, '.git')) + const probe = mock.method(realpathSync, 'native', (path: PathLike) => { + const resolved = originalRealpath(path) + if (resolved === siblingGitFile) { + controller.abort(new Error('discovery deadline expired')) + throw controller.signal.reason + } + return resolved + }) + try { + await assert.rejects( + registerInternalWorkspaceRoot(worktree, worktree, controller.signal), + /discovery deadline expired/, + ) + assert.equal(controller.signal.aborted, true) + assert.equal(getInternalWorkspaceRootRegistration(worktree), null) + } finally { + probe.mock.restore() + rmSync(root, { recursive: true, force: true }) + } + }) + it('does not let optional linked-worktree metadata block opening a project', async () => { const controller = new AbortController() let aborted = false diff --git a/src/main/services/workspace.ts b/src/main/services/workspace.ts index 7f60ca341a..6ab87936a3 100644 --- a/src/main/services/workspace.ts +++ b/src/main/services/workspace.ts @@ -283,6 +283,7 @@ export async function registerInternalWorkspaceRoot( ) siblingRoots.push(dirname(siblingDotGit)) } catch { + signal?.throwIfAborted() // A stale/prunable sibling cannot grant authority; it needs no extra deny path. } } @@ -301,6 +302,9 @@ export async function registerInternalWorkspaceRoot( primaryCheckoutRoot, siblingRoots: Object.freeze([...new Set(siblingRoots)]), }) + // Cancellation can arrive after the last read or while enumerating siblings. + // Never publish a partial deny list after optional discovery has timed out. + signal?.throwIfAborted() internalWorkspaceRoots.set(canonicalExecutionRoot, registration) return registration }