From f121fa442cc4090293ad2cd01d9a4d879ce75adb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 3 Jun 2026 13:17:57 -0700 Subject: [PATCH] [eric] browser: surface a redacted concrete route example so the agent can compose replay_route with {{value}} --- electron/cdp-routes.js | 31 ++++++++++++++++ electron/cdp-routes.test.js | 39 ++++++++++++++++++++ frontend/src/shared/browserCommandHandler.ts | 7 +++- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/electron/cdp-routes.js b/electron/cdp-routes.js index d6ae602d..ada451ab 100644 --- a/electron/cdp-routes.js +++ b/electron/cdp-routes.js @@ -38,6 +38,34 @@ function redactHeaders(headers) { return out; } +// A concrete EXAMPLE url (real values) so the agent can see the param shape and +// template its own input in. The template alone (value-stripped) isn't composable +// for multi-param endpoints. We redact token-shaped query VALUES so a session +// token / api key in the query never lands in the agent's context. The user's own +// search terms stay (the agent typed them; not secret). Path stays as-is. +const SECRET_PARAM = /token|secret|sig|session|password|pwd|jwt|bearer|auth|access|refresh|csrf|api[-_]?key/i; +const TOKEN_PREFIX = /^(sk-|ghp_|gho_|pk_|xox[bap]-|AIza|eyJ|Bearer )/; + +function looksSecretValue(v) { + if (!v) return false; + if (TOKEN_PREFIX.test(v)) return true; + return v.length >= 20 && /[A-Za-z]/.test(v) && /[0-9]/.test(v) && !/\s/.test(v); +} + +function redactExampleUrl(raw) { + try { + const u = new URL(raw); + for (const [k, v] of [...u.searchParams.entries()]) { + if (SECRET_PARAM.test(k) || looksSecretValue(v)) { + u.searchParams.set(k, ''); + } + } + return u.origin + u.pathname + (u.search || ''); + } catch { + return raw; + } +} + // Keep the JSON body's key skeleton with value TYPES, never values. function bodyShape(postData) { if (!postData) return null; @@ -73,6 +101,7 @@ function makeRouteEntry(request, resourceType) { return { method, template, + example: redactExampleUrl(request.url), resourceType, headers: redactHeaders(request.headers), bodyShape: bodyShape(request.postData), @@ -109,6 +138,8 @@ function recordRoute(routesMap, request, resourceType, now = Date.now()) { module.exports = { templateUrl, + redactExampleUrl, + looksSecretValue, redactHeaders, bodyShape, isSafeMethod, diff --git a/electron/cdp-routes.test.js b/electron/cdp-routes.test.js index 8dad08d7..2c3362e4 100644 --- a/electron/cdp-routes.test.js +++ b/electron/cdp-routes.test.js @@ -97,3 +97,42 @@ test('recordRoute evicts least-recently-seen past the cap', () => { // the earliest few paths should have been evicted assert.ok(!m.has('GET https://x.com/p0/a')); }); + +test('redactExampleUrl keeps normal search terms (agent typed them)', () => { + assert.equal( + R.redactExampleUrl('https://x.com/api/search?q=design+engineer&page=2'), + 'https://x.com/api/search?q=design+engineer&page=2', + ); +}); + +test('redactExampleUrl redacts token-shaped param VALUES', () => { + // sensitive param name + assert.equal( + R.redactExampleUrl('https://x.com/api/feed?access_token=abc123xyz&q=cats'), + 'https://x.com/api/feed?access_token=%3Credacted%3E&q=cats', + ); + // high-entropy value even under a benign key + assert.equal( + R.redactExampleUrl('https://x.com/api?sid=aB3xK9mQ2pL7wR4tY8nZ&q=hi'), + 'https://x.com/api?sid=%3Credacted%3E&q=hi', + ); + // known token prefix + assert.ok( + R.redactExampleUrl('https://x.com/api?key=sk-ant-api03-abc').includes('redacted'), + ); +}); + +test('looksSecretValue: tokens yes, plain words no', () => { + assert.ok(R.looksSecretValue('eyJhbGciOiJIUzI1NiIsInR5cCI6')); + assert.ok(R.looksSecretValue('aB3xK9mQ2pL7wR4tY8nZ')); + assert.ok(!R.looksSecretValue('shoes')); + assert.ok(!R.looksSecretValue('design engineer')); + assert.ok(!R.looksSecretValue('2')); +}); + +test('makeRouteEntry carries a redacted example url', () => { + const e = R.makeRouteEntry({ method: 'GET', url: 'https://x.com/api/p?q=ada&token=secretAbc123Long', headers: {} }, 'XHR'); + assert.ok(e.example.includes('q=ada')); + assert.ok(e.example.includes('redacted')); + assert.ok(!e.example.includes('secretAbc123Long')); +}); diff --git a/frontend/src/shared/browserCommandHandler.ts b/frontend/src/shared/browserCommandHandler.ts index 2f8bf9e6..fe633c41 100644 --- a/frontend/src/shared/browserCommandHandler.ts +++ b/frontend/src/shared/browserCommandHandler.ts @@ -762,9 +762,12 @@ async function handleListRoutes(wv: BrowserWebview): Promise if (!safe.length) { return { text: 'No replayable (GET) API routes captured for this site yet. Use the page first so they get recorded, then try again.', url: wv.getURL() }; } - const lines = safe.slice(0, 40).map((r) => `${r.method} ${r.template} (x${r.hits})`); + const lines = safe.slice(0, 40).map((r) => `${r.method} ${r.example || r.template} (seen ${r.hits}x)`); return { - text: `Replayable API routes for this site (safe GETs, call with BrowserReplayRoute):\n${lines.join('\n')}`, + text: `Replayable API routes for this site (safe GETs). To READ the same kind of ` + + `data for many inputs fast: swap the varying value in the URL with {{value}} ` + + `and use a replay_route step in BrowserRepeatFlow (or call BrowserReplayRoute ` + + `per item). Far cheaper than navigating + scraping each page:\n${lines.join('\n')}`, routes: safe.slice(0, 40), url: wv.getURL(), };