Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion src/main/services/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -7,6 +7,7 @@ import {
realpathSync,
symlinkSync,
writeFileSync,
type PathLike,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
Expand All @@ -23,9 +24,11 @@ import {
getProjectRoot,
isResolvedPathInsideWorkspace,
registerAllowedWorkspaceRoot,
registerInternalWorkspaceRoot,
resolveReadablePath,
resolveSshHostForWorkspaceRoot,
resolveWorkspacePath,
runOptionalLinkedWorktreeRegistration,
scheduleAllowedWorkspaceRootsBootstrap,
seedAllowedWorkspaceRoots,
setWorkspaceRootForTest,
Expand Down Expand Up @@ -348,6 +351,66 @@ 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
const registration = runOptionalLinkedWorktreeRegistration(
(signal) =>
new Promise<void>((_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'))
Expand Down
59 changes: 50 additions & 9 deletions src/main/services/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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<void>,
signal: AbortSignal,
): Promise<void> {
let onAbort: (() => void) | undefined
const aborted = new Promise<void>((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
Expand All @@ -139,12 +170,12 @@ async function registerAllowedLinkedWorktree(root: string): Promise<void> {
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
Expand Down Expand Up @@ -211,6 +242,7 @@ export async function assertAllowedWorkspaceRoot(root: string, sshHost?: string)
export async function registerInternalWorkspaceRoot(
checkoutRoot: string,
executionRoot: string = checkoutRoot,
signal?: AbortSignal,
): Promise<InternalWorkspaceRootRegistration> {
const canonicalCheckoutRoot = await canonicalWorkspaceRoot(checkoutRoot, localWorkspaceFs)
const canonicalExecutionRoot = await canonicalWorkspaceRoot(executionRoot, localWorkspaceFs)
Expand All @@ -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')) {
Expand All @@ -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(
Expand All @@ -246,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.
}
}
Expand All @@ -264,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
}
Expand Down
Loading