fix(sandbox): keep redacted session keys UTF-16 safe#102969
Conversation
|
Codex review: needs real behavior proof before merge. Reviewed July 9, 2026, 1:13 PM ET / 17:13 UTC. Summary PR surface: Source +1, Tests +51. Total +52 across 2 files. Reproducibility: yes. at source level: current main uses raw UTF-16 code-unit slices for the redacted sandbox Review metrics: none identified. Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof guidance:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Keep this PR open until a linked canonical PR proves it covers this PR's unique work, or a maintainer confirms closure. Do we have a high-confidence way to reproduce the issue? Yes, at source level: current main uses raw UTF-16 code-unit slices for the redacted sandbox Is this the best way to solve the issue? Yes for the implementation: fixing AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against f1e7081b4d36. Label changesLabel justifications:
Evidence reviewedPR surface: Source +1, Tests +51. Total +52 across 2 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (1 earlier review cycle)
|
|
Land-ready review at exact head What changed:
Review result:
Proof:
Merge intentionally held for the native landing-gate dependency requested by the maintainer; no merge was attempted in this pass. |
|
Merged via squash.
|
* fix(sandbox): keep redacted session keys UTF-16 safe * test(sandbox): cover UTF-16 redaction boundaries --------- Co-authored-by: Peter Steinberger <[email protected]>
…ster regression Adds the mushuiyu886 openclaw#102969/openclaw#102949 proof shape to the existing regression: - LABEL_TRUNCATION_MODE=baseline swaps the SDK truncateUtf16Safe with raw String.prototype.slice via vi.mock, so the same test cases run as control-red on the buggy code. Three of the four boundary cases fail with 'lone high surrogate detected' (one passes: the no-cut baseline). - LABEL_PROOF_DUMP=1 writes a [proof] line per case with the actual length=, hex_tail= of the parsed display_name= value plus the isLoneHighSurrogate verdict. Crabbox can capture the line and the PR body can paste it next to the run URL. - vi.hoisted() lifts the env-var reads so the vi.mock factory (which is hoisted to the top of the file) can reference them safely. No production code change. No new SDK surface. No touched files outside extensions/discord/src/voice/.
…gressContext
P1 follow-up: the regression test now drives the production
resolveDiscordVoiceIngressContext end-to-end instead of mocking it.
Before: vi.mock("./ingress.js") returned a stub
{ senderIsOwner: false, speakerLabel: "tester" } with no
extraSystemPrompt. The test still exercised the real
appendDiscordVoiceParticipantContext, but the upstream auth + speaker
identity layer was stubbed.
After: the upstream resolveDiscordVoiceIngressContext is the real
production function. The test passes:
- a real DiscordVoiceSpeakerContextResolver instance with a stub
client whose fetchMember / fetchUser return emoji-laden display data
- cfg = {} and discordConfig = { groupPolicy: "open" } so
authorizeDiscordVoiceIngress returns { ok: true, channelConfig: null }
via the owner-allow-all short-circuit and the default-open group
policy path
- ownerAllowAll: true so the resolveCommandAuthorizedFromAuthorizers
authorizer is { configured: true, allowed: true }
What now runs for real (not mocked):
- DiscordVoiceSpeakerContextResolver (class, not a stub): exercises
resolveIdentity + cache + resolveIsOwner
- authorizeDiscordVoiceIngress: real production auth check
- buildDiscordGroupSystemPrompt: real production prompt builder
- appendDiscordVoiceParticipantContext: real production roster builder
What stays mocked:
- the SDK truncateUtf16Safe (only via LABEL_TRUNCATION_MODE=baseline
vi.mock, which is the test-only control-red affordance)
- the gateway plugin client (the only way to inject listVoiceChannelStates
state without a real Discord connection)
P2 follow-up: also add the ephemeral worktree control-red shape (the
mushuiyu886 openclaw#102949 / openclaw#102969 gold standard). The test file itself is
unchanged for P2; the proof is captured by running the test in an
ephemeral worktree with the production code reverted to the buggy raw
slice. See 4.5 of the PR body for the full transcript.
Local verification:
- oxlint --threads=1 on the test file: clean (exit 0)
- vitest in fix mode (default): 4 / 4 pass, hex_tail all clean
- vitest in baseline mode (LABEL_TRUNCATION_MODE=baseline): 3 / 4 fail
with lone high surrogate detected at the boundary
- vitest in ephemeral worktree with participant-context.ts reverted to
the original slice(0, 100): 3 / 4 fail with the same lone high
surrogate; the no-cut baseline still passes
- vitest with LABEL_PROOF_DUMP=1 in either mode: emits the [proof]
line with length= / hex_tail= / lone_surrogate= / sample= per case
Co-Authored-By: Claude <[email protected]>
…ster regression Adds the mushuiyu886 openclaw#102969/openclaw#102949 proof shape to the existing regression: - LABEL_TRUNCATION_MODE=baseline swaps the SDK truncateUtf16Safe with raw String.prototype.slice via vi.mock, so the same test cases run as control-red on the buggy code. Three of the four boundary cases fail with 'lone high surrogate detected' (one passes: the no-cut baseline). - LABEL_PROOF_DUMP=1 writes a [proof] line per case with the actual length=, hex_tail= of the parsed display_name= value plus the isLoneHighSurrogate verdict. Crabbox can capture the line and the PR body can paste it next to the run URL. - vi.hoisted() lifts the env-var reads so the vi.mock factory (which is hoisted to the top of the file) can reference them safely. No production code change. No new SDK surface. No touched files outside extensions/discord/src/voice/.
…gressContext
P1 follow-up: the regression test now drives the production
resolveDiscordVoiceIngressContext end-to-end instead of mocking it.
Before: vi.mock("./ingress.js") returned a stub
{ senderIsOwner: false, speakerLabel: "tester" } with no
extraSystemPrompt. The test still exercised the real
appendDiscordVoiceParticipantContext, but the upstream auth + speaker
identity layer was stubbed.
After: the upstream resolveDiscordVoiceIngressContext is the real
production function. The test passes:
- a real DiscordVoiceSpeakerContextResolver instance with a stub
client whose fetchMember / fetchUser return emoji-laden display data
- cfg = {} and discordConfig = { groupPolicy: "open" } so
authorizeDiscordVoiceIngress returns { ok: true, channelConfig: null }
via the owner-allow-all short-circuit and the default-open group
policy path
- ownerAllowAll: true so the resolveCommandAuthorizedFromAuthorizers
authorizer is { configured: true, allowed: true }
What now runs for real (not mocked):
- DiscordVoiceSpeakerContextResolver (class, not a stub): exercises
resolveIdentity + cache + resolveIsOwner
- authorizeDiscordVoiceIngress: real production auth check
- buildDiscordGroupSystemPrompt: real production prompt builder
- appendDiscordVoiceParticipantContext: real production roster builder
What stays mocked:
- the SDK truncateUtf16Safe (only via LABEL_TRUNCATION_MODE=baseline
vi.mock, which is the test-only control-red affordance)
- the gateway plugin client (the only way to inject listVoiceChannelStates
state without a real Discord connection)
P2 follow-up: also add the ephemeral worktree control-red shape (the
mushuiyu886 openclaw#102949 / openclaw#102969 gold standard). The test file itself is
unchanged for P2; the proof is captured by running the test in an
ephemeral worktree with the production code reverted to the buggy raw
slice. See 4.5 of the PR body for the full transcript.
Local verification:
- oxlint --threads=1 on the test file: clean (exit 0)
- vitest in fix mode (default): 4 / 4 pass, hex_tail all clean
- vitest in baseline mode (LABEL_TRUNCATION_MODE=baseline): 3 / 4 fail
with lone high surrogate detected at the boundary
- vitest in ephemeral worktree with participant-context.ts reverted to
the original slice(0, 100): 3 / 4 fail with the same lone high
surrogate; the no-cut baseline still passes
- vitest with LABEL_PROOF_DUMP=1 in either mode: emits the [proof]
line with length= / hex_tail= / lone_surrogate= / sample= per case
Co-Authored-By: Claude <[email protected]>
Round 2 follow-up: slim the regression test and PR body to match the sibling UTF-16-safe cohort (openclaw#106328, openclaw#102949, openclaw#102969) template. - Test: 304 -> 127 lines (-58%) - Removed LABEL_PROOF_DUMP / LABEL_RUN_LOG env vars + dumpDisplayName helper - Extracted renderRosterPrompt(nick) + parseDisplayName(prompt) helpers - Trimmed 47-line test-file header to 9 lines - Kept LABEL_TRUNCATION_MODE=baseline vi.mock for in-process control-red - All 4 emoji-boundary cases preserved - Body: ~30KB -> 10.5KB (-64%) - Dropped Round 1/2/3 rebase history - Dropped hex code-unit dump - Dropped TODO Crabbox run placeholders - Restructured to 4-section template - Production code unchanged: same 1-line + import + named constant patch. Same fix/baseline behavior (4/4 pass in fix mode, 3 fail in baseline). Co-Authored-By: Claude <[email protected]>
Summary
formatSandboxToolPolicyBlockedMessageso the user-visible blocked-tool guidance path is covered.What Problem This Solves
Sandbox blocked-tool guidance redacts long session keys as
prefix…suffix. The old formatter usedtrimmed.slice(0, 6)andtrimmed.slice(-6), so a session key with an emoji or other astral character at either redaction boundary could leave a lone UTF-16 surrogate in the displayedSession:line.Reviewed
origin/mainstill has the raw slices atsrc/agents/sandbox/runtime-status.ts:User Impact
Session:value. Unicode session keys should not render replacement characters or malformed half-surrogates in the guidance that tells the user which sandboxed session was blocked.Origin / follow-up
Follows #102625.
Competition / linked PR analysis
sandbox redacted session key UTF-16inopenclaw/openclawbefore submission.No open same-point PR was found.
Merge risk
Real behavior proof
Session:line of blocked-tool guidance.sessionKey) plus sandbox config (agents.defaults.sandbox.mode=all, tool deny policy) entersformatSandboxToolPolicyBlockedMessage, is resolved byresolveSandboxRuntimeStatusandresolveSandboxToolPolicyForAgent, classified byclassifyToolAgainstSandboxToolPolicy, then renders the user-visible blocked-tool guidance effect containingSession: ....formatSandboxToolPolicyBlockedMessagepath with a real sandbox config and denied tool, then observes the returned multiline guidance exactly as a caller would display it.@openclaw/normalization-core/utf16-slice:truncateUtf16Safefor the prefix andsliceUtf16Safefor the suffix. No external provider constraint is involved.provider=aws, leasecbx_e5d64f409c98, public network, no Tailscale, no IAM instance profile, no credential hydration), Node 24.15.0, pnpm 11.2.2, exact PR heada2ed154337cb729e2dc1b58ca1b0ba50cf8b844a.a2ed154337cb729e2dc1b58ca1b0ba50cf8b844awith 44 passed jobs, 11 scoped skips, and no failures.Review findings addressed
--sessionexplain command.patch is correct, confidence 0.91).Regression Test Plan
src/agents/sandbox/tool-policy.test.ts.abcde\u{1F600}middle123456), suffix boundary (abcdefmiddle\u{1F600}12345), and both boundaries (abcde\u{1F600}middle\u{1F600}12345) while a real sandbox config deniesbrowserand forcesformatSandboxToolPolicyBlockedMessageto render the user-visible guidance.Risk / Compatibility
Existing redaction shape remains
prefix…suffix, and existing control-character escaping is preserved. The only observable change is that redaction will not split a surrogate pair; the displayed prefix or suffix may be one UTF-16 code unit shorter when the boundary lands inside an astral character.What was not changed
redactSessionKeyis the canonical formatter used byformatSandboxToolPolicyBlockedMessagebefore the blocked-tool guidance is returned. This patch fixes that source formatter rather than post-processing a terminal mirror, log copy, or test-only representation.Root Cause
redactSessionKeyshortening the session key prefix and suffix with raw JavaScriptslice. Because JavaScript string indices are UTF-16 code units, slicing at fixed display boundaries can cut between the high and low surrogate of an emoji or another astral code point.redactSessionKey, so the invalid-surrogate source is removed before the message is assembled.