fix(nextcloud-talk): keep error snippets UTF-16 safe#102949
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 9, 2026, 12:18 PM ET / 16:18 UTC. Summary PR surface: Source +1, Tests +25. Total +26 across 2 files. Reproducibility: yes. from source: current main slices collapsed Nextcloud Talk error bodies with Review metrics: none identified. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land the focused formatter/test fix after normal exact-head merge gating, refreshing the branch first only if maintainers require latest-base validation. Do we have a high-confidence way to reproduce the issue? Yes from source: current main slices collapsed Nextcloud Talk error bodies with Is this the best way to solve the issue? Yes. The formatter is the shared source for send and reaction error snippets, and replacing only its prefix slice with the existing SDK helper preserves request, auth, SSRF, status handling, and byte-limit behavior. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 148ec3282f07. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +1, Tests +25. Total +26 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)
|
|
Maintainer verification complete for exact head
No external Nextcloud instance was used. The regression does cross a real local HTTP request/response boundary through |
1 similar comment
|
Maintainer verification complete for exact head
No external Nextcloud instance was used. The regression does cross a real local HTTP request/response boundary through |
|
Merged via squash.
|
* fix(nextcloud-talk): keep error snippets UTF-16 safe * test(nextcloud-talk): assert exact safe error --------- Co-authored-by: Peter Steinberger <[email protected]>
* fix(nextcloud-talk): keep error snippets UTF-16 safe * test(nextcloud-talk): assert exact safe error --------- 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
sendMessageNextcloudTalkso the failing body crosses the real send/error path.Maintainer verification
33c26657224b5af89484d33915c165f311b6ca29(includes an exact thrown-Errorassertion for the malformed-surrogate regression).run_fff37b03fe84fetched the remote PR at the frozen SHA through the trusted untrusted-source bootstrap and passed the complete Nextcloud Talk suite: 16 files, 101 tests.run_32cee4fec874started from the same frozen SHA, replaced only the helper call with the former rawslice(0, 200)behavior in its ephemeral remote worktree, and the focused regression failed as expected: one failed, one passed. The received message contained the split-surrogate replacement character. The mutated lease was discarded.What Problem This Solves
Nextcloud Talk error responses are bounded and then shortened before being included in send/reaction errors. The old formatter used
slice(0, NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_CHARS), so a response body with an emoji or other astral character starting at the truncation boundary could leave a lone UTF-16 surrogate in the thrown error message.Latest
origin/mainstill has the raw slice atextensions/nextcloud-talk/src/send.ts:User Impact
Origin / follow-up
Follows #102833.
truncateUtf16Safe.Competition / linked PR analysis
nextcloud-talk UTF-16 error snippetinopenclaw/openclawbefore submission.No open same-point PR was found.
Merge risk
extensions/nextcloud-talk/src/send.ts. The change runs after an HTTP response is already non-OK and only changes the formatter for the bounded response snippet. It does not alter delivery requests, auth/signing, timeout behavior, success parsing, state/session handling, security policy, or background execution.readResponseTextLimited; the thrown error still includes the same status-specific prefix and ellipsis. The only behavior change is avoiding invalid UTF-16 when the snippet limit lands inside an astral code point.Real behavior proof
room:abc123,hello,channels.nextcloud-talk.baseUrl,botSecret, and private-network opt-in) is accepted asCoreConfigbysendMessageNextcloudTalk, resolved byresolveNextcloudTalkSendContext/resolveNextcloudTalkAccountinto runtime account/baseUrl/secret, sent as a signed POST request to/ocs/v2.php/apps/spreed/api/v1/bot/abc123/message, receives a non-OK Unicode response body from the local Nextcloud-compatible endpoint, passes throughreadResponseTextLimitedandcollapseErrorSnippet, then becomes the thrown user-visibleNextcloud Talk: bad request - ...effect.withServerto run an actual local HTTP server, receives the real POST fromsendMessageNextcloudTalk, returns HTTP 400 with a Unicode text body, and observes the thrown error from the exported send API.truncateUtf16Safefromopenclaw/plugin-sdk/text-utility-runtime, the existing SDK text helper backed by OpenClaw's UTF-16-safe truncation utility. The existingreadResponseTextLimitedbyte budget remains unchanged.openclaw/plugin-sdk/test-env; no mocked fetch in the regression proof.Nextcloud Talk: bad request - ${prefix}…forprefix = "e".repeat(199), proving the emoji that straddles the boundary is dropped instead of leaving a lone surrogate.Review findings addressed
No maintainer review findings exist for this new follow-up branch. Pre-submit checks covered the known follow-up risks: latest main still has the raw slice gap, no same-point open PR was found, and the regression crosses the exported send path with a local HTTP server.
Regression Test Plan
extensions/nextcloud-talk/src/send.fetch-timeout.test.tsfor the new local-server UTF-16-safe error snippet regression;extensions/nextcloud-talk/src/send.cfg-threading.test.tsfor existing send/reaction error-path coverage."e".repeat(199) + "\u{1F600}tail"crosses the real POST path and then hits the snippet limit at the emoji's surrogate pair boundary.Risk / Compatibility
The output remains bounded and still appends the same ellipsis for overlong snippets. The only observable change is that truncation will not split a surrogate pair; snippets may be one UTF-16 code unit shorter when the limit lands inside an astral character.
What was not changed
collapseErrorSnippetis the canonical source formatter used byreadNextcloudTalkErrorSnippetbefore both send and reaction errors are thrown. This patch fixes that formatter rather than post-processing a mirror, projection, log copy, or test-only representation.Root Cause
collapseErrorSnippetshortening collapsed error bodies with raw JavaScriptslice(0, 200). Because JavaScript string indices are UTF-16 code units, slicing at an arbitrary code-unit boundary can cut between the high and low surrogate of an emoji or another astral code point.