Skip to content
Closed
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
62 changes: 62 additions & 0 deletions packages/cli/src/ui/hooks/atCommandProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import {
handleAtCommand,
escapeAtSymbols,
checkPermissions,
unescapeLiteralAt,
} from './atCommandProcessor.js';
import {
Expand Down Expand Up @@ -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([]);
});
});
50 changes: 50 additions & 0 deletions packages/core/src/utils/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/utils/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ function robustRealpath(p: string, visited = new Set<string>()): 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);
Expand All @@ -437,12 +437,16 @@ function robustRealpath(p: string, visited = new Set<string>()): 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;
Expand Down