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
62 changes: 62 additions & 0 deletions packages/acp-bridge/src/bridgeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,28 @@ describe('BridgeClient — BridgeFileSystem injection seam (F1 step 5)', () => {
expect((err.data as { hint?: unknown }).hint).toBeUndefined();
});

it('readTextFile maps parse_error FsError to invalid params', async () => {
const readText = vi.fn(async (): Promise<ReadTextFileResponse> => {
throw makeFsError(
'parse_error',
'limit must be a positive integer, got 1.5',
{ status: 400 },
);
});
const client = makeClient({ writeText: vi.fn(), readText });

const err = (await client
.readTextFile({ path: '/x', sessionId: 'sess:test', limit: 1.5 })
.catch((e) => e)) as Error & { code?: number; data?: unknown };

expect(err.name).toBe('RequestError');
expect(err.code).toBe(-32602);
expect(err.data).toMatchObject({
errorKind: 'parse_error',
status: 400,
});
});

it('passes non-FsError errors through unchanged (no RequestError wrap)', async () => {
// Plain Error → bridgeClient must NOT wrap it. Only structured
// FsError gets the reshape. ACP's default serialization is
Expand Down Expand Up @@ -401,6 +423,46 @@ describe('BridgeClient — BridgeFileSystem injection seam (F1 step 5)', () => {

expect(response.content).toBe('on-disk-content');
});

it('readTextFile rejects fractional positive limits before slicing inline content', async () => {
const client = makeClient(/* no fileSystem */);
const target = path.join(tmpDir, 'lines.txt');
await fsp.writeFile(target, 'a\nb\nc\n', 'utf8');

const err = await client
.readTextFile({
path: target,
sessionId: 'sess:test',
line: 1,
limit: 1.5,
})
.catch((e: unknown) => e);

expect(err).toBeInstanceOf(RequestError);
expect((err as Error).message).toContain(
'`limit` must be a positive integer, got 1.5',
);
});

it('readTextFile rejects fractional positive lines before slicing inline content', async () => {
const client = makeClient(/* no fileSystem */);
const target = path.join(tmpDir, 'lines.txt');
await fsp.writeFile(target, 'a\nb\nc\n', 'utf8');

const err = await client
.readTextFile({
path: target,
sessionId: 'sess:test',
line: 1.5,
limit: 1,
})
.catch((e: unknown) => e);

expect(err).toBeInstanceOf(RequestError);
expect((err as Error).message).toContain(
'`line` must be a positive integer, got 1.5',
);
});
});
});

Expand Down
23 changes: 22 additions & 1 deletion packages/acp-bridge/src/bridgeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,8 @@ function isTrustedArtifactToolUpdate(
*/
function preserveFsErrorOverAcp(err: unknown): never {
if (isFsErrorShape(err)) {
throw new RequestError(-32603, err.message, {
const code = err.kind === 'parse_error' ? -32602 : -32603;
throw new RequestError(code, err.message, {
errorKind: err.kind,
...(err.hint !== undefined ? { hint: err.hint } : {}),
...(err.status !== undefined ? { status: err.status } : {}),
Expand Down Expand Up @@ -1879,6 +1880,26 @@ export class BridgeClient implements Client {
if (typeof params.limit === 'number' && params.limit <= 0) {
return { content: '' };
}
if (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, addressed in the latest push. I added the symmetric positive-integer guard for line in the inline fallback, so fractional line values now fail before reaching sliceLineRange(), matching the limit validation shape.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The guard condition params.limit > 0 && !Number.isSafeInteger(params.limit) has a NaN-shaped hole. NaN > 0 evaluates to false, so NaN falls through both the limit <= 0 early-return (since NaN <= 0 is also false) and this new guard, reaching sliceLineRange with NaN parameters. The sister validation in workspace-file-system.ts:456 uses !Number.isSafeInteger(opts.limit) || opts.limit < 1, which catches NaN because !Number.isSafeInteger(NaN) is true. Adopting the same pattern here would close the gap and align the two paths:

Suggested change
if (
if (typeof params.limit === 'number' && !Number.isSafeInteger(params.limit)) {
if (params.limit < 1) {
return { content: '' };
}
throw RequestError.invalidParams(
undefined,
`\`limit\` must be a positive integer, got ${params.limit}`,
);
}

— qwen3.7-max via Qwen Code /review

typeof params.limit === 'number' &&
params.limit > 0 &&
!Number.isSafeInteger(params.limit)
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new inline guard correctly throws RequestError.invalidParams() (JSON-RPC -32602, Invalid Params). However, the adapter path (production qwen serve) wraps the same WorkspaceFileSystem parse_error as RequestError(-32603) (Internal Error) via preserveFsErrorOverAcp(). The same client error produces different JSON-RPC codes depending on the path: -32602 inline vs -32603 in production.

Consider having preserveFsErrorOverAcp map parse_error kind to -32602 (Invalid Params), or adding a fractional-limit guard in the adapter before wfs.readText so the error is thrown before the -32603 wrapping.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, addressed. I updated preserveFsErrorOverAcp() so FsError('parse_error') maps to JSON-RPC Invalid Params (-32602) while preserving the existing data.errorKind, data.status, and data.hint fields. Other filesystem error kinds still use the existing internal-error mapping.

throw RequestError.invalidParams(
undefined,
`\`limit\` must be a positive integer, got ${params.limit}`,
);
}
if (
typeof params.line === 'number' &&
params.line > 0 &&
!Number.isSafeInteger(params.line)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The limit <= 0 early-return at line 1880 runs before this line validation, so a request like { line: 1.5, limit: 0 } silently returns { content: '' } without flagging the fractional line. Moving the line guard before the limit <= 0 check — or at least running both validations before any early-return — would ensure malformed parameters are always reported regardless of ordering.

— qwen3.7-max via Qwen Code /review

) {
throw RequestError.invalidParams(
undefined,
`\`line\` must be a positive integer, got ${params.line}`,
);
}
// BSA0E: cap the file size we'll buffer into RSS at 100 MiB so a
// request like `{ line: 1, limit: 10 }` against a 500 MB log
// doesn't cost the daemon 500 MB of memory just to return 10
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/serve/bridge-file-system-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,28 @@ describe('createBridgeFileSystemAdapter', () => {
});
expect(response.content).toBe('x\ny\n');
});

it('readText surfaces workspace-file-system parse_error for fractional positive limits', async () => {
const target = path.join(tmpDir, 'fractional-limit.txt');
await fsp.writeFile(target, 'x\ny\nz\n', 'utf8');
const adapter = createBridgeFileSystemAdapter(
buildFactory({ trusted: true }),
);

const err = await adapter
.readText({
path: target,
sessionId: 'sess:test',
line: 1,
limit: 1.5,
})
.catch((e: unknown) => e);

expect((err as { kind?: string }).kind).toBe('parse_error');
expect((err as Error).message).toContain(
'limit must be a positive integer',
);
});
});

describe('boundary enforcement', () => {
Expand Down
Loading