fix(shared): don't mangle escaped newlines inside valid JSON document bodies
normalizeEscapedLineBreaks() unconditionally rewrote every literal \n/\r two-character sequence in text fields (issue descriptions, comments, document bodies, etc.) into a raw newline byte, to fix clients that send literal-backslash-n markdown text instead of real line breaks. When a document body is itself a JSON string (e.g. an ISO brief published via json.dumps(brief, indent=2)), the \n inside an embedded string value (e.g. narrative) is a required JSON escape, not literal prose. Rewriting it into a raw control-character byte corrupts the stored body: it's no longer valid JSON, and any consumer doing json.loads()/JSON.parse() on it fails with "Invalid control character". Skip the rewrite when the incoming value already parses as valid JSON, so JSON-payload bodies round-trip byte-for-byte while plain markdown/prose text keeps the existing literal-\n normalization behavior. Reported on SPC-39026 (Standard Power): every brief-iso document body (published by every EU/NA ISO agent) hit this exact corruption. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
eb9f954bae
commit
9afd7f1dc7
|
|
@ -442,6 +442,27 @@ describe("issue validators", () => {
|
|||
expect(document.body).toBe("# Plan\n\nShip it");
|
||||
});
|
||||
|
||||
it("preserves escaped newlines inside a JSON document body byte-for-byte", () => {
|
||||
// Regression test for SPC-39026: a document body that is itself a JSON
|
||||
// string (e.g. `json.dumps(brief, indent=2)`) must round-trip exactly —
|
||||
// the `\n` inside the `narrative` value is a required JSON string escape,
|
||||
// not literal text a client failed to turn into a line break, so it must
|
||||
// not be rewritten into a raw control character.
|
||||
const jsonBody = JSON.stringify(
|
||||
{ narrative: "Line 1\n\nLine 2", note: "keep\\nliteral" },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const document = upsertIssueDocumentSchema.parse({
|
||||
format: "markdown",
|
||||
body: jsonBody,
|
||||
});
|
||||
|
||||
expect(document.body).toBe(jsonBody);
|
||||
expect(() => JSON.parse(document.body)).not.toThrow();
|
||||
});
|
||||
|
||||
it("clamps oversized requestDepth values on create", () => {
|
||||
const parsed = createIssueSchema.parse({
|
||||
title: "Clamp request depth",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export function normalizeEscapedLineBreaks(value: string): string {
|
||||
// Skip valid JSON payloads: their `\n`/`\r` sequences are structural string
|
||||
// escapes, not literal text a client mistakenly failed to turn into a real
|
||||
// line break. Rewriting those bytes turns escaped-newline JSON into raw
|
||||
// control characters, breaking `JSON.parse`/`json.loads` for any consumer
|
||||
// reading the stored value back as data (see SPC-39026).
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return value;
|
||||
} catch {
|
||||
// Not JSON — fall through to the legacy literal-escape normalization
|
||||
// used for human/agent-authored markdown text.
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/\\r\\n/g, "\n")
|
||||
.replace(/\\n/g, "\n")
|
||||
|
|
|
|||
Loading…
Reference in New Issue