From 709bb24e474b4a032e3c6e24b7d24581da4b24eb Mon Sep 17 00:00:00 2001
From: Petr Glaser <12586960+BleedingDev@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:34:13 +0200
Subject: [PATCH 1/6] test: reproduce open recovery capture review findings
Exercise environment-array exposure in page callbacks, future and nested iframe scopes, sensitive value getters, and existing fail-closed expectations through the real page worker. Use only local fixtures and synthetic environment values.
---
.github/workflows/ci.yml | 3 +
package.json | 2 +-
test/recovery-capture.smoke.mjs | 178 ++++++++++++++++++++++++++++++++
3 files changed, 182 insertions(+), 1 deletion(-)
create mode 100644 test/recovery-capture.smoke.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 68a9fac..1a3f706 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,3 +49,6 @@ jobs:
- name: Review regressions for retention and preflight
if: always() && !cancelled()
run: timeout 90s node test/review-followups.smoke.mjs
+ - name: Recovery capture isolation and late frames
+ if: always() && !cancelled()
+ run: timeout 120s node test/recovery-capture.smoke.mjs
diff --git a/package.json b/package.json
index 0f22e5a..b5efe18 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,7 @@
"test:python": "python3 -m unittest discover -s worker/tests -v",
"check": "npm run typecheck && npm test && npm run test:python",
"prepack": "npm run build",
- "test:browser": "node test/adapter.smoke.mjs && node test/workflows.smoke.mjs && node test/vision.smoke.mjs && node test/review-followups.smoke.mjs"
+ "test:browser": "node test/adapter.smoke.mjs && node test/workflows.smoke.mjs && node test/vision.smoke.mjs && node test/review-followups.smoke.mjs && node test/recovery-capture.smoke.mjs"
},
"dependencies": {
"@effect/platform-node": "4.0.0-rc.116",
diff --git a/test/recovery-capture.smoke.mjs b/test/recovery-capture.smoke.mjs
new file mode 100644
index 0000000..02110c4
--- /dev/null
+++ b/test/recovery-capture.smoke.mjs
@@ -0,0 +1,178 @@
+import assert from 'node:assert/strict';
+import * as fs from 'node:fs';
+import path from 'node:path';
+import { fixture } from './support/chrome.mjs';
+import { Cdp } from '../dist/cdp.js';
+import { createRun, atomic } from '../dist/journal.js';
+import { attachPage, pageJob, checkOne } from '../dist/page-worker.js';
+
+// All secrets and hostile pages in this suite are synthetic and local.
+const key = 'FE2E_CAPTURE_PRIVATE_INPUT', previous = process.env[key];
+const secret = 'capture-secret-not-for-other-documents';
+process.env[key] = secret;
+const f = await fixture(), raw = await Cdp.open(f.session);
+const passed = [], failures = [];
+const retentionError = error => error?.code === 'retention_forbidden';
+
+function assertNotRetained(journal, value = secret) {
+ for (const file of fs.readdirSync(journal.dir, { recursive: true })) {
+ const full = path.join(journal.dir, file);
+ if (fs.statSync(full).isFile()) assert.equal(fs.readFileSync(full, 'utf8').includes(value), false, file);
+ }
+}
+async function scenario(name, task, markup, check) {
+ const { targetId } = await raw.send('Target.createTarget', { url: 'about:blank' });
+ const connection = await attachPage(f.session, targetId);
+ try {
+ const page = connection.page;
+ page.setDefaultTimeout(5_000);
+ await page.goto(f.url, { waitUntil: 'load' });
+ const remoteUrl = await page.locator('#remote').getAttribute('src');
+ await page.setContent(typeof markup === 'function' ? markup(remoteUrl) : markup);
+ const input = { url: f.url, goal: name, ...task };
+ const { journal } = createRun(f.directory, input, false, f.profile);
+ journal.append('target', { targetId, browserId: f.session.browserId });
+ atomic(path.join(f.directory, 'targets', f.session.namespace, `${targetId}.json`), { targetId, browserId: f.session.browserId });
+ const invoke = (operation = 'capture', extra = {}) => pageJob({
+ root: f.directory, runId: journal.runId, session: f.session, targetId,
+ operation, steps: input.steps, timeoutMs: 20_000, ...extra,
+ }, new AbortController().signal);
+ await check({ page, journal, invoke });
+ passed.push(name);
+ } catch (error) {
+ failures.push(name);
+ console.error(`${name}: ${error.stack ?? error}`);
+ } finally {
+ await connection.browser.close();
+ await raw.send('Target.closeTarget', { targetId });
+ }
+}
+async function watchEvaluationArrays(page) {
+ await page.evaluate(() => {
+ globalThis.evaluatedStrings = [];
+ const original = Array.prototype.includes;
+ Array.prototype.includes = function (...args) {
+ for (let i = 0; i < this.length; i++) if (typeof this[i] === 'string') globalThis.evaluatedStrings.push(this[i]);
+ return Reflect.apply(original, this, args);
+ };
+ });
+}
+const futurePage = remoteUrl => `
Before frame
+ `;
+try {
+ for (const scope of ['same document', 'other origin']) {
+ await scenario(`No environment values enter recovery evaluations: ${scope}`, {
+ steps: [{ kind: 'fill', selector: '#token', valueFromEnv: key, ...(scope === 'other origin' ? { frames: ['#remote'] } : {}) }],
+ recovery: { fields: [{ key: 'public', selector: '#public' }] },
+ }, remote => ``, async ({ page, journal, invoke }) => {
+ await watchEvaluationArrays(page);
+ await invoke();
+ assert.equal((await page.evaluate(() => globalThis.evaluatedStrings)).includes(secret), false, 'Unrelated page learned an environment secret');
+ assert.equal(journal.recoveryFields().public.value, 'public value');
+ assertNotRetained(journal);
+ });
+ }
+ await scenario('Host-side comparison rejects a reflected environment value', {
+ steps: [{ kind: 'fill', selector: '#frameName', frames: ['#remote'], valueFromEnv: key }],
+ recovery: { fields: [{ key: 'public', selector: '#public' }] },
+ }, remote => ``, async ({ page, journal, invoke }) => {
+ // Simulate a value the site already knows. It must not be persisted as public input.
+ await page.locator('#public').fill(secret);
+ await watchEvaluationArrays(page);
+ await assert.rejects(() => invoke(), retentionError);
+ assert.equal((await page.evaluate(() => globalThis.evaluatedStrings)).includes(secret), false);
+ assert.deepEqual(journal.recoveryFields(), {});
+ assertNotRetained(journal);
+ });
+
+ for (const retain of [false, true]) for (const nested of [false, true]) {
+ const frames = nested ? ['#late', '#nested'] : ['#late'];
+ const selector = nested ? '#nestedName' : '#frameName';
+ await scenario(`Future ${nested ? 'nested ' : ''}frame does not block its creation; recovery=${retain}`, {
+ steps: [{ kind: 'click', selector: '#reveal' }, { kind: 'fill', selector, frames, valueFromEnv: key }],
+ ...(retain ? { recovery: { fields: [{ key: 'public', selector: '#public' }] } } : {}),
+ }, futurePage, async ({ page, journal, invoke }) => {
+ await invoke('steps');
+ let scope = page;
+ for (const frame of frames) scope = scope.frameLocator(frame);
+ assert.equal(await scope.locator(selector).inputValue(), secret);
+ assert.equal(journal.events().filter(e => e.type === 'action.start').length, 2);
+ if (retain) assert.equal(journal.recoveryFields().public.value, 'keep me');
+ assertNotRetained(journal);
+ });
+ }
+ for (const reconstruct of [false, true]) {
+ const steps = [{ kind: 'click', selector: '#reveal' }, { kind: 'fill', selector: '#frameName', frames: ['iframe#late'], valueFromEnv: key }];
+ await scenario(`Late-frame aliases are rejected before secret dispatch; reconstruct=${reconstruct}`, {
+ steps: reconstruct ? [] : steps,
+ recovery: { fields: [{ key: 'private', selector: 'input#frameName', frames: ['#late'] }], ...(reconstruct ? { reconstruct: steps } : {}) },
+ }, futurePage, async ({ journal, invoke }) => {
+ await assert.rejects(() => invoke('steps', { steps, ...(reconstruct ? { saveProgress: false } : {}) }), retentionError);
+ assert.equal(journal.events().some(e => e.type === 'action.start' && e.data.kind === 'click'), true, 'Frame creation should be allowed');
+ assert.equal(journal.events().some(e => e.type === 'action.start' && e.data.kind === 'fill'), false);
+ assert.deepEqual(journal.recoveryFields(), {});
+ assertNotRetained(journal);
+ });
+ }
+ await scenario('A future reconstruction-only scope does not block navigation', {
+ steps: [{ kind: 'click', selector: '#reveal' }],
+ recovery: { fields: [{ key: 'public', selector: '#public' }], reconstruct: [{ kind: 'fill', selector: '#frameName', frames: ['#late'], valueFromEnv: key }] },
+ }, futurePage, async ({ journal, invoke }) => {
+ await invoke('steps');
+ assert.equal(journal.recoveryFields().public.value, 'keep me');
+ assertNotRetained(journal);
+ });
+
+ for (const type of ['password', 'file', 'otp']) for (const protectedTarget of [false, true]) {
+ const getterSecret = `synthetic-${type}-getter-value`;
+ await scenario(`Sensitive ${type} getter is never invoked; protected locator=${protectedTarget}`, {
+ steps: protectedTarget ? [{ kind: 'fill', selector: '#other', valueFromEnv: key }] : [],
+ recovery: { fields: [{ key: 'private', selector: '#private' }] },
+ }, ``, async ({ page, journal, invoke }) => {
+ await page.evaluate(value => {
+ globalThis.valueReads = 0;
+ const node = document.querySelector('#private');
+ Object.defineProperty(node, 'value', { configurable: true, get() {
+ globalThis.valueReads++; node.removeAttribute('type'); node.removeAttribute('autocomplete'); return value;
+ } });
+ }, getterSecret);
+ let error;
+ try { await invoke(); } catch (caught) { error = caught; }
+ assert.equal(await page.evaluate(() => globalThis.valueReads), 0, 'Classification must happen before the value getter');
+ assert.equal(error?.code, 'retention_forbidden');
+ assert.deepEqual(journal.recoveryFields(), {});
+ assertNotRetained(journal, getterSecret);
+ });
+ }
+ await scenario('An environment-protected getter is not read', {
+ steps: [{ kind: 'fill', selector: 'input#token', valueFromEnv: key }],
+ recovery: { fields: [{ key: 'private', selector: '#token' }] },
+ }, '', async ({ page, journal, invoke }) => {
+ await page.evaluate(() => { globalThis.valueReads = 0; Object.defineProperty(document.querySelector('#token'), 'value', { get() { globalThis.valueReads++; return 'must-not-read'; } }); });
+ await assert.rejects(() => invoke(), retentionError);
+ assert.equal(await page.evaluate(() => globalThis.valueReads), 0);
+ assert.deepEqual(journal.recoveryFields(), {});
+ });
+
+ for (const [label, frames, markup] of [
+ ['ambiguous', ['.ambiguous'], ''],
+ ['non-frame', ['#notAFrame'], ''],
+ ['invalid selector', ['['], ''],
+ ]) {
+ await scenario(`Invalid protected scopes still fail closed: ${label}`, {
+ steps: [{ kind: 'fill', selector: '#token', frames, valueFromEnv: key }],
+ recovery: { fields: [{ key: 'public', selector: '#public' }] },
+ }, markup, async ({ journal, invoke }) => {
+ await assert.rejects(() => invoke());
+ assert.deepEqual(journal.recoveryFields(), {});
+ });
+ }
+ await scenario('Required assertion scopes remain strict', { steps: [] }, 'No iframe
', async ({ page }) => {
+ await assert.rejects(() => checkOne(page, { kind: 'count', frames: ['#missing'], selector: 'input', value: '0' }), error => error.code === 'unsupported_scope');
+ });
+} finally {
+ await raw.close(); await f.cleanup();
+ if (previous === undefined) delete process.env[key]; else process.env[key] = previous;
+}
+console.log(JSON.stringify({ suite: 'recovery capture review regressions', passed, failures, paidModelCalls: 0 }));
+assert.equal(failures.length, 0, `${failures.length} recovery-capture regressions failed`);
From ea20aa7a796ccf5b82e3c79ef8b2f709a78a13dd Mon Sep 17 00:00:00 2001
From: Petr Glaser <12586960+BleedingDev@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:38:22 +0200
Subject: [PATCH 2/6] fix: isolate recovery capture secrets and handle future
frame scopes
Keep environment-value comparisons in Node instead of passing secrets into page-realm evaluations. Reject protected identities and sensitive controls before reading value getters, retaining Playwright's atomic shadow-aware protection.
Resolve only currently present recovery/protected frame scopes without waits; preserve strict malformed/ambiguous/action/check handling. Skip capture entirely without recovery fields, and re-evaluate protection when later frames appear.
---
src/page-worker.ts | 98 ++++++++++++++++++++++++++--------------------
1 file changed, 55 insertions(+), 43 deletions(-)
diff --git a/src/page-worker.ts b/src/page-worker.ts
index 49955b9..fb4cfe6 100644
--- a/src/page-worker.ts
+++ b/src/page-worker.ts
@@ -75,36 +75,61 @@ export async function pollChecks(page: Page, checks: readonly Check[], timeoutMs
await new Promise(resolve => setTimeout(resolve, 100));
}
}
+// Recovery is a passive snapshot: a later step's frame may not exist yet.
+// Pin each existing frame without waiting or relaxing required action/check scopes.
+async function captureScope(page: Page, s: Scoped): Promise<{ frame: Frame; locator: Locator } | undefined> {
+ if (s.shadow === "closed") throw new BrowserError({ code: "unsupported_scope", reason: "Closed shadow-root DOM access is unsupported. Use explicitly requested visual evidence." });
+ let frame = page.mainFrame();
+ for (const selector of s.frames ?? []) {
+ const nodes = await frame.locator(css(selector, s.shadow)).elementHandles();
+ try {
+ if (nodes.length === 0) return undefined;
+ if (nodes.length !== 1 || !(await nodes[0]!.evaluate(e => /^(IFRAME|FRAME)$/.test(e.tagName)))) {
+ throw new BrowserError({ code: "unsupported_scope", reason: "The frame path did not identify exactly one frame. Recovery capture cannot establish its scope." });
+ }
+ const child = await nodes[0]!.contentFrame();
+ if (!child) return undefined;
+ frame = child;
+ } finally { await Promise.allSettled(nodes.map(node => node.dispose())); }
+ }
+ return { frame, locator: frame.locator(css(s.selector ?? "body", s.shadow)) };
+}
async function captureFields(page: Page, journal: Journal, save = true): Promise {
const task = journal.meta().task;
const fields = task.recovery?.fields ?? [];
+ if (fields.length === 0) return;
const protectedSteps = [...(task.steps ?? []), ...(task.recovery?.reconstruct ?? [])]
.filter(s => s.valueFromEnv !== undefined && s.selector !== undefined);
+ // These values never enter browser evaluation arguments. A page may redefine
+ // its prototypes; equality against environment secrets belongs in this process.
+ const protectedValues = new Set(protectedSteps
+ .map(step => process.env[step.valueFromEnv!])
+ .filter((value): value is string => value !== undefined && value.length > 0));
const handles: ElementHandle[] = [];
try {
- const protectedTargets: { locator: Locator; frame: Frame | null }[] = [];
- for (const step of protectedSteps) {
- const { root, locator } = await scoped(page, step);
- const documentElement = await root.locator("html").elementHandle();
- if (!documentElement) continue;
- handles.push(documentElement);
- protectedTargets.push({ locator, frame: await documentElement.ownerFrame() });
- }
- const protectedValues = protectedSteps
- .map(step => process.env[step.valueFromEnv!])
- .filter((value): value is string => value !== undefined && value.length > 0);
for (const f of fields) {
- const { locator } = await scoped(page, f);
- const nodes = await locator.elementHandles();
+ const scope = await captureScope(page, f);
+ if (!scope) continue;
+ const nodes = await scope.locator.elementHandles();
handles.push(...nodes);
if (nodes.length !== 1) continue;
const node = nodes[0]!;
const frame = await node.ownerFrame();
- const current = protectedTargets.filter(p => p.frame === frame).map(p => p.locator);
+ if (!frame) continue;
+ const current: Locator[] = [];
+ // Resolve protection after pinning the candidate. Missing future frames
+ // are skipped, but malformed or ambiguous scopes still fail closed.
+ for (const step of protectedSteps) {
+ const protectedScope = await captureScope(page, step);
+ if (protectedScope?.frame === frame) current.push(protectedScope.locator);
+ }
if (!save) {
for (const protectedLocator of current) {
- const overlaps = await protectedLocator.evaluateAll((elements, target) => elements.includes(target as HTMLElement | SVGElement), node);
+ const overlaps = await protectedLocator.evaluateAll((elements, target) => {
+ for (let i = 0; i < elements.length; i++) if (elements[i] === target) return true;
+ return false;
+ }, node);
if (overlaps) {
throw new BrowserError({ code: "retention_forbidden", reason: "An environment-backed control overlaps the recovery allowlist. Remove it and use its environment reference for reconstruction." });
}
@@ -112,39 +137,26 @@ async function captureFields(page: Page, journal: Journal, save = true): Promise
continue;
}
- // Resolve every protected selector with Playwright's current, shadow-aware
- // locator semantics, compare against the already-pinned recovery node,
- // and read that same node in the very same browser evaluation.
+ // Resolve protected selectors with Playwright's shadow-aware semantics,
+ // reject identity/sensitivity, then read that exact pinned node atomically.
let protectedLocator = current[0];
for (const next of current.slice(1)) protectedLocator = protectedLocator!.or(next);
- const result = protectedLocator
- ? await protectedLocator.evaluateAll((elements, input) => {
- const element = input.target as unknown as Element;
- const hasValue = "value" in element;
- const rawValue = hasValue ? String((element as HTMLInputElement).value) : "";
- if (elements.includes(element as HTMLElement | SVGElement) || input.values.includes(rawValue)) return { actual: "", protected: true };
+ const result: { actual: string; protected?: boolean; error?: string; available?: boolean } = protectedLocator
+ ? await protectedLocator.evaluateAll((elements, target) => {
+ const element = target as unknown as Element;
+ for (let i = 0; i < elements.length; i++) if (elements[i] === element) return { actual: "", protected: true };
+ // A page-controlled value getter can change these attributes. Check
+ // them before any value access, including environment comparisons.
const sensitive = element.matches('input[type=password], input[type=file], [autocomplete="one-time-code"]');
+ if (sensitive) return { actual: "", error: "Sensitive control values cannot be collected." };
+ if (!("value" in element)) return { actual: "", error: "Target is not a value control." };
const rect = element.getBoundingClientRect();
const visible = !!rect.width && !!rect.height && element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
if (!visible) return { actual: "", available: false };
- if (sensitive) return { actual: "", error: "Sensitive control values cannot be collected." };
- if (!hasValue) return { actual: "", error: "Target is not a value control." };
- return { actual: rawValue };
- }, { target: node, values: protectedValues })
- : await node.evaluate((element, values) => {
- const hasValue = "value" in element;
- const rawValue = hasValue ? String((element as HTMLInputElement).value) : "";
- if (values.includes(rawValue)) return { actual: "", protected: true };
- const control = element as Element;
- const sensitive = control.matches('input[type=password], input[type=file], [autocomplete="one-time-code"]');
- const rect = control.getBoundingClientRect();
- const visible = !!rect.width && !!rect.height && control.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
- if (!visible) return { actual: "", available: false };
- if (sensitive) return { actual: "", error: "Sensitive control values cannot be collected." };
- if (!hasValue) return { actual: "", error: "Target is not a value control." };
- return { actual: rawValue };
- }, protectedValues);
- if (result.protected) {
+ return { actual: String((element as HTMLInputElement).value) };
+ }, node)
+ : await node.evaluate(readElement, { kind: "value" });
+ if (result.protected || protectedValues.has(result.actual)) {
throw new BrowserError({ code: "retention_forbidden", reason: "An environment-backed control overlaps the recovery allowlist. Remove it and use its environment reference for reconstruction." });
}
if (result.error) throw new BrowserError({ code: "retention_forbidden", reason: "A recovery allowlist includes a sensitive or unsupported control. Remove it; no value was saved." });
@@ -416,4 +428,4 @@ async function main(): Promise {
catch (e) { process.stdout.write(`${JSON.stringify({ ok: false, error: { code: controller.signal.aborted ? "cancelled" : e instanceof BrowserError ? e.code : "adapter", reason: e instanceof BrowserError ? e.reason : "Browser adapter could not finish. Inspect the run before any retry." } })}\n`); }
finally { clearTimeout(timer); }
}
-if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) void main().catch(() => { process.exitCode = 2; });
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) void main().catch(() => { process.exitCode = 2; });
\ No newline at end of file
From 5f1a0f4b46adfb450bb42b7376ba1341a3ce3edd Mon Sep 17 00:00:00 2001
From: Petr Glaser <12586960+BleedingDev@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:46:01 +0200
Subject: [PATCH 3/6] fix: narrow locator element handles at the recovery
boundary
---
src/page-worker.ts | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/page-worker.ts b/src/page-worker.ts
index fb4cfe6..e76a12a 100644
--- a/src/page-worker.ts
+++ b/src/page-worker.ts
@@ -84,7 +84,7 @@ async function captureScope(page: Page, s: Scoped): Promise<{ frame: Frame; loca
const nodes = await frame.locator(css(selector, s.shadow)).elementHandles();
try {
if (nodes.length === 0) return undefined;
- if (nodes.length !== 1 || !(await nodes[0]!.evaluate(e => /^(IFRAME|FRAME)$/.test(e.tagName)))) {
+ if (nodes.length !== 1 || !(await nodes[0]!.evaluate(e => e.nodeType === 1 && /^(IFRAME|FRAME)$/.test((e as Element).tagName)))) {
throw new BrowserError({ code: "unsupported_scope", reason: "The frame path did not identify exactly one frame. Recovery capture cannot establish its scope." });
}
const child = await nodes[0]!.contentFrame();
@@ -113,7 +113,8 @@ async function captureFields(page: Page, journal: Journal, save = true): Promise
const nodes = await scope.locator.elementHandles();
handles.push(...nodes);
if (nodes.length !== 1) continue;
- const node = nodes[0]!;
+ // Locator matches are Elements; Playwright declares their handles as Node.
+ const node = nodes[0]! as ElementHandle;
const frame = await node.ownerFrame();
if (!frame) continue;
const current: Locator[] = [];
@@ -428,4 +429,4 @@ async function main(): Promise {
catch (e) { process.stdout.write(`${JSON.stringify({ ok: false, error: { code: controller.signal.aborted ? "cancelled" : e instanceof BrowserError ? e.code : "adapter", reason: e instanceof BrowserError ? e.reason : "Browser adapter could not finish. Inspect the run before any retry." } })}\n`); }
finally { clearTimeout(timer); }
}
-if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) void main().catch(() => { process.exitCode = 2; });
\ No newline at end of file
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) void main().catch(() => { process.exitCode = 2; });
From 50889f2bb8bb872caca8d54a4c497375efe4d826 Mon Sep 17 00:00:00 2001
From: Petr Glaser <12586960+BleedingDev@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:56:48 +0200
Subject: [PATCH 4/6] perf: share protected frame traversal within each
recovery snapshot
---
src/page-worker.ts | 31 +++++++++++++++++++++----------
1 file changed, 21 insertions(+), 10 deletions(-)
diff --git a/src/page-worker.ts b/src/page-worker.ts
index e76a12a..ca77c95 100644
--- a/src/page-worker.ts
+++ b/src/page-worker.ts
@@ -57,7 +57,6 @@ export async function checkOne(page: Page, c: Check): Promise {
else {
const count = await locator.count();
if (c.kind === "count") actual = String(count);
- else if (c.kind === "visible" && count === 0) actual = "false";
else if (count !== 1) { actual = `<${count} matching elements>`; available = false; }
else {
const value = await locator.evaluate(readElement, c);
@@ -107,8 +106,9 @@ async function captureFields(page: Page, journal: Journal, save = true): Promise
.filter((value): value is string => value !== undefined && value.length > 0));
const handles: ElementHandle[] = [];
try {
- for (const f of fields) {
- const scope = await captureScope(page, f);
+ const candidates: Array<{ field: (typeof fields)[number]; node: ElementHandle; frame: Frame }> = [];
+ for (const field of fields) {
+ const scope = await captureScope(page, field);
if (!scope) continue;
const nodes = await scope.locator.elementHandles();
handles.push(...nodes);
@@ -116,15 +116,26 @@ async function captureFields(page: Page, journal: Journal, save = true): Promise
// Locator matches are Elements; Playwright declares their handles as Node.
const node = nodes[0]! as ElementHandle;
const frame = await node.ownerFrame();
+ if (frame) candidates.push({ field, node, frame });
+ }
+ if (candidates.length === 0) return;
+
+ // Pin recovery nodes first, then resolve each protected frame path once.
+ // Cache only this snapshot's scopes, never protected node identities/values.
+ const protectedFrames = new Map();
+ const protectedLocators = new Map();
+ for (const step of protectedSteps) {
+ const key = JSON.stringify([step.frames ?? [], step.shadow ?? "none"]);
+ if (!protectedFrames.has(key)) protectedFrames.set(key, (await captureScope(page, step))?.frame);
+ const frame = protectedFrames.get(key);
if (!frame) continue;
- const current: Locator[] = [];
- // Resolve protection after pinning the candidate. Missing future frames
- // are skipped, but malformed or ambiguous scopes still fail closed.
- for (const step of protectedSteps) {
- const protectedScope = await captureScope(page, step);
- if (protectedScope?.frame === frame) current.push(protectedScope.locator);
- }
+ const locators = protectedLocators.get(frame) ?? [];
+ locators.push(frame.locator(css(step.selector!, step.shadow)));
+ protectedLocators.set(frame, locators);
+ }
+ for (const { field: f, node, frame } of candidates) {
+ const current = protectedLocators.get(frame) ?? [];
if (!save) {
for (const protectedLocator of current) {
const overlaps = await protectedLocator.evaluateAll((elements, target) => {
From b3cc23ed4eee84d31f395d1c26edf5fb133a8700 Mon Sep 17 00:00:00 2001
From: Petr Glaser <12586960+BleedingDev@users.noreply.github.com>
Date: Tue, 22 Sep 2026 16:01:40 +0200
Subject: [PATCH 5/6] test: bound recovery frame lookups and retain
absent-control semantics
Assert that eight recovery fields and twenty protected steps over two frame paths require only three path lookups per snapshot, and that the next snapshot resolves afresh. Keep the legacy visible=false result for an absent top-level control and cover it alongside strict missing-frame checks.
---
src/page-worker.ts | 1 +
test/recovery-capture.smoke.mjs | 26 ++++++++++++++++++++++++++
2 files changed, 27 insertions(+)
diff --git a/src/page-worker.ts b/src/page-worker.ts
index ca77c95..ea080e4 100644
--- a/src/page-worker.ts
+++ b/src/page-worker.ts
@@ -57,6 +57,7 @@ export async function checkOne(page: Page, c: Check): Promise {
else {
const count = await locator.count();
if (c.kind === "count") actual = String(count);
+ else if (c.kind === "visible" && count === 0) actual = "false";
else if (count !== 1) { actual = `<${count} matching elements>`; available = false; }
else {
const value = await locator.evaluate(readElement, c);
diff --git a/test/recovery-capture.smoke.mjs b/test/recovery-capture.smoke.mjs
index 02110c4..eb3380d 100644
--- a/test/recovery-capture.smoke.mjs
+++ b/test/recovery-capture.smoke.mjs
@@ -169,6 +169,32 @@ try {
}
await scenario('Required assertion scopes remain strict', { steps: [] }, 'No iframe
', async ({ page }) => {
await assert.rejects(() => checkOne(page, { kind: 'count', frames: ['#missing'], selector: 'input', value: '0' }), error => error.code === 'unsupported_scope');
+ assert.equal((await checkOne(page, { kind: 'visible', selector: '#missing', value: 'false' })).passed, true);
+ });
+
+ const publicFields = Array.from({ length: 8 }, (_, i) => ({ key: `public${i}`, selector: `#public${i}` }));
+ await scenario('Protected frame traversal is shared within, not across, snapshots', {
+ steps: Array.from({ length: 20 }, (_, i) => ({ kind: 'fill', valueFromEnv: key,
+ selector: i % 2 ? '#nestedName' : '#frameName', frames: i % 2 ? ['#remote', '#nested'] : ['#remote'] })),
+ recovery: { fields: publicFields },
+ }, remote => publicFields.map((_, i) => ``).join('') + ``, async ({ page, journal, invoke }) => {
+ // Count real frame-path lookups, not timing: added fields/duplicate steps
+ // must not multiply browser round trips. The second snapshot must be fresh.
+ const prototype = Object.getPrototypeOf(page.mainFrame()), original = prototype.locator;
+ let queries = 0;
+ prototype.locator = function (selector, ...options) {
+ if (selector === 'css:light=#remote' || selector === 'css:light=#nested') queries++;
+ return Reflect.apply(original, this, [selector, ...options]);
+ };
+ try {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ queries = 0;
+ await invoke();
+ assert.equal(queries, 3, 'Resolve the two distinct paths once each per snapshot');
+ assert.equal(Object.keys(journal.recoveryFields()).length, 8);
+ }
+ } finally { prototype.locator = original; }
+ assertNotRetained(journal);
});
} finally {
await raw.close(); await f.cleanup();
From 1438b9c9d9a8b97e73aec8782867597703135875 Mon Sep 17 00:00:00 2001
From: Petr Glaser <12586960+BleedingDev@users.noreply.github.com>
Date: Tue, 22 Sep 2026 16:23:54 +0200
Subject: [PATCH 6/6] fix: carry recovery sensitivity across environment
dispatch
Persist a recovery-value capture suspension before an environment-backed UI dispatch. Current selectors and exact value equality cannot prove a transformed or rerendered credential became public. Preserve prior snapshots and explicit non-secret input intents, while preventing later automatic observations from replacing them.
Honor the same durable boundary in managed Jev, including across reattachment, reconstruction, missing environment values and legacy Playwright receipts without provenance. Keep protected-alias preflight checks active in the scoped adapter.
Add eight real-Chrome formatter/mirror/reconstruction tests, cross-language handoff assertions, and four Python policy tests. No new dependencies or documentation. Addresses PR #4 comment 4072551621.
---
.github/workflows/ci.yml | 3 +
package.json | 2 +-
src/page-worker.ts | 26 +++++-
test/environment-retention.smoke.mjs | 117 +++++++++++++++++++++++++++
worker/jev_runner.py | 16 +++-
worker/tests/test_jev_retention.py | 66 +++++++++++++++
6 files changed, 225 insertions(+), 5 deletions(-)
create mode 100644 test/environment-retention.smoke.mjs
create mode 100644 worker/tests/test_jev_retention.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1a3f706..544ac54 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,3 +52,6 @@ jobs:
- name: Recovery capture isolation and late frames
if: always() && !cancelled()
run: timeout 120s node test/recovery-capture.smoke.mjs
+ - name: Environment retention across dispatch and handoff
+ if: always() && !cancelled()
+ run: timeout 90s node test/environment-retention.smoke.mjs
diff --git a/package.json b/package.json
index b5efe18..3107f04 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,7 @@
"test:python": "python3 -m unittest discover -s worker/tests -v",
"check": "npm run typecheck && npm test && npm run test:python",
"prepack": "npm run build",
- "test:browser": "node test/adapter.smoke.mjs && node test/workflows.smoke.mjs && node test/vision.smoke.mjs && node test/review-followups.smoke.mjs && node test/recovery-capture.smoke.mjs"
+ "test:browser": "node test/adapter.smoke.mjs && node test/workflows.smoke.mjs && node test/vision.smoke.mjs && node test/review-followups.smoke.mjs && node test/recovery-capture.smoke.mjs && node test/environment-retention.smoke.mjs"
},
"dependencies": {
"@effect/platform-node": "4.0.0-rc.116",
diff --git a/src/page-worker.ts b/src/page-worker.ts
index ea080e4..e7020c3 100644
--- a/src/page-worker.ts
+++ b/src/page-worker.ts
@@ -94,12 +94,28 @@ async function captureScope(page: Page, s: Scoped): Promise<{ frame: Frame; loca
}
return { frame, locator: frame.locator(css(s.selector ?? "body", s.shadow)) };
}
+function suspendRecoveryCapture(journal: Journal): void {
+ if (!journal.events().some(e => e.type === "recovery.capture_suspended")) {
+ journal.append("recovery.capture_suspended", {
+ reason: "Environment-backed input may be moved or transformed by the page. Automatic recovery-value capture is disabled for this run; prior snapshots and explicit non-secret input intents remain available.",
+ });
+ }
+}
async function captureFields(page: Page, journal: Journal, save = true): Promise {
const task = journal.meta().task;
const fields = task.recovery?.fields ?? [];
if (fields.length === 0) return;
- const protectedSteps = [...(task.steps ?? []), ...(task.recovery?.reconstruct ?? [])]
- .filter(s => s.valueFromEnv !== undefined && s.selector !== undefined);
+ const steps = [...(task.steps ?? []), ...(task.recovery?.reconstruct ?? [])];
+ const events = journal.events();
+ const suspended = events.some(e => e.type === "recovery.capture_suspended");
+ // Old receipts cannot prove which environment-input boundary was crossed.
+ const legacy = steps.some(s => s.valueFromEnv !== undefined) && events.some(e =>
+ e.type === "action.start" && e.data.engine === "playwright" && e.data.recoveryGuarded !== true);
+ if (suspended || legacy) {
+ if (!suspended) suspendRecoveryCapture(journal);
+ save = false; // Still reject known protected aliases, but never read new page values.
+ }
+ const protectedSteps = steps.filter(s => s.valueFromEnv !== undefined && s.selector !== undefined);
// These values never enter browser evaluation arguments. A page may redefine
// its prototypes; equality against environment secrets belongs in this process.
const protectedValues = new Set(protectedSteps
@@ -224,8 +240,12 @@ async function runSteps(page: Page, journal: Journal, job: PageJob, snap: () =>
let actionId: string | undefined;
if (s.kind !== "checkpoint") {
if (remaining(journal.state()).actions <= 0) throw new BrowserError({ code: "budget_exhausted", reason: "Action budget exhausted." });
+ // Selectors, node identities and exact-value matching cannot follow an
+ // arbitrary formatter/rerender. Persist this boundary before dispatch so
+ // resume, reconstruction and a Jev/Midscene handoff cannot forget it.
+ if (s.valueFromEnv) suspendRecoveryCapture(journal);
actionId = randomUUID();
- journal.append("action.start", { id: actionId, kind: s.kind, safeToRepeat: s.safeToRepeat === true, step: index, selector: s.selector, frames: s.frames, engine: "playwright" });
+ journal.append("action.start", { id: actionId, kind: s.kind, safeToRepeat: s.safeToRepeat === true, step: index, selector: s.selector, frames: s.frames, engine: "playwright", recoveryGuarded: true });
}
switch (s.kind) {
case "navigate": {
diff --git a/test/environment-retention.smoke.mjs b/test/environment-retention.smoke.mjs
new file mode 100644
index 0000000..1627041
--- /dev/null
+++ b/test/environment-retention.smoke.mjs
@@ -0,0 +1,117 @@
+import assert from 'node:assert/strict';
+import * as fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+import { fixture } from './support/chrome.mjs';
+import { Cdp } from '../dist/cdp.js';
+import { createRun, atomic } from '../dist/journal.js';
+import { attachPage, pageJob } from '../dist/page-worker.js';
+
+const key = 'FE2E_RERENDER_SECRET', secret = 'synthetic-private-123';
+const formatted = [...secret].join(' '), previous = process.env[key];
+process.env[key] = secret;
+const f = await fixture(), raw = await Cdp.open(f.session), passed = [];
+const worker = fileURLToPath(new URL('../worker', import.meta.url));
+function assertPrivate(journal) {
+ for (const entry of fs.readdirSync(journal.dir, { recursive: true })) {
+ const file = path.join(journal.dir, entry);
+ if (fs.statSync(file).isFile()) {
+ const text = fs.readFileSync(file, 'utf8');
+ assert.equal(text.includes(secret), false, entry);
+ assert.equal(text.includes(formatted), false, entry);
+ }
+ }
+}
+function checkJevHandoff(journal) {
+ // A fresh Python adapter must honor the durable Node suspension even without
+ // the original environment. Any capture evaluation would fail this test.
+ const env = { ...process.env }; delete env[key];
+ execFileSync('python3', ['-c', `import sys
+sys.path.insert(0, sys.argv[1])
+from jev_runner import Journal, capture
+class Page:
+ def evaluate(self, expression):
+ raise AssertionError('Suspended Jev recovery must not evaluate the page')
+capture(Page(), Journal(sys.argv[2], sys.argv[3]))
+`, worker, f.directory, journal.runId], { env, timeout: 10_000 });
+}
+async function scenario(name, variant, reconstruct, check) {
+ const { targetId } = await raw.send('Target.createTarget', { url: 'about:blank' });
+ const connection = await attachPage(f.session, targetId);
+ try {
+ const page = connection.page;
+ await page.goto(f.url, { waitUntil: 'load' });
+ await page.setContent(`
+ `);
+ const step = { kind: 'fill', selector: '#token', valueFromEnv: key };
+ const task = { url: f.url, goal: name, steps: [step], recovery: {
+ fields: [{ key: 'public', selector: '.active-input' }, { key: 'safe', selector: '#safe' }],
+ ...(reconstruct ? { reconstruct: [{ kind: 'navigate', value: f.url, safeToRepeat: true }, { ...step, safeToRepeat: true }] } : {}),
+ } };
+ const { journal } = createRun(f.directory, task, false, f.profile);
+ journal.append('target', { targetId, browserId: f.session.browserId });
+ atomic(path.join(f.directory, 'targets', f.session.namespace, `${targetId}.json`), { targetId, browserId: f.session.browserId });
+ const invoke = (operation = 'capture', extra = {}) => pageJob({
+ root: f.directory, runId: journal.runId, session: f.session, targetId,
+ operation, steps: [step], timeoutMs: 20_000, ...extra,
+ }, new AbortController().signal);
+ await invoke();
+ await check({ page, journal, step, invoke });
+ assertPrivate(journal); checkJevHandoff(journal);
+ passed.push(name);
+ } finally {
+ await connection.browser.close(); await raw.send('Target.closeTarget', { targetId });
+ }
+}
+try {
+ for (const variant of ['retag', 'replace', 'mirror']) for (const reconstruct of [false, true]) {
+ await scenario(`Formatted ${variant} value is not retained; reconstruct=${reconstruct}`, variant, reconstruct,
+ async ({ page, journal, invoke }) => {
+ await invoke('steps', reconstruct ? { saveProgress: false } : {});
+ assert.equal(await page.locator('.active-input').inputValue(), formatted);
+ const retained = journal.recoveryFields();
+ assert.equal(retained.public.value, 'safe-before');
+ assert.equal(retained.public.source, 'observed');
+ const events = journal.events();
+ const boundary = events.find(e => e.type === 'recovery.capture_suspended');
+ assert.ok(boundary);
+ assert.ok(boundary.seq < events.find(e => e.type === 'action.start').seq);
+ // Reattachment, a later capture and loss of the env value cannot undo suspension.
+ delete process.env[key];
+ try { await invoke(); await invoke(); } finally { process.env[key] = secret; }
+ assert.deepEqual(journal.recoveryFields(), retained);
+ assert.equal(journal.events().filter(e => e.type === 'recovery.capture_suspended').length, 1);
+ });
+ }
+ await scenario('Explicit public inputs remain recoverable after secret dispatch', 'mirror', false,
+ async ({ journal, step, invoke }) => {
+ await invoke('steps', { steps: [step, { kind: 'fill', selector: '#safe', value: 'user-provided-public' }] });
+ assert.equal(journal.recoveryFields().safe.value, 'user-provided-public');
+ assert.equal(journal.recoveryFields().safe.source, 'intent');
+ assert.equal(journal.recoveryFields().public.value, 'safe-before');
+ });
+ await scenario('Legacy dispatch without provenance suspends capture on resume', 'mirror', false,
+ async ({ page, journal, invoke }) => {
+ journal.append('action.start', { id: 'legacy', kind: 'fill', selector: '#token', step: 0, engine: 'playwright' });
+ await page.locator('#token').fill(secret);
+ journal.append('action.end', { id: 'legacy' });
+ delete process.env[key];
+ try { await invoke(); } finally { process.env[key] = secret; }
+ assert.equal(journal.recoveryFields().public.value, 'safe-before');
+ assert.ok(journal.events().some(e => e.type === 'recovery.capture_suspended'));
+ });
+} finally {
+ await raw.close(); await f.cleanup();
+ if (previous === undefined) delete process.env[key]; else process.env[key] = previous;
+}
+console.log(JSON.stringify({ suite: 'environment retention across dispatch and handoff', passed, paidModelCalls: 0 }));
diff --git a/worker/jev_runner.py b/worker/jev_runner.py
index c38cc44..6f2634c 100644
--- a/worker/jev_runner.py
+++ b/worker/jev_runner.py
@@ -72,9 +72,23 @@ def ids(t):
def capture(browser, journal, action=None, text=None):
task = journal.meta["task"]
fields = task.get("recovery", {}).get("fields", [])
+ if not fields:
+ return
+ steps = [*task.get("steps", []), *task.get("recovery", {}).get("reconstruct", [])]
+ events = journal.events()
+ # A scoped secret fill can hand off to Jev after the page has formatted or
+ # moved its value. Never infer that it became public from current selectors.
+ if any(e["type"] == "recovery.capture_suspended" for e in events):
+ return
+ if any(s.get("valueFromEnv") is not None for s in steps) and any(
+ e["type"] == "action.start" and e["data"].get("engine") == "playwright"
+ and e["data"].get("recoveryGuarded") is not True for e in events
+ ):
+ journal.append("recovery.capture_suspended", reason="Legacy dispatch cannot establish the environment-input boundary. Keep existing recovery inputs; do not collect new page values.")
+ return
# Capture also runs after a scoped/environment-backed step hands off to Jev.
# Protect aliases in the top-level document without reading secret values.
- protected = [s["selector"] for s in [*task.get("steps", []), *task.get("recovery", {}).get("reconstruct", [])]
+ protected = [s["selector"] for s in steps
if s.get("valueFromEnv") is not None and s.get("selector") and not s.get("frames")]
for field in fields:
if field.get("frames") or field.get("shadow") == "open":
diff --git a/worker/tests/test_jev_retention.py b/worker/tests/test_jev_retention.py
new file mode 100644
index 0000000..7f222e6
--- /dev/null
+++ b/worker/tests/test_jev_retention.py
@@ -0,0 +1,66 @@
+"""A fresh managed-Jev process must honor the shared capture boundary."""
+import json
+from pathlib import Path
+import sys
+import tempfile
+import time
+import unittest
+from unittest.mock import Mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+from jev_runner import Journal, capture
+
+
+class RecoveryBoundaryTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.run_id = 'r_' + 'a' * 32
+ self.directory = Path(self.temp.name) / 'runs' / self.run_id
+ self.directory.mkdir(parents=True)
+ self.task = {
+ 'steps': [{'kind': 'fill', 'selector': '#token', 'valueFromEnv': 'SYNTHETIC_INPUT'}],
+ 'recovery': {'fields': [{'key': 'public', 'selector': '#public'}]},
+ }
+ self.write_meta()
+ (self.directory / 'events.jsonl').write_text('')
+ self.journal = Journal(self.temp.name, self.run_id)
+ self.page = Mock()
+ self.page.evaluate.return_value = {'value': 'public', 'matches': False}
+
+ def write_meta(self):
+ (self.directory / 'task.json').write_text(json.dumps({
+ 'version': 2, 'expiresAt': time.time() * 1000 + 60_000, 'task': self.task,
+ }))
+
+ def test_suspension_survives_new_journal_and_skips_model_generated_intents(self):
+ self.journal.append('recovery.capture_suspended', reason='environment boundary')
+ fresh = Journal(self.temp.name, self.run_id)
+ capture(self.page, fresh)
+ capture(self.page, fresh, {'kind': 'fill', 'node': 1}, 'model-generated')
+ self.page.evaluate.assert_not_called()
+ self.assertFalse((self.directory / 'recovery.json').exists())
+
+ def test_legacy_playwright_receipt_is_not_assumed_public(self):
+ self.journal.append('action.start', engine='playwright', id='old')
+ capture(self.page, self.journal)
+ capture(self.page, Journal(self.temp.name, self.run_id))
+ self.page.evaluate.assert_not_called()
+ self.assertEqual(sum(e['type'] == 'recovery.capture_suspended' for e in self.journal.events()), 1)
+
+ def test_guarded_public_dispatch_does_not_disable_capture(self):
+ self.journal.append('action.start', engine='playwright', id='public', recoveryGuarded=True)
+ capture(self.page, self.journal)
+ self.page.evaluate.assert_called_once()
+ self.assertEqual(json.loads((self.directory / 'recovery.json').read_text())['public']['value'], 'public')
+
+ def test_legacy_run_without_environment_steps_still_captures(self):
+ self.task['steps'] = [{'kind': 'click', 'selector': '#open'}]
+ self.write_meta()
+ self.journal.append('action.start', engine='playwright', id='old-public')
+ capture(self.page, Journal(self.temp.name, self.run_id))
+ self.page.evaluate.assert_called_once()
+
+
+if __name__ == '__main__':
+ unittest.main()