Skip to content

Commit c672496

Browse files
committed
fix(clawrouter): keep sanitized ids collision-resistant
1 parent be2e91a commit c672496

2 files changed

Lines changed: 55 additions & 4 deletions

File tree

extensions/clawrouter/index.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,43 @@ describe("ClawRouter plugin", () => {
220220
);
221221
});
222222

223+
it("keeps encoded and lone-surrogate attribution ids distinct", () => {
224+
const captured: string[] = [];
225+
const captureAgentId = (agentId: string) => {
226+
const wrapped = wrapClawRouterProviderStream({
227+
provider: "clawrouter",
228+
modelId: "openai/gpt-5.5",
229+
agentId,
230+
streamFn: ((model) => {
231+
captured.push(model.headers?.["X-ClawRouter-Agent-Id"] ?? "");
232+
return {} as ReturnType<StreamFn>;
233+
}) satisfies StreamFn,
234+
} as never);
235+
void wrapped?.(
236+
{
237+
provider: "clawrouter",
238+
api: "openai-responses",
239+
id: "openai/gpt-5.5",
240+
} as never,
241+
{} as never,
242+
{} as never,
243+
);
244+
};
245+
246+
captureAgentId("agent-😀");
247+
captureAgentId(captured[0]!);
248+
captureAgentId("agent-\uD800");
249+
captureAgentId("agent-\uD801");
250+
251+
expect(captured).toHaveLength(4);
252+
expect(captured[1]).not.toBe(captured[0]);
253+
expect(captured[2]).not.toBe(captured[3]);
254+
for (const value of captured) {
255+
expect(value).toMatch(/^[\x20-\x7E]+$/u);
256+
expect(() => new Headers({ "X-ClawRouter-Agent-Id": value })).not.toThrow();
257+
}
258+
});
259+
223260
it("keeps an explicit per-request header ahead of the automatic model-call id", () => {
224261
const calls: Array<{
225262
model: Parameters<StreamFn>[0];

extensions/clawrouter/stream.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Buffer } from "node:buffer";
12
import { createHash } from "node:crypto";
23
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
34
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
@@ -15,21 +16,26 @@ const REQUEST_ID_PATTERN = /^[A-Za-z0-9._~:/+@=-]+$/u;
1516
const REQUEST_ID_UNSAFE_CHARACTER_PATTERN = /[^A-Za-z0-9._~:/+@=-]/gu;
1617
const ATTRIBUTION_PRINTABLE_ASCII_PATTERN = /^[\x20-\x7E]+$/u;
1718
const ATTRIBUTION_NON_PRINTABLE_ASCII_PATTERN = /[^\x20-\x7E]/gu;
19+
const ATTRIBUTION_ENCODED_SUFFIX_PATTERN = /~[a-f0-9]{16}$/u;
1820
const REQUEST_ID_SUFFIX_PATTERN = /:model:\d+$/u;
21+
const REQUEST_ID_ENCODED_SUFFIX_PATTERN = /~[a-f0-9]{16}(?::model:\d+)?$/u;
1922

2023
type BoundedIdPolicy = {
24+
encodedSuffixPattern: RegExp;
2125
maxLength: number;
2226
safePattern: RegExp;
2327
unsafeCharacterPattern: RegExp;
2428
preservedSuffixPattern?: RegExp;
2529
};
2630

2731
const ATTRIBUTION_ID_POLICY: BoundedIdPolicy = {
32+
encodedSuffixPattern: ATTRIBUTION_ENCODED_SUFFIX_PATTERN,
2833
maxLength: ATTRIBUTION_VALUE_MAX_LENGTH,
2934
safePattern: ATTRIBUTION_PRINTABLE_ASCII_PATTERN,
3035
unsafeCharacterPattern: ATTRIBUTION_NON_PRINTABLE_ASCII_PATTERN,
3136
};
3237
const REQUEST_ID_POLICY: BoundedIdPolicy = {
38+
encodedSuffixPattern: REQUEST_ID_ENCODED_SUFFIX_PATTERN,
3339
maxLength: REQUEST_ID_MAX_LENGTH,
3440
safePattern: REQUEST_ID_PATTERN,
3541
unsafeCharacterPattern: REQUEST_ID_UNSAFE_CHARACTER_PATTERN,
@@ -59,12 +65,20 @@ function sanitizeBoundedId(value: string | undefined, policy: BoundedIdPolicy):
5965
if (!normalized) {
6066
return undefined;
6167
}
62-
if (normalized.length <= policy.maxLength && policy.safePattern.test(normalized)) {
68+
if (
69+
normalized.length <= policy.maxLength &&
70+
policy.safePattern.test(normalized) &&
71+
!policy.encodedSuffixPattern.test(normalized)
72+
) {
6373
return normalized;
6474
}
65-
// Fetch converts header values to ByteString. Keep automatic IDs ASCII,
66-
// bounded, readable, and collision-resistant before transport construction.
67-
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, ID_HASH_LENGTH);
75+
// Hash UTF-16 code units losslessly: UTF-8 string hashing replaces lone
76+
// surrogates with U+FFFD. The reserved encoded suffix keeps a rewritten ID
77+
// from aliasing an otherwise safe input that already looks encoded.
78+
const hash = createHash("sha256")
79+
.update(Buffer.from(normalized, "utf16le"))
80+
.digest("hex")
81+
.slice(0, ID_HASH_LENGTH);
6882
const preservedSuffix = policy.preservedSuffixPattern?.exec(normalized)?.[0] ?? "";
6983
const rawPrefix = preservedSuffix ? normalized.slice(0, -preservedSuffix.length) : normalized;
7084
const safePrefix = rawPrefix.replace(policy.unsafeCharacterPattern, "_");

0 commit comments

Comments
 (0)