fix(terminal): WS close reason 按 UTF-8 字节截断,避免超 123 字节抛错
ws 用 Buffer.byteLength 校验 close reason 的 123 字节上限(ws/lib/sender.js), 原 slice(0, 100) 按 UTF-16 code unit 截断,非 ASCII shell 名可达 316 字节 (实测 200 个 CJK 字符),ws.close() 会抛 RangeError 顶掉原始错误。 改为按字节截断且不切碎 code point,并补回归测试。
This commit is contained in:
+21
-4
@@ -1182,18 +1182,35 @@ async function attachAgentList(
|
|||||||
/**
|
/**
|
||||||
* The WS close reason for a failed terminal attach. A missing configured
|
* The WS close reason for a failed terminal attach. A missing configured
|
||||||
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
|
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
|
||||||
* capped — a WS close reason allows at most 123 bytes) that the client maps
|
* capped by BYTES — a WS close reason allows at most 123 bytes, which `ws`
|
||||||
* to a localized, actionable banner; every other failure keeps the raw
|
* validates with `Buffer.byteLength`) that the client maps to a localized,
|
||||||
* message (the model-side tool errors read it verbatim).
|
* actionable banner; every other failure keeps the raw message (the
|
||||||
|
* model-side tool errors read it verbatim).
|
||||||
*/
|
*/
|
||||||
export function wsCloseReasonOf(error: unknown): string {
|
export function wsCloseReasonOf(error: unknown): string {
|
||||||
if (error instanceof SidebarError && error.code === 'shell-not-found') {
|
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 `shell-not-found:${name}`
|
||||||
}
|
}
|
||||||
return error instanceof Error ? error.message : String(error)
|
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.
|
* Wire one terminal socket to its pty: replay transcript, pump both ways.
|
||||||
* Two attach modes share the wire protocol:
|
* Two attach modes share the wire protocol:
|
||||||
|
|||||||
@@ -963,6 +963,19 @@ describe('side card settings routes', () => {
|
|||||||
expect(wsCloseReasonOf('plain')).toBe('plain')
|
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 () => {
|
it('reads the resolved prefs and writes a patch through the seam', async () => {
|
||||||
const route = mountWithSettings(createFakeSettings())
|
const route = mountWithSettings(createFakeSettings())
|
||||||
const read = await invoke(route, 'settings.get', {})
|
const read = await invoke(route, 'settings.get', {})
|
||||||
|
|||||||
Reference in New Issue
Block a user