Repository navigation
Commit 9ecf032
authored
fix: keep the last raw-byte truncations on a rune boundary (#1726)
# Pull Request
## Description
Round 3 of the UTF-8-safe truncation sweep started in #1647 and
continued in #1664 / #1665. Three production sites still cut text with a
raw byte slice (`s[:n]`), so a non-ASCII value whose cut offset lands
inside a multi-byte rune is published as invalid UTF-8, and JSON
encoding rewrites the tail to U+FFFD.
### 1. `internal/storage` — the activity-log per-record cap
`truncateResponse` is the only thing bounding a single activity record.
It now goes through `truncateRuneSafe`, the helper the per-server
tool-call records in the *same package*
(`internal/storage/tool_call_bounds.go`) already use for exactly this
reason.
**Reachability.** An upstream tool response arrives as the `response`
payload of an activity event, and `ActivityService.handleEvent` →
`truncateForStorage` → `storage.TruncateActivityResponse` →
`truncateResponse` applies `activity_max_response_size` (default 64 KB)
on the write path (`internal/runtime/activity_service.go:267`). Any
response over the cap whose byte at the cut offset is a UTF-8
continuation byte — ordinary CJK, Cyrillic, or emoji text — lands in
`config.db` malformed, and from there in `GET /api/v1/activities`, `GET
/api/v1/activities/{id}` and the CSV export. With a 3-byte rune, 2 of
every 3 cut offsets are mid-rune.
### 2. `internal/outputvalidation` — the 500-byte violation `Reason`
A `jsonschema` message quotes the offending instance location, so a
non-ASCII property name or value sits astride the cut.
**Reachability.** `Validator.Validate` calls `truncate(err.Error(),
500)` (`validator.go:157`) on any structured tool output that fails its
`outputSchema`; the `Reason` goes back to the agent and into the audit
line. Measured on the pre-fix code with a 200-rune non-ASCII property
name, shifting the cut by one byte at a time: offsets 0 and 2 produce
invalid UTF-8, offset 1 is valid — i.e. 2 of 3.
### 3. `internal/registries` — the 300-byte Pulse description cap
Registry descriptions are upstream-authored free text.
**Reachability.** `parsePulseWithoutGuesser` fills
`ServerEntry.Description` from `short_description` /
`EXPERIMENTAL_ai_generated_description` for every result of a Pulse
registry search, which the REST API and the Web UI catalog then
JSON-encode. Same 2-of-3 pattern in the test.
The 300 literal is now a named constant (`pulseDescriptionMaxBytes`)
since it appeared twice.
ASCII behaviour is unchanged at all three sites, and the retained prefix
never exceeds the cap.
**Out of scope,** deliberately, so this stays reviewable:
- The CLI table cells in `cmd/mcpproxy` that #1664 also left alone —
sent separately.
- `cmd/mcpproxy/security_cmd.go` and `cmd/mcpproxy/main.go` have the
same shape but are under open PRs (#1714, #1720, #1725), so I have not
touched them.
- `internal/configimport.SanitizeServerName` and
`internal/dockernaming.SanitizeServerName` also cut at a byte offset,
but both strip everything outside `[A-Za-z0-9._-]` first, so no
multi-byte rune can reach the cut. Left as they are.
## Testing
- [x] I have tested these changes locally
- [x] I have added/updated tests that prove my fix is effective or my
feature works
- [x] All existing tests pass
New tests, each failing by name at the base SHA (`14cb288`) and passing
after:
| Test | Package |
|---|---|
| `TestTruncateResponseKeepsRuneBoundary`,
`TestTruncateResponseASCIIUnchanged` | `internal/storage` |
| `TestViolationReasonIsValidUTF8`, `TestTruncateASCIIUnchanged` |
`internal/outputvalidation` |
| `TestPulseDescriptionTruncationIsValidUTF8` | `internal/registries` |
Each boundary test sweeps every offset inside a 3-byte rune rather than
asserting one fixed cap, so it cannot pass by luck.
Commands run (exit status in brackets):
```
git diff --check [0]
gofmt -l internal/storage internal/outputvalidation internal/registries [0, no output]
go vet ./internal/storage ./internal/outputvalidation ./internal/registries [0]
go test ./internal/storage ./internal/outputvalidation ./internal/registries [0]
go test ./internal/runtime -run 'TestToolCallResponseTruncatedForStorage|TestInternalToolCallResponseTruncatedForStorage|TestStorageTruncatedBuiltinStillCountsDeliveredBytes|TestPromptGetResponseTruncatedForStorage|TestDefaultResponseCapAppliesWithoutConfig|TestEmitterTruncationFlagSurvivesUntruncatedRecord' [0, 6/6 pass]
```
The `internal/runtime` selection is the end-to-end activity-truncation
suite that drives the storage cut through the real event path, included
to show the write-path behaviour and the `ResponseTruncated` flag
semantics are unchanged.
Not run: `./scripts/run-all-tests.sh` and `-race` (local disk
constraints on this machine). Nothing here is concurrent.1 parent 92dc64e commit 9ecf032
6 files changed
Lines changed: 159 additions & 14 deletions
File tree
- internal
- outputvalidation
- registries
- storage
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
| 15 | + | |
14 | 16 | | |
15 | 17 | | |
16 | 18 | | |
| |||
225 | 227 | | |
226 | 228 | | |
227 | 229 | | |
228 | | - | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
229 | 236 | | |
230 | 237 | | |
231 | 238 | | |
232 | 239 | | |
233 | | - | |
| 240 | + | |
234 | 241 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| 12 | + | |
12 | 13 | | |
13 | 14 | | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
14 | 19 | | |
15 | 20 | | |
16 | 21 | | |
| |||
702 | 707 | | |
703 | 708 | | |
704 | 709 | | |
705 | | - | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
706 | 715 | | |
707 | | - | |
708 | | - | |
709 | | - | |
710 | | - | |
711 | | - | |
| 716 | + | |
712 | 717 | | |
713 | | - | |
714 | | - | |
715 | | - | |
716 | | - | |
717 | | - | |
| 718 | + | |
718 | 719 | | |
719 | 720 | | |
720 | 721 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
35 | 41 | | |
36 | 42 | | |
37 | 43 | | |
38 | 44 | | |
39 | 45 | | |
40 | 46 | | |
41 | 47 | | |
42 | | - | |
| 48 | + | |
43 | 49 | | |
44 | 50 | | |
45 | 51 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
0 commit comments