diff --git a/docs/plans/2026-09-27-lsp-open-containment.md b/docs/plans/2026-09-27-lsp-open-containment.md new file mode 100644 index 000000000..f92dbf3cd --- /dev/null +++ b/docs/plans/2026-09-27-lsp-open-containment.md @@ -0,0 +1,34 @@ +# LSP open: re-check physical containment at the moment of use (#1268) + +**Gap.** `authorizeContext` (`src/main/ipc/lsp.ts`) validates the physical target, then `LspManager.openDocumentNow` awaits server startup, which can take seconds on a cold spawn. Only after that does it build a lexical `file://` URI and send `didOpen`. If a directory on the path is swapped for a symlink to an outside directory during startup, the server is handed a URI that resolves outside the root. + +**Fix.** +- **Manager:** `OpenDocumentParams.assertPhysicalTarget`, a callback from the authorizing caller. The manager awaits it inside the per-document queue, after server startup and immediately before a NEW server document's `didOpen`. A refusal fails open (returns false: no LSP for this document) and names nothing to the server. +- **IPC:** both open paths (`lsp:open-document`, `lsp:reopen-document`) pass `lspPhysicalTargetAssertion(context)`. It re-runs the same rule: `resolveInsideRoot` + `validateExistingTarget` (no symlink, canonical inside the root) + regular file + an unchanged relative location. +- **Why a callback and not a filesystem check in the manager:** the IPC layer owns authorization for both editor roots and AI Workspace entries, and the manager's unit tests run on fake roots. + +**Tests.** +- **Real filesystem:** the reviewer's probe. Authorize `src/a.ts`, then swap `src` for a symlink to an outside directory: refused. A leaf that became a symlink: refused. An untouched file: passes. A virtual document: nothing to check. +- **Manager:** + - the re-check runs after `initialized` and before `didOpen`; + - a refusal returns false with no notification; + - a pass opens normally. +- **Mutations killed:** no re-check in the manager; no physical validation in the assertion. + +## After review a of #1412 +- **Shared joins:** the re-check runs at the top of the queued step for EVERY open, including one that joins an existing shared document (another alias of the same file). Before, only a new document's `didOpen` was guarded, so a join after a swap sent `didChange` for the escaped URI. +- **Virtual documents:** they are named under `root/.agent-code-lsp`, so that directory must not be a symlink out of the root. They now get a re-check too. +- **No exact relative-path comparison:** a case-only rename on a case-insensitive filesystem still resolves inside the root, and refusing it only lost LSP. Containment plus a regular file is the property. + +**Residuals.** +- **One await:** the window between the re-check and the notification is one await, inherent to any path-based open. +- **A swap after a document is already open** (a later `didChange`, or a document request, for a URI the server already holds) is not guarded. The server already has that URI, and no per-change path check stops it from reading the path later. This issue is the authorization-to-use window of an OPEN. +- **The IPC wiring** of the callback is not covered by a test, because the handlers need Electron. + +## After review b (1cb8b3cf) +Every assertion first checks that the canonical root still resolves to itself (`assertRootUnchanged`). A pathless document's check used to return early when the virtual directory was missing, without checking the root. Review b's other findings are the path-based LSP limit; B6 (owner proxy) accepted this PR as narrowing the window, `Refs #1268`. + +## After review c +- The virtual branch now checks the LEAF `didOpen` names (`virtual-.`, from `lspVirtualDocumentName`, shared with the manager). It must be absent, or a regular file inside the root. A leaf symlink created in advance escaped with no timing window. A pathless open without its leaf name is refused. +- The regular-file re-check is pinned: an authorized file that became a directory is refused. +- The IPC wiring of the callback still has no committed test. A wiring test is feasible with the existing Electron mock; it is left out under the freeze and stated as a residual. diff --git a/src/main/ipc/lsp.ts b/src/main/ipc/lsp.ts index 8b97fee7f..bbf440ff8 100644 --- a/src/main/ipc/lsp.ts +++ b/src/main/ipc/lsp.ts @@ -1,10 +1,11 @@ import { ipcMain, type WebContents } from 'electron' -import { lstat } from 'fs/promises' -import { relative } from 'path' +import { lstat, realpath } from 'fs/promises' +import { join, relative } from 'path' import type { AiWorkspaceRegistry } from '@main/aiWorkspace/AiWorkspaceRegistry.js' import { resolveInsideRoot, validateExistingTarget } from '@main/ipc/editorFs.js' import type { EditorFsRootRegistry } from '@main/ipc/editorFsRootRegistry.js' +import { LSP_VIRTUAL_DIR, lspVirtualDocumentName } from '@main/lspManager.js' import type { LspManager } from '@main/lspManager.js' import type { LspCompletionContext, @@ -12,6 +13,74 @@ import type { LspPosition, } from '@shared/types/lsp.js' +/** + * The manager re-runs the physical check that authorizeContext ran, at the + * moment of use, after server startup (#1268): the path must still resolve, + * without symlinks, to a regular file inside the root. + * + * WHY no "same relative path" check (review a of #1412): on a case- + * insensitive filesystem a case-only rename still resolves, and realpath + * returns the new spelling, so an exact comparison refused a legitimate open. + * Containment is the property that matters. + * + * A pathless (virtual) document still names a file to the server: + * `root/.agent-code-lsp/virtual-.` (makeVirtualServerUri). That + * directory must not be a symlink out of the root. + */ +/** + * The authorized root must still BE the root (#1412 review b): renamed away and + * replaced by a symlink out, every lexical path under it names outside + * content. `workspaceRoot` is the canonical path `roots.authorize` returned, + * so it must still resolve to exactly itself. + */ +async function assertRootUnchanged(workspaceRoot: string): Promise { + if ((await realpath(workspaceRoot)) !== workspaceRoot) throw new Error('LSP workspace root no longer resolves to the authorized root') +} + +export function lspPhysicalTargetAssertion(context: { workspaceRoot: string; filePath: string | null; virtualName?: string }): () => Promise { + const { workspaceRoot, filePath, virtualName } = context + if (filePath === null) { + return async () => { + // The leaf name is required (#1412 review c): without it the check + // cannot see what didOpen will name, so the open is refused. + if (!virtualName) throw new Error('LSP virtual document has no leaf name to check') + // Checked FIRST: a missing virtual directory below proves nothing about + // where the root now points (review b, finding 2). + await assertRootUnchanged(workspaceRoot) + const directory = join(workspaceRoot, LSP_VIRTUAL_DIR) + let entry + try { + entry = await lstat(directory) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + if (entry.isSymbolicLink()) throw new Error('LSP virtual document directory is a symbolic link') + await validateExistingTarget(workspaceRoot, directory) + // The LEAF didOpen names (#1412 review c): a symlink created there in + // advance (its hash is steerable through the renderer's clientUri) + // resolved outside the root with no timing window at all. It must be + // absent, or a regular file inside the root. + const leaf = join(directory, virtualName) + let leafEntry + try { + leafEntry = await lstat(leaf) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + if (leafEntry.isSymbolicLink() || !leafEntry.isFile()) throw new Error('LSP virtual document path is not a regular file') + await validateExistingTarget(workspaceRoot, leaf) + } + } + return async () => { + await assertRootUnchanged(workspaceRoot) + const requested = resolveInsideRoot(workspaceRoot, filePath) + const physical = await validateExistingTarget(workspaceRoot, requested) + if (!(await lstat(physical)).isFile()) throw new Error('LSP document is not a file') + } +} + // LSP-backed code intelligence for Monaco surfaces. // // The renderer's CodeBlock component opens a document per visible @@ -287,6 +356,7 @@ export function registerLspIpc( language: params.language, workspaceRoot: context.workspaceRoot, filePath: context.filePath, + assertPhysicalTarget: lspPhysicalTargetAssertion({ ...context, virtualName: lspVirtualDocumentName(params.clientUri, params.language) }), }) // A page that left meanwhile does not get the marker back: clear() // dropped it, and the close clear() queued behind this entry will @@ -402,6 +472,7 @@ export function registerLspIpc( language: params.language, workspaceRoot: context.workspaceRoot, filePath: context.filePath, + assertPhysicalTarget: lspPhysicalTargetAssertion({ ...context, virtualName: lspVirtualDocumentName(params.clientUri, params.language) }), }) if (!ok) break } diff --git a/src/main/ipc/lspPhysicalTarget.test.ts b/src/main/ipc/lspPhysicalTarget.test.ts new file mode 100644 index 000000000..0a61f047b --- /dev/null +++ b/src/main/ipc/lspPhysicalTarget.test.ts @@ -0,0 +1,117 @@ +import { mkdir, mkdtemp, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), on: vi.fn() } })) + +const { lspPhysicalTargetAssertion } = await import('./lsp.js') +const VIRTUAL = 'virtual-1a2b3c.ts' + +// #1268 review A's real-filesystem probe: authorize `src/a.ts` inside the root, +// then swap `src` for a symlink to an outside directory holding its own +// `a.ts`. The lexical path is unchanged, but it now names an escaped file. +let base = '' +afterEach(async () => { if (base) await rm(base, { recursive: true, force: true }) }) + +async function layout() { + base = await realpath(await mkdtemp(join(tmpdir(), 'lsp-toctou-'))) + const root = join(base, 'root') + const outside = join(base, 'outside') + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(outside, { recursive: true }) + await writeFile(join(root, 'src', 'a.ts'), 'export const inside = 1\n') + await writeFile(join(outside, 'a.ts'), 'export const secret = 1\n') + return { root, outside } +} + +it('passes while the authorized file is still inside the root', async () => { + const { root } = await layout() + await expect(lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: join('src', 'a.ts') })()).resolves.toBeUndefined() +}) + +it('refuses once a directory on the path was swapped for a symlink outside the root', async () => { + const { root, outside } = await layout() + const assertion = lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: join('src', 'a.ts') }) + await rename(join(root, 'src'), join(root, 'src-moved')) + await symlink(outside, join(root, 'src')) + await expect(assertion()).rejects.toThrow(/escapes project root/) +}) + +it('refuses a leaf that became a symlink, even to a file inside the root', async () => { + const { root } = await layout() + const assertion = lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: join('src', 'a.ts') }) + await writeFile(join(root, 'src', 'b.ts'), 'export const other = 1\n') + await rm(join(root, 'src', 'a.ts')) + await symlink(join(root, 'src', 'b.ts'), join(root, 'src', 'a.ts')) + await expect(assertion()).rejects.toThrow(/symbolic links/) +}) + +// Review a of #1412: a virtual document is named under root/.agent-code-lsp, +// so that directory must not be a symlink out of the root. +it('refuses a virtual document whose directory is a symlink out of the root', async () => { + const { root, outside } = await layout() + await symlink(outside, join(root, '.agent-code-lsp')) + await expect(lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: null, virtualName: VIRTUAL })()).rejects.toThrow(/symbolic link/) +}) + +it('passes a virtual document with no directory yet, or a real one', async () => { + const { root } = await layout() + await expect(lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: null, virtualName: VIRTUAL })()).resolves.toBeUndefined() + await mkdir(join(root, '.agent-code-lsp')) + await expect(lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: null, virtualName: VIRTUAL })()).resolves.toBeUndefined() +}) + +// Review a: a case-only rename on a case-insensitive filesystem still resolves +// to the same file inside the root, and must not be refused. +it('accepts a case-only rename of the authorized file', async () => { + const { root } = await layout() + const assertion = lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: join('src', 'a.ts') }) + await rename(join(root, 'src', 'a.ts'), join(root, 'src', 'A.ts')) + const caseInsensitive = await realpath(join(root, 'src', 'a.ts')).then(() => true, () => false) + if (caseInsensitive) await expect(assertion()).resolves.toBeUndefined() + else await expect(assertion()).rejects.toThrow() +}) + +// #1412 review b (finding 2): the ROOT itself swapped for a symlink out, with +// no virtual-document directory at the target. The virtual branch returned +// early on ENOENT without checking the root, and didOpen then named +// `root/.agent-code-lsp/…`, which resolves outside. Every assertion now first +// checks that the root still resolves to itself. +it('refuses a pathless document when the root was replaced by a symlink out', async () => { + const { root, outside } = await layout() + const assertion = lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: null, virtualName: VIRTUAL }) + await rename(root, join(base, 'root-moved')) + await symlink(outside, root) + await expect(assertion()).rejects.toThrow(/root/) +}) + +// #1412 review c: the virtual branch checked `.agent-code-lsp/` but not the +// `virtual-.` leaf didOpen NAMES. A leaf symlink created in advance +// (the hash of a renderer-chosen clientUri is steerable) resolved outside with +// no timing window at all. The leaf must be absent or a regular, contained +// file, and a pathless open without its leaf name is refused (fail closed). +it('refuses a virtual document whose named leaf is a symlink out, or not a file', async () => { + const { root, outside } = await layout() + await mkdir(join(root, '.agent-code-lsp')) + await symlink(join(outside, 'a.ts'), join(root, '.agent-code-lsp', VIRTUAL)) + await expect(lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: null, virtualName: VIRTUAL })()).rejects.toThrow() + await rm(join(root, '.agent-code-lsp', VIRTUAL)) + await mkdir(join(root, '.agent-code-lsp', VIRTUAL)) + await expect(lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: null, virtualName: VIRTUAL })()).rejects.toThrow() + await rm(join(root, '.agent-code-lsp', VIRTUAL), { recursive: true }) + await writeFile(join(root, '.agent-code-lsp', VIRTUAL), '') + await expect(lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: null, virtualName: VIRTUAL })()).resolves.toBeUndefined() + await expect(lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: null })()).rejects.toThrow() +}) + +// #1412 review c (minor): the regular-file re-check had no test. An authorized +// file replaced by a directory before didOpen is refused. +it('refuses an authorized file that became a directory', async () => { + const { root } = await layout() + const assertion = lspPhysicalTargetAssertion({ workspaceRoot: root, filePath: join('src', 'a.ts') }) + await rm(join(root, 'src', 'a.ts')) + await mkdir(join(root, 'src', 'a.ts')) + await expect(assertion()).rejects.toThrow(/not a file/) +}) + diff --git a/src/main/lspDocumentOrdering.test.ts b/src/main/lspDocumentOrdering.test.ts index 61959eb95..198306e3a 100644 --- a/src/main/lspDocumentOrdering.test.ts +++ b/src/main/lspDocumentOrdering.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it, vi } from 'vitest' // Three ordering defects found by the #918 planning audit (#922, #923, #924), // none of which had been reproduced when they were filed. Each test below @@ -21,6 +24,18 @@ vi.mock('electron', () => ({ const { LspManager } = await import('./lspManager.js') const { registerLspIpc } = await import('./ipc/lsp.js') +// The workspace root every test opens under. It must be a REAL, canonical +// directory: since #1268 the IPC open path re-checks, right before `didOpen`, +// that the authorized root still resolves to itself (`lspPhysicalTargetAssertion` +// in ipc/lsp.ts). A made-up REPO does not exist, so that check refused every +// IPC open here and the ordering these tests pin was never reached. Production +// stays strict on purpose: an authorized root that no longer resolves to itself +// is refused. `realpath` matters on macOS, where tmpdir() is /var/... but the +// canonical path is /private/var/...; the roots registry hands out canonical +// paths, so the test must too. Nothing is written inside it. +const REPO = await realpath(await mkdtemp(join(tmpdir(), 'ac-lsp-ordering-'))) +afterAll(() => rm(REPO, { recursive: true, force: true })) + type Manager = InstanceType type Notification = { method: string; params: unknown } @@ -73,7 +88,7 @@ function managerWithServer(options?: { const OPEN = { language: 'typescript', - workspaceRoot: '/repo', + workspaceRoot: REPO, filePath: 'shared.ts', } as const @@ -680,7 +695,7 @@ describe('#922 — text accepted during authorization must not vanish', () => { // window exactly, without a sleep. authorize: async () => { await authorizationPaused - return '/repo' + return REPO }, } registerLspIpc(manager, roots as never, {} as never) @@ -692,7 +707,7 @@ describe('#922 — text accepted during authorization must not vanish', () => { clientUri: 'inmemory://a', content: 'first', language: 'typescript', - workspaceRoot: '/repo', + workspaceRoot: REPO, // null: a virtual document, so authorization is root validation alone and // the test does not depend on a file existing on disk. The ordering // defect is in the queue, not in what is being authorized. @@ -732,7 +747,7 @@ describe('#922 — text accepted during authorization must not vanish', () => { // document the renderer is editing has disappeared underneath it. ipcHandlers.clear() const { manager, server } = managerWithServer() - registerLspIpc(manager, { authorize: async () => '/repo' } as never, {} as never) + registerLspIpc(manager, { authorize: async () => REPO } as never, {} as never) const sender = { id: 1, once: () => {}, on: () => {}, isDestroyed: () => false } const evt = { sender } @@ -740,7 +755,7 @@ describe('#922 — text accepted during authorization must not vanish', () => { clientUri: 'inmemory://a', content: 'first', language: 'typescript', - workspaceRoot: '/repo', + workspaceRoot: REPO, filePath: null, authorization: { kind: 'editor-root' }, }) @@ -766,7 +781,7 @@ describe('#922 — text accepted during authorization must not vanish', () => { // No server for this language: `getOrCreateServer` answers null, which is // what a missing binary produces. ;(manager as unknown as { getOrCreateServer: () => Promise }).getOrCreateServer = async () => null - registerLspIpc(manager, { authorize: async () => '/repo' } as never, {} as never) + registerLspIpc(manager, { authorize: async () => REPO } as never, {} as never) const sender = { id: 1, once: () => {}, on: () => {}, isDestroyed: () => false } const evt = { sender } @@ -774,7 +789,7 @@ describe('#922 — text accepted during authorization must not vanish', () => { clientUri: 'inmemory://none', content: 'first', language: 'typescript', - workspaceRoot: '/repo', + workspaceRoot: REPO, filePath: null, authorization: { kind: 'editor-root' }, }) @@ -799,10 +814,10 @@ describe('#1208 — reopening a document after its server was lost', () => { internal.servers.set(replacement.key, replacement) internal.getOrCreateServer = async () => replacement } - const VIRTUAL = { language: 'typescript', workspaceRoot: '/repo', filePath: null, authorization: { kind: 'editor-root' } } as const + const VIRTUAL = { language: 'typescript', workspaceRoot: REPO, filePath: null, authorization: { kind: 'editor-root' } } as const function setup( - authorize: (sender: unknown, root: string) => Promise = async () => '/repo', + authorize: (sender: unknown, root: string) => Promise = async () => REPO, aiWorkspaces: unknown = {}, ) { ipcHandlers.clear() @@ -834,7 +849,7 @@ describe('#1208 — reopening a document after its server was lost', () => { entered() await new Promise(resolve => { release = resolve }) } - return '/repo' + return REPO }, pauseNext: () => { paused = true }, inside, @@ -878,7 +893,7 @@ describe('#1208 — reopening a document after its server was lost', () => { let allowed = true const { manager, server, evt } = setup(async () => { if (!allowed) throw new Error('root is no longer authorized') - return '/repo' + return REPO }) await ipcHandlers.get('lsp:open-document')!(evt, { ...VIRTUAL, clientUri: 'inmemory://a', content: 'one' }) discard(manager, server) @@ -922,7 +937,7 @@ describe('#1208 — reopening a document after its server was lost', () => { entered() await new Promise(resolve => { release = resolve }) } - return '/repo' + return REPO }) await ipcHandlers.get('lsp:open-document')!(evt, { ...VIRTUAL, clientUri: 'inmemory://a', content: 'one' }) discard(manager, server) @@ -1065,7 +1080,7 @@ describe('#1208 — reopening a document after its server was lost', () => { const aiWorkspaces = { authorizeLspEntry: async () => { if (!entryAlive) throw new Error('AI Workspace entry is gone') - return { workspaceRoot: '/repo', filePath: null } + return { workspaceRoot: REPO, filePath: null } }, } const { manager, server, evt } = setup(async () => { throw new Error('editor roots are not used here') }, aiWorkspaces) @@ -1082,7 +1097,7 @@ describe('#1208 — reopening a document after its server was lost', () => { // #1266 review C6: the same size guard as lsp:open-document, so a reopen // cannot push oversized text past authorization into the manager. it('refuses oversized text before authorizing anything', async () => { - const authorize = vi.fn(async () => '/repo') + const authorize = vi.fn(async () => REPO) const { manager, server, evt } = setup(authorize) await ipcHandlers.get('lsp:open-document')!(evt, { ...VIRTUAL, clientUri: 'inmemory://a', content: 'one' }) discard(manager, server) @@ -1107,7 +1122,7 @@ describe('#1208 — reopening a document after its server was lost', () => { await new Promise(resolve => { release = resolve }) throw new Error('old root revoked') } - return '/repo' + return REPO }) const stale = ipcHandlers.get('lsp:open-document')!(evt, { ...VIRTUAL, clientUri: 'inmemory://a', content: 'old' }) await inside diff --git a/src/main/lspManager.test.ts b/src/main/lspManager.test.ts index e74f13095..e1fdc9abc 100644 --- a/src/main/lspManager.test.ts +++ b/src/main/lspManager.test.ts @@ -677,4 +677,66 @@ describe('LspManager document ownership', () => { }), ]) }) + + // #1268: the caller's physical re-check runs AFTER server startup and + // immediately before didOpen; a refusal fails open (no LSP) and names + // nothing to the server. + it('re-checks the physical target after server startup and sends no didOpen when it fails', async () => { + const manager = new LspManager() + const internal = manager as unknown as LspManagerInternals + let startServer!: () => void + const started = new Promise(resolve => { startServer = resolve }) + const order: string[] = [] + const server = { key: 'server', initialized: started.then(() => { order.push('initialized'); return {} }), closed: false, abandonedRequests: 0 } + internal.servers.set('server', server) + internal.getOrCreateServer = async () => server + const notifications: string[] = [] + internal.sendNotificationIfOpen = async (_server, method) => { notifications.push(method) } + const opening = manager.openDocument({ + clientUri: 'cc-file://root/src/a.ts', content: 'text', language: 'typescript', + workspaceRoot: '/repo', filePath: 'src/a.ts', + assertPhysicalTarget: async () => { order.push('re-check'); throw new Error('escaped') }, + }) + startServer() + await expect(opening).resolves.toBe(false) + expect(order).toEqual(['initialized', 're-check']) + expect(notifications).toEqual([]) + }) + + it('opens normally when the physical re-check passes', async () => { + const manager = new LspManager() + const internal = manager as unknown as LspManagerInternals + const server = { key: 'server', initialized: Promise.resolve({}), closed: false, abandonedRequests: 0 } + internal.servers.set('server', server) + internal.getOrCreateServer = async () => server + const notifications: string[] = [] + internal.sendNotificationIfOpen = async (_server, method) => { notifications.push(method) } + const check = vi.fn(async () => {}) + await expect(manager.openDocument({ + clientUri: 'cc-file://root/src/a.ts', content: 'text', language: 'typescript', + workspaceRoot: '/repo', filePath: 'src/a.ts', assertPhysicalTarget: check, + })).resolves.toBe(true) + expect(check).toHaveBeenCalledTimes(1) + expect(notifications).toEqual(['textDocument/didOpen']) + }) + + // Review a of #1412: an open that JOINS an existing shared document (another + // alias of the same file) must run the re-check too, or a swap between its + // authorization and use sends didChange for the escaped URI. + it('runs the physical re-check when joining an existing shared document', async () => { + const manager = new LspManager() + const internal = manager as unknown as LspManagerInternals + const server = { key: 'server', initialized: Promise.resolve({}), closed: false, abandonedRequests: 0 } + internal.servers.set('server', server) + internal.getOrCreateServer = async () => server + const notifications: string[] = [] + internal.sendNotificationIfOpen = async (_server, method) => { notifications.push(method) } + const common = { content: 'text', language: 'typescript', workspaceRoot: '/repo', filePath: 'src/a.ts' } + await expect(manager.openDocument({ ...common, clientUri: 'cc-file://first/src/a.ts', assertPhysicalTarget: async () => {} })).resolves.toBe(true) + await expect(manager.openDocument({ + ...common, clientUri: 'cc-file://second/src/a.ts', + assertPhysicalTarget: async () => { throw new Error('escaped') }, + })).resolves.toBe(false) + expect(notifications).toEqual(['textDocument/didOpen']) + }) }) diff --git a/src/main/lspManager.ts b/src/main/lspManager.ts index a1469c2dd..deacdd241 100644 --- a/src/main/lspManager.ts +++ b/src/main/lspManager.ts @@ -53,6 +53,21 @@ type OpenDocumentParams = { language: string workspaceRoot: string filePath?: string | null + /** + * Re-check, at the moment of use, that `filePath` still resolves physically + * inside `workspaceRoot` (#1268). Throws when it no longer does. + * + * WHY a callback from the caller and not a check here: the IPC layer owns + * authorization (editor roots, AI Workspace entries) and ran the physical + * check once, BEFORE this open awaited server startup. A cold server spawn + * can take seconds, and in that window a directory under the root can be + * swapped for a symlink to an outside directory, so the lexical URI built + * after startup would name an escaped file. Calling the same authority + * again inside the per-document queue, immediately before didOpen, closes + * that window to the one await this check itself takes. Keeping the + * filesystem out of the manager also keeps its unit tests on fake roots. + */ + assertPhysicalTarget?: () => Promise } type OpenDocumentRecord = { @@ -194,13 +209,18 @@ function hashText(input: string): string { return Math.abs(hash).toString(16) } +/** The root-relative directory virtual (pathless) documents are named under. + * Exported because the IPC layer's physical re-check guards it (#1268). */ +export const LSP_VIRTUAL_DIR = '.agent-code-lsp' + +/** The file name a pathless document is given under LSP_VIRTUAL_DIR. Exported + * so the IPC layer checks exactly the leaf didOpen will name (#1412 review c). */ +export function lspVirtualDocumentName(clientUri: string, language: string): string { + return `virtual-${hashText(clientUri)}.${languageFileExtension(language)}` +} + function makeVirtualServerUri(workspaceRoot: string, clientUri: string, language: string): string { - const ext = languageFileExtension(language) - const filePath = resolve( - workspaceRoot, - '.agent-code-lsp', - `virtual-${hashText(clientUri)}.${ext}`, - ) + const filePath = resolve(workspaceRoot, LSP_VIRTUAL_DIR, lspVirtualDocumentName(clientUri, language)) return pathToFileURL(filePath).href } @@ -537,6 +557,20 @@ export class LspManager extends EventEmitter { // could not fan out for us — this URI had no record when it ran. this.notifyDocumentIntent(key) return await this.serializeServerDocument(key, async () => { + // The caller's physical re-check runs FIRST, for every open (#1268). A + // refusal fails open like every other LSP failure here: the editor keeps + // working, without LSP for this document. Review a of #1412: running it + // only before a NEW document's didOpen let an open that JOINED an + // existing shared document (another alias of the same file) pass after + // a swap and send didChange for the now-escaped URI. + if (params.assertPhysicalTarget) { + try { + await params.assertPhysicalTarget() + } catch { + return false + } + if (server.closed) return false + } const existing = this.docs.get(params.clientUri) if (existing) { if (existing.serverKey !== server.key || existing.serverUri !== serverUri) {