diff --git a/extensions/clawrouter/index.test.ts b/extensions/clawrouter/index.test.ts index 0dae623c56f..5828b5f4d63 100644 --- a/extensions/clawrouter/index.test.ts +++ b/extensions/clawrouter/index.test.ts @@ -171,6 +171,7 @@ describe("ClawRouter plugin", () => { Authorization: "Bearer runtime-proxy-key", }); expect(calls[0]?.headers?.["X-ClawRouter-Session-Id"]).toHaveLength(256); + expect(calls[0]?.headers?.["X-ClawRouter-Session-Id"]).toMatch(/~[a-f0-9]{16}$/u); expect(calls[0]?.headers?.["X-Request-ID"]).toHaveLength(128); expect(calls[0]?.headers?.["X-Request-ID"]).toMatch(/~[a-f0-9]{16}:model:1$/u); expect(calls[1]?.headers?.["X-Request-ID"]).toMatch(/~[a-f0-9]{16}:model:2$/u); @@ -178,6 +179,84 @@ describe("ClawRouter plugin", () => { expect(calls[2]?.headers?.["X-Request-ID"]).toMatch(/^turn-_~[a-f0-9]{16}:model:3$/u); }); + it("sanitizes Unicode attribution to distinct printable ASCII ByteStrings", () => { + const captured: Array> = []; + const baseStreamFn: StreamFn = (model) => { + captured.push(model.headers ?? {}); + return {} as ReturnType; + }; + + for (const [agentId, sessionId] of [ + ["agent-πŸ˜€δΈ­ζ–‡-id", "session-πŸš€ζ΅‹θ―•-id"], + ["agent-πŸš€δΈ­ζ–‡-id", "session-πŸ˜€ζ΅‹θ―•-id"], + ] as const) { + const wrapped = wrapClawRouterProviderStream({ + provider: "clawrouter", + modelId: "openai/gpt-5.5", + agentId, + streamFn: baseStreamFn, + } as never); + + void wrapped?.( + { + provider: "clawrouter", + api: "openai-responses", + id: "openai/gpt-5.5", + } as never, + {} as never, + { sessionId } as never, + ); + } + + expect(captured).toHaveLength(2); + for (const headers of captured) { + expect(headers["X-ClawRouter-Agent-Id"]).toMatch(/^agent-___-id~[a-f0-9]{16}$/u); + expect(headers["X-ClawRouter-Session-Id"]).toMatch(/^session-___-id~[a-f0-9]{16}$/u); + expect(() => new Headers(headers)).not.toThrow(); + } + expect(captured[0]?.["X-ClawRouter-Agent-Id"]).not.toBe(captured[1]?.["X-ClawRouter-Agent-Id"]); + expect(captured[0]?.["X-ClawRouter-Session-Id"]).not.toBe( + captured[1]?.["X-ClawRouter-Session-Id"], + ); + }); + + it("keeps encoded and lone-surrogate attribution ids distinct", () => { + const captured: string[] = []; + const captureAgentId = (agentId: string) => { + const wrapped = wrapClawRouterProviderStream({ + provider: "clawrouter", + modelId: "openai/gpt-5.5", + agentId, + streamFn: ((model) => { + captured.push(model.headers?.["X-ClawRouter-Agent-Id"] ?? ""); + return {} as ReturnType; + }) satisfies StreamFn, + } as never); + void wrapped?.( + { + provider: "clawrouter", + api: "openai-responses", + id: "openai/gpt-5.5", + } as never, + {} as never, + {} as never, + ); + }; + + captureAgentId("agent-πŸ˜€"); + captureAgentId(captured[0]!); + captureAgentId("agent-\uD800"); + captureAgentId("agent-\uD801"); + + expect(captured).toHaveLength(4); + expect(captured[1]).not.toBe(captured[0]); + expect(captured[2]).not.toBe(captured[3]); + for (const value of captured) { + expect(value).toMatch(/^[\x20-\x7E]+$/u); + expect(() => new Headers({ "X-ClawRouter-Agent-Id": value })).not.toThrow(); + } + }); + it("keeps an explicit per-request header ahead of the automatic model-call id", () => { const calls: Array<{ model: Parameters[0]; diff --git a/extensions/clawrouter/stream.ts b/extensions/clawrouter/stream.ts index f9689eca676..cda5b4e675a 100644 --- a/extensions/clawrouter/stream.ts +++ b/extensions/clawrouter/stream.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; @@ -10,9 +11,36 @@ const CLIENT_HEADER = "X-ClawRouter-Client"; const AGENT_HEADER = "X-ClawRouter-Agent-Id"; const SESSION_HEADER = "X-ClawRouter-Session-Id"; const REQUEST_ID_HEADER = "X-Request-ID"; -const REQUEST_ID_HASH_LENGTH = 16; +const ID_HASH_LENGTH = 16; const REQUEST_ID_PATTERN = /^[A-Za-z0-9._~:/+@=-]+$/u; const REQUEST_ID_UNSAFE_CHARACTER_PATTERN = /[^A-Za-z0-9._~:/+@=-]/gu; +const ATTRIBUTION_PRINTABLE_ASCII_PATTERN = /^[\x20-\x7E]+$/u; +const ATTRIBUTION_NON_PRINTABLE_ASCII_PATTERN = /[^\x20-\x7E]/gu; +const ATTRIBUTION_ENCODED_SUFFIX_PATTERN = /~[a-f0-9]{16}$/u; +const REQUEST_ID_SUFFIX_PATTERN = /:model:\d+$/u; +const REQUEST_ID_ENCODED_SUFFIX_PATTERN = /~[a-f0-9]{16}(?::model:\d+)?$/u; + +type BoundedIdPolicy = { + encodedSuffixPattern: RegExp; + maxLength: number; + safePattern: RegExp; + unsafeCharacterPattern: RegExp; + preservedSuffixPattern?: RegExp; +}; + +const ATTRIBUTION_ID_POLICY: BoundedIdPolicy = { + encodedSuffixPattern: ATTRIBUTION_ENCODED_SUFFIX_PATTERN, + maxLength: ATTRIBUTION_VALUE_MAX_LENGTH, + safePattern: ATTRIBUTION_PRINTABLE_ASCII_PATTERN, + unsafeCharacterPattern: ATTRIBUTION_NON_PRINTABLE_ASCII_PATTERN, +}; +const REQUEST_ID_POLICY: BoundedIdPolicy = { + encodedSuffixPattern: REQUEST_ID_ENCODED_SUFFIX_PATTERN, + maxLength: REQUEST_ID_MAX_LENGTH, + safePattern: REQUEST_ID_PATTERN, + unsafeCharacterPattern: REQUEST_ID_UNSAFE_CHARACTER_PATTERN, + preservedSuffixPattern: REQUEST_ID_SUFFIX_PATTERN, +}; function hasControlCharacter(value: string): boolean { for (let index = 0; index < value.length; index += 1) { @@ -24,7 +52,7 @@ function hasControlCharacter(value: string): boolean { return false; } -function normalizeAttributionValue(value: string | undefined): string | undefined { +function normalizeHeaderId(value: string | undefined): string | undefined { const normalized = value?.trim(); if (!normalized || hasControlCharacter(normalized)) { return undefined; @@ -32,36 +60,32 @@ function normalizeAttributionValue(value: string | undefined): string | undefine return normalized; } -function sanitizeAttributionValue(value: string | undefined): string | undefined { - const normalized = normalizeAttributionValue(value); +function sanitizeBoundedId(value: string | undefined, policy: BoundedIdPolicy): string | undefined { + const normalized = normalizeHeaderId(value); if (!normalized) { return undefined; } - return normalized.slice(0, ATTRIBUTION_VALUE_MAX_LENGTH); -} - -function sanitizeRequestId(value: string | undefined): string | undefined { - const normalized = normalizeAttributionValue(value); - if (!normalized) { + if ( + normalized.length <= policy.maxLength && + policy.safePattern.test(normalized) && + !policy.encodedSuffixPattern.test(normalized) + ) { return normalized; } - if (normalized.length <= REQUEST_ID_MAX_LENGTH && REQUEST_ID_PATTERN.test(normalized)) { - return normalized; - } - // Retain the per-call suffix while bounding long run ids; the hash keeps - // distinct long prefixes from collapsing onto the same audit identifier. + // Hash UTF-16 code units losslessly: UTF-8 string hashing replaces lone + // surrogates with U+FFFD. The reserved encoded suffix keeps a rewritten ID + // from aliasing an otherwise safe input that already looks encoded. const hash = createHash("sha256") - .update(normalized) + .update(Buffer.from(normalized, "utf16le")) .digest("hex") - .slice(0, REQUEST_ID_HASH_LENGTH); - const modelSuffix = normalized.match(/:model:\d+$/u)?.[0] ?? ""; - const rawPrefix = modelSuffix ? normalized.slice(0, -modelSuffix.length) : normalized; - const safePrefix = rawPrefix.replace(REQUEST_ID_UNSAFE_CHARACTER_PATTERN, "_"); - const boundedSuffix = `~${hash}${modelSuffix}`; - if (boundedSuffix.length >= REQUEST_ID_MAX_LENGTH) { - return `${safePrefix.slice(0, REQUEST_ID_MAX_LENGTH - hash.length - 1)}~${hash}`; - } - return `${safePrefix.slice(0, REQUEST_ID_MAX_LENGTH - boundedSuffix.length)}${boundedSuffix}`; + .slice(0, ID_HASH_LENGTH); + const preservedSuffix = policy.preservedSuffixPattern?.exec(normalized)?.[0] ?? ""; + const rawPrefix = preservedSuffix ? normalized.slice(0, -preservedSuffix.length) : normalized; + const safePrefix = rawPrefix.replace(policy.unsafeCharacterPattern, "_"); + const hashSuffix = `~${hash}`; + const boundedSuffix = `${hashSuffix}${preservedSuffix}`; + const suffix = boundedSuffix.length < policy.maxLength ? boundedSuffix : hashSuffix; + return `${safePrefix.slice(0, policy.maxLength - suffix.length)}${suffix}`; } function findHeader(headers: Record, target: string): string | undefined { @@ -95,9 +119,13 @@ function withClawRouterHeaders( } } setHeaderDefault(next, CLIENT_HEADER, "openclaw"); - setHeaderDefault(next, AGENT_HEADER, sanitizeAttributionValue(params.agentId)); - setHeaderDefault(next, SESSION_HEADER, sanitizeAttributionValue(params.sessionId)); - setHeaderDefault(next, REQUEST_ID_HEADER, sanitizeRequestId(params.requestId)); + setHeaderDefault(next, AGENT_HEADER, sanitizeBoundedId(params.agentId, ATTRIBUTION_ID_POLICY)); + setHeaderDefault( + next, + SESSION_HEADER, + sanitizeBoundedId(params.sessionId, ATTRIBUTION_ID_POLICY), + ); + setHeaderDefault(next, REQUEST_ID_HEADER, sanitizeBoundedId(params.requestId, REQUEST_ID_POLICY)); if (params.apiKey) { next.Authorization = `Bearer ${params.apiKey}`; }