From 9afd7f1dc78fdc6c755bd0ea3fe08a961e27512f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 12 Sep 2026 12:13:02 +0000 Subject: [PATCH 1/3] 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 --- packages/shared/src/validators/issue.test.ts | 21 ++++++++++++++++++++ packages/shared/src/validators/text.ts | 13 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 29e165a825..a1d3454d1e 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -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", diff --git a/packages/shared/src/validators/text.ts b/packages/shared/src/validators/text.ts index 322597a900..52112c5d81 100644 --- a/packages/shared/src/validators/text.ts +++ b/packages/shared/src/validators/text.ts @@ -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")