From 1406a5debd8c76d3b481d2d59eb6e2b1fc2b0737 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Sun, 3 May 2026 16:55:16 +0530 Subject: [PATCH] fix(core): handle ENAMETOOLONG in robustRealpath pasting a long string containing @ ends up in resolveToRealPath via parseAllAtCommands, and the leaf trips ENAMETOOLONG. the catch only matched ENOENT/EISDIR, so it escaped the unguarded await in AppContainer as an unhandled rejection. extend the catch list, same shape as 41f1ea467. closes #26368 --- .../src/ui/hooks/atCommandProcessor.test.ts | 62 +++++++++++++++++++ packages/core/src/utils/paths.test.ts | 50 +++++++++++++++ packages/core/src/utils/paths.ts | 8 ++- 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index ca2ecf7bc1f..ea34956ae12 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -16,6 +16,7 @@ import { import { handleAtCommand, escapeAtSymbols, + checkPermissions, unescapeLiteralAt, } from './atCommandProcessor.js'; import { @@ -1540,3 +1541,64 @@ describe('unescapeLiteralAt', () => { expect(unescapeLiteralAt(escapeAtSymbols(input))).toBe(input); }); }); + +// Regression coverage for issue #26368: pasting a long string containing an +// unescaped `@` previously crashed the CLI with an unhandled promise rejection +// because `robustRealpath` did not catch ENAMETOOLONG. checkPermissions is the +// async surface that runs synchronously inside an `await` on `handleFinalSubmit`, +// so any error escaping it becomes the unhandled rejection. +describe('checkPermissions — long-paste regression (#26368)', () => { + let testRootDir: string; + let mockConfig: Config; + + beforeEach(async () => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + + testRootDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'checkperms-enametoolong-'), + ); + + // Minimal Config surface — checkPermissions only uses these methods, + // plus categorizeAtCommands' calls into getAgentRegistry/getResourceRegistry. + mockConfig = { + getTargetDir: () => testRootDir, + validatePathAccess: () => null, + getAgentRegistry: () => undefined, + getResourceRegistry: () => ({ + findResourceByUri: () => undefined, + getAllResources: () => [], + }), + } as unknown as Config; + }); + + afterEach(async () => { + await fsPromises.rm(testRootDir, { recursive: true, force: true }); + }); + + it('should not reject when the @-token is longer than PATH_MAX', async () => { + // 5000 chars is well over macOS PATH_MAX (1024) and Linux PATH_MAX (4096). + // Real filesystems will reject the lstat/realpath syscall with ENAMETOOLONG. + const longToken = 'a'.repeat(5000); + const query = `Look at this trace @${longToken}`; + + // Must not throw, must not reject — just return an empty list because + // the candidate path can't possibly exist on disk. + await expect(checkPermissions(query, mockConfig)).resolves.toEqual([]); + }); + + it('should not reject when the pasted blob contains multiple over-long @-tokens', async () => { + const longToken = 'b'.repeat(3000); + const query = `error @${longToken} and also @${longToken}\nstack trace continues`; + + await expect(checkPermissions(query, mockConfig)).resolves.toEqual([]); + }); + + it('should still resolve normally for an unescaped @ with no following token', async () => { + // Sanity check: a lone @ in pasted text should be treated as text and + // produce no permission entries — independently of the ENAMETOOLONG fix. + const query = 'send mail to user @ example dot com'; + + await expect(checkPermissions(query, mockConfig)).resolves.toEqual([]); + }); +}); diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 09b58d41870..567fb18ef51 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -561,6 +561,56 @@ describe('resolveToRealPath', () => { expect(resolveToRealPath(input)).toBe(expected); }); + // Regression test for issue #26368: a long pasted blob containing an + // unescaped `@` was routed through resolveToRealPath, where fs.realpathSync + // (and the fallback fs.lstatSync) raised ENAMETOOLONG. The error escaped + // robustRealpath and surfaced as an unhandled promise rejection that crashed + // the CLI. resolveToRealPath must swallow ENAMETOOLONG and return the input + // path unchanged so downstream consumers (validatePathAccess, fileExists) + // can treat it as "not a real path" and let the caller fall through. + it('should not throw ENAMETOOLONG when a very long string is provided', () => { + vi.spyOn(fs, 'realpathSync').mockImplementationOnce(() => { + const err = new Error('name too long') as NodeJS.ErrnoException; + err.code = 'ENAMETOOLONG'; + throw err; + }); + + const longString = 'a'.repeat(5000); + expect(() => resolveToRealPath(longString)).not.toThrow(); + }); + + it('should return resolved path even if fs.realpathSync fails with ENAMETOOLONG', () => { + vi.spyOn(fs, 'realpathSync').mockImplementationOnce(() => { + const err = new Error('name too long') as NodeJS.ErrnoException; + err.code = 'ENAMETOOLONG'; + throw err; + }); + + const longString = 'a'.repeat(5000); + // robustRealpath's fallback for a single-segment over-long name resolves + // path.dirname(p) === p, so the input is returned as-is after path.resolve. + expect(resolveToRealPath(longString)).toBe(path.resolve(longString)); + }); + + it('should return decoded path even if fs.lstatSync fails with ENAMETOOLONG inside the symlink fallback', () => { + // First realpathSync throws ENOENT to enter the lstat fallback; + // then lstatSync throws ENAMETOOLONG (this is what actually fires on + // macOS for over-long leaf segments). + vi.spyOn(fs, 'realpathSync').mockImplementation(() => { + const err = new Error('not found') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + vi.spyOn(fs, 'lstatSync').mockImplementation(() => { + const err = new Error('name too long') as NodeJS.ErrnoException; + err.code = 'ENAMETOOLONG'; + throw err; + }); + + const longString = 'a'.repeat(5000); + expect(() => resolveToRealPath(longString)).not.toThrow(); + }); + it('should recursively resolve symlinks for non-existent child paths', () => { const parentPath = path.resolve('/some/parent/path'); const resolvedParentPath = path.resolve('/resolved/parent/path'); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index fee8b8d8556..da97bda08cc 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -425,7 +425,7 @@ function robustRealpath(p: string, visited = new Set()): string { e && typeof e === 'object' && 'code' in e && - (e.code === 'ENOENT' || e.code === 'EISDIR') + (e.code === 'ENOENT' || e.code === 'EISDIR' || e.code === 'ENAMETOOLONG') ) { try { const stat = fs.lstatSync(p); @@ -437,12 +437,16 @@ function robustRealpath(p: string, visited = new Set()): string { } catch (lstatError: unknown) { // Not a symlink, or lstat failed. Re-throw if it's not an expected // ENOENT (e.g., a permissions error), otherwise resolve parent. + // ENAMETOOLONG is included so a pasted blob containing an unescaped + // `@` cannot crash the process via an unhandled rejection (issue #26368). if ( !( lstatError && typeof lstatError === 'object' && 'code' in lstatError && - (lstatError.code === 'ENOENT' || lstatError.code === 'EISDIR') + (lstatError.code === 'ENOENT' || + lstatError.code === 'EISDIR' || + lstatError.code === 'ENAMETOOLONG') ) ) { throw lstatError;