fix(transcript): redact bodies before anything clips them (MUL-7227)

Review of #8242 found that deriving the inline timeline unredacted put raw
bodies in front of clips that redact afterwards, and a pattern only matches
while both its ends are present. `StepBody` cut at its display clip and then
redacted; a step summary cut to 200 characters and then redacted. A secret
straddling either cut lost the tail its pattern needed and rendered its head.
The whole-transcript redaction this branch removed had been hiding that
ordering, in the dialog and everywhere else.

Redaction now happens where it did before — over a whole body, before any cut
— but on the bodies being rendered instead of on every message in the
transcript. `redactTraceRow` / `redactTraceStep` redact the rows on screen and
the one step the collapsed summary reads; `StepBody` redacts, then clips. The
bound is what this branch is for: a live run rebuilding on each 100ms flush
pays for a dozen rows, not for thousands of messages. Measured on the same
chain as before: ~15-25ms per flush at 3000 messages, now ~0.2ms.

Also closes a leak the review turned up while auditing that path, older than
this branch: `DiffDetailSurface` handed raw lines to the syntax highlighter and
rendered its markup with `dangerouslySetInnerHTML`, so `redactSecrets` only
ever ran on the fallback — redaction was conditional on a language lookup
failing. Diff lines are built from tool input too, which carries no upstream
redaction at all, so an `Edit` to a recognized file type displayed whatever the
agent wrote. Lines are redacted once, before highlighting.

Second finding: removing `refetchOnWindowFocus` left the initial handshake
uncovered. The transcript fetch and the WS subscription are not ordered, so a
row persisted between them is in neither the response nor any frame this client
received, and `staleTime: Infinity` means nothing fetches it again — the run
stays live and visible, so no mount or liveness transition fires either.
`onReconnect` deliberately skips the first connection, which is exactly the one
that matters here. `WSClient.onConnect` fires on every authenticated
connection, and the sync layer uses it to invalidate `task-messages` alone —
once per connection rather than on every window focus.

Six regression tests, each verified to fail without its fix. The redaction ones
render the real `StepBody` and detail surfaces; the inline suite mocks them,
which is why these boundaries were missed. Verified: pnpm typecheck, pnpm lint
(0 errors), pnpm test — core 1724, views 4995, desktop 626, web 266, docs 15.

