diff --git a/src/index.ts b/src/index.ts index 4ac489a..4638174 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1182,18 +1182,35 @@ async function attachAgentList( /** * The WS close reason for a failed terminal attach. A missing configured * shell gets a SHORT machine-readable marker (`shell-not-found:`, - * capped — a WS close reason allows at most 123 bytes) that the client maps - * to a localized, actionable banner; every other failure keeps the raw - * message (the model-side tool errors read it verbatim). + * capped by BYTES — a WS close reason allows at most 123 bytes, which `ws` + * validates with `Buffer.byteLength`) that the client maps to a localized, + * actionable banner; every other failure keeps the raw message (the + * model-side tool errors read it verbatim). */ export function wsCloseReasonOf(error: unknown): string { if (error instanceof SidebarError && error.code === 'shell-not-found') { - const name = shellDisplayName(String(error.meta?.shell ?? '')).slice(0, 100) + const name = truncateUtf8Bytes(shellDisplayName(String(error.meta?.shell ?? '')), 100) return `shell-not-found:${name}` } return error instanceof Error ? error.message : String(error) } +/** + * Truncate to at most `maxBytes` UTF-8 bytes without splitting a code point. + * A character-count `slice` does not bound the WS close reason: `ws` measures + * `Buffer.byteLength` against its 123-byte cap, and the resulting throw would + * replace the very error the reason describes. + */ +function truncateUtf8Bytes(value: string, maxBytes: number): string { + if (Buffer.byteLength(value) <= maxBytes) return value + let truncated = '' + for (const character of value) { + if (Buffer.byteLength(truncated + character) > maxBytes) break + truncated += character + } + return truncated +} + /** * Wire one terminal socket to its pty: replay transcript, pump both ways. * Two attach modes share the wire protocol: diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 54a3770..3ec800c 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -963,6 +963,19 @@ describe('side card settings routes', () => { expect(wsCloseReasonOf('plain')).toBe('plain') }) + it('caps the close reason by UTF-8 bytes so the ws 123-byte limit holds', () => { + // 200 CJK characters are ~600 bytes: a character-count slice would still + // overflow the cap `ws` enforces with Buffer.byteLength. + const reason = wsCloseReasonOf(new SidebarError( + 'shell-not-found', + 'shell executable not found', + 400, + { shell: `/bin/${'终'.repeat(200)}` }, + )) + expect(reason.startsWith('shell-not-found:')).toBe(true) + expect(Buffer.byteLength(reason)).toBeLessThanOrEqual(123) + }) + it('reads the resolved prefs and writes a patch through the seam', async () => { const route = mountWithSettings(createFakeSettings()) const read = await invoke(route, 'settings.get', {})