1+ import { Buffer } from "node:buffer" ;
12import { createHash } from "node:crypto" ;
23import type { StreamFn } from "openclaw/plugin-sdk/agent-core" ;
34import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry" ;
@@ -10,9 +11,36 @@ const CLIENT_HEADER = "X-ClawRouter-Client";
1011const AGENT_HEADER = "X-ClawRouter-Agent-Id" ;
1112const SESSION_HEADER = "X-ClawRouter-Session-Id" ;
1213const REQUEST_ID_HEADER = "X-Request-ID" ;
13- const REQUEST_ID_HASH_LENGTH = 16 ;
14+ const ID_HASH_LENGTH = 16 ;
1415const REQUEST_ID_PATTERN = / ^ [ A - Z a - z 0 - 9 . _ ~ : / + @ = - ] + $ / u;
1516const REQUEST_ID_UNSAFE_CHARACTER_PATTERN = / [ ^ A - Z a - z 0 - 9 . _ ~ : / + @ = - ] / gu;
17+ const ATTRIBUTION_PRINTABLE_ASCII_PATTERN = / ^ [ \x20 - \x7E ] + $ / u;
18+ const ATTRIBUTION_NON_PRINTABLE_ASCII_PATTERN = / [ ^ \x20 - \x7E ] / gu;
19+ const ATTRIBUTION_ENCODED_SUFFIX_PATTERN = / ~ [ a - f 0 - 9 ] { 16 } $ / u;
20+ const REQUEST_ID_SUFFIX_PATTERN = / : m o d e l : \d + $ / u;
21+ const REQUEST_ID_ENCODED_SUFFIX_PATTERN = / ~ [ a - f 0 - 9 ] { 16 } (?: : m o d e l : \d + ) ? $ / u;
22+
23+ type BoundedIdPolicy = {
24+ encodedSuffixPattern : RegExp ;
25+ maxLength : number ;
26+ safePattern : RegExp ;
27+ unsafeCharacterPattern : RegExp ;
28+ preservedSuffixPattern ?: RegExp ;
29+ } ;
30+
31+ const ATTRIBUTION_ID_POLICY : BoundedIdPolicy = {
32+ encodedSuffixPattern : ATTRIBUTION_ENCODED_SUFFIX_PATTERN ,
33+ maxLength : ATTRIBUTION_VALUE_MAX_LENGTH ,
34+ safePattern : ATTRIBUTION_PRINTABLE_ASCII_PATTERN ,
35+ unsafeCharacterPattern : ATTRIBUTION_NON_PRINTABLE_ASCII_PATTERN ,
36+ } ;
37+ const REQUEST_ID_POLICY : BoundedIdPolicy = {
38+ encodedSuffixPattern : REQUEST_ID_ENCODED_SUFFIX_PATTERN ,
39+ maxLength : REQUEST_ID_MAX_LENGTH ,
40+ safePattern : REQUEST_ID_PATTERN ,
41+ unsafeCharacterPattern : REQUEST_ID_UNSAFE_CHARACTER_PATTERN ,
42+ preservedSuffixPattern : REQUEST_ID_SUFFIX_PATTERN ,
43+ } ;
1644
1745function hasControlCharacter ( value : string ) : boolean {
1846 for ( let index = 0 ; index < value . length ; index += 1 ) {
@@ -24,44 +52,40 @@ function hasControlCharacter(value: string): boolean {
2452 return false ;
2553}
2654
27- function normalizeAttributionValue ( value : string | undefined ) : string | undefined {
55+ function normalizeHeaderId ( value : string | undefined ) : string | undefined {
2856 const normalized = value ?. trim ( ) ;
2957 if ( ! normalized || hasControlCharacter ( normalized ) ) {
3058 return undefined ;
3159 }
3260 return normalized ;
3361}
3462
35- function sanitizeAttributionValue ( value : string | undefined ) : string | undefined {
36- const normalized = normalizeAttributionValue ( value ) ;
63+ function sanitizeBoundedId ( value : string | undefined , policy : BoundedIdPolicy ) : string | undefined {
64+ const normalized = normalizeHeaderId ( value ) ;
3765 if ( ! normalized ) {
3866 return undefined ;
3967 }
40- return normalized . slice ( 0 , ATTRIBUTION_VALUE_MAX_LENGTH ) ;
41- }
42-
43- function sanitizeRequestId ( value : string | undefined ) : string | undefined {
44- const normalized = normalizeAttributionValue ( value ) ;
45- if ( ! normalized ) {
46- return normalized ;
47- }
48- if ( normalized . length <= REQUEST_ID_MAX_LENGTH && REQUEST_ID_PATTERN . test ( normalized ) ) {
68+ if (
69+ normalized . length <= policy . maxLength &&
70+ policy . safePattern . test ( normalized ) &&
71+ ! policy . encodedSuffixPattern . test ( normalized )
72+ ) {
4973 return normalized ;
5074 }
51- // Retain the per-call suffix while bounding long run ids; the hash keeps
52- // distinct long prefixes from collapsing onto the same audit identifier.
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.
5378 const hash = createHash ( "sha256" )
54- . update ( normalized )
79+ . update ( Buffer . from ( normalized , "utf16le" ) )
5580 . digest ( "hex" )
56- . slice ( 0 , REQUEST_ID_HASH_LENGTH ) ;
57- const modelSuffix = normalized . match ( / : m o d e l : \d + $ / u) ?. [ 0 ] ?? "" ;
58- const rawPrefix = modelSuffix ? normalized . slice ( 0 , - modelSuffix . length ) : normalized ;
59- const safePrefix = rawPrefix . replace ( REQUEST_ID_UNSAFE_CHARACTER_PATTERN , "_" ) ;
60- const boundedSuffix = `~${ hash } ${ modelSuffix } ` ;
61- if ( boundedSuffix . length >= REQUEST_ID_MAX_LENGTH ) {
62- return `${ safePrefix . slice ( 0 , REQUEST_ID_MAX_LENGTH - hash . length - 1 ) } ~${ hash } ` ;
63- }
64- return `${ safePrefix . slice ( 0 , REQUEST_ID_MAX_LENGTH - boundedSuffix . length ) } ${ boundedSuffix } ` ;
81+ . slice ( 0 , ID_HASH_LENGTH ) ;
82+ const preservedSuffix = policy . preservedSuffixPattern ?. exec ( normalized ) ?. [ 0 ] ?? "" ;
83+ const rawPrefix = preservedSuffix ? normalized . slice ( 0 , - preservedSuffix . length ) : normalized ;
84+ const safePrefix = rawPrefix . replace ( policy . unsafeCharacterPattern , "_" ) ;
85+ const hashSuffix = `~${ hash } ` ;
86+ const boundedSuffix = `${ hashSuffix } ${ preservedSuffix } ` ;
87+ const suffix = boundedSuffix . length < policy . maxLength ? boundedSuffix : hashSuffix ;
88+ return `${ safePrefix . slice ( 0 , policy . maxLength - suffix . length ) } ${ suffix } ` ;
6589}
6690
6791function findHeader ( headers : Record < string , string > , target : string ) : string | undefined {
@@ -95,9 +119,13 @@ function withClawRouterHeaders(
95119 }
96120 }
97121 setHeaderDefault ( next , CLIENT_HEADER , "openclaw" ) ;
98- setHeaderDefault ( next , AGENT_HEADER , sanitizeAttributionValue ( params . agentId ) ) ;
99- setHeaderDefault ( next , SESSION_HEADER , sanitizeAttributionValue ( params . sessionId ) ) ;
100- setHeaderDefault ( next , REQUEST_ID_HEADER , sanitizeRequestId ( params . requestId ) ) ;
122+ setHeaderDefault ( next , AGENT_HEADER , sanitizeBoundedId ( params . agentId , ATTRIBUTION_ID_POLICY ) ) ;
123+ setHeaderDefault (
124+ next ,
125+ SESSION_HEADER ,
126+ sanitizeBoundedId ( params . sessionId , ATTRIBUTION_ID_POLICY ) ,
127+ ) ;
128+ setHeaderDefault ( next , REQUEST_ID_HEADER , sanitizeBoundedId ( params . requestId , REQUEST_ID_POLICY ) ) ;
101129 if ( params . apiKey ) {
102130 next . Authorization = `Bearer ${ params . apiKey } ` ;
103131 }
0 commit comments