From 8d7a067c8d82a592bfb3dcacf05305c39550cbc6 Mon Sep 17 00:00:00 2001 From: astarring Date: Wed, 9 Sep 2026 02:35:42 +0800 Subject: feat: add per-tab request headers (#30) Expose explicit set and clear tools so environment-routed pages can be opened without leaking header values or carrying headers across unrelated browsing. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com Co-authored-by: zouzhigang --- CHANGELOG.md | 6 +++ README.md | 6 +++ skills/pi-browser-harness/SKILL.md | 3 ++ src/cdp/commands.ts | 4 ++ src/domains/headers.ts | 89 +++++++++++++++++++++++++++++++++++ src/prompt.ts | 1 + src/registry.ts | 3 ++ test/cdp/commands-test.ts | 5 ++ test/domains/headers-test.ts | 95 ++++++++++++++++++++++++++++++++++++++ test/domains/registry-test.ts | 2 + 10 files changed, 214 insertions(+) create mode 100644 src/domains/headers.ts create mode 100644 test/domains/headers-test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f13c2e..50fafb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to pi-browser-harness will be documented in this file. +## Unreleased + +### Added + +- **Per-tab extra HTTP request headers.** `browser_set_headers` applies headers to subsequent document, subresource, fetch, and XHR requests on the current owned tab through CDP, while `browser_clear_headers` removes them. Header values are not echoed in tool output, and guidance requires clearing them before switching tabs or navigating to another origin. + ## 0.11.1 — 2026-08-12 ### Fixed diff --git a/README.md b/README.md index e4e89d7..60d7d7f 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,12 @@ Both run in isolated tabs and never disturb your current page. |---|---| | `browser_network_requests` | Requests captured on the current tab since attach. Filter by URL, method, status, resource type, or recency; optional response bodies. | | `browser_http_get` | Plain GET outside the browser. Much faster than navigate-then-read for JSON APIs — but no JS rendering. | +| `browser_set_headers` | Set extra HTTP request headers on the current owned tab. Values are not echoed in output. | +| `browser_clear_headers` | Clear extra HTTP request headers from the current owned tab. | + +Extra headers persist for subsequent document, subresource, fetch, and XHR requests until cleared or +the tab closes. Create or select an owned tab before setting them, and clear them before switching +tabs or navigating the tab to another origin. ### Files and viewport diff --git a/skills/pi-browser-harness/SKILL.md b/skills/pi-browser-harness/SKILL.md index 36d0346..c1dcef7 100644 --- a/skills/pi-browser-harness/SKILL.md +++ b/skills/pi-browser-harness/SKILL.md @@ -21,6 +21,9 @@ What do you need to know? ├─ Network behavior on the current page? │ → browser_network_requests │ + ├─ A page requires custom request headers? + │ → create/select owned tab → browser_set_headers → navigate/interact → browser_clear_headers + │ ├─ Find pages on the web about a topic? │ → browser_web_search (ranked SERP — links only; follow up with browser_read_page) │ diff --git a/src/cdp/commands.ts b/src/cdp/commands.ts index 580f8cf..08ddf41 100644 --- a/src/cdp/commands.ts +++ b/src/cdp/commands.ts @@ -228,6 +228,10 @@ export const COMMANDS = { Type.Object({ requestId: Type.String() }), Type.Object({ body: Type.String(), base64Encoded: Type.Optional(Type.Boolean()) }), ), + "Network.setExtraHTTPHeaders": cmd( + Type.Object({ headers: Type.Record(Type.String(), Type.String()) }), + Empty, + ), "Emulation.setDeviceMetricsOverride": cmd(Type.Object({}, { additionalProperties: true }), Empty), "Accessibility.getFullAXTree": cmd( Type.Object({ depth: Type.Optional(Type.Number()) }, { additionalProperties: true }), diff --git a/src/domains/headers.ts b/src/domains/headers.ts new file mode 100644 index 0000000..035c084 --- /dev/null +++ b/src/domains/headers.ts @@ -0,0 +1,89 @@ +import { Type } from "typebox"; +import type { BrowserClient } from "../client"; +import { type Result, err, ok } from "../util/result"; +import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; +import { cdpCall } from "./cdp-call"; + +const HeaderValue = Type.String({ + maxLength: 8192, + pattern: "^[^\\r\\n]*$", +}); + +const SetHeadersArgs = Type.Object({ + headers: Type.Record(Type.String(), HeaderValue, { + minProperties: 1, + maxProperties: 64, + description: "Extra HTTP request headers for the current owned tab. Names must be valid HTTP tokens.", + }), +}); + +const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +const currentOwnedTarget = ( + client: BrowserClient, +): Result<{ readonly targetId: string }, ToolErr> => { + const current = client.current(); + if (current === undefined || current === null) { + return err({ kind: "invalid_state", message: "No tab attached." }); + } + if (!client.owns(current.targetId)) { + return err({ + kind: "invalid_state", + message: `Current tab ${current.targetId} is not owned by this harness session.`, + }); + } + return ok({ targetId: current.targetId }); +}; + +export const setHeadersTool = defineBrowserTool({ + name: "browser_set_headers", + label: "Browser Set Headers", + description: + "Set extra HTTP request headers on the current owned tab. Headers persist for subsequent document, subresource, fetch, and XHR requests until cleared or the tab closes.", + promptSnippet: "Set extra request headers on the current owned tab", + promptGuidelines: [ + "If no tab is attached yet, call browser_new_tab without a URL first.", + "Call before navigation when a site requires routing or environment headers.", + "Call browser_clear_headers before switching tabs or navigating this tab to another origin.", + "Header values are never echoed in tool output.", + ], + parameters: SetHeadersArgs, + concurrency: "serialized", + async handler(args, { client }): Promise> { + const target = currentOwnedTarget(client); + if (!target.success) return target; + const invalidName = Object.keys(args.headers).find((name) => !HEADER_NAME.test(name)); + if (invalidName !== undefined) { + return err({ kind: "invalid_state", message: `Invalid HTTP header name: ${JSON.stringify(invalidName)}` }); + } + const result = await cdpCall(client, "Network.setExtraHTTPHeaders", { headers: args.headers }); + if (!result.success) return result; + const headerNames = Object.keys(args.headers).sort(); + return ok({ + text: `Set ${headerNames.length} extra HTTP header(s) on tab ${target.data.targetId}: ${headerNames.join(", ")}. Values are hidden. Clear them before switching tabs or navigating to another origin.`, + details: { targetId: target.data.targetId, headerNames, count: headerNames.length }, + }); + }, +}); + +export const clearHeadersTool = defineBrowserTool({ + name: "browser_clear_headers", + label: "Browser Clear Headers", + description: "Clear all extra HTTP request headers previously set on the current owned tab.", + promptSnippet: "Clear extra request headers from the current owned tab", + promptGuidelines: [ + "Call after finishing an environment-specific workflow, before switching tabs, or before navigating to another origin.", + ], + parameters: Type.Object({}), + concurrency: "serialized", + async handler(_args, { client }): Promise> { + const target = currentOwnedTarget(client); + if (!target.success) return target; + const result = await cdpCall(client, "Network.setExtraHTTPHeaders", { headers: {} }); + if (!result.success) return result; + return ok({ + text: `Cleared extra HTTP headers from tab ${target.data.targetId}.`, + details: { targetId: target.data.targetId }, + }); + }, +}); diff --git a/src/prompt.ts b/src/prompt.ts index c336c0c..9a28abd 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -41,6 +41,7 @@ every edit and save. Coordinates go stale; guessed selectors miss. | Read an article's clean text | \`browser_read_page\` (reader mode; url or owned tab) | | Extract specific DOM values | \`browser_execute_js\` | | Hit a JSON/API endpoint | \`browser_http_get\` (raw GET, outside the browser) | +| Set or clear request headers on the current tab | \`browser_set_headers\` · \`browser_clear_headers\` | | Click / fill / type / select / focus / press a key | \`browser_click\` · \`browser_fill\` · \`browser_type\` · \`browser_select_option\` · \`browser_focus\` · \`browser_press_key\` | | Upload a file · drag · resize viewport | \`browser_upload_file\` · \`browser_drag_and_drop\` · \`browser_viewport_resize\` | | Go to a URL / open many / manage tabs | \`browser_navigate\` · \`browser_new_tab\` · \`browser_open_urls\` · \`browser_list_tabs\` · \`browser_switch_tab\` · \`browser_go_back\`/\`go_forward\`/\`reload\` | diff --git a/src/registry.ts b/src/registry.ts index 69a1845..eaa60cd 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -16,6 +16,7 @@ import { uploadFileTool, downloadTool, printToPdfTool } from "./domains/files"; import { viewportResizeTool } from "./domains/viewport"; import { dragAndDropTool } from "./domains/drag"; import { httpGetTool, networkRequestsTool } from "./domains/network"; +import { clearHeadersTool, setHeadersTool } from "./domains/headers"; import { consoleTool } from "./domains/console"; import { snapshotTool } from "./domains/snapshot"; import { executeJsTool, runScriptTool } from "./domains/js"; @@ -58,6 +59,8 @@ export const ALL_TOOLS: ReadonlyArray = [ dragAndDropTool, httpGetTool, networkRequestsTool, + setHeadersTool, + clearHeadersTool, consoleTool, snapshotTool, executeJsTool, diff --git a/test/cdp/commands-test.ts b/test/cdp/commands-test.ts index ad1b057..738189d 100644 --- a/test/cdp/commands-test.ts +++ b/test/cdp/commands-test.ts @@ -42,6 +42,11 @@ describe("cdp command table", () => { const r = decodeResult("Nope.method" as "Page.enable", {}); assert.equal(r.success, false); }); + + test("accepts an empty Network.setExtraHTTPHeaders response", () => { + const r = decodeResult("Network.setExtraHTTPHeaders", {}); + assert.equal(r.success, true); + }); }); describe("Accessibility.getFullAXTree schema", () => { diff --git a/test/domains/headers-test.ts b/test/domains/headers-test.ts new file mode 100644 index 0000000..4a268c2 --- /dev/null +++ b/test/domains/headers-test.ts @@ -0,0 +1,95 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { Compile } from "typebox/compile"; +import { clearHeadersTool, setHeadersTool } from "../../src/domains/headers"; +import type { HandlerContext } from "../../src/util/tool"; +import { createFakeClient, type FakeClient } from "./fake-client"; + +const ctxFor = (fake: FakeClient): HandlerContext => ({ + client: fake.client, + signal: undefined, + onUpdate: () => {}, + extensionCtx: undefined as never, +}); + +describe("browser_set_headers", () => { + test("sets headers on the current owned tab without echoing values", async () => { + const fake = await createFakeClient(); + const result = await setHeadersTool.handler( + { + headers: { + "x-tt-env": "ppe_mini_game_rating_phase4", + "x-use-ppe": "1", + }, + }, + ctxFor(fake), + ); + + assert.equal(result.success, true); + if (!result.success) return; + assert.doesNotMatch(result.data.text, /ppe_mini_game_rating_phase4/); + assert.deepEqual(result.data.details, { + targetId: "t1", + headerNames: ["x-tt-env", "x-use-ppe"], + count: 2, + }); + assert.deepEqual(fake.callsTo("Network.setExtraHTTPHeaders"), [ + { + method: "Network.setExtraHTTPHeaders", + params: { + headers: { + "x-tt-env": "ppe_mini_game_rating_phase4", + "x-use-ppe": "1", + }, + }, + sessionId: "s1", + }, + ]); + }); + + test("refuses a current tab that is not owned by the harness", async () => { + const fake = await createFakeClient(); + fake.ownership.remove("t1"); + const result = await setHeadersTool.handler({ headers: { "x-test": "value" } }, ctxFor(fake)); + + assert.equal(result.success, false); + if (result.success) return; + assert.equal(result.error.kind, "invalid_state"); + assert.match(result.error.message, /not owned/); + assert.equal(fake.callsTo("Network.setExtraHTTPHeaders").length, 0); + }); + + test("schema rejects empty or malformed header sets", () => { + const validate = Compile(setHeadersTool.parameters); + assert.equal(validate.Check({ headers: {} }), false); + assert.equal(validate.Check({ headers: { "x-test": "line one\nline two" } }), false); + assert.equal(validate.Check({ headers: { "x-test": "value" } }), true); + }); + + test("handler rejects invalid HTTP header names", async () => { + const fake = await createFakeClient(); + const result = await setHeadersTool.handler({ headers: { "bad name": "value" } }, ctxFor(fake)); + + assert.equal(result.success, false); + if (result.success) return; + assert.equal(result.error.kind, "invalid_state"); + assert.match(result.error.message, /Invalid HTTP header name/); + assert.equal(fake.callsTo("Network.setExtraHTTPHeaders").length, 0); + }); +}); + +describe("browser_clear_headers", () => { + test("clears extra headers on the current owned tab", async () => { + const fake = await createFakeClient(); + const result = await clearHeadersTool.handler({}, ctxFor(fake)); + + assert.equal(result.success, true); + assert.deepEqual(fake.callsTo("Network.setExtraHTTPHeaders"), [ + { + method: "Network.setExtraHTTPHeaders", + params: { headers: {} }, + sessionId: "s1", + }, + ]); + }); +}); diff --git a/test/domains/registry-test.ts b/test/domains/registry-test.ts index 2003b6c..be020a3 100644 --- a/test/domains/registry-test.ts +++ b/test/domains/registry-test.ts @@ -34,6 +34,8 @@ const SERIALIZED = new Set([ "browser_print_to_pdf", "browser_viewport_resize", "browser_drag_and_drop", + "browser_set_headers", + "browser_clear_headers", "browser_web_search", ]); -- cgit v1.2.3