diff --git a/packages/cli/src/acp-integration/live/capture-screen-context.test.ts b/packages/cli/src/acp-integration/live/capture-screen-context.test.ts index 9437d46a7dd..cabf2d91ae8 100644 --- a/packages/cli/src/acp-integration/live/capture-screen-context.test.ts +++ b/packages/cli/src/acp-integration/live/capture-screen-context.test.ts @@ -98,6 +98,29 @@ describe('CaptureScreenContextTool', () => { }, ); + it('reports the dedicated symlink error on every platform', async () => { + const target = await captureFile(); + const link = join(target.directory, 'linked.png'); + await symlink(target.path, link); + const tool = new CaptureScreenContextTool( + async () => ({ + appName: 'Finder', + accessibilityText: '', + screenshotPath: link, + }), + target.directory, + ); + + const result = await tool.build({}).execute(new AbortController().signal); + + // The exact message pins the explicit lstat guard: without it, win32 + // reads through the link (no error at all) while POSIX falls back to + // O_NOFOLLOW's generic ELOOP message. + expect(result.error?.message).toBe( + 'Host returned a symbolic link screenshot path.', + ); + }); + it('rejects a screenshot outside the Host private directory', async () => { const outside = await captureFile(); const allowed = await mkdtemp(join(tmpdir(), 'capture-screen-allowed-')); diff --git a/packages/cli/src/acp-integration/live/capture-screen-context.ts b/packages/cli/src/acp-integration/live/capture-screen-context.ts index b1dc3c04497..8b3078b781f 100644 --- a/packages/cli/src/acp-integration/live/capture-screen-context.ts +++ b/packages/cli/src/acp-integration/live/capture-screen-context.ts @@ -5,7 +5,7 @@ */ import { constants } from 'node:fs'; -import { open, unlink } from 'node:fs/promises'; +import { lstat, open, unlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { @@ -52,6 +52,11 @@ function resolvePrivatePngPath(path: string, captureDirectory: string): string { } async function readPrivatePng(path: string): Promise { + // Windows silently ignores O_NOFOLLOW, so a symlinked screenshot path + // would be followed and read on win32. Probe the link itself first. + if ((await lstat(path)).isSymbolicLink()) { + throw new Error('Host returned a symbolic link screenshot path.'); + } const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { const stat = await handle.stat();