Files
PaperClipAI/server/src/__tests__/run-secret-redaction.test.ts
656ecfa585 fix(server): keep Date fields intact through secret redaction; harden chat notice timestamps (#10984)
## 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 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-06 10:29:07 -05:00

79 lines
3.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { REDACTED_EVENT_VALUE } from "../redaction.js";
import { redactRegisteredSecretValues } from "../services/run-secret-redaction.js";
const secret = "q2a-exact-secret-value";
describe("registered run secret redaction", () => {
it("redacts exact values across comment and heartbeat/wake projections", () => {
const result = redactRegisteredSecretValues({
comment: { body: `agent pasted ${secret} in a comment` },
heartbeatContext: {
issue: { description: `do not expose ${secret}` },
wakeComment: { body: secret },
},
wakePayload: {
comments: [{ body: `prefix-${secret}-suffix` }],
continuationSummary: { body: secret },
},
}, [secret]);
expect(result).toEqual({
comment: { body: `agent pasted ${REDACTED_EVENT_VALUE} in a comment` },
heartbeatContext: {
issue: { description: `do not expose ${REDACTED_EVENT_VALUE}` },
wakeComment: { body: REDACTED_EVENT_VALUE },
},
wakePayload: {
comments: [{ body: `prefix-${REDACTED_EVENT_VALUE}-suffix` }],
continuationSummary: { body: REDACTED_EVENT_VALUE },
},
});
});
it("redacts run detail, event, and transcript fields and strips registry material", () => {
const result = redactRegisteredSecretValues({
contextSnapshot: {
issueId: "issue-1",
paperclipSecretRedactions: [{ material: { ciphertext: "encrypted" } }],
},
stdoutExcerpt: `stdout ${secret}`,
events: [{ message: secret, payload: { output: secret } }],
log: { content: `tool returned ${secret}` },
}, [secret]);
expect(result).toEqual({
contextSnapshot: { issueId: "issue-1" },
stdoutExcerpt: `stdout ${REDACTED_EVENT_VALUE}`,
events: [{ message: REDACTED_EVENT_VALUE, payload: { output: REDACTED_EVENT_VALUE } }],
log: { content: `tool returned ${REDACTED_EVENT_VALUE}` },
});
});
it("replaces longer registered values before overlapping shorter values", () => {
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");
});
});