From 656ecfa585b31938e2685ffab3db22e794474803 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:29:07 -0500 Subject: [PATCH] fix(server): keep Date fields intact through secret redaction; harden chat notice timestamps (#10984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The task chat thread renders issue comments, system notices, and run transcripts > - The server routes comment payloads through the run-secret redaction walker before it sends them > - The walker rebuilds each object with `Object.entries`, and this collapses `Date` instances to `{}` > - The chat renderer then calls `.toISOString()` on an invalid date and throws, and the thread falls back to the error banner > - This pull request keeps `Date` instances intact in redacted responses and makes the renderer safe against bad timestamps > - The benefit is that task threads with system notices render correctly again ## Linked Issues or Issue Description **What happened** Task threads that contain a system notice showed the banner "Chat renderer hit an internal state error." in place of the conversation. This occurred on many tasks. **Expected behavior** The thread renders all comments and system notices with correct timestamps. **Steps to reproduce** 1. Open a task that has at least one system notice comment (for example a "Workspace ready" notice). 2. `GET /api/issues/{id}/comments` returns `createdAt: {}` for every comment because the secret-redaction walker collapses `Date` objects. 3. The system-notice row calls `new Date({}).toISOString()`. This throws `RangeError: Invalid time value` and trips the thread error boundary. **Version / deployment** Regression from #9934 (`e43f187ca`). It applies to all deployments that include that commit. ## What Changed - `server/src/services/run-secret-redaction.ts`: `redactRegisteredSecretValues` now returns `Date` instances as-is. Dates hold no redactable text, and the `Object.entries` rebuild turned them into `{}`. - `ui/src/components/IssueChatThread.tsx`: the system-notice row formats its timestamp with a new `toValidIsoString` helper. A value that does not parse as a date now degrades to "no timestamp" instead of a render crash. - Regression tests at three layers: - Walker unit tests: `Date` values survive with and without registered secret values. - Route test: `GET /issues/:id/comments` serializes `createdAt` / `updatedAt` as ISO strings. - Render test: a system notice with a malformed `createdAt` renders without the error boundary. ## Verification - `npx vitest run --root server src/__tests__/run-secret-redaction.test.ts` — 5 passed. - `npx vitest run --root server src/__tests__/issue-comment-redaction.test.ts` — 4 passed (embedded Postgres route test). - `cd ui && npx vitest run src/components/IssueChatThread.test.tsx src/components/IssueChatThreadSystemNotice.test.tsx src/lib/issue-chat-messages.test.ts` — 121 passed. - Each new test was run against the unfixed code and failed there, which confirms it guards the regression. - A local sweep rendered 47 real issue threads through `IssueChatThread`: 7 tripped the boundary before the fix, 0 after. ## Risks - Low risk. The server change only preserves `Date` objects that the walker destroyed before. String redaction behavior does not change, and the registry-key stripping does not change. - The UI change only affects the timestamp of system-notice rows and omits it when the value is invalid. ## Model Used - Claude Fable 5 (`claude-fable-5`), Anthropic. Agentic coding session with tool use (file edits, shell, Vitest). No extended-context or special reasoning mode. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Claude Fable 5 Co-authored-by: Paperclip --- .../__tests__/issue-comment-redaction.test.ts | 21 ++++++++++++++ .../__tests__/run-secret-redaction.test.ts | 21 ++++++++++++++ server/src/services/run-secret-redaction.ts | 3 ++ ui/src/components/IssueChatThread.tsx | 14 +++++++++- .../IssueChatThreadSystemNotice.test.tsx | 28 +++++++++++++++++++ 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/issue-comment-redaction.test.ts b/server/src/__tests__/issue-comment-redaction.test.ts index d53392056c..423f1ab345 100644 --- a/server/src/__tests__/issue-comment-redaction.test.ts +++ b/server/src/__tests__/issue-comment-redaction.test.ts @@ -187,6 +187,27 @@ describeEmbeddedPostgres("deleted issue comment redaction", () => { expect(JSON.stringify(wakePayload)).not.toContain("secret metadata"); }); + it("serializes comment timestamps as ISO strings through the redacted comments route (PAP-16607)", async () => { + const { companyId, issueId } = await seedIssue(); + const commentId = randomUUID(); + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId, + authorUserId: "board-user-1", + body: "ordinary comment", + }); + + const response = await request(createApp(companyId)).get(`/api/issues/${issueId}/comments`); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body).toHaveLength(1); + // Secret redaction must not collapse Date instances to `{}` — the chat + // renderer needs parseable timestamps. + expect(typeof response.body[0].createdAt).toBe("string"); + expect(Number.isNaN(new Date(response.body[0].createdAt).getTime())).toBe(false); + expect(typeof response.body[0].updatedAt).toBe("string"); + }); + it("excludes deleted comment bodies from company search", async () => { const { companyId, issueId } = await seedIssue(); await db.insert(issueComments).values({ diff --git a/server/src/__tests__/run-secret-redaction.test.ts b/server/src/__tests__/run-secret-redaction.test.ts index 5bff578f14..fa2dd60a06 100644 --- a/server/src/__tests__/run-secret-redaction.test.ts +++ b/server/src/__tests__/run-secret-redaction.test.ts @@ -54,4 +54,25 @@ describe("registered run secret redaction", () => { expect(redactRegisteredSecretValues("token-extended token", ["token-extended", "token"])) .toBe(`${REDACTED_EVENT_VALUE} ${REDACTED_EVENT_VALUE}`); }); + + it("preserves Date instances instead of collapsing them to empty objects (PAP-16607)", () => { + const createdAt = new Date("2026-08-06T12:00:00.000Z"); + const result = redactRegisteredSecretValues({ + comment: { body: `agent pasted ${secret}`, createdAt, updatedAt: createdAt }, + nested: [{ finishedAt: createdAt }], + }, [secret]); + + expect(result.comment.createdAt).toBeInstanceOf(Date); + expect(result.comment.createdAt.toISOString()).toBe("2026-08-06T12:00:00.000Z"); + expect(result.comment.updatedAt).toBeInstanceOf(Date); + expect(result.nested[0]?.finishedAt).toBeInstanceOf(Date); + expect(result.comment.body).toBe(`agent pasted ${REDACTED_EVENT_VALUE}`); + }); + + it("preserves Date instances when no secret values are registered", () => { + const createdAt = new Date("2026-08-06T12:00:00.000Z"); + const result = redactRegisteredSecretValues({ createdAt }, []); + expect(result.createdAt).toBeInstanceOf(Date); + expect(result.createdAt.toISOString()).toBe("2026-08-06T12:00:00.000Z"); + }); }); diff --git a/server/src/services/run-secret-redaction.ts b/server/src/services/run-secret-redaction.ts index 4604d54ada..4ca7963e95 100644 --- a/server/src/services/run-secret-redaction.ts +++ b/server/src/services/run-secret-redaction.ts @@ -42,6 +42,9 @@ function redactText(input: string, values: string[]) { export function redactRegisteredSecretValues(input: T, values: string[]): T { if (typeof input === "string") return redactText(input, values) as T; if (Array.isArray(input)) return input.map((value) => redactRegisteredSecretValues(value, values)) as T; + // Dates carry no redactable text; rebuilding them via Object.entries would + // collapse them to `{}` and break every timestamp in redacted responses. + if (input instanceof Date) return input; const record = asRecord(input); if (!record) return input; return Object.fromEntries( diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index c540cab21a..c6af475e50 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -773,6 +773,18 @@ function toIsoString(value: string | Date | null | undefined): string | null { return typeof value === "string" ? value : value.toISOString(); } +/** + * ISO timestamp for display, or undefined when the value does not parse as a + * real date. Comment timestamps can arrive malformed (e.g. a server + * serialization bug turning Dates into `{}`); formatting must degrade to "no + * timestamp" instead of throwing mid-render (PAP-16607). + */ +function toValidIsoString(value: Date | string | number | undefined): string | undefined { + if (value === undefined || value === null) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + function loadDraft(draftKey: string): string { try { return localStorage.getItem(draftKey) ?? ""; @@ -2737,7 +2749,7 @@ function SystemNoticeCommentRow({ {bodyText} ), - timestamp: message.createdAt ? new Date(message.createdAt).toISOString() : undefined, + timestamp: toValidIsoString(message.createdAt), source, runAgentId, }); diff --git a/ui/src/components/IssueChatThreadSystemNotice.test.tsx b/ui/src/components/IssueChatThreadSystemNotice.test.tsx index 410d6b3068..8419c54ebe 100644 --- a/ui/src/components/IssueChatThreadSystemNotice.test.tsx +++ b/ui/src/components/IssueChatThreadSystemNotice.test.tsx @@ -150,6 +150,34 @@ describe("IssueChatThread system notice routing", () => { expect(container.querySelectorAll('[data-message-role="user"]').length).toBe(0); }); + it("renders a system notice with a malformed createdAt without tripping the error boundary (PAP-16607)", () => { + const comment: IssueChatComment = { + id: "comment-system-bad-date", + companyId: "company-1", + issueId: "issue-1", + authorType: "system", + authorAgentId: null, + authorUserId: null, + body: "Workspace ready.", + presentation: { + kind: "system_notice", + tone: "info", + title: "Workspace ready", + detailsDefaultOpen: false, + }, + metadata: { version: 1, sections: [] }, + // A server serialization bug (Dates collapsed to `{}` by secret + // redaction) shipped comments whose timestamps parse to Invalid Date. + createdAt: {} as unknown as Date, + updatedAt: {} as unknown as Date, + }; + + renderThread([comment]); + + expect(container.textContent).not.toContain("Chat renderer hit an internal state error."); + expect(container.textContent).toContain("Workspace ready"); + }); + it("expands metadata when detailsDefaultOpen is true", () => { const comment: IssueChatComment = { id: "comment-system-open",