Reported-by: Niko
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
J
2026-09-10 15:27:23 +08:00
co-authored by multica-agent
parent 9f7514b342
commit 096c92d8bb
12 changed files with 299 additions and 12 deletions
+26
View File
@@ -48,6 +48,7 @@ export class WSClient {
// on each connect() so a fresh connection logs once again.
private badFrameLogged = false;
private onReconnectCallbacks = new Set<() => void>();
private onConnectCallbacks = new Set<() => void>();
private anyHandlers = new Set<(msg: WSMessage) => void>();
private logger: Logger;
@@ -192,6 +193,13 @@ export class WSClient {
}
}
}
for (const cb of this.onConnectCallbacks) {
try {
cb();
} catch {
// ignore connect callback errors
}
}
this.hasConnectedBefore = true;
}
@@ -212,6 +220,7 @@ export class WSClient {
this.handlers.clear();
this.anyHandlers.clear();
this.onReconnectCallbacks.clear();
this.onConnectCallbacks.clear();
}
on(event: WSEventType, handler: EventHandler) {
@@ -238,6 +247,23 @@ export class WSClient {
};
}
/**
* Every authenticated connection, including the first one.
*
* `onReconnect` skips the first deliberately: a client that has never been
* subscribed has no missed events to recover. That reasoning does not hold
* for data this client read over HTTP before the socket came up — the fetch
* and the subscription are not ordered, so a row persisted in between is
* broadcast to nobody here and is absent from the response. Consumers whose
* only live source is this socket need the first connection too.
*/
onConnect(callback: () => void) {
this.onConnectCallbacks.add(callback);
return () => {
this.onConnectCallbacks.delete(callback);
};
}
send(message: WSMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
@@ -37,6 +37,7 @@ function createRecordingWs(): {
}),
onAny: vi.fn(() => () => {}),
onReconnect: vi.fn(() => () => {}),
onConnect: vi.fn(() => () => {}),
} as unknown as WSClient;
return { ws, handlers };
}
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { api } from "../api";
import { WSClient } from "../api/ws-client";
import { useTaskMessages } from "../chat/queries";
import type { TaskMessagePayload } from "../types/events";
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
vi.mock("../api", () => ({ api: { listTaskMessages: vi.fn() } }));
vi.mock("../platform/workspace-storage", () => ({
getCurrentWsId: () => "ws-1", getCurrentSlug: () => "test-ws",
createWorkspaceAwareStorage: (adapter: unknown) => adapter,
registerForWorkspaceRehydration: () => {},
}));
vi.mock("../paths", () => ({ useHasOnboarded: () => true, resolvePostAuthDestination: () => "/" }));
class FakeWebSocket {
static last: FakeWebSocket;
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor() { FakeWebSocket.last = this; }
close() {}
send() {}
}
const id = "11111111-1111-4111-8111-111111111111";
const msg = (seq: number): TaskMessagePayload => ({ task_id: id, issue_id: "issue-1", seq, type: "text", content: `fragment ${seq}` });
const stores = { authStore: Object.assign(() => ({}), {
getState: () => ({ user: { id: "u1" } }), subscribe: () => () => {}, setState: () => {},
}) } as unknown as RealtimeSyncStores;
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
describe("timeline repair on connect", () => {
it("recovers a row persisted between the transcript fetch and the first handshake", async () => {
// The fetch and the subscription are not ordered. seq 2 is written while
// the socket is still connecting, so the response never saw it and its
// broadcast reached nobody here. `staleTime: Infinity` means nothing would
// fetch it again, and the run stays live and visible throughout — so no
// mount or liveness transition fires either. Only the connection itself
// marks the point where this client's view could have a hole in it.
vi.stubGlobal("WebSocket", FakeWebSocket);
const ws = new WSClient("ws://example.test/ws", { cookieAuth: true });
ws.connect();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } });
const wrapper = ({ children }: { children: ReactNode }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
vi.mocked(api.listTaskMessages).mockResolvedValue([msg(1)]);
const { result, unmount } = renderHook(
() => { useRealtimeSync(ws, stores); return useTaskMessages(id, true); },
{ wrapper },
);
await waitFor(() => expect(result.current.data?.map((m) => m.seq)).toEqual([1]));
vi.mocked(api.listTaskMessages).mockResolvedValue([msg(1), msg(2), msg(3)]);
act(() => {
FakeWebSocket.last.onopen?.();
FakeWebSocket.last.onmessage?.({ data: JSON.stringify({ type: "task:message", payload: msg(3) }) });
});
await waitFor(() => expect(result.current.data?.map((m) => m.seq)).toEqual([1, 2, 3]));
unmount(); ws.disconnect(); qc.clear();
});
});
@@ -38,6 +38,7 @@ function createMockWs(handlers: Handlers): WSClient {
}),
onAny: vi.fn(() => () => {}),
onReconnect: vi.fn(() => () => {}),
onConnect: vi.fn(() => () => {}),
} as unknown as WSClient;
}
@@ -38,6 +38,7 @@ function createMockWs(): WSClient {
on: vi.fn(() => () => {}),
onAny: vi.fn(() => () => {}),
onReconnect: vi.fn(() => () => {}),
onConnect: vi.fn(() => () => {}),
} as unknown as WSClient;
}
@@ -1746,6 +1746,23 @@ export function useRealtimeSync(
};
}, [ws, qc, authStore, onToast]);
// Connect -> repair timelines fetched before this client was subscribed.
//
// `taskMessagesOptions` holds `staleTime: Infinity` and is kept current by
// `task:message` frames, so a row persisted between the HTTP read and the
// socket coming up is in neither: the response was snapshotted before it
// existed, and its broadcast went out while nobody here was listening.
// Removing the focus refetch (MUL-7227) took away the accident that used to
// paper over this, so it is repaired where it happens instead — once per
// connection, and only for the caches whose sole live source is the socket,
// rather than on every window focus.
useEffect(() => {
if (!ws) return;
return ws.onConnect(() => {
qc.invalidateQueries({ queryKey: chatKeys.taskMessagesAll() });
});
}, [ws, qc]);
// Reconnect -> refetch all data to recover missed events
useEffect(() => {
if (!ws) return;
@@ -1796,10 +1796,15 @@ export function StepBody({ item }: { item: TimelineItem }) {
// server-side budget and keeps the clip at its existing length; nothing
// about how long an input renders is this change's business.
const clip = item.type === "tool_result" ? null : DISPLAY_CLIP_CHARS;
// Redact the whole body, then clip it. The other order drops the tail a
// pattern needs to match, so a secret straddling the clip renders its
// head (MUL-7227 review) — and the clip length is a display choice that
// must not decide what stays hidden.
const safe = redactSecrets(text);
const clipped =
clip !== null && text.length > clip
? `${redactSecrets(text.slice(0, clip))}\n${t(($) => $.transcript.display_clipped)}`
: redactSecrets(text);
clip !== null && safe.length > clip
? `${safe.slice(0, clip)}\n${t(($) => $.transcript.display_clipped)}`
: safe;
const path = item.type === "tool_use" ? readPathFromInput(item.input) : undefined;
return (
<ToolDetailSurface
@@ -1,4 +1,4 @@
import type { TimelineItem } from "./build-timeline";
import { redactTimelineItem, type TimelineItem } from "./build-timeline";
/**
* A run's steps, derived from the flat event stream.
@@ -192,6 +192,35 @@ export function groupSteps(steps: TraceStep[]): TraceRow[] {
return rows;
}
/**
* Redact every body this row can put on screen.
*
* Applied to the rows actually being rendered rather than to the whole
* transcript: scanning every message on each live flush is what made the
* client stall (MUL-7227), and the rows on screen are a bounded slice of it.
* What must not change is the order — a row is redacted whole, before its
* summary is cut to 200 characters and before `StepBody` reaches its display
* clip, because a secret straddling either cut cannot be matched afterwards.
*/
export function redactTraceRow(row: TraceRow): TraceRow {
if (isGroupRow(row)) return { ...row, steps: row.steps.map(redactCallStep) };
return redactTraceStep(row);
}
/** `redactTraceRow` for a single step. */
export function redactTraceStep(step: TraceStep): TraceStep {
if (step.kind === "call") return redactCallStep(step);
return { ...step, item: redactTimelineItem(step.item) };
}
function redactCallStep(step: TraceCallStep): TraceCallStep {
return {
...step,
call: step.call && redactTimelineItem(step.call),
result: step.result && redactTimelineItem(step.result),
};
}
// `TraceMessageStep` carries three kinds on one interface, so a `kind` check
// alone narrows the property without dropping the constituent. These predicates
// are what let callers switch on a row and get a usable type back.
@@ -58,11 +58,25 @@ export function appendTimelineItem(items: TimelineItem[], item: TimelineItem): T
* actually render should build the structure and skip this.
*/
export function redactTimelineItems(items: TimelineItem[]): TimelineItem[] {
return items.map((item) => ({
return items.map(redactTimelineItem);
}
/**
* Redact one record's bodies.
*
* Whole bodies, before anything downstream clips or summarizes them. Every
* consumer of a body cuts first and redacts second — `StepBody` at its display
* clip, the step summaries at 200 characters — so a pattern that spans the cut
* loses the tail it needs to match and the head renders. Redacting the record
* first is what made that ordering harmless, and it stays harmless as long as
* a raw record never reaches a renderer.
*/
export function redactTimelineItem(item: TimelineItem): TimelineItem {
return {
...item,
content: item.content ? redactSecrets(item.content) : item.content,
output: item.output ? redactSecrets(item.output) : item.output,
}));
};
}
/**
@@ -178,6 +178,17 @@ export function DiffDetailSurface({ lines, path }: { lines: TraceDiffLine[]; pat
const added = lines.filter((l) => l.kind === "add").length;
const removed = lines.filter((l) => l.kind === "remove").length;
// Redacted here, before highlighting, rather than only in the fallback below.
// The highlighter returns markup that is rendered with
// `dangerouslySetInnerHTML`, so whatever it is handed reaches the screen
// without passing the fallback's `redactSecrets` — which made the redaction
// conditional on a language lookup failing. Diff lines are also built from
// tool input, which carries no upstream redaction at all.
const safeLines = useMemo(
() => lines.map((line) => (line.text ? { ...line, text: redactSecrets(line.text) } : line)),
[lines],
);
// Each side is highlighted as one block so multi-line strings and comments
// keep their grammar, then split back per line to sit in the diff gutter.
// Runs only when the row is expanded, which is where this component mounts.
@@ -190,13 +201,13 @@ export function DiffDetailSurface({ lines, path }: { lines: TraceDiffLine[]; pat
context: null,
};
for (const kind of ["add", "remove", "context"] as const) {
const side = lines.filter((l) => l.kind === kind);
const side = safeLines.filter((l) => l.kind === kind);
if (side.length > 0) {
sides[kind] = highlightToLines(side.map((l) => l.text).join("\n"), language);
}
}
return sides;
}, [lines, path]);
}, [safeLines, path]);
return (
<div className="relative">
@@ -210,7 +221,7 @@ export function DiffDetailSurface({ lines, path }: { lines: TraceDiffLine[]; pat
isLong && !showAll && "max-h-52 overflow-hidden",
)}
>
{renderDiffRows(lines, highlighted)}
{renderDiffRows(safeLines, highlighted)}
</pre>
{isLong && !showAll && (
<div className="absolute inset-x-0 bottom-0 flex h-12 items-end justify-center rounded-b-md bg-gradient-to-b from-transparent to-background">
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { api } from "@multica/core/api";
import type { AgentTask } from "@multica/core/types";
import type { TaskMessagePayload } from "@multica/core/types/events";
import { renderWithI18n } from "../../test/i18n";
import { InlineCommentRun } from "../../issues/components/inline-comment-run";
import { StepBody } from "./agent-transcript-dialog";
import { buildTimelineStructure } from "./build-timeline";
/**
* Redaction must run over a whole body before anything cuts it down.
*
* These bodies reach the screen through several clips — `StepBody`'s display
* clip, the 200-character step summary — and a pattern only matches while both
* of its ends are present. Cutting first and redacting second therefore hides
* nothing and renders the head of the secret. The inline run deriving its
* timeline unredacted (MUL-7227) is what put raw bodies in front of those
* clips, so each case here pins the order rather than the redaction.
*
* Deliberately renders the real `StepBody` and the real detail surfaces: the
* inline suite mocks them, which is exactly why these boundaries were missed.
*/
vi.mock("@multica/core/api", () => ({ api: {
getIssue: vi.fn(), listTaskMessages: vi.fn(), cancelTask: vi.fn(), rerunIssue: vi.fn(),
}, dispatchReasonCode: () => undefined }));
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "workspace" }));
vi.mock("@multica/core/workspace/hooks", () => ({ useActorName: () => ({ getActorName: () => "Reviewer" }) }));
vi.mock("../actor-avatar", () => ({ ActorAvatar: () => <span /> }));
vi.mock("./use-trace-issue-labels", () => ({ useTraceIssueLabels: () => (text: string) => text }));
afterEach(() => { cleanup(); vi.clearAllMocks(); });
const id = "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc";
const fakeKey = "AKIA1234567890ABCDEF";
const task: AgentTask = {
id, agent_id: "agent", runtime_id: "runtime", issue_id: "issue", status: "running", priority: 0,
created_at: "2026-09-07T00:00:00Z", started_at: "2026-09-07T00:00:00Z", dispatched_at: null,
completed_at: null, result: null, error: null,
};
const item = (msgs: TaskMessagePayload[]) => buildTimelineStructure(msgs)[0]!;
describe("redaction runs before every clip", () => {
it.each(["text", "thinking"] as const)("hides a %s secret straddling the display clip", (type) => {
// Split across two frames so it only exists after coalescing, and placed so
// the key spans the 8000-character clip.
const view = renderWithI18n(<StepBody item={item([
{ task_id: id, issue_id: "issue", seq: 1, type, content: "x".repeat(7989) + " " + fakeKey.slice(0, 10) },
{ task_id: id, issue_id: "issue", seq: 2, type, content: fakeKey.slice(10) },
])} />);
expect(view.container.textContent).not.toContain("AKIA123456");
});
it("hides a PEM body whose closing marker falls past the display clip", () => {
const material = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=";
const keyStart = `-----BEGIN PRIVATE KEY-----\n${material}\n`;
const view = renderWithI18n(<StepBody item={item([
{ task_id: id, issue_id: "issue", seq: 1, type: "text", content: "x".repeat(7999 - keyStart.length) + "\n" + keyStart },
{ task_id: id, issue_id: "issue", seq: 2, type: "text", content: "-----END PRIVATE KEY-----" },
])} />);
expect(view.container.textContent).not.toContain(material);
});
it("hides a secret past the 200-character cut in the inline step summary", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.mocked(api.listTaskMessages).mockResolvedValue([
{ task_id: id, issue_id: "issue", seq: 1, type: "tool_result", tool: "exec_command", output: "x".repeat(189) + " " + fakeKey },
]);
const view = renderWithI18n(<QueryClientProvider client={client}>
<InlineCommentRun run={{ task, commentId: "comment", hasReply: false }} />
</QueryClientProvider>);
await screen.findByText("exec_command");
fireEvent.click(screen.getByRole("button", { name: /View activity/ }));
const summary = view.container.querySelector("details summary");
expect(summary?.textContent).not.toContain("AKIA123456");
// The same string is also the hover title, which no clip protects.
expect(summary?.querySelector("[title]")?.getAttribute("title")).not.toContain("AKIA123456");
client.clear();
});
it("hides a secret in a syntax-highlighted diff", () => {
// The highlighter returns markup rendered with dangerouslySetInnerHTML, so
// redaction that only runs on the un-highlighted fallback never applies to
// a file whose language resolves. Built from tool input, which carries no
// upstream redaction at all.
const view = renderWithI18n(<StepBody item={item([
{ task_id: id, issue_id: "issue", seq: 1, type: "tool_use", tool: "Edit",
input: { file_path: "config.ts", old_string: "const value = 0;", new_string: `const value = '${fakeKey}';` } },
])} />);
expect(view.container.querySelector(".hljs")).not.toBeNull();
expect(view.container.textContent).not.toContain(fakeKey);
});
});
@@ -18,7 +18,7 @@ import { cn } from "@multica/ui/lib/utils";
import { UI_EASE_IN, UI_EASE_OUT, UI_MOTION_DURATION } from "@multica/ui/lib/motion";
import { AgentTranscriptDialog, StepBody } from "../../common/task-transcript/agent-transcript-dialog";
import { buildTimelineStructure, redactTimelineItems, type TimelineItem } from "../../common/task-transcript/build-timeline";
import { buildSteps, groupSteps, isCallStep, isGroupRow, type TraceRow } from "../../common/task-transcript/build-steps";
import { buildSteps, groupSteps, isCallStep, isGroupRow, redactTraceRow, redactTraceStep, type TraceRow } from "../../common/task-transcript/build-steps";
import { traceEventSummary, traceToolArgSummary } from "../../common/task-transcript/trace-event-presenter";
import { redactSecrets } from "../../common/task-transcript/redact";
import { ReadonlyContent } from "../../editor";
@@ -96,6 +96,13 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal
const formatText = useTraceIssueLabels(useWorkspaceId(), task.issue_id, items, loadTranscript);
const steps = useMemo(() => buildSteps(items), [items]);
const rows = useMemo(() => groupSteps(steps), [steps]);
// Only the rows on screen are redacted, and they are redacted whole before
// anything below cuts them. That bound is the point: the transcript this
// slice comes from can be thousands of messages (MUL-7227).
const visibleRows = useMemo(
() => rows.slice(-visibleCount).map(redactTraceRow),
[rows, visibleCount],
);
useEffect(() => {
if (!active) return;
const timer = setInterval(() => setNow(Date.now()), 1000);
@@ -111,7 +118,13 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal
const output = !hasReply ? commentRunOutput(task) : null;
const latest = steps.findLast((step) => step.kind !== "text" || step.item.content?.trim());
const pendingCall = steps.findLast((step) => isCallStep(step) && !step.result);
const current = pendingCall ?? latest;
// Redacted before it is summarized, not after: `traceToolArgSummary` and
// friends cut to 200 characters, and a pattern spanning that cut cannot be
// matched once the tail is gone. One step, so this stays off the hot path.
const current = useMemo(() => {
const step = pendingCall ?? latest;
return step && redactTraceStep(step);
}, [pendingCall, latest]);
// Keep the last activity visible after a tool returns, until new progress arrives.
const activitySummary = current && isCallStep(current)
? redactSecrets(traceToolArgSummary(current.call?.input, { formatText }) || current.tool)
@@ -202,7 +215,7 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal
{!isPending && !isError && rows.length === 0 && <p className="text-caption text-muted-foreground">{t(($) => $.inline_run.empty)}</p>}
{rows.length > visibleCount && <button type="button" className="py-1 text-caption text-muted-foreground hover:text-foreground"
onClick={() => setVisibleCount((count) => count + 12)}>{t(($) => $.inline_run.show_earlier, { count: rows.length - visibleCount })}</button>}
{rows.slice(-visibleCount).map((row) => <InlineStep key={row.seq} row={row} live={active} formatText={formatText} />)}
{visibleRows.map((row) => <InlineStep key={row.seq} row={row} live={active} formatText={formatText} />)}
<button type="button" className="flex items-center gap-1.5 rounded py-2 text-caption text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={openFullLog}>{t(($) => $.inline_run.full_log)}<ExternalLink className="size-3" /></button>
</div>}