Compare commits

..

4 Commits

Author SHA1 Message Date
claude code agent 227
ec128d54b4 test(ssrf): add IP-level bypass-vector cases (ported from GLM branch)
Adds explicit isIpAllowed cases for the CGNAT, ULA (fd00::/8) and IPv4-mapped
IPv6 loopback (::ffff:127.0.0.1) sample addresses from the parallel
safety-coverage branch. The mapped-loopback case is genuinely new (the existing
table only covered the mapped *private* variant); CGNAT and ULA ranges were
already covered with other samples and are kept here as explicit regression
guards for these specific addresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:00:43 +03:00
claude code agent 227
cedea4072b refactor(ai-chat)!: unify provider error formatting via describeProviderError
Behaviour change (split out of the test commit per review, and now covered).

Both the stream onError log line and the error text streamed to the client were
formatted by separate inline blocks that only emitted "<status>: <message>".
Route both through the shared describeProviderError() so formatting stays in one
place.

BEHAVIOUR CHANGE: describeProviderError additionally appends a single-line,
300-char-truncated snippet of the provider responseBody/text. So the log line
AND the user-facing stream error now include that snippet (e.g. the HTML error
page from a misconfigured endpoint), which previously neither did. This is
intentional — it makes a misconfigured external endpoint diagnosable — and is
safe: the API key travels in the Authorization header and is never echoed in
the response body (see the util's docstring). A `fallback` param is added so
each call site keeps its own default ('AI stream error' for the stream).

Adds ai-error.util.spec.ts covering the formatter, including the appended /
truncated body snippet, so this behaviour is no longer untested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:59:55 +03:00
claude code agent 227
1e650262a4 fix(ai-chat): record chats that fail on their first turn
Behaviour change (split out of the test commit per review).

In AI SDK v6 the useChat `onFinish` callback does NOT fire when the stream
errors. A brand-new chat whose very first turn fails would therefore never run
the post-turn path: the chat list was not invalidated and the client never
adopted the server-created chat id — so the failed chat only appeared in
history after a manual refresh (the server already creates the row and stores
the error message). Running the same `onTurnFinished()` handler on `onError`
makes the failed chat show up immediately. The error itself is still surfaced
to the user via the existing `error` state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:58:57 +03:00
claude code agent 227
f1980cf425 test(ai-chat): safety-critical coverage + a11y + pure refactors
Unit tests for the safety-critical paths: crypto secret-box (round-trip,
tamper detection, wrong key), the SSRF guard (blocked ranges + DNS-rebinding),
the ai-chat tools service, the page-embedding repo, and the
assistant-parts/serialization helpers. Those server helpers (assistantParts,
rowToUiMessage, serializeSteps) are exported ONLY for the tests — no runtime
change.

Also: keyboard a11y on the chat history header and conversation rows
(role/tabIndex/Enter+Space), and DRY refactors that move shared logic into one
place (isToolPart -> tool-parts util; buildInitialValues in the MCP form).

The behaviour-changing edits that previously rode along in this commit are
split out into the following two commits, per review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:58:44 +03:00
2 changed files with 75 additions and 0 deletions

View File

@@ -46,6 +46,20 @@ describe('isIpAllowed', () => {
expect(isIpAllowed(ip).ok).toBe(false);
});
// IP-level bypass vectors ported from the safety-coverage branch. CGNAT
// (100.64/10) and the ULA range (fc00::/7) are already exercised above with
// other sample addresses; the genuinely distinct case is the IPv4-mapped
// IPv6 *loopback* (::ffff:127.0.0.1) — the table above only had the mapped
// *private* variant. fd00::/8 is the commonly-assigned ULA prefix, kept as an
// explicit regression guard.
it.each([
['CGNAT', '100.64.0.1'],
['ULA fd00::/8', 'fd00::1'],
['IPv4-mapped IPv6 loopback', '::ffff:127.0.0.1'],
])('blocks bypass vector %s (%s)', (_label, ip) => {
expect(isIpAllowed(ip).ok).toBe(false);
});
it('allows a public IPv4 (8.8.8.8)', () => {
expect(isIpAllowed('8.8.8.8').ok).toBe(true);
});

View File

@@ -0,0 +1,61 @@
import { describeProviderError } from './ai-error.util';
/**
* Unit tests for describeProviderError: the shared formatter used both for the
* server log line and for the error text streamed back to the client. This
* pins the behaviour, including the one behaviour change introduced when the
* two inline formatters were unified: a truncated, single-line snippet of the
* provider `responseBody`/`text` is appended (so a misconfigured endpoint's
* HTML error page is diagnosable). The util guarantees the API key is never in
* the response body, so this is safe to surface.
*/
describe('describeProviderError', () => {
it('uses the fallback for a null/empty/undefined error', () => {
expect(describeProviderError(null, 'AI stream error')).toBe(
'AI stream error',
);
expect(describeProviderError('', 'AI stream error')).toBe('AI stream error');
expect(describeProviderError(undefined)).toBe('Unknown error');
});
it('returns a non-empty plain string error as-is', () => {
expect(describeProviderError('boom')).toBe('boom');
});
it('formats statusCode + message', () => {
expect(
describeProviderError({ statusCode: 401, message: 'Unauthorized' }),
).toBe('401: Unauthorized');
});
it('falls back to message when there is no statusCode', () => {
expect(describeProviderError({ message: 'nope' })).toBe('nope');
});
it('appends a whitespace-collapsed response body snippet', () => {
const out = describeProviderError({
statusCode: 502,
message: 'Bad Gateway',
responseBody: '<html>\n <body>upstream error</body>\n</html>',
});
expect(out.startsWith('502: Bad Gateway | response body: ')).toBe(true);
// Newlines and runs of spaces are collapsed to single spaces.
expect(out).toContain('<html> <body>upstream error</body> </html>');
});
it('reads `text` when responseBody is absent', () => {
expect(describeProviderError({ message: 'e', text: 'body-text' })).toBe(
'e | response body: body-text',
);
});
it('truncates a long body to 300 chars + ellipsis', () => {
const out = describeProviderError({
message: 'e',
responseBody: 'x'.repeat(500),
});
expect(out).toContain('…');
// 'e | response body: ' + 300 chars + '…'
expect(out.length).toBeLessThan('e | response body: '.length + 305);
});
});