fix(record): safety problem and bug fix

This commit is contained in:
Ljy-0827
2026-08-23 21:26:40 +08:00
committed by GitHub
parent 17af1af65a
commit 8ea5477e11
8 changed files with 101 additions and 27 deletions
@@ -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 });
@@ -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");
});
});
@@ -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: [
@@ -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 = {
@@ -171,6 +171,7 @@ export class RecordingObservationRuntime {
startUrl: input.startUrl,
stoppedBy: input.stoppedBy,
bskVersion: input.bskVersion,
redactValues: this.#redactValues,
});
}
}
@@ -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: [],
};
@@ -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<string, StepAnnotation[]>();
@@ -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,
}),
@@ -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<number, number>;
}
export function reduceTraceStepsV3(steps: RecordingDraftStep[]): ReducedTraceV3 {
export function reduceTraceStepsV3(
steps: RecordingDraftStep[],
options: { redactValues?: boolean } = {},
): ReducedTraceV3 {
const output: StepV3[] = [];
const stepIdByDraftId = new Map<number, number>();
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);