aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md6
-rw-r--r--README.md6
-rw-r--r--skills/pi-browser-harness/SKILL.md3
-rw-r--r--src/cdp/commands.ts4
-rw-r--r--src/domains/headers.ts89
-rw-r--r--src/prompt.ts1
-rw-r--r--src/registry.ts3
-rw-r--r--test/cdp/commands-test.ts5
-rw-r--r--test/domains/headers-test.ts95
-rw-r--r--test/domains/registry-test.ts2
10 files changed, 214 insertions, 0 deletions
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<Result<ToolOk, ToolErr>> {
+ 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<Result<ToolOk, ToolErr>> {
+ 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<AnyBrowserToolDefinition> = [
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",
]);