diff --git a/apps/extension/src/lib/__tests__/recording-observation.test.ts b/apps/extension/src/lib/__tests__/recording-observation.test.ts index 18b4959..71bcfa4 100644 --- a/apps/extension/src/lib/__tests__/recording-observation.test.ts +++ b/apps/extension/src/lib/__tests__/recording-observation.test.ts @@ -11,7 +11,7 @@ function sessionWithInput(redactValues = false): RecordingObservationSession { const session = new RecordingObservationSession({ redactValues }); const state = session.registry.register({ url: URL, - rawVomText: '@vom 1\ntextbox "Password" value="••••••" [ref=e1]', + vomText: '@vom 1\ntextbox "Password" value="••••••" [ref=e1]', }); session.cursor.lastSettled = { stateId: state.id, @@ -34,7 +34,7 @@ function sessionWithInput(redactValues = false): RecordingObservationSession { return session; } -function finalizedFillBody(value: string, redactValues: boolean): string { +function finalizedFillTrace(value: string, redactValues: boolean) { const session = sessionWithInput(redactValues); const draft: RecordingDraftStep = { op: "fill", @@ -53,15 +53,20 @@ function finalizedFillBody(value: string, redactValues: boolean): string { startedAt: "2026-08-12T00:00:00.000Z", stoppedBy: "user_finish", bskVersion: "test", - }).states[0]!.body; + redactValues, + }); +} + +function finalizedFillBody(value: string, redactValues: boolean): string { + return finalizedFillTrace(value, redactValues).states[0]!.body; } describe("record observation annotations", () => { it("omits a fill literal when values are redacted", () => { const secret = "hunter2-private"; - const body = finalizedFillBody(secret, true); - expect(body).toContain("step 1: fill"); - expect(body).not.toContain(secret); + const dumped = JSON.stringify(finalizedFillTrace(secret, true)); + expect(dumped).toContain("step 1: fill"); + expect(dumped).not.toContain(secret); }); it("keeps ordinary fill details", () => { @@ -73,7 +78,7 @@ describe("record observation annotations", () => { const state = registry.register({ url: "https://example.com/a\nb", title: "hello\nworld", - rawVomText: "@vom 1", + vomText: "@vom 1", }); const trace = buildTraceV3({ registry, @@ -91,18 +96,18 @@ describe("recording state ownership", () => { it("deduplicates within one recording and isolates ids between recordings", () => { const first = new RecordingStateRegistry(); const second = new RecordingStateRegistry(); - expect(first.register({ url: URL, rawVomText: "same" }).id).toBe("s1"); - expect(first.register({ url: URL, rawVomText: "same" }).id).toBe("s1"); - expect(second.register({ url: URL, rawVomText: "other" }).id).toBe("s1"); + expect(first.register({ url: URL, vomText: "same" }).id).toBe("s1"); + expect(first.register({ url: URL, vomText: "same" }).id).toBe("s1"); + expect(second.register({ url: URL, vomText: "other" }).id).toBe("s1"); }); it("enriches metadata when a deduplicated observation becomes more complete", () => { const registry = new RecordingStateRegistry(); - registry.register({ url: URL, rawVomText: "same" }); + registry.register({ url: URL, vomText: "same" }); const state = registry.register({ url: URL, title: "Login", - rawVomText: "same", + vomText: "same", truncated: true, }); expect(state).toMatchObject({ id: "s1", title: "Login", truncated: true }); diff --git a/apps/extension/src/lib/__tests__/recording-runtime.test.ts b/apps/extension/src/lib/__tests__/recording-runtime.test.ts index d993946..9e93138 100644 --- a/apps/extension/src/lib/__tests__/recording-runtime.test.ts +++ b/apps/extension/src/lib/__tests__/recording-runtime.test.ts @@ -14,7 +14,7 @@ import { RecordingObservationRuntime } from "../recording/recording-runtime"; function observation() { return { rootFrameId: "root", - index: new ObservationNodeIndex({ rootFrameId: "root", frameDocuments: [], refs: [] }), + index: new ObservationNodeIndex({ rootFrameId: "root", matchNodes: [], refs: [] }), url: "https://example.com/", title: "Example", vomText: '@vom 1\nRootWebArea "Example"', @@ -95,4 +95,39 @@ describe("RecordingObservationRuntime", () => { expect(captureRecordingObservation).toHaveBeenCalledTimes(2); expect(trace.states).toHaveLength(1); }); + + it("keeps runtime matching data out of the serialized trace", async () => { + const captured = observation(); + captured.index = new ObservationNodeIndex({ + rootFrameId: "root", + matchNodes: [ + { + frameId: "root", + backendNodeId: 42, + tag: "button", + rect: { x: 10, y: 20, w: 100, h: 30 }, + localRect: { x: 10, y: 20, w: 100, h: 30 }, + }, + ], + refs: [{ ref: "e1", backendNodeId: 42, role: "button", name: "Submit", line: 1 }], + }); + captureRecordingObservation.mockResolvedValueOnce(captured); + const recording = runtime(); + + await recording.captureInitial(7); + const dumped = JSON.stringify( + recording.buildTrace({ + drafts: [], + startedAt: "2026-08-19T00:00:00.000Z", + stoppedBy: "user_finish", + bskVersion: "0.1.6", + }), + ); + + expect(dumped).toContain("RootWebArea"); + expect(dumped).not.toContain("matchNodes"); + expect(dumped).not.toContain("localRect"); + expect(dumped).not.toContain("backendNodeId"); + expect(dumped).not.toContain("ObservationNodeIndex"); + }); }); diff --git a/apps/extension/src/lib/__tests__/trace-reducer-v3.test.ts b/apps/extension/src/lib/__tests__/trace-reducer-v3.test.ts index 93434d1..df60cf7 100644 --- a/apps/extension/src/lib/__tests__/trace-reducer-v3.test.ts +++ b/apps/extension/src/lib/__tests__/trace-reducer-v3.test.ts @@ -6,6 +6,33 @@ import { reduceTraceStepsV3 } from "../recording/trace-reducer-v3"; import type { RecordingDraftStep } from "../recording/types"; describe("trace reducer v3", () => { + it("removes form literals when value redaction is requested", () => { + const reduced = reduceTraceStepsV3( + [ + { + op: "fill", + value: "private@example.com", + preStateId: "s1", + postStateId: "s1", + }, + { + op: "select", + values: ["private-account-id"], + labels: ["Private account"], + preStateId: "s1", + postStateId: "s1", + }, + ], + { redactValues: true }, + ); + + expect(reduced.steps[0]).toMatchObject({ op: "fill", value: "***", redacted: true }); + expect(reduced.steps[1]).toMatchObject({ op: "select" }); + expect(reduced.steps[1]).not.toHaveProperty("selection"); + expect(JSON.stringify(reduced.steps)).not.toContain("private@example.com"); + expect(JSON.stringify(reduced.steps)).not.toContain("private-account-id"); + }); + it("collapses redirect hops while retaining draft-to-step identity", () => { const drafts: RecordingDraftStep[] = [ { op: "navigate", url: "https://example.com/start", preStateId: "s1", postStateId: "s2" }, @@ -36,7 +63,7 @@ describe("trace reducer v3", () => { it("builds the wire model from protocol constants", () => { const registry = new RecordingStateRegistry(); - const state = registry.register({ url: "https://example.com", rawVomText: "@vom 1" }); + const state = registry.register({ url: "https://example.com", vomText: "@vom 1" }); const trace = buildTraceV3({ registry, drafts: [ diff --git a/apps/extension/src/lib/recording/observation-session.ts b/apps/extension/src/lib/recording/observation-session.ts index 9cfd43d..d2f0148 100644 --- a/apps/extension/src/lib/recording/observation-session.ts +++ b/apps/extension/src/lib/recording/observation-session.ts @@ -74,7 +74,7 @@ export class RecordingObservationSession { const state = this.registry.register({ url: captured.url, title: captured.title, - rawVomText: captured.vomText, + vomText: captured.vomText, truncated: captured.truncated, }); const observation: RegisteredObservation = { diff --git a/apps/extension/src/lib/recording/recording-runtime.ts b/apps/extension/src/lib/recording/recording-runtime.ts index d33b055..f3bdae7 100644 --- a/apps/extension/src/lib/recording/recording-runtime.ts +++ b/apps/extension/src/lib/recording/recording-runtime.ts @@ -171,6 +171,7 @@ export class RecordingObservationRuntime { startUrl: input.startUrl, stoppedBy: input.stoppedBy, bskVersion: input.bskVersion, + redactValues: this.#redactValues, }); } } diff --git a/apps/extension/src/lib/recording/state-registry.ts b/apps/extension/src/lib/recording/state-registry.ts index fe5f0ea..3fbdcc3 100644 --- a/apps/extension/src/lib/recording/state-registry.ts +++ b/apps/extension/src/lib/recording/state-registry.ts @@ -2,7 +2,8 @@ export interface RecordedStateEntry { id: string; url: string; title?: string; - rawVomText: string; + /** Rendered VOM text; capture indexes and source DOM/AX data never enter the registry. */ + vomText: string; truncated: boolean; stepsHere: number[]; } @@ -19,10 +20,10 @@ export class RecordingStateRegistry { register(input: { url: string; title?: string; - rawVomText: string; + vomText: string; truncated?: boolean; }): RecordedStateEntry { - const identity = stateIdentity(input.url, input.rawVomText); + const identity = stateIdentity(input.url, input.vomText); const existingId = this.#idByIdentity.get(identity); if (existingId) { const existing = this.#entriesById.get(existingId)!; @@ -35,7 +36,7 @@ export class RecordingStateRegistry { id: `s${this.#nextId}`, url: input.url, ...(input.title ? { title: input.title } : {}), - rawVomText: input.rawVomText, + vomText: input.vomText, truncated: input.truncated ?? false, stepsHere: [], }; diff --git a/apps/extension/src/lib/recording/trace-builder-v3.ts b/apps/extension/src/lib/recording/trace-builder-v3.ts index 5798c22..7fc1fee 100644 --- a/apps/extension/src/lib/recording/trace-builder-v3.ts +++ b/apps/extension/src/lib/recording/trace-builder-v3.ts @@ -39,8 +39,9 @@ export function buildTraceV3(input: { startUrl?: string; stoppedBy: StopReason; bskVersion: string; + redactValues?: boolean; }): TraceV3 { - const reduced = reduceTraceStepsV3(input.drafts); + const reduced = reduceTraceStepsV3(input.drafts, { redactValues: input.redactValues }); const entries = publishedEntries(input.registry, reduced.steps); const publishedId = new Map(entries.map((entry, index) => [entry.id, `s${index + 1}`])); const annotationsByState = new Map(); @@ -65,7 +66,7 @@ export function buildTraceV3(input: { url: entry.url, title: entry.title, stepIds: remapDraftIds(entry.stepsHere, reduced.stepIdByDraftId), - vomText: entry.rawVomText, + vomText: entry.vomText, annotations: annotationsByState.get(entry.id) ?? [], stepIdByDraftId: reduced.stepIdByDraftId, }), diff --git a/apps/extension/src/lib/recording/trace-reducer-v3.ts b/apps/extension/src/lib/recording/trace-reducer-v3.ts index 58b83e3..7ffe91f 100644 --- a/apps/extension/src/lib/recording/trace-reducer-v3.ts +++ b/apps/extension/src/lib/recording/trace-reducer-v3.ts @@ -58,7 +58,7 @@ function selection(values: string[], labels?: string[]): Array<{ value: string; })); } -function reduceDraft(draft: RecordingDraftStep, id: number): StepV3 | null { +function reduceDraft(draft: RecordingDraftStep, id: number, redactValues: boolean): StepV3 | null { if (!shouldIncludeDraft(draft)) return null; const state = draft.preStateId ?? draft.postStateId; const resultState = draft.postStateId ?? draft.preStateId; @@ -81,13 +81,14 @@ function reduceDraft(draft: RecordingDraftStep, id: number): StepV3 | null { target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget), }; case "fill": + const fillIsRedacted = redactValues || draft.redacted === true; return { op: "fill", ...common, target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget), - value: draft.value, + value: fillIsRedacted ? "***" : draft.value, commit: draft.commit ?? "blur", - ...(draft.redacted ? { redacted: true } : {}), + ...(fillIsRedacted ? { redacted: true } : {}), }; case "press": return { @@ -104,7 +105,7 @@ function reduceDraft(draft: RecordingDraftStep, id: number): StepV3 | null { op: "select", ...common, target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget), - selection: selection(draft.values, draft.labels), + ...(!redactValues ? { selection: selection(draft.values, draft.labels) } : {}), }; case "scroll": return { op: "scroll", ...common }; @@ -116,11 +117,14 @@ export interface ReducedTraceV3 { stepIdByDraftId: Map; } -export function reduceTraceStepsV3(steps: RecordingDraftStep[]): ReducedTraceV3 { +export function reduceTraceStepsV3( + steps: RecordingDraftStep[], + options: { redactValues?: boolean } = {}, +): ReducedTraceV3 { const output: StepV3[] = []; const stepIdByDraftId = new Map(); for (const { draft, draftIds } of collapseRedirects(steps)) { - const step = reduceDraft(draft, output.length + 1); + const step = reduceDraft(draft, output.length + 1, options.redactValues ?? false); if (!step) continue; output.push(step); for (const draftId of draftIds) stepIdByDraftId.set(draftId, step.id);