diff --git a/.pi/extensions/context-grep/README.md b/.pi/extensions/context-grep/README.md deleted file mode 100644 index c227364..0000000 --- a/.pi/extensions/context-grep/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# context-grep Pi extension - -Project-local Pi extension that enriches `bash` `rg`/`grep` results with AST context and back-references. The agent searches as usual; the extension transparently appends enclosing functions and their callers. - -## How it helps - -Every time the agent greps for code it plans to modify, it automatically sees: -- The enclosing function/class (not just the matched line) -- Who calls that function ("Called from: ← callerName (file:line)") - -This reduces iterative grep→read→grep cycles by ~44% on investigation tasks (measured via A/B testing). - -## Enable - -1. Trust this project in Pi. -2. Ensure `ast-grep` is installed and on `PATH`. -3. Start Pi in this repo — auto-discovered, or `/reload`. - -## Disable - -- `pi --no-extensions`, or -- Remove `.pi/extensions/context-grep/`, then `/reload`. - -## Output format - -```text -── AST context (N containers, deduplicated) ──────────────────────────── - -▶ service/src/config.ts:20-29 [fn loadBaseConfig] (grep hits: [20]) - │ - │ Called from: - │ ← main (service/src/index.ts:20) - │ ← loadConfig (service/src/config.ts:32) - │ - export function loadBaseConfig(env) { - ... - } -``` - -## Behavior - -- Original search output stays at top unchanged. -- AST context appended when ast-grep finds enclosing containers. -- Back-references added when a grep hit lands on a function definition line. -- Script/heredoc commands correctly rejected (no false enrichment). -- If `ast-grep` unavailable: warns once, stays passive. -- Any error: returns original result unchanged. - -## Supported file extensions - -`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.py`, `.rs`, `.go` - -## Source + tests - -Checked TypeScript source: `tools/pi/context-grep/` - -```bash -pnpm test:pi-tooling # run tests -pnpm typecheck:pi-tooling # typecheck source + tests -pnpm typecheck:pi-shim # typecheck Pi shim -``` diff --git a/.pi/extensions/context-grep/index.ts b/.pi/extensions/context-grep/index.ts deleted file mode 100644 index 2677b78..0000000 --- a/.pi/extensions/context-grep/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { isBashToolResult, isGrepToolResult } from "@earendil-works/pi-coding-agent"; -import { enrichSearchToolResult } from "../../../tools/pi/context-grep/src/index.ts"; - -export default function contextGrep(pi: ExtensionAPI) { - const sessionState = { - availability: "unknown" as "unknown" | "ready" | "unavailable", - warnedUnavailable: false, - }; - - pi.on("tool_result", async (event, ctx) => { - if (event.isError) return; - - const onAstGrepUnavailable = () => { - if (sessionState.warnedUnavailable || !ctx.hasUI) return; - sessionState.warnedUnavailable = true; - ctx.ui.notify( - "context-grep: ast-grep unavailable; leaving raw search output unchanged", - "warning", - ); - }; - - if (isGrepToolResult(event)) { - const content = await enrichSearchToolResult({ - toolName: "grep", - content: event.content, - cwd: ctx.cwd, - inputPath: event.input.path, - signal: ctx.signal, - sessionState, - onAstGrepUnavailable, - }); - return content ? { content } : undefined; - } - - if (isBashToolResult(event)) { - const content = await enrichSearchToolResult({ - toolName: "bash", - content: event.content, - cwd: ctx.cwd, - command: event.input.command, - signal: ctx.signal, - sessionState, - onAstGrepUnavailable, - }); - return content ? { content } : undefined; - } - }); -} diff --git a/.pi/extensions/context-grep/tsconfig.json b/.pi/extensions/context-grep/tsconfig.json deleted file mode 100644 index 19cba55..0000000 --- a/.pi/extensions/context-grep/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "noEmit": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "allowImportingTsExtensions": true, - "types": ["node"] - }, - "include": ["index.ts", "types/**/*.d.ts", "../../../tools/pi/context-grep/src/**/*.ts"] -} diff --git a/.pi/extensions/context-grep/types/pi-coding-agent.d.ts b/.pi/extensions/context-grep/types/pi-coding-agent.d.ts deleted file mode 100644 index 50b92fe..0000000 --- a/.pi/extensions/context-grep/types/pi-coding-agent.d.ts +++ /dev/null @@ -1,59 +0,0 @@ -declare module "@earendil-works/pi-coding-agent" { - export interface TextContent { - type: "text"; - text: string; - } - - export interface ImageContent { - type: string; - [key: string]: unknown; - } - - export type ToolContent = TextContent | ImageContent; - - export interface BashToolResultEvent { - toolName: "bash"; - content: ToolContent[]; - isError: boolean; - input: { - command: string; - timeout?: number; - }; - } - - export interface GrepToolResultEvent { - toolName: "grep"; - content: ToolContent[]; - isError: boolean; - input: { - path?: string; - }; - } - - export type ToolResultEvent = BashToolResultEvent | GrepToolResultEvent; - - export interface ExtensionContext { - cwd: string; - hasUI: boolean; - signal?: AbortSignal; - ui: { - notify(message: string, level: "info" | "warning" | "error"): void; - }; - } - - export interface ExtensionAPI { - on( - event: "tool_result", - handler: ( - event: ToolResultEvent, - ctx: ExtensionContext, - ) => - | Promise<{ content?: ToolContent[] } | undefined> - | { content?: ToolContent[] } - | undefined, - ): void; - } - - export function isBashToolResult(event: ToolResultEvent): event is BashToolResultEvent; - export function isGrepToolResult(event: ToolResultEvent): event is GrepToolResultEvent; -} diff --git a/cli/package.json b/cli/package.json index 6a28a87..a7a6cb1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/cli", - "version": "0.8.0", + "version": "0.9.0", "private": true, "type": "module", "bin": { diff --git a/cli/src/__tests__/client.test.ts b/cli/src/__tests__/client.test.ts index 145c08b..6cbf585 100644 --- a/cli/src/__tests__/client.test.ts +++ b/cli/src/__tests__/client.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { type ClientGlobalArgs, sendAction, validateResponse } from "../client.js"; import type { ActionParams, TabHandle } from "../types.js"; +import { PROTOCOL_VERSION } from "../types.js"; import { extractUrl } from "./fetch-helper.js"; import { createTestStateDir } from "./helpers/test-state-dir.js"; @@ -25,7 +26,7 @@ const DEFAULT_RESPONSE_DATA = { text: "hello" }; function successResponse(id: string, data: unknown = DEFAULT_RESPONSE_DATA) { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, ok: true, data, @@ -36,7 +37,7 @@ function successResponse(id: string, data: unknown = DEFAULT_RESPONSE_DATA) { function errorResponse(id: string, code = "ELEMENT_NOT_FOUND") { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, ok: false, error: { @@ -135,7 +136,7 @@ describe("validateResponse", () => { }); it("rejects wrong protocol_version", () => { - const result = validateResponse({ ...successResponse(reqId), protocol_version: 2 }, reqId); + const result = validateResponse({ ...successResponse(reqId), protocol_version: 99 }, reqId); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toContain("protocol_version"); @@ -184,7 +185,10 @@ describe("validateResponse", () => { }); it("rejects error response without error object", () => { - const result = validateResponse({ protocol_version: 1, id: reqId, ok: false }, reqId); + const result = validateResponse( + { protocol_version: PROTOCOL_VERSION, id: reqId, ok: false }, + reqId, + ); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toContain("'error' object"); @@ -193,7 +197,7 @@ describe("validateResponse", () => { it("rejects error response with error missing code", () => { const result = validateResponse( - { protocol_version: 1, id: reqId, ok: false, error: { category: "target" } }, + { protocol_version: PROTOCOL_VERSION, id: reqId, ok: false, error: { category: "target" } }, reqId, ); expect(result.ok).toBe(false); @@ -267,7 +271,7 @@ describe("sendAction", () => { expect(calls).toHaveLength(1); expect(calls[0]!.url).toBe("http://127.0.0.1:9615/"); const body = JSON.parse(calls[0]!.init.body as string); - expect(body.protocol_version).toBe(1); + expect(body.protocol_version).toBe(PROTOCOL_VERSION); expect(body.id).toBe(reqId); expect(body.action).toBe("text"); expect(body.params).toEqual({ selector: ".content" }); diff --git a/cli/src/__tests__/command-registry.test.ts b/cli/src/__tests__/command-registry.test.ts index 9cfd94a..f9400f9 100644 --- a/cli/src/__tests__/command-registry.test.ts +++ b/cli/src/__tests__/command-registry.test.ts @@ -14,6 +14,7 @@ const DESTRUCTIVE: Action[] = [ "tab.unpin", "tab.open", "tab.close", + "tab.activate", "session.create", "session.bind", "session.unbind", diff --git a/cli/src/__tests__/command-test-helpers.ts b/cli/src/__tests__/command-test-helpers.ts index 0d42e1c..dd80892 100644 --- a/cli/src/__tests__/command-test-helpers.ts +++ b/cli/src/__tests__/command-test-helpers.ts @@ -6,6 +6,7 @@ */ import { writeFileSync } from "node:fs"; import { join } from "node:path"; +import { PROTOCOL_VERSION } from "@bproxy/shared"; import type { ClientGlobalArgs, SendOptions } from "../client.js"; import { sendAction } from "../client.js"; import { extractUrl } from "./fetch-helper.js"; @@ -34,7 +35,7 @@ export function makeGlobals( export function successResponse(id: string, data: unknown = {}) { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, ok: true, data, diff --git a/cli/src/__tests__/commands-control.test.ts b/cli/src/__tests__/commands-control.test.ts index f626596..f60ba50 100644 --- a/cli/src/__tests__/commands-control.test.ts +++ b/cli/src/__tests__/commands-control.test.ts @@ -12,6 +12,7 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { type ClientGlobalArgs, type SendOptions, sendAction } from "../client.js"; +import { PROTOCOL_VERSION } from "../types.js"; import { createMockFetch, makeGlobals, @@ -24,7 +25,7 @@ import { createTestStateDir } from "./helpers/test-state-dir.js"; function errorResponse(id: string, code: string, message = "error") { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, ok: false, error: { code, message }, diff --git a/cli/src/__tests__/commands-read-parsing.test.ts b/cli/src/__tests__/commands-read-parsing.test.ts index b54d2f2..a3d19a1 100644 --- a/cli/src/__tests__/commands-read-parsing.test.ts +++ b/cli/src/__tests__/commands-read-parsing.test.ts @@ -227,3 +227,65 @@ describe("optional param omission patterns", () => { expect(params).toEqual({ activate: true, debugger: true }); }); }); + +// ─── links numeric param parsing ──────────────────────────────────────── + +function parseInteger(raw: string, min: number): number | null { + if (!/^[0-9]+$/.test(raw)) return null; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < min) return null; + return value; +} + +function parseLimit(raw: string): number | null { + return parseInteger(raw, 1); +} + +function parseOffset(raw: string): number | null { + return parseInteger(raw, 0); +} + +describe("links --limit parsing logic", () => { + it("parses valid positive integer", () => { + expect(parseLimit("50")).toBe(50); + }); + + it("rejects zero", () => { + expect(parseLimit("0")).toBeNull(); + }); + + it("rejects negative number", () => { + expect(parseLimit("-5")).toBeNull(); + }); + + it("rejects non-numeric string", () => { + expect(parseLimit("abc")).toBeNull(); + }); + + it("rejects float string", () => { + expect(parseLimit("3.5")).toBeNull(); + }); +}); + +describe("links --offset parsing logic", () => { + it("parses valid non-negative integer", () => { + expect(parseOffset("0")).toBe(0); + expect(parseOffset("100")).toBe(100); + }); + + it("rejects negative number", () => { + expect(parseOffset("-1")).toBeNull(); + }); + + it("rejects non-numeric string", () => { + expect(parseOffset("abc")).toBeNull(); + }); + + it("rejects empty string", () => { + expect(parseOffset("")).toBeNull(); + }); + + it("rejects float string", () => { + expect(parseOffset("3.5")).toBeNull(); + }); +}); diff --git a/cli/src/__tests__/commands-read.test.ts b/cli/src/__tests__/commands-read.test.ts index 23c3ed7..4a0c7dc 100644 --- a/cli/src/__tests__/commands-read.test.ts +++ b/cli/src/__tests__/commands-read.test.ts @@ -9,6 +9,8 @@ */ import { describe, expect, it } from "vitest"; import { type SendOptions, sendAction } from "../client.js"; +import { transformTextExitPlan } from "../commands/text.js"; +import { PROTOCOL_VERSION } from "../types.js"; import { createMockFetch, makeGlobals, @@ -55,6 +57,72 @@ describe("text command", () => { params: { selector: "#content" }, }); }); + + it("applies --after marker slicing to successful stdout only", () => { + const plan = { + code: 0 as const, + stdout: successResponse("text-id", { text: "alpha MARK beta" }), + }; + + const transformed = transformTextExitPlan(plan, { after: "MARK" }); + + expect(transformed.stdout).toMatchObject({ + ok: true, + data: { text: "MARK beta", markerFound: true, markerOffset: 6 }, + }); + }); + + it("adds markerFound false when --after marker is missing", () => { + const plan = { code: 0 as const, stdout: successResponse("text-id", { text: "alpha beta" }) }; + + const transformed = transformTextExitPlan(plan, { after: "MARK", limitChars: 3 }); + + expect(transformed.stdout).toMatchObject({ + ok: true, + data: { text: "alpha beta", markerFound: false }, + }); + }); + + it("applies --limit-chars from the beginning when --after is omitted", () => { + const plan = { code: 0 as const, stdout: successResponse("text-id", { text: "abcdef" }) }; + + const transformed = transformTextExitPlan(plan, { limitChars: 3 }); + + expect(transformed.stdout).toMatchObject({ ok: true, data: { text: "abc" } }); + expect( + (transformed.stdout as { data: Record }).data["markerFound"], + ).toBeUndefined(); + }); + + it("combines --after and --limit-chars after the marker", () => { + const plan = { code: 0 as const, stdout: successResponse("text-id", { text: "abc MARK def" }) }; + + const transformed = transformTextExitPlan(plan, { after: "MARK", limitChars: 6 }); + + expect(transformed.stdout).toMatchObject({ + ok: true, + data: { text: "MARK d", markerFound: true, markerOffset: 4 }, + }); + }); + + it("does not transform protocol error responses", () => { + const plan = { + code: 1 as const, + stdout: { + protocol_version: PROTOCOL_VERSION, + id: "text-id", + ok: false, + error: { + code: "TAB_NOT_FOUND", + category: "target", + retry: "never", + message: "Missing tab", + }, + }, + }; + + expect(transformTextExitPlan(plan, { after: "MARK" })).toBe(plan); + }); }); describe("links command", () => { @@ -83,6 +151,28 @@ describe("links command", () => { params: { selector: "#search", visibleOnly: true, limit: 10 }, }); }); + + it("sends links action with href-contains filter", async () => { + const home = setupTempHome(); + const { plan, calls } = await sendWithCapture("links", { hrefContains: "/in/" }, home); + + expect(plan.code).toBe(0); + expect(calls[0]!.body).toMatchObject({ + action: "links", + params: { hrefContains: "/in/" }, + }); + }); + + it("sends links action with offset for pagination", async () => { + const home = setupTempHome(); + const { plan, calls } = await sendWithCapture("links", { offset: 50, limit: 25 }, home); + + expect(plan.code).toBe(0); + expect(calls[0]!.body).toMatchObject({ + action: "links", + params: { offset: 50, limit: 25 }, + }); + }); }); describe("images command", () => { @@ -361,7 +451,7 @@ describe("request envelope structure", () => { await sendAction("text", {}, makeGlobals(home), opts); const body = calls[0]!.body; - expect(body["protocol_version"]).toBe(1); + expect(body["protocol_version"]).toBe(PROTOCOL_VERSION); expect(body["id"]).toBe(requestId); expect(body["session"]).toBe("m4q7z2"); expect(body["deadline"]).toBeGreaterThan(Date.now() - 10000); @@ -496,7 +586,7 @@ describe("response pass-through", () => { const home = setupTempHome(); const requestId = "test-id-001"; const responseBody = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: requestId, ok: false, error: { diff --git a/cli/src/__tests__/commands-write.test.ts b/cli/src/__tests__/commands-write.test.ts index 01088d8..232bcc7 100644 --- a/cli/src/__tests__/commands-write.test.ts +++ b/cli/src/__tests__/commands-write.test.ts @@ -10,6 +10,7 @@ */ import { describe, expect, it } from "vitest"; import { type SendOptions, sendAction } from "../client.js"; +import { PROTOCOL_VERSION } from "../types.js"; import { createMockFetch, makeGlobals, @@ -361,7 +362,7 @@ describe("fill/fill-form never invent or retry method/world", () => { const home = setupTempHome(); const requestId = "test-id-001"; const errorResponse = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: requestId, ok: false, error: { @@ -395,7 +396,7 @@ describe("fill/fill-form never invent or retry method/world", () => { const home = setupTempHome(); const requestId = "test-id-001"; const errorResponse = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: requestId, ok: false, error: { diff --git a/cli/src/__tests__/design-assertions.test.ts b/cli/src/__tests__/design-assertions.test.ts index a34ae02..da6bc9a 100644 --- a/cli/src/__tests__/design-assertions.test.ts +++ b/cli/src/__tests__/design-assertions.test.ts @@ -14,6 +14,7 @@ import { describe, expect, it } from "vitest"; import { type ClientGlobalArgs, sendAction } from "../client.js"; import { allRegisteredActions } from "../command-registry.js"; import type { Action } from "../types.js"; +import { PROTOCOL_VERSION } from "../types.js"; import { createTestStateDir } from "./helpers/test-state-dir.js"; // ─── Test infrastructure ─────────────────────────────────────────────── @@ -50,7 +51,7 @@ function makeVerboseGlobals(home: string): ClientGlobalArgs { function successResponse(id: string) { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, ok: true, data: { text: "hello" }, @@ -61,7 +62,7 @@ function successResponse(id: string) { function errorResponse(id: string, code: string) { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, ok: false, error: { code, message: "Something failed" }, @@ -134,6 +135,7 @@ describe("action coverage", () => { "tab.unpin": "commands/tab/unpin.ts", "tab.open": "commands/tab/open.ts", "tab.close": "commands/tab/close.ts", + "tab.activate": "commands/tab/activate.ts", "session.create": "commands/session/create.ts", "session.list": "commands/session/list.ts", "session.bind": "commands/session/bind.ts", @@ -320,7 +322,7 @@ describe("stdout cleanliness", () => { const plan = await sendAction("text", {}, makeGlobals(home), { fetch, requestId }); expect(plan.code).toBe(0); const output = plan.stdout as Record; - expect(output["protocol_version"]).toBe(1); + expect(output["protocol_version"]).toBe(PROTOCOL_VERSION); expect(output["ok"]).toBe(true); expect(output["id"]).toBe(requestId); }); diff --git a/cli/src/__tests__/exit.test.ts b/cli/src/__tests__/exit.test.ts index af5fa1a..937f6ff 100644 --- a/cli/src/__tests__/exit.test.ts +++ b/cli/src/__tests__/exit.test.ts @@ -8,6 +8,7 @@ import { exitUsageError, } from "../exit.js"; import type { BproxyResponse } from "../types.js"; +import { PROTOCOL_VERSION } from "../types.js"; /** Fake writable stream that captures written data. */ function fakeStream(): NodeJS.WritableStream & { data: string } { @@ -27,7 +28,7 @@ function fakeStream(): NodeJS.WritableStream & { data: string } { describe("exitFromResponse", () => { it("maps ok:true response to exit code 0", () => { const response = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: "req-1", ok: true, data: { text: "hello" }, @@ -43,7 +44,7 @@ describe("exitFromResponse", () => { it("maps ok:false response to exit code 1", () => { const response = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: "req-2", ok: false, error: { code: "TIMEOUT", message: "Request timed out" }, @@ -184,4 +185,57 @@ describe("executeExitPlan", () => { expect(stderr.data).toBe(""); }); + + it("produces valid JSON for payloads >64KB (truncation regression)", () => { + const stdout = fakeStream(); + const stderr = fakeStream(); + + // Generate a links response with enough entries to exceed 64KB + const links = Array.from({ length: 250 }, (_, i) => ({ + text: `Link ${i} with some longer text to increase payload size`, + href: `https://example.com/path/to/resource/${i}?query=parameter&more=data&extra=padding`, + target: { selector: `a[href="/path/to/resource/${i}"]` }, + handle: `ln${i + 1}`, + title: `Title for link number ${i} with additional descriptive text`, + rel: "noopener noreferrer", + targetAttr: "_blank", + visible: true, + })); + + const response = { + protocol_version: PROTOCOL_VERSION, + id: "req-large", + ok: true, + data: { links, total: 500, capped: false }, + page: { url: "https://example.com", title: "Test", state: "ready", busy: false }, + replay: false, + }; + + const plan: ExitPlan = { code: 0, stdout: response }; + executeExitPlan(plan, { stdout, stderr, exit: () => {} }); + + // Verify the output is valid JSON and exceeds 64KB + expect(stdout.data.length).toBeGreaterThan(65_536); + const parsed = JSON.parse(stdout.data); + expect(parsed.ok).toBe(true); + expect(parsed.data.links).toHaveLength(250); + expect(parsed.data.total).toBe(500); + }); + + it("uses synchronous write to real stdout fd when no deps injected", () => { + // Verify the code path with no injected streams calls writeFileSync + // by checking that the plan executes without error for large payloads. + // (The actual synchronous write is tested by the integration test.) + const largeData = { + items: Array.from({ length: 500 }, (_, i) => ({ id: i, value: "x".repeat(200) })), + }; + const plan: ExitPlan = { code: 0, stdout: largeData }; + + // With injected stream, large payloads still produce valid JSON + const stdout = fakeStream(); + executeExitPlan(plan, { stdout, stderr: fakeStream(), exit: () => {} }); + + expect(stdout.data.length).toBeGreaterThan(65_536); + expect(() => JSON.parse(stdout.data)).not.toThrow(); + }); }); diff --git a/cli/src/__tests__/screenshot-file.test.ts b/cli/src/__tests__/screenshot-file.test.ts index cce52cd..45ed342 100644 --- a/cli/src/__tests__/screenshot-file.test.ts +++ b/cli/src/__tests__/screenshot-file.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { type ClientGlobalArgs, type SendOptions, sendAction } from "../client.js"; import { writeScreenshotFile } from "../screenshot-file.js"; import type { BproxyResponse } from "../types.js"; +import { PROTOCOL_VERSION } from "../types.js"; import { createTestStateDir } from "./helpers/test-state-dir.js"; // A tiny 1x1 red PNG encoded in base64 @@ -102,7 +103,7 @@ function setupHome(): string { function screenshotResponse(id: string) { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, ok: true, data: { base64: INT_TINY_PNG, format: "png" }, diff --git a/cli/src/__tests__/smoke.integration.test.ts b/cli/src/__tests__/smoke.integration.test.ts index 151cced..a274c3f 100644 --- a/cli/src/__tests__/smoke.integration.test.ts +++ b/cli/src/__tests__/smoke.integration.test.ts @@ -20,6 +20,7 @@ import { existsSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import WebSocket from "ws"; +import { PROTOCOL_VERSION } from "../types.js"; import { createTestStateDir } from "./helpers/test-state-dir.js"; // ─── Constants ───────────────────────────────────────────────────────── @@ -207,7 +208,7 @@ describe("CLI integration smoke", () => { const parsed = parseJson(result.stdout) as Record; expect(parsed["ok"]).toBe(true); - expect(parsed["protocol_version"]).toBe(1); + expect(parsed["protocol_version"]).toBe(PROTOCOL_VERSION); const data = parsed["data"] as Record; expect(Array.isArray(data["sessions"])).toBe(true); }); @@ -258,7 +259,7 @@ describe("CLI integration smoke", () => { const parsed = parseJson(result.stdout) as Record; expect(parsed["ok"]).toBe(true); - expect(parsed["protocol_version"]).toBe(1); + expect(parsed["protocol_version"]).toBe(PROTOCOL_VERSION); const data = parsed["data"] as Record; expect(Array.isArray(data["requests"])).toBe(true); }); @@ -313,11 +314,11 @@ describe("CLI integration smoke", () => { // Set up WS to respond to forwarded requests ws.on("message", (raw: unknown) => { const req = JSON.parse(String(raw)) as Record; - if (req["protocol_version"] !== 1) return; + if (req["protocol_version"] !== PROTOCOL_VERSION) return; if (req["action"] === "tab.open") { ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req["id"], ok: true, data: { tabId: 99, url: "https://example.com" }, @@ -334,7 +335,7 @@ describe("CLI integration smoke", () => { } ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req["id"], ok: true, data: { text: "mock-response-text" }, diff --git a/cli/src/__tests__/version.test.ts b/cli/src/__tests__/version.test.ts index a637807..a1535a9 100644 --- a/cli/src/__tests__/version.test.ts +++ b/cli/src/__tests__/version.test.ts @@ -12,10 +12,10 @@ describe("bproxy --version", () => { expect(result).toMatch(/^bproxy v\d+\.\d+\.\d+ \(protocol v\d+\)$/); }); - it("includes protocol v1", () => { + it("includes protocol v2", () => { const binPath = join(__dirname, "../../dist/bproxy.mjs"); const result = execSync(`node "${binPath}" --version`, { encoding: "utf8" }).trim(); - expect(result).toContain("protocol v1"); + expect(result).toContain("protocol v2"); }); it("exits with code 0", () => { diff --git a/cli/src/client.ts b/cli/src/client.ts index 59c4db2..e047742 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -28,6 +28,7 @@ import type { BproxyResponse, ClientGlobalArgs, } from "./types.js"; +import { PROTOCOL_VERSION } from "./types.js"; export { validateResponse } from "./response-validation.js"; export type { ClientGlobalArgs } from "./types.js"; @@ -169,7 +170,7 @@ function buildRequest( ctx: RequestContext, ): BproxyRequest { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: ctx.requestId, action, nick: ctx.nick, diff --git a/cli/src/command-registry.ts b/cli/src/command-registry.ts index cc5ba8a..540353f 100644 --- a/cli/src/command-registry.ts +++ b/cli/src/command-registry.ts @@ -28,6 +28,7 @@ const DESTRUCTIVE_ACTIONS: ReadonlySet = new Set([ "tab.unpin", "tab.open", "tab.close", + "tab.activate", "session.create", "session.bind", "session.unbind", diff --git a/cli/src/commands/links.ts b/cli/src/commands/links.ts index 9c6bb5a..adaf846 100644 --- a/cli/src/commands/links.ts +++ b/cli/src/commands/links.ts @@ -15,6 +15,11 @@ export default defineCommand({ default: false, }, limit: { type: "string", description: "Maximum links to return" }, + "href-contains": { + type: "string", + description: "Filter links by substring match on absolute href", + }, + offset: { type: "string", description: "Number of matching links to skip (pagination)" }, }, async run({ args }) { const globals = extractGlobals(args); @@ -27,8 +32,8 @@ export default defineCommand({ params.visibleOnly = true; } if (typeof args.limit === "string") { - const limit = Number.parseInt(args.limit, 10); - if (Number.isNaN(limit) || limit <= 0) { + const limit = parseIntegerArg(args.limit, { min: 1 }); + if (limit === null) { executeExitPlan( exitUsageError(`Invalid limit: ${args.limit}. Must be a positive integer.`), ); @@ -36,8 +41,28 @@ export default defineCommand({ } params.limit = limit; } + if (typeof args["href-contains"] === "string") { + params.hrefContains = args["href-contains"]; + } + if (typeof args.offset === "string") { + const offset = parseIntegerArg(args.offset, { min: 0 }); + if (offset === null) { + executeExitPlan( + exitUsageError(`Invalid offset: ${args.offset}. Must be a non-negative integer.`), + ); + return; + } + params.offset = offset; + } const plan = await sendAction("links", params, globals); executeExitPlan(plan); }, }); + +function parseIntegerArg(raw: string, options: { min: number }): number | null { + if (!/^\d+$/.test(raw)) return null; + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed < options.min) return null; + return parsed; +} diff --git a/cli/src/commands/tab.ts b/cli/src/commands/tab.ts index d3d4134..c2c71be 100644 --- a/cli/src/commands/tab.ts +++ b/cli/src/commands/tab.ts @@ -8,6 +8,7 @@ export default defineCommand({ unpin: () => import("./tab/unpin.js").then((m) => m.default), open: () => import("./tab/open.js").then((m) => m.default), close: () => import("./tab/close.js").then((m) => m.default), + activate: () => import("./tab/activate.js").then((m) => m.default), }, run() {}, }); diff --git a/cli/src/commands/tab/activate.ts b/cli/src/commands/tab/activate.ts new file mode 100644 index 0000000..f0037c4 --- /dev/null +++ b/cli/src/commands/tab/activate.ts @@ -0,0 +1,3 @@ +import { defineTabHandleCommand } from "./shared.js"; + +export default defineTabHandleCommand("tab.activate", "Activate (foreground) a session-owned tab"); diff --git a/cli/src/commands/tab/shared.ts b/cli/src/commands/tab/shared.ts index 3a5f97e..e641ed8 100644 --- a/cli/src/commands/tab/shared.ts +++ b/cli/src/commands/tab/shared.ts @@ -4,7 +4,7 @@ import { executeExitPlan, exitUsageError } from "../../exit.js"; import { extractGlobals, globalArgs, parseTabHandle } from "../../globals.js"; import type { ActionParams } from "../../types.js"; -type TabHandleAction = "tab.close" | "tab.pin" | "tab.unpin"; +type TabHandleAction = "tab.close" | "tab.pin" | "tab.unpin" | "tab.activate"; export function defineTabHandleCommand(action: TabHandleAction, description: string) { return defineCommand({ diff --git a/cli/src/commands/text.ts b/cli/src/commands/text.ts index 254a534..110e63b 100644 --- a/cli/src/commands/text.ts +++ b/cli/src/commands/text.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import { sendAction } from "../client.js"; -import { executeExitPlan } from "../exit.js"; +import { type ExitPlan, executeExitPlan, exitUsageError } from "../exit.js"; import { extractGlobals, globalArgs } from "../globals.js"; import type { ActionParams } from "../types.js"; @@ -9,6 +9,11 @@ export default defineCommand({ args: { ...globalArgs, selector: { type: "string", description: "CSS selector to scope extraction" }, + after: { type: "string", description: "Emit text starting at the first marker match" }, + "limit-chars": { + type: "string", + description: "Maximum characters to emit after CLI-local text slicing", + }, }, async run({ args }) { const globals = extractGlobals(args); @@ -16,7 +21,83 @@ export default defineCommand({ if (typeof args.selector === "string") { params.selector = args.selector; } + + const limitChars = parsePositiveIntegerArg(args["limit-chars"]); + if (limitChars === null) { + executeExitPlan( + exitUsageError( + `Invalid limit-chars: ${String(args["limit-chars"])}. Must be a positive integer.`, + ), + ); + return; + } + const plan = await sendAction("text", params, globals); - executeExitPlan(plan); + executeExitPlan( + transformTextExitPlan(plan, { + after: typeof args.after === "string" ? args.after : undefined, + limitChars, + }), + ); }, }); + +interface TextTransformOptions { + after?: string; + limitChars?: number; +} + +interface TextSuccessResponse { + ok: true; + data: { text: string; [key: string]: unknown }; + [key: string]: unknown; +} + +export function transformTextExitPlan(plan: ExitPlan, options: TextTransformOptions): ExitPlan { + if (plan.code !== 0 || plan.stdout === undefined || !isTextSuccessResponse(plan.stdout)) { + return plan; + } + if (options.after === undefined && options.limitChars === undefined) return plan; + + const text = plan.stdout.data.text; + const transformed = transformTextData(text, options); + return { + ...plan, + stdout: { + ...plan.stdout, + data: { ...plan.stdout.data, ...transformed }, + }, + }; +} + +function transformTextData(text: string, options: TextTransformOptions) { + if (options.after !== undefined) { + const markerOffset = text.indexOf(options.after); + if (markerOffset < 0) return { text, markerFound: false }; + + const sliced = applyLimit(text.slice(markerOffset), options.limitChars); + return { text: sliced, markerFound: true, markerOffset }; + } + + return { text: applyLimit(text, options.limitChars) }; +} + +function applyLimit(text: string, limitChars: number | undefined): string { + return limitChars === undefined ? text : text.slice(0, limitChars); +} + +function parsePositiveIntegerArg(value: unknown): number | undefined | null { + if (value === undefined) return undefined; + if (typeof value !== "string" || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} + +function isTextSuccessResponse(value: unknown): value is TextSuccessResponse { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { ok?: unknown; data?: unknown }; + if (candidate.ok !== true || typeof candidate.data !== "object" || candidate.data === null) { + return false; + } + return typeof (candidate.data as { text?: unknown }).text === "string"; +} diff --git a/cli/src/exit.ts b/cli/src/exit.ts index 5617472..470fbe8 100644 --- a/cli/src/exit.ts +++ b/cli/src/exit.ts @@ -11,6 +11,7 @@ * calls process.exit. */ +import { writeFileSync } from "node:fs"; import type { BproxyResponse } from "./types.js"; /** Exit plan returned by commands for testability. */ @@ -56,6 +57,11 @@ export function exitUsageError(message: string): ExitPlan { /** * Execute an exit plan: write output and call process.exit. * This should only be called at the outermost CLI boundary. + * + * Stdout writes use synchronous fd-level I/O when writing to the real + * process.stdout. This prevents truncation when large JSON payloads + * (>64KB) exceed the pipe buffer and the consuming process has not + * drained yet. Injected test streams still use the stream `.write()` API. */ export function executeExitPlan( plan: ExitPlan, @@ -65,15 +71,25 @@ export function executeExitPlan( exit?: (code: number) => void; } = {}, ): void { - const stdout = deps.stdout ?? process.stdout; - const stderr = deps.stderr ?? process.stderr; + const stdout = deps.stdout; + const stderr = deps.stderr; const exit = deps.exit ?? ((code: number) => process.exit(code)); if (plan.stdout !== undefined) { - stdout.write(`${JSON.stringify(plan.stdout)}\n`); + const payload = `${JSON.stringify(plan.stdout)}\n`; + if (stdout) { + stdout.write(payload); + } else { + writeFileSync(1, payload); + } } if (plan.stderr !== undefined) { - stderr.write(`${plan.stderr}\n`); + const message = `${plan.stderr}\n`; + if (stderr) { + stderr.write(message); + } else { + writeFileSync(2, message); + } } exit(plan.code); diff --git a/cli/src/response-validation.ts b/cli/src/response-validation.ts index 27013cb..043230c 100644 --- a/cli/src/response-validation.ts +++ b/cli/src/response-validation.ts @@ -1,4 +1,5 @@ import type { BproxyResponse } from "./types.js"; +import { PROTOCOL_VERSION } from "./types.js"; interface ValidationOk { ok: true; @@ -31,7 +32,7 @@ export function validateResponse(body: unknown, expectedId: string): ValidationR } function validateHeaders(obj: Record, expectedId: string): ValidationError | null { - if (obj["protocol_version"] !== 1) { + if (obj["protocol_version"] !== PROTOCOL_VERSION) { return { ok: false, reason: `Unexpected protocol_version: ${JSON.stringify(obj["protocol_version"])}`, diff --git a/docs/internal/architecture.md b/docs/internal/architecture.md index b0b0562..c6fc9ef 100644 --- a/docs/internal/architecture.md +++ b/docs/internal/architecture.md @@ -105,7 +105,7 @@ The shared contract between all three components. Every message uses the same JS ```json { - "protocol_version": 1, + "protocol_version": 2, "id": "01HZX9C2K8R7Q3VG9MNPYJVZ4D", "action": "fill", "nick": "halbot", @@ -120,7 +120,7 @@ Responses: ```json { - "protocol_version": 1, + "protocol_version": 2, "id": "01HZX9C2K8R7Q3VG9MNPYJVZ4D", "ok": true, "data": { "filled": true }, @@ -181,7 +181,7 @@ Errors use a single RFC 9457-aligned envelope: | `select` | Custom-dropdown helper: click trigger, wait for menu, click option. | | `wait` | Strategies: `selector` / `url` / `navigation`. DOM polling. | | `require-human` | Surfaces interstitial to user. Blocks until `session resume`. | -| `tab` / `session` | Lifecycle and configuration verbs (`session.*` daemon-local; `tab.*` forwarded). | +| `tab` / `session` | Lifecycle and configuration verbs (`session.*` daemon-local; `tab.list` daemon-local; `tab.open` / `tab.close` / `tab.pin` / `tab.unpin` / `tab.activate` forwarded). | | `debug.log` | Extension ring buffer (last N requests, queryable by `id`). | | `debug.last` | Daemon trace ring buffer (capacity 200, in-memory). | | `debug.status` | Full system state (daemon, WS clients, sessions, tab ownership, paused). | diff --git a/docs/internal/journal/2026-04-30-default-instrumentation-strategy.md b/docs/internal/journal/2026-04-30-default-instrumentation-strategy.md index bd0f1b6..6af7df6 100644 --- a/docs/internal/journal/2026-04-30-default-instrumentation-strategy.md +++ b/docs/internal/journal/2026-04-30-default-instrumentation-strategy.md @@ -83,7 +83,7 @@ The extension is mostly invisible until the agent explicitly targets a tab. Capa Two concrete use cases inform concept B and live in their own document: [`docs/scenarios.md`](../scenarios.md). - **Google topic research** — URL-driven happy path. The whole flow runs in read mode with `navigate` + `text` only; no clicks, no scroll, no shim. Validates that read mode covers a substantial autonomous workflow with effectively zero bot-detection footprint. -- **LinkedIn daily feed snapshot** — SPA + lazy-loaded feed. Adds a `scroll` primitive (ISOLATED-world `window.scrollBy` + DOM polling, no MutationObserver) and surfaces three escape hatches kept on the shelf for incremental escalation: permalink-driven full-body retrieval, `chrome.debugger` trusted scroll, and the Voyager internal API. +- **Authenticated feed snapshot** — SPA + lazy-loaded feed. Adds a `scroll` primitive (ISOLATED-world `window.scrollBy` + DOM polling, no MutationObserver) and surfaces three escape hatches kept on the shelf for incremental escalation: permalink-driven full-body retrieval, `chrome.debugger` trusted scroll, and the Voyager internal API. New scenarios should be added to `scenarios.md`; this journal only tracks the design-process reasoning behind them. @@ -136,10 +136,10 @@ This is probably the realistic landing point. The pivot question is not "A or B" - **2026-04-30** — both concepts captured. No pivot committed. Continuing reflection. - **2026-04-30** — Google-search worked example added. Validates that read mode covers a substantial autonomous use case with effectively zero bot-detection footprint when paired with URL-first navigation. - **2026-04-30** — Two requirements confirmed regardless of default-mode decision: (1) daemon-enforced human-pace throttling, (2) interstitial detection with `HUMAN_REQUIRED` structured error and explicit `session resume` hand-off. These belong in concept B's spec. -- **2026-04-30** — LinkedIn snapshot scenario added. Surfaces a third confirmed primitive for read mode: `bproxy scroll` with ISOLATED-world implementation (`window.scrollBy` + DOM polling, no MutationObserver). CLI surface stays narrow — agents pass intent (`--by`, optionally `--until-stable`); the extension owns the human-shaped behaviour governed by the session's `--pacing` setting. +- **2026-04-30** — Authenticated feed snapshot scenario added. Surfaces a third confirmed primitive for read mode: `bproxy scroll` with ISOLATED-world implementation (`window.scrollBy` + DOM polling, no MutationObserver). CLI surface stays narrow — agents pass intent (`--by`, optionally `--until-stable`); the extension owns the human-shaped behaviour governed by the session's `--pacing` setting. - **2026-04-30** — Worked-example sections moved out of this journal into `docs/scenarios.md`. The journal stays focused on design reasoning; scenarios accumulate as a separate growing document. -- **2026-04-30** — MVP posture confirmed: ship read mode + paced `scroll` + DOM polling + `HUMAN_REQUIRED` first; the three LinkedIn escape hatches (permalink retrieval, `chrome.debugger` trusted scroll, Voyager API) stay on the shelf and only earn their cost when real usage shows the basic loop is insufficient. Avoids over-engineering ahead of usage signal. +- **2026-04-30** — MVP posture confirmed: ship read mode + paced `scroll` + DOM polling + `HUMAN_REQUIRED` first; the three feed-snapshot escape hatches (permalink retrieval, `chrome.debugger` trusted scroll, Voyager API) stay on the shelf and only earn their cost when real usage shows the basic loop is insufficient. Avoids over-engineering ahead of usage signal. - **2026-04-30** — Form-fill scenario added (Scenario 3 in `scenarios.md`). Sharpens the meaning of "interact mode": it is read mode plus a small set of paste-shaped write primitives (`fill`, `fill-form`, `select`, `elements --form`, `require-human --for-attach`), not a wholesale switch into concept A's heavy instrumentation. Concept A may not have a separate identity at all — there is just one default mode (read mode) with write primitives that turn on when used. -- **2026-04-30** — Paste-as-default established. Earlier reasoning assumed paced character-by-character typing was the realistic primitive; correction: real humans paste from saved info docs / resumes / LinkedIn / templates and never type their CV. `bproxy fill` defaults to paste-flavored input events (`inputType: "insertFromPaste"`); per-character typing is opt-in. The session's `--pacing` value governs delay between fields, not within fields. +- **2026-04-30** — Paste-as-default established. Earlier reasoning assumed paced character-by-character typing was the realistic primitive; correction: real humans paste from saved info docs / resumes / notes docs / templates and never type their CV. `bproxy fill` defaults to paste-flavored input events (`inputType: "insertFromPaste"`); per-character typing is opt-in. The session's `--pacing` value governs delay between fields, not within fields. - **2026-04-30** — "Don't submit" handoff confirmed as load-bearing for write-heavy flows. Agent prepares the form; user reviews and clicks submit. The user-driven submit is `isTrusted: true` and often offsets any lower bot score from the fill behaviour. Mirrors the "agent prepares, user digests" posture from the read scenarios — consistent project-wide pattern of agent-as-preparer-not-actor. - **2026-04-30** — Decision finalized in ADR-001: default instrumentation is **read mode** (Accepted). This journal remains as historical design context. diff --git a/docs/internal/journal/2026-05-08-poc-paste-fill.md b/docs/internal/journal/2026-05-08-poc-paste-fill.md index a56be45..d1d37f5 100644 --- a/docs/internal/journal/2026-05-08-poc-paste-fill.md +++ b/docs/internal/journal/2026-05-08-poc-paste-fill.md @@ -1,11 +1,11 @@ -# PoC 3 — LinkedIn composer write path (retrospective, compact) +# PoC 3 — production composer write path (retrospective, compact) Date range: 2026-05-08 → 2026-05-09 Status: closed ## Original question -Can extension-driven write logic reliably set content in LinkedIn’s post composer without relying on synthetic paste/input events? +Can extension-driven write logic reliably set content in a production site's post composer without relying on synthetic paste/input events? --- @@ -78,7 +78,7 @@ So modal shell appears first; editor runtime is async shortly after. - This produced false hits on app containers. 3. **Assuming Lexical/fiber as mandatory target path** - - Useful as a possible route, but not a guaranteed one on live LinkedIn surfaces. + - Useful as a possible route, but not a guaranteed one on live production surfaces. - Concrete session validated Quill runtime path instead. 4. **Late adoption of `MAIN` world** @@ -101,7 +101,7 @@ So modal shell appears first; editor runtime is async shortly after. - Synthetic event simulation is not required for this surface. - Shadow-aware discovery is required. -- Runtime API mutation is implementable and reliable on the validated LinkedIn session. +- Runtime API mutation is implementable and reliable on the validated production session. - **Execution world choice is architectural:** runtime-handle access (`__quill` and similar page-owned objects) required `chrome.scripting.executeScript(..., { world: 'MAIN' })` in this PoC path; isolated world produced false negatives. - Practical implementation must be runtime-adaptive (Quill confirmed here; Lexical/fiber may exist on other builds/surfaces). diff --git a/docs/internal/journal/2026-05-12-anti-bot-detection-vs-cdp.md b/docs/internal/journal/2026-05-12-anti-bot-detection-vs-cdp.md index c31ab89..fc4c1a3 100644 --- a/docs/internal/journal/2026-05-12-anti-bot-detection-vs-cdp.md +++ b/docs/internal/journal/2026-05-12-anti-bot-detection-vs-cdp.md @@ -51,7 +51,7 @@ The residual surface is real but narrower. Honest list: - **`event.isTrusted === false`** on every synthetic input we dispatch. This is the **load-bearing risk**. `chrome.scripting.executeScript` cannot produce trusted events; only `chrome.debugger`'s `Input.dispatchKeyEvent` can, and we are explicitly refusing to attach the debugger by default. Any page handler that does `if (!e.isTrusted) return` will reject our input. - **Event-sequence shape.** A paste-flavored `InputEvent({inputType:'insertFromPaste'})` lacks the preceding `keydown` → `keypress` → `beforeinput(insertText)` → `keyup` chain that real typing produces. `selectionchange` cadence is non-human. -- **Extension-resource probing if `web_accessible_resources` exists.** In MV3, extension resources are not web-accessible by default; a web page cannot reliably fetch `chrome-extension:///manifest.json` or arbitrary packaged files unless they are declared as WAR. Scanners such as LinkedIn-style extension detectors work by probing known `(extension_id, WAR_path)` pairs. Therefore a WAR-less extension with no visible DOM mutations is not exposed to deterministic resource-fetch enumeration. +- **Extension-resource probing if `web_accessible_resources` exists.** In MV3, extension resources are not web-accessible by default; a web page cannot reliably fetch `chrome-extension:///manifest.json` or arbitrary packaged files unless they are declared as WAR. Scanners such as popular extension-detector probes work by probing known `(extension_id, WAR_path)` pairs. Therefore a WAR-less extension with no visible DOM mutations is not exposed to deterministic resource-fetch enumeration. - **WAR-less residual channels.** Chromium timing side-channels on failed `chrome-extension://` probes may distinguish installed vs non-installed IDs probabilistically, and behavioral/execution-trace fingerprinting can detect extensions that visibly alter DOM or page timing. These are narrower, noisy, and not fixable by WAR policy alone. - **DOM polling cadence.** A fixed `setInterval(poll, N)` is a behavioral tell if `N` is constant. - **Focus/visibility tells.** `document.hasFocus()` and `document.visibilityState` reflect reality. Input arriving at a `visibilityState === 'hidden'` tab is humanly impossible. diff --git a/docs/internal/journal/2026-05-30-linkedin-scroll-and-eval-gaps.md b/docs/internal/journal/2026-05-30-linkedin-scroll-and-eval-gaps.md index 71a6c32..2beb471 100644 --- a/docs/internal/journal/2026-05-30-linkedin-scroll-and-eval-gaps.md +++ b/docs/internal/journal/2026-05-30-linkedin-scroll-and-eval-gaps.md @@ -1,13 +1,13 @@ -# LinkedIn Scenario 2 findings — scroll false-success and missing eval enable path +# Scenario 2 findings — scroll false-success and missing eval enable path -> Update 2026-05-31: the missing eval enable path from this note has now been addressed via an extension popup Eval mode control. Follow-up debugging and the current LinkedIn MAIN-world `result: null` finding are recorded in [`2026-05-31-linkedin-eval-mode-and-main-world-null-result.md`](./2026-05-31-linkedin-eval-mode-and-main-world-null-result.md). +> Update 2026-05-31: the missing eval enable path from this note has now been addressed via an extension popup Eval mode control. Follow-up debugging and the current MAIN-world `result: null` finding are recorded in the 2026-05-31 follow-up eval note. Date: 2026-05-30 Status: proposed ## Context -During Phase 5 Scenario 2 validation against a real LinkedIn feed tab, the browser loaded successfully and `screenshot` confirmed a normal signed-in feed view in the foreground. +During Phase 5 Scenario 2 validation against a real target-site feed tab, the browser loaded successfully and `screenshot` confirmed a normal signed-in feed view in the foreground. Session used in the run: - session: `va52vh` @@ -33,8 +33,8 @@ Both responses were: "stable": true }, "page": { - "url": "https://www.linkedin.com/feed/", - "title": "Feed | LinkedIn", + "url": "https://app.example.test/feed/", + "title": "Feed | Example App", "state": "ready", "busy": false } @@ -44,7 +44,7 @@ Both responses were: Observed real behavior: - the viewport did **not** move; - the operator visually confirmed the page stayed in the same place; -- LinkedIn only showed a small "New publications" badge/update signal; +- The site only showed a small "New publications" badge/update signal; - the command still returned `ok: true` and `stable: true`. Interpretation: @@ -55,7 +55,7 @@ Interpretation: Expected hardening direction: - require evidence of actual movement before reporting success; - distinguish "scroll request accepted but no movement happened" from a real successful scroll; -- add a regression test for the LinkedIn-shaped case where `window.scrollBy(...)` plus DOM polling yields `stable: true` but `scrollY` remains unchanged. +- add a regression test for the target-site-shaped case where `window.scrollBy(...)` plus DOM polling yields `stable: true` but `scrollY` remains unchanged. ## Finding 2 — `eval` exists at the CLI but has no supported enable path @@ -87,5 +87,5 @@ This is not a hidden workaround issue; it is an intentionally deferred control-p ## Follow-up classification -- **Phase 5 in-scope blocker:** `scroll` false-success on LinkedIn Scenario 2. -- **Historical control-plane gap (now addressed on 2026-05-31):** this note originally recorded that no shipped eval-enable path existed. That is no longer true; see [`2026-05-31-linkedin-eval-mode-and-main-world-null-result.md`](./2026-05-31-linkedin-eval-mode-and-main-world-null-result.md) for the shipped popup control and the remaining real-site runtime finding. +- **Phase 5 in-scope blocker:** `scroll` false-success on target-site Scenario 2. +- **Historical control-plane gap (now addressed on 2026-05-31):** this note originally recorded that no shipped eval-enable path existed. That is no longer true; see the 2026-05-31 follow-up eval note for the shipped popup control and the remaining real-site runtime finding. diff --git a/docs/internal/journal/2026-05-30-screenshot-output-gap.md b/docs/internal/journal/2026-05-30-screenshot-output-gap.md index 242f3e5..8ee595d 100644 --- a/docs/internal/journal/2026-05-30-screenshot-output-gap.md +++ b/docs/internal/journal/2026-05-30-screenshot-output-gap.md @@ -5,7 +5,7 @@ Status: proposed ## Context -During Phase 5 real-system validation, `screenshot` was used to confirm the actual page state before continuing Scenario 1 (Google SERP) and Scenario 2 (LinkedIn feed). +During Phase 5 real-system validation, `screenshot` was used to confirm the actual page state before continuing Scenario 1 (Google SERP) and Scenario 2 (authenticated feed). The command succeeded, but the CLI returned the image only as a large base64 string inside protocol JSON. diff --git a/docs/internal/journal/2026-05-31-linkedin-eval-mode-and-main-world-null-result.md b/docs/internal/journal/2026-05-31-linkedin-eval-mode-and-main-world-null-result.md index aa288ff..5bb1193 100644 --- a/docs/internal/journal/2026-05-31-linkedin-eval-mode-and-main-world-null-result.md +++ b/docs/internal/journal/2026-05-31-linkedin-eval-mode-and-main-world-null-result.md @@ -1,4 +1,4 @@ -# LinkedIn eval debugging — popup enable path shipped, MAIN-world result is `null` on LinkedIn +# Target-site eval debugging — popup enable path shipped, MAIN-world result is `null` on the target site > **Course correction (2026-06-12):** This investigation is now considered a documented deviation from the intended bproxy path. The useful finding is not “replace string eval with debugger-backed eval”; it is that arbitrary page evaluation does not belong in bproxy at all. Page/runtime investigation should use normal browser debugging tools such as Chrome DevTools Protocol. bproxy should remain a thin sensor/actuator bridge and the `eval` command should be removed. @@ -7,13 +7,13 @@ Status: superseded by course correction on 2026-06-12 ## Context -Phase 5 Scenario 2 needed `eval` as a debugging/inspection tool before reworking scroll on LinkedIn. +Phase 5 Scenario 2 needed `eval` as a debugging/inspection tool before reworking scroll on target site. The previous blocker was that `eval` existed at the CLI, but there was no shipped operator path to enable it. Work on 2026-05-31 focused on: 1. shipping an extension popup control for eval mode; 2. making `EVAL_DISABLED` actionable for the agent; -3. using built-in observability to debug real LinkedIn eval behavior; +3. using built-in observability to debug real target site eval behavior; 4. instrumenting the MAIN-world eval path to show what Chrome actually returned. All validation in this note used smoke daemons started via `scripts/smoke/daemon.ts`, so commands had to target the printed `BPROXY_HOME` with `--home `. Looking only at the default `~/.bproxy` state dir is misleading during smoke runs. @@ -66,12 +66,12 @@ Conclusion: This was only a control check, not evidence that eval works on real hostile apps. -### Step 2 — run LinkedIn eval +### Step 2 — run target site eval Command shape used repeatedly: ```bash -node cli/dist/bproxy.mjs tab open --url https://www.linkedin.com/ --home +node cli/dist/bproxy.mjs tab open --url https://app.example.test/ --home node cli/dist/bproxy.mjs eval -s --allow-eval --code 'return 1+1' --home ``` @@ -136,11 +136,11 @@ Conclusion: Goal: - if the page threw something meaningful (for example an `EvalError`), return its `name` and `message`. -This was useful, but it did **not** explain the LinkedIn failure because the next LinkedIn run produced a different error. +This was useful, but it did **not** explain the target site failure because the next target site run produced a different error. ### Finding — wrapper error before page-level diagnostics -After the first instrumentation change, LinkedIn eval returned: +After the first instrumentation change, target site eval returned: ```json { @@ -158,7 +158,7 @@ Interpretation: - it was an extension wrapper error; - the background expected a structured result object with `.ok`, but the received value was `null`. -This shifted the debugging question from “what did LinkedIn throw?” to “what did `chrome.scripting.executeScript(...)` actually return?” +This shifted the debugging question from “what did target site throw?” to “what did `chrome.scripting.executeScript(...)` actually return?” ### Instrumentation 2 — expose raw `executeScript` response shape @@ -195,9 +195,9 @@ That produced this real Chrome error: This was fixed by stripping `debugName` before calling the Chrome API. -## Real LinkedIn result after malformed-result diagnostics +## Real target site result after malformed-result diagnostics -After reloading the rebuilt extension and repeating the smoke run, LinkedIn eval first returned a malformed MAIN-world result diagnostic: +After reloading the rebuilt extension and repeating the smoke run, target site eval first returned a malformed MAIN-world result diagnostic: ```json { @@ -231,7 +231,7 @@ After reloading the rebuilt extension and repeating the smoke run, LinkedIn eval A follow-up probe was then added: when eval returns a malformed/null result, the extension runs a second MAIN-world function that does **not** use string evaluation and simply returns a structured object. -After reloading again and repeating the smoke run, LinkedIn eval returned: +After reloading again and repeating the smoke run, target site eval returned: ```json { @@ -251,8 +251,8 @@ After reloading again and repeating the smoke run, LinkedIn eval returned: "ok": true, "result": { "probe": true, "value": 2 }, "page": { - "url": "https://www.linkedin.com/", - "title": "Feed | LinkedIn", + "url": "https://app.example.test/", + "title": "Feed | Example App", "readyState": "interactive", "busyHint": true } @@ -263,8 +263,8 @@ After reloading again and repeating the smoke run, LinkedIn eval returned: ``` Interpretation: -- MAIN-world injection itself works on LinkedIn; -- returning structured objects from MAIN world works on LinkedIn; +- MAIN-world injection itself works on target site; +- returning structured objects from MAIN world works on target site; - the broken part is specifically the string-evaluation path used by the current `eval` implementation. ## Cross-check with Dev Browser on Chrome port 9222 @@ -273,18 +273,18 @@ To distinguish page behavior from extension behavior, the same real Chrome was i What was done: - listed tabs via `browser.listPages()` to confirm the CDP connection worked; -- opened a fresh LinkedIn page through Dev Browser; +- opened a fresh target site page through Dev Browser; - ran plain page evaluation: `page.evaluate(() => 1 + 1)`; - ran function-constructor evaluation: `page.evaluate(() => Function("return 1+1")())`; - ran a wrapped version that imitated the extension's `injectedEval` shape and returned `{ ok, result, page }`. -Observed result on the real LinkedIn feed page: +Observed result on the real target site feed page: - direct evaluation returned `2`; - `Function("return 1+1")()` also returned `2`; - the wrapped object `{ ok: true, result: 2, page: ... }` was returned successfully. Interpretation: -- LinkedIn does not simply reject `Function(...)` in all contexts; +- target site does not simply reject `Function(...)` in all contexts; - the page-level JavaScript logic used by `injectedEval` works when executed through CDP/Playwright evaluation; - the current failure is therefore narrower: it is specific to the extension-side `chrome.scripting.executeScript(..., world: "MAIN")` path or to a difference between extension injection and CDP evaluation. @@ -317,8 +317,8 @@ What the docs suggest: 4. **`debug.last` was not useful in this smoke run.** It returned an empty request list. -5. **The current LinkedIn failure is now much narrower.** - A non-eval MAIN-world probe succeeds on LinkedIn, so MAIN-world injection and structured return values are working. +5. **The current target site failure is now much narrower.** + A non-eval MAIN-world probe succeeds on target site, so MAIN-world injection and structured return values are working. 6. **The broken part is specifically the current string-eval mechanism.** The failing path is the current `Function(code)`-style evaluation inside extension-injected MAIN-world code. diff --git a/docs/internal/journal/2026-05-31-scroll-container-investigation.md b/docs/internal/journal/2026-05-31-scroll-container-investigation.md index ab0bd59..68c1d32 100644 --- a/docs/internal/journal/2026-05-31-scroll-container-investigation.md +++ b/docs/internal/journal/2026-05-31-scroll-container-investigation.md @@ -1,4 +1,4 @@ -# Scroll container investigation — LinkedIn feed +# Scroll container investigation — authenticated feed > **Course correction (2026-06-12):** This investigation is now considered a documented deviation from the intended bproxy path. The useful finding is not “teach bproxy to infer the right scroll container”; it is that page-structure investigation and scroll-target choice belong to the agent, with normal browser debugging tools available when needed. bproxy should expose explicit scroll actuator primitives and honest movement/no-op reporting, not generalized page-layout heuristics. @@ -8,13 +8,13 @@ Status: superseded by course correction on 2026-06-12 ## Context Following the scroll false-success bug documented in -`2026-05-30-linkedin-scroll-and-eval-gaps.md`, this session investigated why -`window.scrollBy()` has no effect on LinkedIn and what the correct approach to +`the 2026-05-30 scroll/eval findings note`, this session investigated why +`window.scrollBy()` has no effect on the target site and what the correct approach to finding the real scroll container is. -## Finding 1 — `window.scrollBy()` is never the right target on LinkedIn +## Finding 1 — `window.scrollBy()` is never the right target on the authenticated feed page -LinkedIn's feed page does not scroll at the window/viewport level. +the target site's feed page does not scroll at the window/viewport level. `window.scrollY` stays at 0 regardless of how many times `window.scrollBy()` is called. The actual scroll container is a `
` element deep in the React tree. @@ -28,7 +28,7 @@ Confirmed via `dev-browser --connect http://localhost:9222` + `page.evaluate()`: clientHeight: 1020 ``` -This is not a LinkedIn quirk — it is the standard pattern for any React SPA that +This is not a the target site quirk — it is the standard pattern for any React SPA that uses a persistent nav/sidebar layout: `overflow: hidden` on `` and ``, a full-height scrollable `
` or equivalent container div. @@ -39,7 +39,7 @@ at the center of the viewport. Walking up its ancestor chain with `getComputedStyle(el).overflowY` and `el.scrollHeight > el.clientHeight` finds the real scroll container. -On LinkedIn this walk went through 15 ancestors (IMG → FIGURE → A → … → MAIN) +On the target site this walk went through 15 ancestors (IMG → FIGURE → A → … → MAIN) before finding `
`. This approach is correct because: @@ -56,7 +56,7 @@ hits the wrong container first on pages with multiple scrollable regions. ## Finding 3 — pages can have multiple scroll containers `elementFromPoint` returns one point, which belongs to one scroll container. On -LinkedIn there are at least: the main feed (`
`), the +the target site there are at least: the main feed (`
`), the messaging panel, comment threads inside posts. If the center point lands inside one of the smaller containers, the ancestor walk returns that container instead of the main feed. @@ -74,7 +74,7 @@ The `scroll` action API should expose this as an optional `--container` (or ## Finding 4 — backgrounded tab prevents React from rendering the feed When the tab opened by `tab open` is not the active foreground tab in Chrome, -LinkedIn's JavaScript does not render feed posts. `text` returned only 268 chars +the target site's JavaScript does not render feed posts. `text` returned only 268 chars (skip-navigation links) even after 8 seconds, while a screenshot of the already- loaded tab in the same Chrome window showed a full feed. The content script and the screenshot were targeting different tabs. diff --git a/docs/internal/journal/2026-06-12-diagnostic-commands-proposal.md b/docs/internal/journal/2026-06-12-diagnostic-commands-proposal.md index 1433c76..ac427de 100644 --- a/docs/internal/journal/2026-06-12-diagnostic-commands-proposal.md +++ b/docs/internal/journal/2026-06-12-diagnostic-commands-proposal.md @@ -5,7 +5,7 @@ Status: **implemented** ✅ ## Context -LinkedIn investigation revealed that when bproxy sensors fail (e.g., `text` returning 282 chars instead of 20K), agents had no way to self-diagnose. Required dev-browser/CDP as a second tool. +Target-site investigation revealed that when bproxy sensors fail (e.g., `text` returning 282 chars instead of 20K), agents had no way to self-diagnose. Required dev-browser/CDP as a second tool. ## Decision @@ -61,4 +61,4 @@ New files: `inspect.ts`, `snapshot.ts`, `snapshot-roles.ts`, `read-deps.ts` (ext ## Validated in production -Tested against live LinkedIn profile and job search — both commands work end-to-end through the full bproxy stack (CLI → daemon → WebSocket → extension → content script → response). +Tested against live target-site profile and job-search — both commands work end-to-end through the full bproxy stack (CLI → daemon → WebSocket → extension → content script → response). diff --git a/docs/internal/journal/2026-06-12-linkedin-agent-instructions.md b/docs/internal/journal/2026-06-12-linkedin-agent-instructions.md index c1c2c2d..4ad2d17 100644 --- a/docs/internal/journal/2026-06-12-linkedin-agent-instructions.md +++ b/docs/internal/journal/2026-06-12-linkedin-agent-instructions.md @@ -1,4 +1,4 @@ -# LinkedIn navigation with bproxy +# Target-site navigation with bproxy Date: 2026-06-12 (updated after inspect/snapshot implementation) @@ -6,7 +6,7 @@ Date: 2026-06-12 (updated after inspect/snapshot implementation) | Goal | Command | |------|---------| -| Navigate | `bproxy navigate --url "https://www.linkedin.com/..." -s ` | +| Navigate | `bproxy navigate --url "https://app.example.test/..." -s ` | | Page map | `bproxy outline -s ` | | Full text | `bproxy text -s ` (14K+ chars on profiles) | | Scoped text | `bproxy text --selector "section[aria-label='Primary content']" -s ` | @@ -21,7 +21,7 @@ Date: 2026-06-12 (updated after inspect/snapshot implementation) ### Scroll container -LinkedIn sets `overflow: hidden` on html/body. The scroll container is `main` (it auto-detects). Standard viewport scroll works via bproxy. +The target site sets `overflow: hidden` on html/body. The scroll container is `main` (it auto-detects). Standard viewport scroll works via bproxy. ### Lazy loading @@ -29,7 +29,7 @@ Sections below the fold load on scroll. Scroll down and re-check with `outline` ### Deep wrapper nesting -LinkedIn nests content 8-12 div wrappers deep inside `display: contents` parents. When using `snapshot`: +The target site nests content 8-12 div wrappers deep inside `display: contents` parents. When using `snapshot`: - Use `--maxDepth 12` to reach semantic elements - Scope to `section[aria-label='Primary content']` to avoid wasting depth on page chrome @@ -47,7 +47,7 @@ Look for: `display=contents` + `rect=0x0` + high `descendants`/`textLength` → `aria-label` values are locale-dependent ("Primary content" in English, "Contenido principal" in Spanish). Use `outline` to discover actual headings, don't hardcode labels. -## LinkedIn DOM structure +## Target-site DOM structure ``` main [scrollable] @@ -72,7 +72,7 @@ Multiple visual sections are grouped into single DOM containers. ### Read a profile ```bash -bproxy navigate --url "https://www.linkedin.com/in//" -s +bproxy navigate --url "https://app.example.test/in//" -s bproxy outline -s # see loaded sections bproxy scroll --direction down -s # load lazy content bproxy text -s # full text (14K+ chars) @@ -81,7 +81,7 @@ bproxy text -s # full text (14K+ chars) ### Search and open a job posting ```bash -bproxy navigate --url "https://www.linkedin.com/jobs/search/?keywords=delivery%20lead" -s +bproxy navigate --url "https://app.example.test/jobs/search/?keywords=delivery%20lead" -s bproxy links -s # find /jobs/view/ links bproxy navigate --url "" -s bproxy text --selector "section[aria-label='Primary content']" -s diff --git a/docs/internal/journal/2026-06-12-linkedin-navigation-findings.md b/docs/internal/journal/2026-06-12-linkedin-navigation-findings.md index 6dc98ee..3b9e576 100644 --- a/docs/internal/journal/2026-06-12-linkedin-navigation-findings.md +++ b/docs/internal/journal/2026-06-12-linkedin-navigation-findings.md @@ -1,11 +1,11 @@ -# LinkedIn navigation findings — `display: contents` visibility bug +# Target-site navigation findings — `display: contents` visibility bug Date: 2026-06-12 Status: **resolved** (fix landed + `inspect`/`snapshot` commands implemented) ## Root cause -LinkedIn uses `display: contents` CSS on 75+ wrapper divs. These elements have zero `getBoundingClientRect()` but their children are fully visible. +The target site uses `display: contents` CSS on 75+ wrapper divs. These elements have zero `getBoundingClientRect()` but their children are fully visible. bproxy's `isElementVisible()` → `hasZeroRect()` treated them as hidden → entire content subtrees (2K+ descendants, 15K+ chars) were pruned during recursive traversal. @@ -26,14 +26,14 @@ function hasZeroRect(element: Element): boolean { ## Result -- `bproxy text` on LinkedIn profile: 282 chars → **20,791 chars** ✅ +- `bproxy text` on target-site profile: 282 chars → **20,791 chars** ✅ - `bproxy elements` button labels: 187 → **236** elements with text ✅ ## Why `outline` and `elements` worked before the fix `walkComposedElements` (BFS flat walker) unconditionally enqueues children before the outer loop filters by visibility. The `display: contents` div was skipped but its children were already queued. -## LinkedIn page structure +## Target-site page structure ``` main [scrollable] diff --git a/docs/internal/journal/2026-06-13-interaction-primitives-gap.md b/docs/internal/journal/2026-06-13-interaction-primitives-gap.md index 137477f..f755b7b 100644 --- a/docs/internal/journal/2026-06-13-interaction-primitives-gap.md +++ b/docs/internal/journal/2026-06-13-interaction-primitives-gap.md @@ -1,7 +1,7 @@ # Interaction primitives gap — click, dismiss, activate **Date:** 2026-06-13 -**Context:** Production test after Sonar tech-debt cleanup. Task: read BCG LinkedIn posts and homepage, compare messaging. +**Context:** Production test after Sonar tech-debt cleanup. Task: read target-site posts and homepage, compare messaging. ## Observation diff --git a/docs/internal/plans/phases/00-poc.md b/docs/internal/plans/phases/00-poc.md index eeff4cf..a5d0057 100644 --- a/docs/internal/plans/phases/00-poc.md +++ b/docs/internal/plans/phases/00-poc.md @@ -4,7 +4,7 @@ title: Phase 0 — PoC > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** De-risk three load-bearing technical assumptions in the bproxy design — MV3 SW WebSocket lifecycle, CLI → extension pairing transport, and a write technique for hostile rich-text editors (LinkedIn post composer) — and capture findings as journal memos and (where appropriate, and only after the relevant PoC closes) ADR amendments. +**Goal:** De-risk three load-bearing technical assumptions in the bproxy design — MV3 SW WebSocket lifecycle, CLI → extension pairing transport, and a write technique for hostile rich-text editors (production post composer) — and capture findings as journal memos and (where appropriate, and only after the relevant PoC closes) ADR amendments. **Architecture:** Three independent throwaway PoCs under `poc//`, each answering one question with the smallest possible code. PoCs are standalone Node projects (their own `package.json`), never imported by production packages, but committed for reference. Each closes with a journal memo (question / method / finding / implication / verdict). @@ -755,7 +755,7 @@ EOF --- -## Task 3: PoC 3 — Write technique for hostile rich-text editors (LinkedIn post composer) +## Task 3: PoC 3 — Write technique for hostile rich-text editors (production post composer) **Files:** - Update: `poc/paste-fill/README.md` @@ -767,7 +767,7 @@ EOF - Update: `poc/paste-fill/extension/popup.js` - Append to: `docs/journal/2026-05-08-poc-paste-fill.md` -**Question:** Can the bproxy extension write text into LinkedIn's post composer (Lexical-backed contenteditable, hosted inside an open shadow root, with the editor's paste handler gating on `isTrusted`) by: +**Question:** Can the bproxy extension write text into a production site's post composer (Lexical-backed contenteditable, hosted inside an open shadow root, with the editor's paste handler gating on `isTrusted`) by: 1. Locating the editor element via a shadow-piercing recursive walker rooted at `document`. 2. Walking React fibers (`__reactFiber$*` / `__reactProps$*`) up from that element to obtain the Lexical editor instance. @@ -779,13 +779,13 @@ EOF **Constraint (binding) — no decision-doc edits in this task:** Do **not** modify `docs/decisions.md` (ADRs), `docs/architecture.md`, or `docs/solution/*` as part of PoC 3. ADR-007 and adjacent decision records are revisited as a separate task only **after** this PoC closes with a verdict. The journal memo for PoC 3 closes with a verdict; ADR work is downstream of the verdict, not inside this task. -**Target:** LinkedIn feed at `https://www.linkedin.com/feed/`. Post composer modal opened by clicking "Start a post". +**Target:** feed page at `https://app.example.test/feed/`. Post composer modal opened by clicking "Start a post". **Timebox:** 2 days. If a definitive answer isn't reached, mark the PoC inconclusive and decide next step. **Execution mode for this PoC (required): Dev Browser connected to real Chrome** -Use this workflow to inspect LinkedIn DOM and run popup-driven extension actions against a persistent logged-in Chrome profile: +Use this workflow to inspect the target-site DOM and run popup-driven extension actions against a persistent logged-in Chrome profile: 1. Quit Chrome fully. 2. Start Chrome with remote debugging and persistent profile: @@ -794,7 +794,7 @@ Use this workflow to inspect LinkedIn DOM and run popup-driven extension actions --remote-debugging-port=9222 \ --user-data-dir="$HOME/context/personal/chrome-devtools-profile" ``` -3. Open `https://www.linkedin.com/feed/` and sign in (once). Reuse this same profile path in later sessions. +3. Open `https://app.example.test/feed/` and sign in (once). Reuse this same profile path in later sessions. 4. Verify CDP is reachable: ```bash curl -sS http://127.0.0.1:9222/json/version @@ -806,7 +806,7 @@ Use this workflow to inspect LinkedIn DOM and run popup-driven extension actions console.log(JSON.stringify(tabs, null, 2)); EOF ``` -6. Attach to the LinkedIn tab by `id` from `listPages()` when you need scripted DOM/fiber reconnaissance: +6. Attach to the target-site tab by `id` from `listPages()` when you need scripted DOM/fiber reconnaissance: ```bash dev-browser --connect <<'EOF' const page = await browser.getPage(""); @@ -829,7 +829,7 @@ The composer lives inside an open shadow root (`div#interop-outlet.theme--light` - [x] **Step 3: Resolve editor runtime handle (Quill, not Lexical/fiber).** -The plan originally targeted React-fiber traversal to a Lexical editor instance. Live LinkedIn surface revealed **Quill**: the editor container is `.editor-content.ql-container` with a `__quill` runtime handle. Implemented `findQuillInRoot(root)`: first tries direct query for `.editor-content.ql-container, .ql-container, .editor-content` checking `.__quill`, then falls back to star-scan of all elements in the scoped root. Progressive wait with short pauses (50–400ms) handles async runtime mount. Execution in `MAIN` world (`chrome.scripting.executeScript(..., { world: 'MAIN' })`) is required — isolated world cannot see page-owned runtime handles. +The plan originally targeted React-fiber traversal to a Lexical editor instance. Live production surface revealed **Quill**: the editor container is `.editor-content.ql-container` with a `__quill` runtime handle. Implemented `findQuillInRoot(root)`: first tries direct query for `.editor-content.ql-container, .ql-container, .editor-content` checking `.__quill`, then falls back to star-scan of all elements in the scoped root. Progressive wait with short pauses (50–400ms) handles async runtime mount. Execution in `MAIN` world (`chrome.scripting.executeScript(..., { world: 'MAIN' })`) is required — isolated world cannot see page-owned runtime handles. - [x] **Step 4: Write via Quill runtime API.** @@ -843,24 +843,24 @@ Separate `pageTaskRead()` function locates the Quill handle through the same sha `pageTaskSnapshot()` uses a recursive `walk(root, hostLabel, out)` that descends into every `node.shadowRoot`, attributing each element to its shadow host. The snapshot includes `hasQuill` flag per element for runtime-handle visibility. Replaced the old content-script-based snapshot which was light-DOM only. -- [x] **Step 7: Validate end-to-end against the live LinkedIn composer.** +- [x] **Step 7: Validate end-to-end against the live production composer.** In Chrome: 1. `chrome://extensions` → enable Developer mode → Load unpacked → select `poc/paste-fill/extension/`. -2. Open `https://www.linkedin.com/feed/`. +2. Open `https://app.example.test/feed/`. 3. Click the extension icon to open the popup. 4. Click "Open composer" — verify the modal appears (Step 1 fix is exercised). 5. Click "Fill composer" with a known test value. 6. Click "Check current composer text" — value reads back equal to the input. 7. Manually type one extra character at the end of the composer. The composer must preserve ``, not reset. -8. Walk LinkedIn's own preview / post-confirmation flow far enough to verify the content is treated as legitimate. **Do not submit.** +8. Walk the site's own preview / post-confirmation flow far enough to verify the content is treated as legitimate. **Do not submit.** -Record observed behaviour in scratch notes for Step 8: which selector landed the editor element, which fiber-key path exposed the instance, which API call mutated state, and any LinkedIn-specific quirks (placeholder behaviour, validation re-runs, dev warnings in console). +Record observed behaviour in scratch notes for Step 8: which selector landed the editor element, which fiber-key path exposed the instance, which API call mutated state, and any site-specific quirks (placeholder behaviour, validation re-runs, dev warnings in console). - [x] **Step 8: Append the verdict section to the journal memo.** -Journal rewritten as compact retrospective covering the full investigation arc (Phases A–E), wrong turns, and confirmed conclusions. Verdict: ⚠️ **Modifies** the strict Lexical/fiber framing — runtime API approach confirmed on LinkedIn via Quill handle, not React fiber walk. Key architectural constraints documented: MAIN world required for runtime-handle access; DOM discovery should be progressive and intent-scoped. +Journal rewritten as compact retrospective covering the full investigation arc (Phases A–E), wrong turns, and confirmed conclusions. Verdict: ⚠️ **Modifies** the strict Lexical/fiber framing — runtime API approach confirmed on the production site via Quill handle, not React fiber walk. Key architectural constraints documented: MAIN world required for runtime-handle access; DOM discovery should be progressive and intent-scoped. `docs/decisions.md`, `docs/architecture.md`, and `docs/solution/*` not edited — ADR work is downstream. @@ -869,7 +869,7 @@ Journal rewritten as compact retrospective covering the full investigation arc ( ```bash git add poc/paste-fill docs/journal/2026-05-08-poc-paste-fill.md git commit -m "$(cat <<'EOF' -poc: validate fiber-walk write on LinkedIn post composer +poc: validate fiber-walk write on production post composer PoC 3 from docs/plans/phases/00-poc.md. Findings in docs/journal/2026-05-08-poc-paste-fill.md. diff --git a/docs/internal/plans/phases/00.5-decisions-distilled.md b/docs/internal/plans/phases/00.5-decisions-distilled.md index bf4c350..56ff946 100644 --- a/docs/internal/plans/phases/00.5-decisions-distilled.md +++ b/docs/internal/plans/phases/00.5-decisions-distilled.md @@ -21,7 +21,7 @@ title: Phase 0.5 — Decisions Distilled --- -## From PoC 3: LinkedIn Composer Write Path (2026-05-08→09) +## From PoC 3: Production Composer Write Path (2026-05-08→09) | # | Decision/Constraint | Tag | Target Doc | Target Section/ADR | |---|---------------------|-----|------------|-------------------| @@ -35,7 +35,7 @@ title: Phase 0.5 — Decisions Distilled | 3.8 | **Version stamping in logs** — required to eliminate stale-build confusion | decision | `docs/solution/extension.md` | § Observability — ring buffer schema | | 3.9 | **Staged execution flow:** click → detect shadow modal → wait for runtime → write → verify | decision | `docs/solution/extension.md` | § Write actions — method semantics | | 3.10 | **Closed shadow roots explicitly out of scope** — open shadow roots only | decision | `docs/decisions.md` | ADR-014: scope declaration | -| 3.11 | **Modal timing varies** — dialog ~101ms, editor ~409ms (LinkedIn specific) | assumption | `docs/scenarios.md` | Scenario 2 — LinkedIn snapshot timing notes | +| 3.11 | **Modal timing varies** — dialog ~101ms, editor ~409ms (site-specific) | assumption | `docs/scenarios.md` | Scenario 2 — authenticated feed snapshot timing notes | --- diff --git a/docs/internal/plans/phases/03-extension.md b/docs/internal/plans/phases/03-extension.md index 3cc28d0..343bf17 100644 --- a/docs/internal/plans/phases/03-extension.md +++ b/docs/internal/plans/phases/03-extension.md @@ -473,7 +473,7 @@ WXT should be configured with `srcDir: "src"` so entrypoints live under `extensi - CLI command UX and argument parsing. Phase 4 owns `bproxy` commands. - Agent-side fill-method selection guidance beyond keeping `docs/skills/fill-method-selection.md` references accurate. The extension must not implement method selection. -- Real-site scenario validation against Google/LinkedIn/application forms. Phase 5 owns external scenario hardening. +- Real-site scenario validation against Google/authenticated feed sites/application forms. Phase 5 owns external scenario hardening. - Closed shadow-root support. - Network shims or stealth patches. - Public docs deployment. diff --git a/docs/internal/plans/phases/04-cli.md b/docs/internal/plans/phases/04-cli.md index b138821..abcec71 100644 --- a/docs/internal/plans/phases/04-cli.md +++ b/docs/internal/plans/phases/04-cli.md @@ -397,7 +397,7 @@ If the implementation discovers a better layout, update `docs/public/solution/cl ## Out of scope for Phase 4 -- Real-site scenario hardening against Google, LinkedIn, or application forms. Phase 5 owns external validation. +- Real-site scenario hardening against Google, authenticated feed sites, or application forms. Phase 5 owns external validation. - New browser capabilities or extension action handlers. Phase 4 may expose existing actions; it should not add page-side behavior. - Agent-side fill-method selection guidance beyond keeping links/docs accurate. The CLI must not implement method selection. - Closed shadow-root support, network shims, stealth patches, or trusted input simulation. diff --git a/docs/internal/plans/phases/05-integration-hardening.md b/docs/internal/plans/phases/05-integration-hardening.md index 894bd56..9b78351 100644 --- a/docs/internal/plans/phases/05-integration-hardening.md +++ b/docs/internal/plans/phases/05-integration-hardening.md @@ -30,7 +30,7 @@ title: Phase 5 - Integration & hardening 5. **`session bind` binds logical tabs.** `session bind --tab t1` moves the current session binding to a session-owned logical tab. Raw Chrome ids are rejected at the CLI and daemon boundary. 6. **Structured link extraction exists.** A first-class `links` action/CLI command returns visible page links as structured data (`text`, `href`, `target`, optional metadata) so research workflows do not need shell/Node HTML parsing. 7. **Generated selectors are robust.** `elements` never fails an entire command because one generated selector contains a newline, quote, backslash, or other CSS-hostile attribute value. Unsafe selector generation falls back to a safe target representation or omits only the unsafe selector field with traceable metadata. -8. **Documented scenarios run against the real system.** Scenario 1 (Google research) runs autonomously to completion in a fresh paired setup. Scenario 2 (LinkedIn snapshot) validates scroll/pause behaviour and handles `HUMAN_REQUIRED`. Scenario 3 (form fill) fills a real form to the user-review step. +8. **Documented scenarios run against the real system.** Scenario 1 (Google research) runs autonomously to completion in a fresh paired setup. Scenario 2 (authenticated feed snapshot) validates scroll/pause behaviour and handles `HUMAN_REQUIRED`. Scenario 3 (form fill) fills a real form to the user-review step. 9. **Docs match code.** `docs/public/solution/*.md`, `docs/internal/architecture.md`, `docs/internal/scenarios.md`, package READMEs, and affected public views describe the shipped Phase 5 behaviour - not Phase 4 compatibility and not future wishes. 10. **Fast local guardrails exist.** Pre-commit hooks run a fast, low-friction subset of the existing gates, while CI/root checks remain authoritative. 11. **Static and runtime gates pass:** `pnpm check`, `pnpm test`, `pnpm docs:build`, relevant package builds, `pnpm views:regen`, and the Phase 5 smoke/manual scenario checks. @@ -230,24 +230,24 @@ docs/public/views/auto/*.svg # MODIFIED by pnpm views:regen - [X] Add or update a local smoke script for fresh paired setup: `service start` → pair extension → `tab open` → `navigate` → `text` → `links` → `session close`. - [X] Run the Phase 5 scenario transcripts (written in Task 1) as the acceptance scripts. The transcripts define the exact commands and expected response shapes. - [X] Scenario 1 transcript was executed against a real Google SERP and completed successfully. - - [X] Scenario 2 validated through scroll fix, inspect/snapshot diagnostics, and live LinkedIn runs (Task 8a). + - [X] Scenario 2 validated through scroll fix, inspect/snapshot diagnostics, and live target-site runs (Task 8a). - [X] Scenario 3 transcript was executed to the human-review boundary and completed without any submit action. - [X] Run Scenario 1 (Google topic research) with a real Chrome profile. The human handles any login/CAPTCHA. Document the command transcript and results. - [X] Fresh `tab open` bootstrap returned generated session + logical tab handle. - [X] Real Google SERP validated `links` extraction and rendered-text reads. - [X] Real run showed the transcript should use `#search`, not `main`, for this page shape. -- [X] Run Scenario 2 (LinkedIn snapshot) far enough to validate scroll pacing, foreground-tab behaviour, and `HUMAN_REQUIRED`/pause handling. If site conditions make full automation inappropriate, document the bounded manual result. +- [X] Run Scenario 2 (authenticated feed snapshot) far enough to validate scroll pacing, foreground-tab behaviour, and `HUMAN_REQUIRED`/pause handling. If site conditions make full automation inappropriate, document the bounded manual result. - [X] Feed loads in a real signed-in Chrome profile and stays usable in the foreground; screenshot confirmed the expected feed view. - [X] Reproduced blocker: `scroll -s --by viewport --direction down --until-stable` returned `ok: true`, `stable: true`, and `scrolledPx: 0` twice while the viewport visibly did not move. - [X] Reproduced deviation: `eval --allow-eval` existed as a tempting debugging path, but the 2026-06-12 course correction rejects arbitrary eval as a bproxy feature. Use CDP/devtools for page investigation instead. - - [X] Fixed the LinkedIn scroll false-success class without adding scroll-container inference: `scroll` now supports explicit `ElementTarget` and returns honest `moved`/before/after data. See `docs/internal/journal/2026-05-30-linkedin-scroll-and-eval-gaps.md` and `docs/internal/journal/2026-05-31-scroll-container-investigation.md`. - - [X] Re-run is obsolete: scroll explicit-target fix (`78cc005`), inspect/snapshot diagnostic commands (`d3e6e73`), and live LinkedIn validation in Task 8a collectively satisfy the Scenario 2 acceptance criteria. No separate re-run needed. + - [X] Fixed the target-site scroll false-success class without adding scroll-container inference: `scroll` now supports explicit `ElementTarget` and returns honest `moved`/before/after data. See the 2026-05-30 scroll/eval findings note and `docs/internal/journal/2026-05-31-scroll-container-investigation.md`. + - [X] Re-run is obsolete: scroll explicit-target fix (`78cc005`), inspect/snapshot diagnostic commands (`d3e6e73`), and live target-site validation in Task 8a collectively satisfy the Scenario 2 acceptance criteria. No separate re-run needed. - [X] Run Scenario 3 (form fill) against a real or realistic application form to the user-review step; verify no submit action exists in the flow. - - [X] Used the LinkedIn post composer modal as a realistic human-review form boundary. + - [X] Used the target-site composer modal as a realistic human-review form boundary. - [X] `elements --form` discovered a shadow-route textbox target under `#interop-outlet`. - [X] `fill --method paste --world isolated` successfully inserted visible draft text into the composer. - [X] No submit/publish action was used; validation stopped at human review. -- [X] Record any new real-use findings in `docs/internal/journal/` rather than silently expanding Phase 5. Current findings to preserve: Google false-positive `busy` reporting, LinkedIn `scroll` false-success, the eval course correction, and screenshot file-output UX gap. +- [X] Record any new real-use findings in `docs/internal/journal/` rather than silently expanding Phase 5. Current findings to preserve: Google false-positive `busy` reporting, target-site `scroll` false-success, the eval course correction, and screenshot file-output UX gap. **Done when:** the phase's success criteria are demonstrated against the documented workflows or explicitly deferred with a journaled reason and a follow-up plan. @@ -255,14 +255,14 @@ docs/public/views/auto/*.svg # MODIFIED by pnpm views:regen ## Task 8a: Diagnostic sensor commands (`inspect` + `snapshot`) -**Purpose:** Give agents self-diagnostic capability when existing sensors fail. Discovered during LinkedIn scenario validation (Task 8, Scenario 2) — `text` returned 282 chars due to `display: contents` wrappers; agent had no way to diagnose without dev-browser/CDP. +**Purpose:** Give agents self-diagnostic capability when existing sensors fail. Discovered during target-site scenario validation (Task 8, Scenario 2) — `text` returned 282 chars due to `display: contents` wrappers; agent had no way to diagnose without dev-browser/CDP. - [X] Implement `inspect` action — CSS selector → structural metadata (rect, computed styles, descendants, textLength, scroll state). Same fixed-schema sensor pattern as `text`/`dom`. - [X] Implement `snapshot` action — accessibility tree sensor. Walks DOM + ARIA semantics, immune to CSS layout tricks. Returns indented text optimized for LLM consumption. - [X] Wire through all 6 layers: shared types → service schemas → extension forwarded-actions → content RPC → content handlers → CLI commands. - [X] Tests: 17 new tests (7 inspect, 10 snapshot), all existing 329+187+162 tests pass. - [X] Quality gates: `pnpm check` passes (typecheck, format, lint, arch, deadcode). -- [X] Validated in production against live LinkedIn profile and job search pages. +- [X] Validated in production against live target-site profile and job-search pages. **Done when:** agents can run `bproxy inspect --selector "section > div"` and immediately see `{display: "contents", rect: 0×0, descendants: 2273, textLength: 15116}` — self-diagnosing layout-transparent wrappers without reaching for a second tool. @@ -279,7 +279,7 @@ docs/public/views/auto/*.svg # MODIFIED by pnpm views:regen - [X] Ensure every daemon/extension error returned through protocol uses a complete `BproxyError` shape — test: invalid session, invalid tab handle, paused session, no extension, timeout, and malformed extension response all produce well-formed error envelopes. - [X] Update `debug.status`/`debug.last` to show generated sessions and logical tabs without leaking raw Chrome ids in normal fields. Ring buffer wired in production; verified by test. - [X] Tighten page `busy` reporting. Real Google Scenario 1 showed `state: "ready"` with `busy: true` even though the page was visually static and both `text`/`links` succeeded; the current heuristic likely treats hidden or stale `[aria-busy]` / `progressbar` DOM as active work. Refine `busy` to require visible/relevant busy indicators and add a regression test for this false-positive shape. -- [X] Tighten `scroll` success semantics. Real LinkedIn Scenario 2 returned `ok: true`, `stable: true`, and `scrolledPx: 0` twice while the viewport visibly did not move. `scroll` now reports `moved`, supports explicit element targets, and avoids generalized container-inference heuristics. +- [X] Tighten `scroll` success semantics. Real target-site Scenario 2 returned `ok: true`, `stable: true`, and `scrolledPx: 0` twice while the viewport visibly did not move. `scroll` now reports `moved`, supports explicit element targets, and avoids generalized container-inference heuristics. - [X] Keep raw internal ids out of `--verbose` unless explicitly classified as debug-only and documented. CLI `VerboseEntry` confirmed clean; daemon logs documented as operator-only. - [X] Add tests for timeout, no extension, invalid session, invalid tab handle, paused session, malformed extension responses, false-positive `busy` detection, and false-success `scroll` reporting under the new model. @@ -379,5 +379,5 @@ docs/public/views/auto/*.svg # MODIFIED by pnpm views:regen - Closed shadow-root support. - New stealth mechanisms, network shims, trusted input simulation, or broad anti-detection bypasses. - Arbitrary page eval and debugger screenshots through CLI/service control paths. -- Site-specific scrapers or LinkedIn Voyager API access. +- Site-specific scrapers or internal feed API access. - Making scenario validation fully CI-automated against third-party websites. Real-site checks can remain manual/local with documented transcripts because external sites are unstable and account-dependent. diff --git a/docs/internal/plans/phases/09-tooling.md b/docs/internal/plans/phases/09-tooling.md index 3529347..c3f93dc 100644 --- a/docs/internal/plans/phases/09-tooling.md +++ b/docs/internal/plans/phases/09-tooling.md @@ -4,6 +4,8 @@ status: complete date: 2026-06-22 --- +> **Decommissioned in Phase 10.** The Phase 9 search-tooling extension and repo-local Pi tooling were removed in `docs/internal/plans/phases/10-agent-session-dx.md` after follow-up evaluation showed they did not improve sustained implementation efficiency or quality. + ## Phase 9: Agent search tooling **Motivation:** Real Pi sessions show that models strongly prefer learned shell search commands (`rg`, `grep`, `find | grep`) over novel or even native search tools. If bproxy wants better agent navigation during real maintenance, the useful path is to enrich the search output agents already request. diff --git a/docs/internal/plans/phases/10-agent-session-dx.md b/docs/internal/plans/phases/10-agent-session-dx.md new file mode 100644 index 0000000..f57dc7f --- /dev/null +++ b/docs/internal/plans/phases/10-agent-session-dx.md @@ -0,0 +1,299 @@ +--- +title: "Phase 10: Agent session DX improvements" +status: complete +date: 2026-06-22 +issue: "#21" +--- + +## Phase 10: Agent session DX improvements + +**Motivation:** Issue #21 reports friction points observed during ~100 bproxy commands in a real agentic feed-capture session. Four accepted requests reduce boilerplate in automation workflows without violating the sensor/actuator boundary. + +**Source decisions:** + +- [ADR-017](../../decisions.md#adr-017-sensoractuator-boundary) — extension is sensor+actuator only +- [ADR-022](../../decisions.md#adr-022-extension-control-routing-for-background-tab-actions) — background-handled tab actions +- [ADR-023](../../decisions.md#adr-023-first-class-links-extraction-and-phase-5-sessiontab-errors) — first-class `links` read action +- [ADR-024](../../decisions.md#adr-024-no-arbitrary-page-eval-and-no-scroll-target-inference) — no strategy in extension +- [ADR-027](../../decisions.md#adr-027-daemon-owned-element-target-aliases-for-readact-workflows) — element handle aliases +- [ADR-028](../../decisions.md#adr-028-temporary-files-confined-to-bproxy_home) — temp files stay under BPROXY_HOME + +--- + +## Scope + +| # | Feature | Layer | Risk | +|---|---------|-------|------| +| 1 | `tab activate` command | shared + CLI + service + extension | Low — follows `tab.pin` pattern exactly | +| 2 | `links --href-contains` filter | shared + CLI + extension | Low — content-script read-time predicate | +| 3 | Response truncation fix + `links --offset` | CLI investigation + shared + CLI + extension | Medium — requires root-cause analysis | +| 5 | `text --after` marker extraction | CLI-local post-processing | Low — no protocol change | + +**Rejected (see issue review):** +- `scroll --until-end` — violates ADR-017 and ADR-024 (agent owns loop strategy) +- `--activate-if-needed` on destructive actions — violates ADR-017 (hidden recovery strategy) + +--- + +## Feature 1: `tab activate` command + +### Intent + +Give agents an explicit, one-shot way to foreground a tab before issuing destructive actions. Eliminates the `TAB_NOT_VISIBLE` → `screenshot --activate` → retry workaround. + +### Design + +`tab.activate` is a background-handled browser action (same tier as `tab.pin`, `tab.close`). It uses `chrome.tabs.update(tabId, { active: true })` and optionally focuses the window with `chrome.windows.update(windowId, { focused: true })`. + +**Wire shape:** + +- Action name: `tab.activate` +- Params: `{ tab?: TabHandle }` — same pattern as `tab.pin`. Omitted = session's bound tab. +- Result: `{ tab: TabHandle; activated: true }` +- Destructive: yes (changes browser visual state) +- Forwarded to extension background SW (not content script) + +**No auto-activation side-effect.** The existing `screenshot --activate` flag remains unchanged. No other action gains implicit activate behavior. + +### Implementation touchpoints + +**shared/** +- `actions.ts` — add `'tab.activate'` to `Action` union. Add params and result entries. Add to `ForwardedActionParams`. +- `protocol-shape.assertions.ts` — add compile-time assertion for `tab.activate` shape. + +**cli/** +- `commands/tab/activate.ts` — new leaf command. Accepts `--tab tN` (optional). Follows `tab/pin.ts` structure exactly. +- `command-registry.ts` — classify `tab.activate` as destructive. +- `bproxy.ts` — register `tab activate` subcommand under the `tab` group. + +**service/** +- `routes/tab-actions.ts` — add `tab.activate` to `isTabMediated`. Add an explicit `if (cmd.action === "tab.activate")` branch in `handleBoundTabAction` **before** the fallback `return await handleTabClose(...)` — the current code uses an implicit else for close, so any new branch must precede it. +- `schemas.ts` — add `tab.activate` to forwarded action set and params schema. + +**extension/** +- `background/forwarded-actions.ts` — add `'tab.activate'` to `BrowserAction` type and both action arrays. +- `background/browser-actions.ts` — implement `handleTabActivate`: call `tabs.update(tabId, { active: true })`, then `windows.update(windowId, { focused: true })`. Return `{ activated: true }` with current page state. +- `background/browser-actions.ts` — add a new `BrowserWindowsSeam` interface (separate from `BrowserTabsSeam`) with a single method: `update(windowId: number, updateInfo: Record): Promise`. Inject it into `BrowserActionHandlerDeps`. This keeps tab and window concerns in separate seams and makes test faking straightforward. + +### Edge cases + +- Tab already active → succeed immediately (idempotent). +- Tab does not exist (closed externally) → propagate Chrome API error as `TAB_NOT_FOUND`. +- Window focus: always attempt window focus alongside tab activation. This ensures cross-window scenarios work. +- Element handle invalidation: `tab.activate` does NOT invalidate element handles — the page identity has not changed, only the tab's foreground status. + +--- + +## Feature 2: `links --href-contains` + +### Intent + +Allow agents to filter links at extraction time, eliminating post-processing pipelines for every link-based workflow. + +### Design + +Add an optional `hrefContains` string param to the `links` action. The content script applies it as a case-sensitive substring match on the resolved absolute `href` before adding a link to the result array. + +**Wire shape change:** + +- `ActionParams['links']` gains: `hrefContains?: string` +- `ForwardedActionParams['links']` gains the same field (pass-through) +- No new result fields. Filtered links simply have fewer entries. + +The filter applies **after** href normalization (so the agent matches against absolute URLs) and **before** the limit cap (so `--limit 5 --href-contains "/in/"` returns at most 5 matching links, not 5 links from which matches are then extracted). + +### Implementation touchpoints + +**shared/** +- `actions.ts` — add `hrefContains?: string` to `ActionParams['links']`. Note: `ForwardedActionParams['links']` is defined as a type alias (`ActionParams["links"]`) so the change propagates automatically — there is only one source edit. + +**cli/** +- `commands/links.ts` — add `--href-contains` flag (type: string, optional). Wire into params. + +**service/** +- `schemas.ts` — add `hrefContains` as optional string in the `links` params Zod schema. + +**extension/** +- `content/actions/links.ts` — in `handleLinks`, after `toLinkInfo` produces a valid link, check `request.params.hrefContains`. If set and `link.href` does not include the substring, skip the entry. + +### Constraints + +- Case-sensitive match. Agents control casing in their filter string. +- Empty string `""` matches everything (no-op). `undefined` means no filter. +- No regex. This is a substring predicate, not a query language. Regex would expand the attack surface in the content script. +- Handle numbering: when `hrefContains` reduces the returned set, daemon mints handles `ln1..lnN` for the N returned links only. Handles are always 1-based within the response, not correlated to any page-global link index. A subsequent read (with or without filter) invalidates all previously minted link handles for that page — this is standard ADR-027 re-read invalidation. + +--- + +## Feature 3: Response truncation fix + `links --offset` pagination + +### Intent + +Guarantee valid JSON output regardless of response size (fix the truncation bug), and add offset-based pagination for large link extractions. + +### Part A: Truncation investigation and fix + +The reported symptom is "unterminated string at char 65200" when parsing `bproxy links --limit 200` output. Potential root causes: + +1. **Daemon response serialization** — Fastify body serialization hitting a buffer boundary. Unlikely given Fastify's streaming JSON serializer, but verify. +2. **CLI stdout pipe buffering** — `process.stdout.write` with large payloads may fail silently if the consuming pipe closes early or buffers fill. The CLI must handle write backpressure or verify full write completion. +3. **Shell pipe truncation** — if the agent pipes through `python3 -c ...` and the python process exits early or the pipe buffer fills, the shell may truncate stdout. + +**Investigation steps (ordered by likelihood):** +1. Write a test in `cli/__tests__/` that generates a links response with 200+ entries (synthetic large JSON, >64KB), passes it through the CLI output path, and asserts the result parses as valid JSON. +2. Write a service-level integration test: daemon returns a large `links` payload; CLI receives and outputs complete valid JSON. +3. If the issue is CLI-side: ensure `writeJson` in `output.ts` handles backpressure correctly (await drain if `write` returns false, or use synchronous `writeFileSync` on fd 1). +4. If the issue is not reproducible in controlled tests: document the finding and recommended `--limit` ceiling in CLI help text for `links`. + +**Fix principle:** The CLI output contract requires exactly one valid JSON object on stdout. If the write pipeline can produce truncated output under any condition, that is a bug. + +**Important:** The actual stdout write for protocol commands happens through `executeExitPlan` in `cli/src/exit.ts` (`stdout.write(...)`) — not through `writeJson` in `output.ts`. Investigation must cover **both** code paths. The `writeJson` function is used in limited contexts; the primary protocol output path is `executeExitPlan`. + +### Part B: `links --offset` pagination + +Add offset-based pagination so agents can retrieve large link sets in bounded chunks. + +**Wire shape change:** + +- `ActionParams['links']` gains: `offset?: number` +- `ForwardedActionParams['links']` gains the same field. +- `ActionResult['links']` gains: `total: number` — the count of all matching links (before offset/limit slicing), enabling agents to know whether more pages remain. + +Semantics: +- `offset` defaults to `0`. +- The content script collects all matching links (applying `hrefContains` and `visibleOnly` filters, up to `MAX_COLLECTION_CAP`), records `total`, then slices `[offset, offset + limit)` for the response. +- If `offset >= total`, return `{ links: [], total }`. +- If collection hit the cap, include `capped: true` in the result so agents know the total is approximate. + +Result type becomes: `{ links: Array; total: number; capped?: boolean }`. + +**Implementation touchpoints:** + +**shared/** +- `actions.ts` — add `offset?: number` to `ActionParams['links']` (propagates to `ForwardedActionParams['links']` automatically via the type alias). Add `total: number` to `ActionResult['links']`. + +**⚠️ Protocol result shape change:** Adding required `total: number` to `ActionResult['links']` (currently `{ links: Array }`) is a breaking change to the result type. All tests asserting `links` response shape across all packages must be updated. The compile-time `_AssertResults` guard will surface this. The daemon's `decorateReadHandles` spreads response data, which preserves `total` from the extension response — no logic change needed there. + +**cli/** +- `commands/links.ts` — add `--offset` flag (type: string, parsed as non-negative integer). + +**service/** +- `schemas.ts` — add `offset` as optional non-negative integer in the `links` params Zod schema. + +**extension/** +- `content/actions/links.ts` — refactor `handleLinks`: + 1. Collect all matching links into a full array (respecting `hrefContains` and `visibleOnly`). + 2. Record `total = fullArray.length`. + 3. Slice: `fullArray.slice(offset, offset + limit)`. + 4. Return `{ links: sliced, total }`. + + Note: the current implementation breaks on first `limit` hit during iteration. Refactor to separate collection from slicing. The `MAX_LINK_LIMIT` (500) remains as a hard cap on the returned slice size, not on total collection. + + **Collection safety cap:** Add a `MAX_COLLECTION_CAP` (2000) that limits how many matching links are collected before slicing. On pages with thousands of links, the content script must not build an unbounded array. If collection hits the cap, stop iteration, set `total` to the cap value, and include `capped: true` in the result. This prevents content-script OOM on adversarial pages. + +### Performance consideration + +Collecting all links before slicing means the content script walks the entire DOM regardless of offset. For most pages this is negligible (few hundred links). The `MAX_COLLECTION_CAP` (2000) provides a hard safety boundary. If pages with thousands of links become a real concern, a future optimization can short-circuit after `offset + limit` entries when no `total` is needed — but that changes semantics (no accurate total). Defer this optimization. + +### Handle numbering with offset + +When `--offset 50 --limit 50` returns 50 links, the daemon mints handles `ln1`–`ln50` for that response slice (not `ln51`–`ln100`). A subsequent call with different offset **invalidates** all previously minted link handles for that page (standard re-read invalidation per ADR-027). Agents must use handles from the most recent `links` response only. + +--- + +## Feature 5: `text --after` marker extraction + +### Intent + +Allow agents to extract text starting from a known marker string, reducing post-processing for structured page content. + +### Design + +This is a **CLI-local post-processing step**. The protocol and extension remain unchanged — the content script still returns full text for the scoped selector. The CLI slices the result before emitting to stdout. + +**Rationale for CLI-local:** +- Keeps the extension thin (ADR-017 sensor boundary). +- The text action already returns full `innerText`. Adding string manipulation to the content script adds no value — the same bytes travel over the wire regardless. +- CLI-local slicing is consistent with how `screenshot` does file materialization locally. + +**CLI interface:** + +- `--after ` — find first occurrence of the marker string in the returned text, emit only the text starting from that position (inclusive of the marker). +- `--limit-chars ` — when combined with `--after`, truncate the result to at most N characters. Without `--after`, `--limit-chars` applies to the full text from the beginning. + +**Behavior:** +- If `--after` marker is not found in the text: emit the full text unchanged. Include a `markerFound: false` field in the output data so the agent can detect this. +- If found: emit `{ text: , markerFound: true, markerOffset: }`. +- The output `data` shape remains `{ text: string }` augmented with the optional marker metadata. This is a CLI-level transformation — the protocol `ActionResult['text']` type does not change. + +**Implementation touchpoints:** + +**cli/ only** — no shared/service/extension changes. + +- `commands/text.ts` — add `--after` (type: string, optional) and `--limit-chars` (type: string, parsed as positive integer, optional) flags. +- Post-processing logic: after receiving the successful response from `sendAction`, apply transformation **only when `plan.code === 0 && plan.stdout`** (same guard pattern as `screenshot`). If `--after` is set: + 1. Extract `text` from `response.data`. + 2. Find `text.indexOf(afterMarker)`. + 3. If found: slice from that index, apply `--limit-chars` if set, augment data with `markerFound: true` and `markerOffset`. + 4. If not found: pass through unchanged, add `markerFound: false`. +- On error responses (`ok: false`), do not attempt any post-processing. +- The modified data is emitted as the stdout JSON. The response `ok: true` status and `page` fields pass through unchanged. + +**Output contract preservation:** stdout is still exactly one JSON object. The `data` object gains optional `markerFound` and `markerOffset` fields that are present only when `--after` is used. + +### Why this does not violate the protocol + +The `text` action's protocol result type (`ActionResult['text']`) is `{ text: string }`. The CLI is free to transform the output before emission — it already does this for `screenshot` (base64 → file). The additional fields (`markerFound`, `markerOffset`) are CLI-output metadata, not protocol fields. An agent calling the daemon directly (without CLI) gets the full text and must do its own slicing. + +--- + +## Implementation order + +Tasks are ordered by dependency and value: + +1. **Feature 1 (tab.activate)** ✅ — Done. Unblocks all other features by eliminating the `TAB_NOT_VISIBLE` error class in agent workflows. +2. **Feature 2 (links --href-contains)** ✅ — Done. Substring filter on absolute href, applied before limit cap. +3. **Feature 3A (truncation fix)** ✅ — Done. `executeExitPlan` uses synchronous `writeFileSync(1, ...)` for stdout, preventing pipe-buffer truncation on large payloads (>64KB). +4. **Feature 3B (links --offset)** ✅ — Done. Collect-then-slice refactoring with `MAX_COLLECTION_CAP` (2000), `total`, `capped`, and offset-based pagination. Protocol version bumped 1→2 (breaking wire change: required `total` field in links result). All `protocol_version` literals replaced with named `PROTOCOL_VERSION` constant for cohesion-of-name. +5. **Feature 5 (text --after)** ✅ — Done. CLI-local output transformation with `--after`, `--limit-chars`, marker metadata, and error-response pass-through. + +--- + +## Documentation updates + +After implementation, update: + +- `docs/public/solution/shared.md` — new action, new params fields (`hrefContains`, `offset`), new result fields (`total`, `capped`), new `tab.activate` action +- `docs/public/solution/cli.md` — new `tab activate` command entry, updated `links` flag table (`--href-contains`, `--offset`), updated `text` flag table (`--after`, `--limit-chars`), recommended `--limit` ceiling note in `links` command description +- `docs/public/solution/extension.md` — `tab.activate` in browser-handled action list, `BrowserWindowsSeam` in deps +- `docs/public/solution/service.md` — `tab.activate` routing note +- `docs/internal/architecture.md` — add `tab.activate` to action table +- `docs/public/views/02-containers.md` — no change needed (tab.activate doesn't alter container boundaries, confirm only) + +--- + +## Validation + +All features must pass before the phase is considered complete: + +- `pnpm check` passes (typecheck + format + lint + arch + deadcode) ✅ +- `pnpm test` passes across all packages ✅ +- Feature 1: unit test for `tab.activate` in extension (background-actions), service (tab-actions routing), CLI (command wiring). Integration: activate a background tab, subsequent destructive action succeeds without `TAB_NOT_VISIBLE`. +- Feature 2: unit test in extension `links.ts` — verify `hrefContains` filters correctly (match, no-match, empty string, undefined). CLI test for arg parsing. +- Feature 3A: integration test with >64KB JSON payload through CLI output path — verify valid JSON on stdout. +- Feature 3B: unit test in extension `links.ts` — verify offset/limit slicing and `total` accuracy. CLI test for `--offset` arg parsing. +- Feature 5: unit test in CLI `text.ts` — verify `--after` slicing (marker found, not found, combined with `--limit-chars`). +- Command registry exhaustiveness: adding `tab.activate` to the Action union must trigger a compile error if the registry is not updated. + +--- + +## Deferred + +| Item | Reason | +|---|---| +| `--activate` flag on all destructive commands | Syntactic sugar over `tab activate` + command. Defer until usage shows whether the two-command pattern creates real friction. | +| `text --before` marker | No evidence of need. Trivial to add later with same pattern as `--after`. | +| `links --href-regex` | Regex in content script is a complexity/security surface expansion. Substring matching covers stated use cases. | +| `links --text-contains` | Not requested. Add when real use case appears. | +| Response streaming / chunked transfer | Overkill for current payload sizes if truncation bug is fixed. | +| Raise `MAX_COLLECTION_CAP` above 2000 | No evidence needed yet. 2000 covers all stated use cases. Revisit on real demand. | diff --git a/docs/internal/plans/roadmap.md b/docs/internal/plans/roadmap.md index 9e794a8..3673bdb 100644 --- a/docs/internal/plans/roadmap.md +++ b/docs/internal/plans/roadmap.md @@ -110,7 +110,7 @@ Per-phase detail files live under [`docs/internal/plans/phases/`](./phases/) as **Output:** generated session capability handles (6-char base32, no prefix, e.g. `m4q8z2`); session-scoped logical tab handles; `tab open` as the fresh bootstrap path (sole auto-create-session command); scoped `tab list`; `session bind --tab `; a first-class `links` action; hardened selector generation; Scenarios 1–3 from [`docs/internal/scenarios.md`](../scenarios.md) validated against the real system; deadline/error/observability hardening; documentation reconciled with shipped code; pre-commit hooks wired to a fast subset of `pnpm check` per [`docs/internal/quality-gates.md`](../quality-gates.md). -**Done when:** a fresh paired setup can run `tab open -> navigate -> text -> links -> session close` without fake tab ids, raw Chrome ids, implicit shared `default` state, manual rebinding, or external HTML parsing; `session close` automatically closes session-owned Chrome tabs; Scenario 1 (Google research) runs autonomously to completion; Scenario 2 (LinkedIn snapshot) handles scroll/pause/`HUMAN_REQUIRED` correctly; Scenario 3 (form fill) fills a real application form to the user-review step; docs match the implemented command and response shapes; pre-commit hooks block commits that fail the selected fast gates. +**Done when:** a fresh paired setup can run `tab open -> navigate -> text -> links -> session close` without fake tab ids, raw Chrome ids, implicit shared `default` state, manual rebinding, or external HTML parsing; `session close` automatically closes session-owned Chrome tabs; Scenario 1 (Google research) runs autonomously to completion; Scenario 2 (authenticated feed snapshot) handles scroll/pause/`HUMAN_REQUIRED` correctly; Scenario 3 (form fill) fills a real application form to the user-review step; docs match the implemented command and response shapes; pre-commit hooks block commits that fail the selected fast gates. **Out of scope:** public npm packaging, release artifacts, extension distribution, installer/update flows, broad existing-tab adoption, closed shadow-root support, new stealth mechanisms, arbitrary page eval, debugger control paths, and generalized scroll-container inference. Phase 5 hardens behaviour; Phase 7 packages it. diff --git a/docs/internal/scenarios.md b/docs/internal/scenarios.md index 62e242f..3057869 100644 --- a/docs/internal/scenarios.md +++ b/docs/internal/scenarios.md @@ -11,8 +11,8 @@ The framing assumption across all scenarios: **the user is in front of the brows For fast localhost validation before running the real-site transcripts, use `extension/scripts/smoke/workflow.ts`. It mirrors the Scenario 1 command shape against the local fixture server: wait for a paired extension, `tab open`, -`text`, `links`, `navigate`, `text`, then `session close`. Real Google, -LinkedIn, and form-flow transcripts still belong in journal entries because +`text`, `links`, `navigate`, `text`, then `session close`. Real Google transcripts, +feed-snapshot transcripts, and form-flow transcripts still belong in journal entries because those runs depend on live accounts, consent, and site conditions. --- @@ -86,15 +86,15 @@ Google search is URL-driven for everything. Reads happen against a server-render --- -## Scenario 2 — LinkedIn daily feed snapshot +## Scenario 2 — Authenticated feed snapshot -The agent opens a dedicated LinkedIn feed tab with `bproxy tab open --url https://www.linkedin.com/feed/`, receives a generated session id such as `p7k2qm` bound to logical tab `t1`, and then works only inside that session. The user is signed in (or resolves login/consent in that tab if needed). The task is to capture today's feed: who posted what, with truncated bodies and permalinks, ready for the user to digest. +The agent opens a dedicated feed tab with `bproxy tab open --url https://app.example.test/feed/`, receives a generated session id such as `p7k2qm` bound to logical tab `t1`, and then works only inside that session. The user is signed in (or resolves login/consent in that tab if needed). The task is to capture today's feed: who posted what, with truncated bodies and permalinks, ready for the user to digest. -### Why LinkedIn is harder than Google +### Why this is harder than Google -1. **It's a SPA with lazy-loaded feed.** No `?start=20` equivalent. Posts load only when scroll position approaches them — LinkedIn's page uses an IntersectionObserver to fire Voyager API requests as the user scrolls. **No scroll, no posts.** +1. **It's a SPA with lazy-loaded feed.** No `?start=20` equivalent. Posts load only when scroll position approaches them — the page uses an IntersectionObserver to fire internal feed API requests as the user scrolls. **No scroll, no posts.** 2. **The feed truncates post bodies.** Long posts show ~3 lines + "see more." Full text isn't in the DOM until either the user clicks "see more" or navigates to the post's permalink page. -3. **LinkedIn's bot detection watches scroll behaviour.** Scroll velocity, pause patterns, reverse-scroll moments, and tab visibility (`document.hidden`) are part of their signal. A perfectly-paced programmatic scroll has a different signature than a human's even when slow. +3. **The site's bot detection watches scroll behaviour.** Scroll velocity, pause patterns, reverse-scroll moments, and tab visibility (`document.hidden`) are part of its signal. A perfectly-paced programmatic scroll has a different signature than a human's even when slow. ### Agent flow @@ -111,15 +111,15 @@ The agent opens a dedicated LinkedIn feed tab with `bproxy tab open --url https: 9. on any interstitial: HUMAN_REQUIRED → stop ``` -Note step 8: **the agent's job is to prepare a digest, not to read every full body upfront.** Truncated bodies are usually enough for the user to decide "do I care." Full body retrieval becomes on-demand via popup click or permalink visit—the LinkedIn "see more" button opens a shadow-DOM modal (`#interop-outlet`, validated in PoC 3). +Note step 8: **the agent's job is to prepare a digest, not to read every full body upfront.** Truncated bodies are usually enough for the user to decide "do I care." Full body retrieval becomes on-demand via popup click or permalink visit—the site's "see more" button opens a shadow-DOM modal (`#interop-outlet`, validated in PoC 3). ### Phase 5 command transcript ```bash -bproxy tab open --url https://www.linkedin.com/feed/ -# -> { ok: true, data: { session: "p7k2qm", tab: "t1", bound: true, url: "https://www.linkedin.com/feed/" } } +bproxy tab open --url https://app.example.test/feed/ +# -> { ok: true, data: { session: "p7k2qm", tab: "t1", bound: true, url: "https://app.example.test/feed/" } } -# user resolves login / consent in the visible tab if LinkedIn requires it +# user resolves login / consent in the visible tab if the site requires it bproxy scroll -s p7k2qm --by viewport --direction down # If moved=false, inspect the page and retry with an explicit target, for example: @@ -167,7 +167,7 @@ The remaining detection risk is **scroll fingerprinting**. There is no perfect m ### Tab-focus subtlety -LinkedIn's lazy-loader checks `document.visibilityState`. A backgrounded tab will not lazy-load. The snapshot must run while the tab is foregrounded. For a daily flow this is natural — the user can leave the tab visible — but the extension should not silently activate the tab. If the tab is not visible when a `scroll` command arrives, return a structured `TAB_NOT_VISIBLE` error rather than steal focus. +The feed page's lazy-loader checks `document.visibilityState`. A backgrounded tab will not lazy-load. The snapshot must run while the tab is foregrounded. For a daily flow this is natural — the user can leave the tab visible — but the extension should not silently activate the tab. If the tab is not visible when a `scroll` command arrives, return a structured `TAB_NOT_VISIBLE` error rather than steal focus. ### Escape hatches if pure read mode hits limits @@ -177,12 +177,12 @@ In rough order of preference, kept on the shelf for incremental escalation as re **2. `chrome.debugger` for trusted scroll.** `Input.dispatchScrollEvent` via CDP produces `isTrusted: true` scroll, lifting the scroll-fingerprint risk. Cost: yellow Chrome banner for the duration of the snapshot. Probably an acceptable opt-in for a daily flow the user kicked off intentionally. -**3. Voyager API direct call.** LinkedIn's own page calls `https://www.linkedin.com/voyager/api/feed/...` with the user's session cookies. From an ISOLATED-world content script the same `fetch('/voyager/...')` works (same-origin, same cookies, CSRF token from the rendered page). +**3. Site-internal feed API direct call.** The page's own frontend calls an internal feed API with the user's session cookies. From an ISOLATED-world content script the same same-origin fetch pattern would work with the page's existing auth context. - Pros: zero scroll, zero clicks, zero rendering. Returns full post bodies, no truncation. Fastest possible execution. -- Cons: LinkedIn's terms of service prohibit scraping; the legal posture for personal aggregation against your own logged-in account is a different question than scraping at scale, but it is not risk-free. Internal API is undocumented and changes without notice. +- Cons: a site's terms of service may prohibit scraping; the legal posture for personal aggregation against your own logged-in account is a different question than scraping at scale, but it is not risk-free. Internal APIs are undocumented and change without notice. -This is genuinely the cleanest technical solution and a real legal grey zone. Phase 4 does **not** ship a domain-policy command for this; any future `linkedin.com` API opt-in would need an explicit out-of-scope command and warning copy. Off by default. +This is genuinely the cleanest technical solution and a real legal grey zone. Phase 4 does **not** ship a domain-policy command for this; any future site-specific API opt-in would need an explicit out-of-scope command and warning copy. Off by default. ### Recommended posture @@ -203,7 +203,7 @@ Application forms have the heaviest bot detection on the web — invisible reCAP - Any CAPTCHA challenge fires on submit, not during fill — the user encounters it naturally. - The agent never has to "look human enough to pass scoring." It has to "produce values the user can review and ship." -This is the same pattern as the LinkedIn digest: agent prepares, user reviews and acts. +This is the same pattern as the feed digest: agent prepares, user reviews and acts. ### Agent flow @@ -237,7 +237,7 @@ bproxy session close -s c6v3n4 Real humans do not type their CV into application forms. The actual mix: - **Personal info** (name, email, phone, address): Chrome autofill or paste from a saved info doc. Never typed. -- **Resume content** (work history, education, skills): pasted from CV / LinkedIn / Google Doc. +- **Resume content** (work history, education, skills): pasted from CV / notes doc / Google Doc. - **Cover letter**: pasted from a template, sometimes edited. - **Custom questions** ("why this company?"): pasted from a reusable answers file, occasionally typed when composing fresh. - **Yes/No, dropdowns, dates**: clicked. diff --git a/docs/internal/skills/fill-method-selection.md b/docs/internal/skills/fill-method-selection.md index 53bbd40..d7903e2 100644 --- a/docs/internal/skills/fill-method-selection.md +++ b/docs/internal/skills/fill-method-selection.md @@ -91,7 +91,7 @@ Explicitly out of scope per [ADR-014](../decisions.md#adr-014-shadow-dom-aware-d - Editor may not be mounted yet—modal shell vs runtime mount timing - Retry with progressive wait (100ms → 200ms → 400ms max 3 attempts) -- See PoC 3: LinkedIn dialog ~101ms, editor ~409ms +- See PoC 3: target-site dialog ~101ms, editor ~409ms ### `paste` fails (framework rejects value) diff --git a/docs/public/guide/install.md b/docs/public/guide/install.md index 464c2b0..64d4f7c 100644 --- a/docs/public/guide/install.md +++ b/docs/public/guide/install.md @@ -27,7 +27,7 @@ Verify the install: ```bash bproxy --version -# bproxy v0.7.0 (protocol v1) +# bproxy v0.9.0 (protocol v2) ``` ## Install the Chrome extension @@ -72,7 +72,7 @@ bproxy service status Expected output when everything is connected: ```json -{"running":true,"pid":12345,"port":9615,"version":"0.7.0","protocolVersion":1} +{"running":true,"pid":12345,"port":9615,"version":"0.9.0","protocolVersion":2} ``` For a comprehensive check of all components: diff --git a/docs/public/guide/usage.md b/docs/public/guide/usage.md index d3789c5..fa215bc 100644 --- a/docs/public/guide/usage.md +++ b/docs/public/guide/usage.md @@ -14,10 +14,22 @@ bproxy service start If this is your first time, pair the extension (see [Install](./install.md)). +## Pick an agent nick + +Every protocol command needs an explicit agent nickname: + +```text +-n +``` + +Generate one nick per task and reuse it. It must be 6 lowercase alphanumeric characters, start with a letter, and match `/^[a-z][a-z0-9]{5}$/`. Examples below use `` as a placeholder. + +Service lifecycle commands (`service start|stop|status|restart|install|uninstall`) and `doctor` do not need a user nick. + ## Open a tab ```bash -bproxy tab open --url https://example.com +bproxy tab open --url https://example.com -n ``` Response: @@ -30,25 +42,34 @@ Response: "tab": "t1", "bound": true, "url": "https://example.com", - "tmpDir": "/home/user/.bproxy/tmp/sessions/m4q7z2" + "tmpDir": "/home/user/.bproxy/tmp/sessions/m4q7z2", + "ownerHash": "a3f7c012" } } ``` -This auto-creates a session (e.g., `m4q7z2`) and binds it to logical tab `t1`. Use `-s m4q7z2` for all subsequent commands in this session. +This auto-creates a session and binds it to logical tab `t1`. Use `-n -s m4q7z2` for later commands in this session. ## Read the page **Get page text:** ```bash -bproxy text -s m4q7z2 +bproxy text -n -s m4q7z2 ``` +Extract from a marker in CLI output: + +```bash +bproxy text -n -s m4q7z2 --after "Main content" --limit-chars 4000 +``` + +When `--after` is used, output data includes `markerFound` and, when found, `markerOffset`. If the marker is missing, bproxy emits the full text with `markerFound:false`. + **Get links:** ```bash -bproxy links -s m4q7z2 +bproxy links -n -s m4q7z2 --limit 50 ``` Response includes structured links with handles for easy targeting: @@ -59,35 +80,54 @@ Response includes structured links with handles for easy targeting: "data": { "links": [ { "text": "More information...", "href": "https://www.iana.org/...", "handle": "ln1" } - ] + ], + "total": 42 } } ``` +Filter and paginate large link sets: + +```bash +bproxy links -n -s m4q7z2 --href-contains "/in/" --limit 25 --offset 50 +``` + +`--href-contains` is a case-sensitive substring match on normalized absolute hrefs. `--offset` skips matching links before the returned slice. If the page hits the collection safety cap, data includes `capped:true`. + **Get interactive elements:** ```bash -bproxy elements -s m4q7z2 +bproxy elements -n -s m4q7z2 --form +``` + +## Activate before destructive actions + +Destructive actions require the target tab to be visible. If a tab is backgrounded, run: + +```bash +bproxy tab activate -n -s m4q7z2 ``` +This foregrounds the bound tab and focuses its Chrome window. No other command auto-activates hidden tabs, except `screenshot --activate` for screenshots only. + ## Click a link or element Use the handle returned by `links` or `elements`: ```bash -bproxy click -s m4q7z2 --element ln1 +bproxy click -n -s m4q7z2 --element ln1 ``` Or target by CSS selector: ```bash -bproxy click -s m4q7z2 --selector 'a[href="/about"]' +bproxy click -n -s m4q7z2 --selector 'a[href="/about"]' ``` ## Fill a form ```bash -bproxy fill -s m4q7z2 --element el2 --value "hello@example.com" --method paste --world isolated +bproxy fill -n -s m4q7z2 --element el2 --value "hello@example.com" --method paste --world isolated ``` The `--method` flag is required. Choose based on the target: @@ -98,24 +138,26 @@ The `--method` flag is required. Choose based on the target: | Bare `[contenteditable]` | `direct` | `isolated` | | Rich editor (Quill, Lexical, etc.) | `runtime-api` | `main` | -For richer editors, first run `bproxy elements -s m4q7z2 --form` and use any returned `runtimeHandle` to choose `runtime-api` + `main`. +For richer editors, first run `bproxy elements -n -s m4q7z2 --form` and use any returned `runtimeHandle` to choose `runtime-api` + `main`. ## Scroll ```bash -bproxy scroll -s m4q7z2 --direction down +bproxy scroll -n -s m4q7z2 --direction down ``` Scroll a specific element: ```bash -bproxy scroll -s m4q7z2 --element el5 --direction down +bproxy scroll -n -s m4q7z2 --element el5 --direction down ``` +bproxy does not infer page-specific scroll containers. Pass the element you want scrolled, or omit target to scroll the viewport/document. + ## Navigate to a URL ```bash -bproxy navigate -s m4q7z2 --url https://example.com/page2 +bproxy navigate -n -s m4q7z2 --url https://example.com/page2 ``` ## Handle human-required situations @@ -130,7 +172,7 @@ When the agent encounters a CAPTCHA, login wall, or consent screen, bproxy retur "category": "policy", "retry": "conditional", "message": "CAPTCHA detected", - "suggestedAction": "resolve the interstitial in the browser, then `bproxy session resume`" + "suggestedAction": "resolve the interstitial in the browser, then resume the session" } } ``` @@ -138,13 +180,13 @@ When the agent encounters a CAPTCHA, login wall, or consent screen, bproxy retur The human resolves the situation in the browser, then: ```bash -bproxy session resume -s m4q7z2 +bproxy session resume -n -s m4q7z2 ``` ## Close the session ```bash -bproxy session close -s m4q7z2 +bproxy session close -n -s m4q7z2 ``` This closes all tabs owned by the session and cleans up temporary artifacts. @@ -157,34 +199,43 @@ bproxy service stop ## Command reference +Protocol commands below need `-n `; session-bound commands also need `-s id` unless noted. + | Command | Description | |---------|-------------| -| `tab open --url ` | Open tab, auto-create session | -| `text -s id [--selector]` | Extract page text | -| `links -s id [--selector] [--limit N]` | Extract structured links | -| `elements -s id [--form]` | List interactive elements | -| `outline -s id` | Landmarks + headings | -| `dom -s id [--selector] [--depth N]` | Simplified DOM subtree | -| `inspect -s id --selector ` | Layout, scroll info, computed styles | -| `snapshot -s id` | Accessible DOM tree | -| `click -s id --element ` | Click an element | -| `hover -s id --element ` | Hover an element | -| `scroll -s id [--direction] [--element]` | Scroll viewport or element | -| `fill -s id --element --value --method --world ` | Fill a field | -| `fill-form -s id --json ` | Bulk fill in one round-trip | -| `select -s id --element --option-text ` | Select dropdown option | -| `navigate -s id --url ` | Navigate to URL | -| `screenshot -s id [--output-dir]` | Capture visible tab | -| `wait -s id --strategy --target ` | Wait for condition | -| `require-human -s id --reason ` | Signal human needed | -| `session create [--label]` | Create session without tab | -| `session list` | List active sessions | -| `session resume -s id` | Resume paused session | -| `session close -s id` | Close session + tabs | -| `service start [--port]` | Start daemon | -| `service stop` | Stop daemon | -| `service status` | Daemon status (token-free) | -| `service install` | Register auto-start | -| `service uninstall` | Remove auto-start | -| `doctor` | Validate full operational chain | +| `tab open -n nick --url ` | Open tab; auto-create session if `-s` omitted | +| `tab activate -n nick -s id [--tab tN]` | Foreground session tab and focus window | +| `tab list -n nick -s id` | List session tabs | +| `tab close -n nick -s id [--tab tN]` | Close tab | +| `tab pin -n nick -s id [--tab tN]` / `tab unpin ...` | Pin/unpin tab | +| `text -n nick -s id [--selector] [--after S] [--limit-chars N]` | Extract page text; optional CLI-local slicing | +| `links -n nick -s id [--selector] [--visible-only] [--limit N] [--href-contains S] [--offset N]` | Extract structured links with `total` / optional `capped` | +| `images -n nick -s id [--selector]` | Extract visible images | +| `elements -n nick -s id [--form]` | List interactive elements | +| `outline -n nick -s id` | Landmarks + headings | +| `dom -n nick -s id [--selector] [--depth N]` | Simplified DOM subtree | +| `inspect -n nick -s id --selector ` | Layout, scroll info, computed styles | +| `snapshot -n nick -s id` | Accessible DOM tree | +| `click -n nick -s id --element ` | Click an element | +| `hover -n nick -s id --element ` | Hover an element | +| `scroll -n nick -s id [--direction] [--element]` | Scroll viewport or explicit element | +| `fill -n nick -s id --element --value --method --world ` | Fill a field | +| `fill-form -n nick -s id --json ` | Bulk fill in one round-trip | +| `select -n nick -s id --element --option-text ` | Select dropdown option | +| `navigate -n nick -s id --url ` | Navigate to URL | +| `screenshot -n nick -s id [--activate] [--output-dir]` | Capture visible tab to file | +| `wait -n nick -s id --strategy --target ` | Wait for condition | +| `require-human -n nick -s id --reason ` | Signal human needed | +| `session create -n nick [--label]` | Create session without tab; no `-s` | +| `session list -n nick` | List this nick's active sessions; no `-s` | +| `session bind -n nick -s id --tab tN [--pacing human\|fast]` | Bind session to tab / pacing | +| `session unbind -n nick -s id` | Unbind session | +| `session resume -n nick -s id` | Resume paused session | +| `session close -n nick -s id` | Close session + tabs | +| `debug status -n nick` / `status -n nick` | Nick-scoped daemon/session status; no `-s` | +| `debug last -n nick [--count N]` | Nick-scoped daemon traces; no `-s` | +| `debug log -n nick -s id [--id ID] [--limit N]` | Extension trace ring buffer | +| `service start\|stop\|status\|restart` | Daemon lifecycle; no nick | +| `service install\|uninstall` | Register/remove login service; no nick | +| `doctor` | Validate operational chain; no user nick | | `--version` | Print version + protocol | diff --git a/docs/public/index.md b/docs/public/index.md index 25e179b..02bef16 100644 --- a/docs/public/index.md +++ b/docs/public/index.md @@ -4,7 +4,7 @@ title: bproxy ## Why this exists -Most of my work is information research, analysis, and synthesis. Agents are good at the routine part — collecting data, transforming it, distilling patterns — but they need browser access to do it, and the services I use (Google, LinkedIn, Medium) actively detect automation. +Most of my work is information research, analysis, and synthesis. Agents are good at the routine part — collecting data, transforming it, distilling patterns — but they need browser access to do it, and the services I use (Google, authenticated feed sites, Medium) actively detect automation. I have two options: diff --git a/docs/public/solution/cli.md b/docs/public/solution/cli.md index 34fa136..10949a6 100644 --- a/docs/public/solution/cli.md +++ b/docs/public/solution/cli.md @@ -32,8 +32,8 @@ cli/ ├── types.ts # re-exports from @bproxy/shared ├── commands/ │ ├── navigate.ts # navigate --url - │ ├── text.ts # text [--selector] - │ ├── links.ts # links [--selector] [--visible-only] [--limit N] + │ ├── text.ts # text [--selector] [--after] [--limit-chars N] + │ ├── links.ts # links [--selector] [--visible-only] [--limit N] [--href-contains S] [--offset N] │ ├── images.ts # images [--selector] │ ├── elements.ts # elements [--form] │ ├── outline.ts # outline @@ -50,12 +50,14 @@ cli/ │ ├── wait.ts # wait --strategy --target [--timeout] │ ├── require-human.ts # require-human --reason [--for-attach] │ ├── status.ts # top-level status (alias for debug.status) + │ ├── doctor.ts # doctor [--home] │ ├── service/ - │ │ ├── index.ts # subCommands: start, stop, status, restart + │ │ ├── index.ts # subCommands: start, stop, status, restart, install, uninstall │ │ ├── start.ts # service start [--port] [--home] │ │ ├── stop.ts # service stop [--home] │ │ ├── status.ts # service status [--home] (token-free) - │ │ └── restart.ts # service restart [--port] [--home] + │ │ ├── restart.ts # service restart [--port] [--home] + │ │ └── install.ts # service install|uninstall [--home] │ ├── session/ │ │ ├── create.ts # session create [--label] │ │ ├── list.ts # session list @@ -68,7 +70,8 @@ cli/ │ │ ├── pin.ts # tab pin [--tab tN] │ │ ├── unpin.ts # tab unpin │ │ ├── open.ts # tab open --url - │ │ └── close.ts # tab close [--tab tN] + │ │ ├── close.ts # tab close [--tab tN] + │ │ └── activate.ts # tab activate [--tab tN] │ └── debug/ │ ├── log.ts # debug log [--id] [--limit] │ ├── last.ts # debug last [--count] @@ -90,7 +93,7 @@ Every leaf command defines these via `globalArgs` spread: | `--home` | | string | `~/.bproxy` | Override `BPROXY_HOME` state directory | | `--verbose` | `-v` | boolean | `false` | Write structured diagnostics to stderr | -`--nick` is required on every protocol-backed command, including `debug.*`, `session.*`, `tab.*`, and the top-level `status` alias. Service lifecycle commands (`service start|stop|status|restart`) are the only CLI surface that does not require it. +`--nick` is required on every protocol-backed command, including `debug.*`, `session.*`, `tab.*`, and the top-level `status` alias. Service lifecycle commands (`service start|stop|status|restart|install|uninstall`) and `doctor` do not require a user nick. ## Exit Codes @@ -138,7 +141,7 @@ export default defineCommand({ 2. Token preflight (exists, mode `0600`, owner) → exit `2` on failure 3. Read port file → exit `2` if daemon not running 4. Parse `--timeout` → exit `2` if invalid -5. Validate `--nick` and build `BproxyRequest` with `protocol_version: 1`, nick, session, deadline, destructive flag +5. Validate `--nick` and build `BproxyRequest` with the shared `PROTOCOL_VERSION` (currently `2`), nick, session, deadline, destructive flag 6. Verbose pre-request stderr entry (no token leaked) 7. POST to `http://127.0.0.1:{port}/` with Bearer auth + abort timeout (deadline + 2s) 8. Fetch failure → exit `2` (connection refused, abort timeout) @@ -186,14 +189,26 @@ Spawns the service binary's `stop` command. Prints: Token-free, process-liveness based. Prints: ```json -{"running":true,"pid":123,"port":9615,"version":"0.7.0","protocolVersion":1} +{"running":true,"pid":123,"port":9615,"version":"0.9.0","protocolVersion":2} ``` -or `{"running":false,"version":"0.7.0","protocolVersion":1}`. +or `{"running":false,"version":"0.9.0","protocolVersion":2}`. ### `bproxy service restart [--port N] [--home DIR]` Composition: stop then start. Produces the same JSON as start. +### `bproxy service install [--home DIR]` + +Registers a login service using launchd on macOS or systemd user services on Linux. Prints `{ installed: true, ... }` on success. + +### `bproxy service uninstall` + +Removes the launchd/systemd registration. Prints `{ uninstalled: true }` on success. + +## Doctor Command + +`bproxy doctor [--home DIR]` validates the local operational chain: Node version, CLI/service binary resolution, daemon liveness, protocol version agreement, extension WS connectivity, state directory health, and auto-start registration status. It emits one JSON report and exits `0` only when every check is ok. It does not require a user `--nick`; the daemon probe uses an internal diagnostic nick. + ## Session Commands Daemon-local (no extension required): @@ -215,6 +230,12 @@ Forwarded to extension (require connected WS client): - `tab unpin` — unpin current tab (destructive) - `tab open --url ` — open new tab, auto-create a session owned by the supplied nick if `-s` omitted, and return `tmpDir` + `ownerHash` (destructive) - `tab close [--tab tN]` — close tab (destructive) +- `tab activate [--tab tN]` — foreground a session-owned tab and focus its window (destructive) + +## Read Commands + +- `links [--selector S] [--visible-only] [--limit N] [--href-contains S] [--offset N]` — extract structured links. `--href-contains` is a case-sensitive substring match on normalized absolute hrefs, applied before `--limit`; `--offset` paginates matching links. Keep `--limit` bounded for stdout readability; use `--offset` for large pages. +- `text [--selector S] [--after MARKER] [--limit-chars N]` — extract text. `--after` and `--limit-chars` are CLI-local output transformations; the daemon/extension still receive only the normal `text` action params. ## Debug Commands @@ -265,7 +286,7 @@ There is intentionally no `eval` command. Arbitrary page/runtime investigation b `command-registry.ts` classifies every shared `Action` as destructive or non-destructive. A compile-time exhaustiveness assertion ensures adding a new shared action without updating the registry causes a build failure. -**Destructive:** navigate, scroll, click, hover, fill, fill-form, select, tab.pin, tab.unpin, tab.open, tab.close, session.create, session.bind, session.unbind, session.resume, session.close, require-human. +**Destructive:** navigate, scroll, click, hover, fill, fill-form, select, tab.pin, tab.unpin, tab.open, tab.close, tab.activate, session.create, session.bind, session.unbind, session.resume, session.close, require-human. **Non-destructive:** text, links, images, elements, outline, dom, inspect, snapshot, screenshot, wait, tab.list, session.list, debug.log, debug.last, debug.status. diff --git a/docs/public/solution/extension.md b/docs/public/solution/extension.md index 915ec7b..4f0d15a 100644 --- a/docs/public/solution/extension.md +++ b/docs/public/solution/extension.md @@ -170,7 +170,7 @@ Flow: 3. popup validates the bootstrap payload shape: - `extensionToken` non-empty string - `wsUrl` loopback `ws://` - - `protocolVersion === 1` + - `protocolVersion === 2` - `expiresAt > Date.now()` - `nonce` present 4. popup stores the bootstrap payload as **one atomic record** in `chrome.storage.local`; @@ -243,7 +243,7 @@ Handled through `src/content/**` and routed via background/content RPC. | Action | Notes | |---|---| -| `text`, `links`, `images`, `elements`, `outline`, `dom` | Read-only DOM extraction; `links` returns structured URLs, traverses open shadow roots, and can filter to visible/in-viewport anchors | +| `text`, `links`, `images`, `elements`, `outline`, `dom` | Read-only DOM extraction; `links` returns structured URLs, traverses open shadow roots, can filter to visible/in-viewport anchors, can filter absolute hrefs by substring, and supports offset pagination | | `inspect` | Computed-style and layout inspection for specific selectors (rect, display, descendants, scroll info) | | `snapshot` | Accessible DOM tree serialization (text-based, depth-limited, optional interactive-only mode) | | `scroll`, `wait` | Jittered polling only; no `MutationObserver`. `scroll` targets only the viewport/document by default or an explicit agent-supplied `ElementTarget`; it never infers scroll containers. | @@ -254,6 +254,17 @@ Handled through `src/content/**` and routed via background/content RPC. | `fill-form` | Multi-field isolated-world writes with hidden-field guard and read-back verification | | `select` | Trigger + poll + option click + verification | +`links` collection semantics: + +- normalize each anchor href to an absolute URL before filtering; +- apply `hrefContains` as a case-sensitive substring check on the normalized href; +- apply `visibleOnly` before counting; +- collect matching links up to `MAX_COLLECTION_CAP` (2000), set `capped:true` if hit; +- return `total` as the count of collected matching links before `offset`/`limit` slicing; +- slice with `offset` then `limit` (returned limit is capped at 500). + +The browser action handler receives separate seams for tab and window APIs (`BrowserTabsSeam` and `BrowserWindowsSeam`) so tests can fake activation/focus behavior without mixing concerns. + ### MAIN-world one-shot actions Handled in `src/background/main-world*.ts`. @@ -281,6 +292,7 @@ Handled in `src/background/browser-actions.ts`. | `screenshot(debugger=true)` | currently returns `DEBUGGER_DISABLED` unless a future explicit opt-in ships with permission + flag wiring | | `tab.list` | **not forwarded** — daemon resolves from session tab registry without extension involvement | | `tab.open`, `tab.close`, `tab.pin`, `tab.unpin` | Chrome tabs API only; does not take ownership of daemon session state | +| `tab.activate` | Chrome tabs/windows API; activates the tab and focuses its window | | `require-human` | returns structured `HUMAN_REQUIRED` for daemon pause handling | --- diff --git a/docs/public/solution/service.md b/docs/public/solution/service.md index 221fe06..ad0494c 100644 --- a/docs/public/solution/service.md +++ b/docs/public/solution/service.md @@ -171,7 +171,9 @@ Ownership rules: | `session.create`, `session.list`, `session.bind`, `session.unbind`, `session.resume`, `session.close` | ✅ | ❌ (session.close forwards tab.close sub-requests) | | `tab.list` | ✅ (reads session tab registry) | ❌ | | `debug.log` | ❌ | ✅ | -| browser and tab actions (`navigate`, `text`, `links`, `inspect`, `snapshot`, `scroll`, `click`, `hover`, `fill`, `tab.open`, `tab.close`, `tab.pin`, `tab.unpin`, ...) | ❌ | ✅ | +| browser and tab actions (`navigate`, `text`, `links`, `inspect`, `snapshot`, `scroll`, `click`, `hover`, `fill`, `tab.open`, `tab.close`, `tab.pin`, `tab.unpin`, `tab.activate`, ...) | ❌ | ✅ | + +`tab.activate` follows the bound-tab mediation path: the daemon resolves the supplied logical tab handle (or the session's bound tab), forwards a background-handled request to the extension with the raw Chrome tab id, and returns `{ tab, activated: true }` without invalidating element handles. ### `session.*` semantics @@ -222,7 +224,7 @@ Response (200): "data": { "extensionToken": "base64urlEncodedToken...", "wsUrl": "ws://127.0.0.1:9615/ws", - "protocolVersion": 1, + "protocolVersion": 2, "issuedAt": 1714000000000, "expiresAt": 1714000300000, "nonce": "01J..." @@ -346,11 +348,12 @@ Commands targeting the same tab are serialized (queue, not parallel). Prevents r `src/element-handles.ts` owns short-lived daemon-side aliases for read→act workflows. - `elements` mints `el1`, `el2`, ... and `links` mints `ln1`, `ln2`, ... +- link handles are minted only for the returned `links` slice; `--offset 50 --limit 50` still returns handles `ln1` through `ln50` - handles are scoped to `{session, logical tab, page}` - page identity uses a daemon-maintained navigation epoch plus the minted page URL - resolution happens before forwarding, so the extension still receives only explicit `ElementTarget` params - bounds are enforced in memory only: TTL 120s, per-scope cap 200, global cap 1000 -- fresh reads replace prior handles for the same `{session, tab, sourceAction}` scope +- fresh reads replace prior handles for the same `{session, tab, sourceAction}` scope; changing `links` filter/offset invalidates prior `lnN` handles for that page - session close and explicit tab close invalidate affected handles; WS disconnect clears epoch knowledge so later resolutions fail closed as stale until a fresh navigation is observed ## Pacing Engine @@ -509,12 +512,12 @@ The service binary emits stable JSON on stdout for each lifecycle command: **`status`** — daemon running: ```json -{"running":true,"pid":123,"port":9615,"version":"0.7.0","protocolVersion":1} +{"running":true,"pid":123,"port":9615,"version":"0.9.0","protocolVersion":2} ``` **`status`** — daemon not running: ```json -{"running":false,"version":"0.7.0","protocolVersion":1} +{"running":false,"version":"0.9.0","protocolVersion":2} ``` Lifecycle failures write plain text to stderr and exit non-zero. @@ -630,7 +633,7 @@ Logs are plain JSON lines in `~/.bproxy/logs/YYYY-MM-DD.log`. Grep by `id`: grep '01HZX9C2K8' ~/.bproxy/logs/2026-05-08.log ``` -Or use `bproxy debug last` which returns the last N request traces from the daemon's in-memory ring buffer (capacity 200). Each trace records `{ id, action, session, receivedAt, elapsedMs, ok, errorCode? }`. The ring buffer is populated on every command response and survives across requests but resets on daemon restart. +Or use `bproxy debug last -n ` which returns the last N request traces from the daemon's in-memory ring buffer (capacity 200), scoped to that nick's live sessions. Each trace records `{ id, action, session, receivedAt, elapsedMs, ok, errorCode? }`. The ring buffer is populated on every command response and survives across requests but resets on daemon restart. ## Testing diff --git a/docs/public/solution/shared.md b/docs/public/solution/shared.md index 900cf4f..47c09d1 100644 --- a/docs/public/solution/shared.md +++ b/docs/public/solution/shared.md @@ -18,7 +18,8 @@ shared/ ├── actions.ts # action names, per-action params and results ├── handles.ts # daemon-owned element handle aliases at the CLI/daemon boundary ├── errors.ts # error codes, categories, structured error shape - └── sessions.ts # nick/session/tab identifiers, validation helpers, pacing mode + ├── sessions.ts # nick/session/tab identifiers, validation helpers, pacing mode + └── version.ts # VERSION and PROTOCOL_VERSION constants ``` ## Protocol Envelope @@ -28,9 +29,10 @@ shared/ import type { Action, ActionParams, ActionResult, ForwardedActionParams } from './actions'; import type { BproxyError } from './errors'; import type { Nick, SessionId } from './sessions'; +import type { PROTOCOL_VERSION } from './version'; export interface BproxyRequest { - protocol_version: 1; + protocol_version: typeof PROTOCOL_VERSION; id: string; action: A; nick: Nick; @@ -55,7 +57,7 @@ export type BproxyForwardedRequest = Omit { - protocol_version: 1; + protocol_version: typeof PROTOCOL_VERSION; id: string; ok: true; data: ActionResult[A]; @@ -64,7 +66,7 @@ export interface BproxySuccessResponse { } export interface BproxyErrorResponse { - protocol_version: 1; + protocol_version: typeof PROTOCOL_VERSION; id: string; ok: false; error: BproxyError; @@ -105,7 +107,7 @@ export type Action = | 'select' | 'wait' | 'require-human' - | 'tab.list' | 'tab.pin' | 'tab.unpin' | 'tab.open' | 'tab.close' + | 'tab.list' | 'tab.pin' | 'tab.unpin' | 'tab.open' | 'tab.close' | 'tab.activate' | 'session.create' | 'session.list' | 'session.bind' | 'session.unbind' | 'session.resume' | 'session.close' | 'debug.log' | 'debug.last' | 'debug.status'; @@ -134,7 +136,7 @@ export type ClientElementTarget = ElementTarget | ElementHandleRef; export interface ActionParams { navigate: { url: string }; text: { selector?: string }; - links: { selector?: string; visibleOnly?: boolean; limit?: number }; + links: { selector?: string; visibleOnly?: boolean; limit?: number; hrefContains?: string; offset?: number }; images: { selector?: string }; elements: { form?: boolean }; outline: Record; @@ -167,6 +169,7 @@ export interface ActionParams { 'tab.unpin': { tab?: TabHandle }; 'tab.open': { url: string }; 'tab.close': { tab?: TabHandle }; + 'tab.activate': { tab?: TabHandle }; 'session.create': { label?: string }; 'session.list': Record; 'session.bind': { tab: TabHandle; pacing?: PacingMode }; @@ -182,7 +185,7 @@ export interface ActionParams { export interface ActionResult { navigate: { url: string; title: string; loadTime: number }; text: { text: string }; - links: { links: Array }; + links: { links: Array; total: number; capped?: boolean }; images: { images: Array<{ src: string; alt: string; width: number; height: number }> }; elements: { elements: Array }; outline: { landmarks: Array; headings: Array }; @@ -212,6 +215,7 @@ export interface ActionResult { 'tab.unpin': { tab: TabHandle; pinned: false }; 'tab.open': { session: SessionId; tab: TabHandle; bound: boolean; url: string; tmpDir: string; ownerHash: string }; 'tab.close': { tab: TabHandle; closed: true }; + 'tab.activate': { tab: TabHandle; activated: true }; 'session.create': { session: SessionId; label?: string; tmpDir: string; ownerHash: string }; 'session.list': { sessions: Array }; 'session.bind': { session: SessionId; tab: TabHandle }; diff --git a/docs/public/views/06-threat-model.md b/docs/public/views/06-threat-model.md index 7e2c715..3d22151 100644 --- a/docs/public/views/06-threat-model.md +++ b/docs/public/views/06-threat-model.md @@ -107,7 +107,7 @@ Figure 5. Data-flow diagram with trust boundaries — the three dashed-red enclo | Polling / DOM settle | High-signal instrumentation or bundle hygiene regressions | No `MutationObserver`; jittered polling only; Task 16 scans the production artifact to keep `MutationObserver` out of shipped output | | Screenshot escalation | `chrome.debugger` would widen capability and show a user-visible Chrome banner | Normal screenshots use `captureVisibleTab`; debugger screenshots remain gated behind `DEBUGGER_DISABLED`, with no `debugger` permission in the manifest today | | Web-accessible resources | Deterministic extension-resource probing by pages or scanners | `web_accessible_resources` is absent by default; build hook strips WXT's empty array stub so the manifest stays default-deny (ADR-016) | -| Hidden-tab destructive actions | Clicking, hovering, or writing to a background tab may produce misleading state or bot-signal issues | Content polling checks `document.visibilityState` and destructive actions bail with `TAB_NOT_VISIBLE` unless future protocol metadata explicitly opts into user-initiated hidden-tab behavior | +| Hidden-tab destructive actions | Clicking, hovering, or writing to a background tab may produce misleading state or bot-signal issues | Content polling checks `document.visibilityState`; destructive actions bail with `TAB_NOT_VISIBLE`; agents must explicitly run `tab activate` before retrying. No hidden auto-activation or fallback chain. | | Navigation push over WS | Top-level navigation events carry Chrome tab ids | Tab ids stay inside the daemon↔extension boundary only; they are used for page-epoch tracking and are never exposed to CLI stdout, agent-visible responses, or public handle strings | ## Still out of scope diff --git a/eslint.config.js b/eslint.config.js index dec2a9b..b81ced9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,10 +11,8 @@ export default [ "**/.output/**", "**/.wxt/**", "**/.tmp/**", - ".pi/extensions/**", "poc/**", "docs/**", - "tools/pi/**", "views/scripts/**", ], }, diff --git a/extension/package.json b/extension/package.json index 3e8b5dc..6026ea7 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/extension", - "version": "0.8.0", + "version": "0.9.0", "private": true, "type": "module", "scripts": { diff --git a/extension/scripts/smoke/common.ts b/extension/scripts/smoke/common.ts index 9595074..a90dbb1 100644 --- a/extension/scripts/smoke/common.ts +++ b/extension/scripts/smoke/common.ts @@ -2,13 +2,14 @@ import { randomUUID } from "node:crypto"; import { existsSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import type { - Action, - ActionParams, - BproxyRequest, - BproxyResponse, - BproxySuccessResponse, - ElementInfo, +import { + type Action, + type ActionParams, + type BproxyRequest, + type BproxyResponse, + type BproxySuccessResponse, + type ElementInfo, + PROTOCOL_VERSION, } from "@bproxy/shared"; const DESTRUCTIVE_ACTIONS = new Set([ @@ -86,7 +87,7 @@ export function buildRequest( options: SendCommandOptions = {}, ): BproxyRequest { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: options.id ?? randomUUID(), action, nick: (options.nick ?? "smoke1") as BproxyRequest["nick"], diff --git a/extension/src/background/__tests__/browser-actions.test.ts b/extension/src/background/__tests__/browser-actions.test.ts index 78574c2..dfa473b 100644 --- a/extension/src/background/__tests__/browser-actions.test.ts +++ b/extension/src/background/__tests__/browser-actions.test.ts @@ -1,4 +1,10 @@ -import type { BproxyError, BproxyForwardedRequest, PageState, SessionId } from "@bproxy/shared"; +import { + type BproxyError, + type BproxyForwardedRequest, + type PageState, + PROTOCOL_VERSION, + type SessionId, +} from "@bproxy/shared"; import { describe, expect, it, vi } from "vitest"; import { createBrowserActionHandler } from "../browser-actions"; import type { TabLike } from "../tabs"; @@ -32,6 +38,7 @@ interface HarnessOverrides { create?: (createProperties: Record) => Promise; remove?: (tabId: number) => Promise; captureVisibleTab?: (windowId?: number, options?: { format?: "png" | "jpeg" }) => Promise; + windowsUpdate?: (windowId: number, updateInfo: Record) => Promise; isDebuggerScreenshotEnabled?: () => boolean | Promise; captureDebuggerScreenshot?: ( tab: TargetTab, @@ -43,10 +50,12 @@ function createHarness(overrides: HarnessOverrides = {}) { const mainWorld = createMainWorldSeam(); const tabRuntime = createTabRuntimeSeam(overrides, now); const tabs = createTabsSeam(overrides); + const windows = createWindowsSeam(overrides); const handler = createBrowserActionHandler({ mainWorld, tabRuntime, tabs, + windows: windows.windows, now: () => now.value, isDebuggerScreenshotEnabled: overrides.isDebuggerScreenshotEnabled, captureDebuggerScreenshot: overrides.captureDebuggerScreenshot, @@ -56,6 +65,7 @@ function createHarness(overrides: HarnessOverrides = {}) { mainWorld, ...tabRuntime, ...tabs, + ...windows, handler, }; } @@ -102,6 +112,11 @@ function createTabsSeam(overrides: HarnessOverrides) { }; } +function createWindowsSeam(overrides: HarnessOverrides) { + const windowsUpdate = vi.fn(overrides.windowsUpdate ?? (async () => ({}))); + return { windowsUpdate, windows: { update: windowsUpdate } }; +} + function createUpdateResult(target: TargetTab) { return async (tabId: number, updateProperties: Record) => tab({ @@ -274,6 +289,38 @@ describe("createBrowserActionHandler", () => { expect(closed).toMatchObject({ data: {} }); }); + it("tab.activate foregrounds tab and focuses window", async () => { + const h = createHarness({ + update: async (tabId: number, updateProperties: Record) => + tab({ + id: tabId, + active: updateProperties["active"] === true, + windowId: 7, + }), + }); + + const result = await h.handler.handleBrowserAction(tabActivateRequest()); + + expect(h.update).toHaveBeenCalledWith(42, { active: true }); + expect(h.windowsUpdate).toHaveBeenCalledWith(7, { focused: true }); + expect(result).toMatchObject({ data: { activated: true } }); + }); + + it("tab.activate fails closed when Chrome omits windowId", async () => { + const h = createHarness({ + update: async (tabId: number) => { + const updated = tab({ id: tabId, active: true }); + return { ...updated, windowId: undefined }; + }, + }); + + await expect(h.handler.handleBrowserAction(tabActivateRequest())).rejects.toMatchObject({ + code: "SCRIPT_ERROR", + message: "Target tab 42 has no windowId for activation", + }); + expect(h.windowsUpdate).not.toHaveBeenCalled(); + }); + it("propagates TAB_NOT_FOUND on tab actions that resolve a missing tab", async () => { const missing: BproxyError = { code: "TAB_NOT_FOUND", @@ -321,7 +368,7 @@ function fillRequest( overrides: Partial> = {}, ): BproxyForwardedRequest<"fill"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-fill", action: "fill", params: overrides.params ?? { @@ -341,7 +388,7 @@ function navigateRequest( overrides: Partial> = {}, ): BproxyForwardedRequest<"navigate"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-nav", action: "navigate", params: overrides.params ?? { url: "https://example.test/" }, @@ -356,7 +403,7 @@ function screenshotRequest( overrides: Partial> = {}, ): BproxyForwardedRequest<"screenshot"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-shot", action: "screenshot", params: overrides.params ?? {}, @@ -371,7 +418,7 @@ function tabOpenRequest( overrides: Partial> = {}, ): BproxyForwardedRequest<"tab.open"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-open", action: "tab.open", params: overrides.params ?? { url: "https://opened.test/" }, @@ -386,7 +433,7 @@ function tabCloseRequest( overrides: Partial> = {}, ): BproxyForwardedRequest<"tab.close"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-close", action: "tab.close", params: overrides.params ?? {}, @@ -401,7 +448,7 @@ function tabPinRequest( overrides: Partial> = {}, ): BproxyForwardedRequest<"tab.pin"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-pin", action: "tab.pin", params: overrides.params ?? {}, @@ -416,7 +463,7 @@ function tabUnpinRequest( overrides: Partial> = {}, ): BproxyForwardedRequest<"tab.unpin"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-unpin", action: "tab.unpin", params: overrides.params ?? {}, @@ -431,7 +478,7 @@ function requireHumanRequest( overrides: Partial> = {}, ): BproxyForwardedRequest<"require-human"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-human", action: "require-human", params: overrides.params ?? { reason: "Need manual step" }, @@ -441,3 +488,18 @@ function requireHumanRequest( target: overrides.target ?? { tabId: 42 }, }; } + +function tabActivateRequest( + overrides: Partial> = {}, +): BproxyForwardedRequest<"tab.activate"> { + return { + protocol_version: PROTOCOL_VERSION, + id: overrides.id ?? "req-activate", + action: "tab.activate", + params: overrides.params ?? {}, + session: overrides.session ?? TEST_SESSION, + deadline: overrides.deadline ?? 10_000, + destructive: overrides.destructive ?? true, + target: overrides.target ?? { tabId: 42 }, + }; +} diff --git a/extension/src/background/__tests__/dedupe.test.ts b/extension/src/background/__tests__/dedupe.test.ts index 8498859..7868550 100644 --- a/extension/src/background/__tests__/dedupe.test.ts +++ b/extension/src/background/__tests__/dedupe.test.ts @@ -1,11 +1,11 @@ -import type { BproxyResponse } from "@bproxy/shared"; +import { type BproxyResponse, PROTOCOL_VERSION } from "@bproxy/shared"; import { describe, expect, it } from "vitest"; import { createFakeStorageItem } from "../../test/fakes/storage"; import { createDedupe, type DedupeStore } from "../dedupe"; function okResponse(id: string): BproxyResponse { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, ok: true, data: { text: "x" }, diff --git a/extension/src/background/__tests__/dispatcher.test.ts b/extension/src/background/__tests__/dispatcher.test.ts index 4947cb2..3a78359 100644 --- a/extension/src/background/__tests__/dispatcher.test.ts +++ b/extension/src/background/__tests__/dispatcher.test.ts @@ -1,4 +1,9 @@ -import type { BproxyForwardedRequest, BproxyResponse, PageState } from "@bproxy/shared"; +import { + type BproxyForwardedRequest, + type BproxyResponse, + type PageState, + PROTOCOL_VERSION, +} from "@bproxy/shared"; import { describe, expect, it, vi } from "vitest"; import { createFakeStorageItem } from "../../test/fakes/storage"; import { createDedupe } from "../dedupe"; @@ -15,7 +20,7 @@ const PAGE: PageState = { function makeRequest(overrides: Partial = {}): BproxyForwardedRequest { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-1", action: overrides.action ?? "text", params: overrides.params ?? {}, @@ -127,7 +132,7 @@ describe("parseForwardedRequest", () => { it("rejects tab.list so the extension cannot enumerate browser tabs", () => { const parsed = parseForwardedRequest( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: "req-1", action: "tab.list", params: {}, @@ -160,7 +165,7 @@ describe("dispatcher", () => { const h = makeHarness(); await h.dispatcher.handleMessage( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: "bad-1", action: "text", params: { selector: 123 }, diff --git a/extension/src/background/__tests__/main-world.test.ts b/extension/src/background/__tests__/main-world.test.ts index 2e57449..361b633 100644 --- a/extension/src/background/__tests__/main-world.test.ts +++ b/extension/src/background/__tests__/main-world.test.ts @@ -1,4 +1,4 @@ -import type { BproxyForwardedRequest, SessionId } from "@bproxy/shared"; +import { type BproxyForwardedRequest, PROTOCOL_VERSION, type SessionId } from "@bproxy/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { doc, el, type FakeDocument, type FakeElement } from "../../test/fixtures/fake-dom"; import { createMainWorldExecutor, type MainWorldExecuteDetails } from "../main-world"; @@ -100,7 +100,7 @@ function fillRequest( overrides: Partial> = {}, ): BproxyForwardedRequest<"fill"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-fill-main", action: "fill", params: overrides.params ?? { diff --git a/extension/src/background/__tests__/responses.test.ts b/extension/src/background/__tests__/responses.test.ts index e5629b1..b83ca7d 100644 --- a/extension/src/background/__tests__/responses.test.ts +++ b/extension/src/background/__tests__/responses.test.ts @@ -1,4 +1,10 @@ -import type { BproxyError, BproxyForwardedRequest, PageState, SessionId } from "@bproxy/shared"; +import { + type BproxyError, + type BproxyForwardedRequest, + type PageState, + PROTOCOL_VERSION, + type SessionId, +} from "@bproxy/shared"; import { describe, expect, it } from "vitest"; import { errorResponse, successResponse } from "../responses"; @@ -6,7 +12,7 @@ const TEST_SESSION = "m4q7z2" as SessionId; function req(id: string): BproxyForwardedRequest<"text"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, action: "text", params: {}, @@ -25,7 +31,7 @@ const PAGE: PageState = { }; describe("response builders", () => { - it("successResponse copies id, sets protocol_version=1, ok=true, replay=false by default", () => { + it("successResponse copies id, sets protocol_version=PROTOCOL_VERSION, ok=true, replay=false by default", () => { const res = successResponse({ request: req("abc"), data: { text: "hello" }, @@ -33,7 +39,7 @@ describe("response builders", () => { }); expect(res).toEqual({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: "abc", ok: true, data: { text: "hello" }, @@ -57,7 +63,7 @@ describe("response builders", () => { expect(res.page).toBe(PAGE); }); - it("errorResponse copies id, sets protocol_version=1, ok=false, and the error payload", () => { + it("errorResponse copies id, sets protocol_version=PROTOCOL_VERSION, ok=false, and the error payload", () => { const err: BproxyError = { code: "ELEMENT_NOT_FOUND", category: "target", @@ -67,7 +73,7 @@ describe("response builders", () => { const res = errorResponse({ request: req("xyz"), error: err }); expect(res).toEqual({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: "xyz", ok: false, error: err, diff --git a/extension/src/background/__tests__/storage.test.ts b/extension/src/background/__tests__/storage.test.ts index 2b6c307..d6be6c0 100644 --- a/extension/src/background/__tests__/storage.test.ts +++ b/extension/src/background/__tests__/storage.test.ts @@ -1,3 +1,4 @@ +import { PROTOCOL_VERSION } from "@bproxy/shared"; import { describe, expect, it } from "vitest"; import { bootstrapItem, @@ -17,7 +18,7 @@ describe("storage items", () => { const payload: PairingBootstrap = { extensionToken: "tok", wsUrl: "ws://127.0.0.1:9615", - protocolVersion: 1, + protocolVersion: PROTOCOL_VERSION, issuedAt: 100, expiresAt: 200, nonce: "n", diff --git a/extension/src/background/__tests__/tabs.test.ts b/extension/src/background/__tests__/tabs.test.ts index f5bb703..001c6ac 100644 --- a/extension/src/background/__tests__/tabs.test.ts +++ b/extension/src/background/__tests__/tabs.test.ts @@ -1,4 +1,9 @@ -import type { BproxyForwardedRequest, PageState, SessionId } from "@bproxy/shared"; +import { + type BproxyForwardedRequest, + type PageState, + PROTOCOL_VERSION, + type SessionId, +} from "@bproxy/shared"; import { describe, expect, it, vi } from "vitest"; import { createFakeStorageItem } from "../../test/fakes/storage"; import { createContentInjector } from "../injection"; @@ -16,7 +21,7 @@ function makeRequest( overrides: Partial> = {}, ): BproxyForwardedRequest<"text"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? "req-1", action: overrides.action ?? "text", params: overrides.params ?? { selector: "main" }, diff --git a/extension/src/background/__tests__/ws-client.test.ts b/extension/src/background/__tests__/ws-client.test.ts index 65af5d0..7010799 100644 --- a/extension/src/background/__tests__/ws-client.test.ts +++ b/extension/src/background/__tests__/ws-client.test.ts @@ -1,3 +1,4 @@ +import { PROTOCOL_VERSION } from "@bproxy/shared"; import { describe, expect, it, vi } from "vitest"; import { createFakeStorageItem } from "../../test/fakes/storage"; import type { PairingBootstrap } from "../storage"; @@ -26,7 +27,7 @@ function happyBootstrap(overrides: Partial = {}): PairingBoots return { extensionToken: "tok-abc", wsUrl: "ws://127.0.0.1:9615/ws", - protocolVersion: 1, + protocolVersion: PROTOCOL_VERSION, issuedAt: 1000, expiresAt: 1_000_000, nonce: "n-1", diff --git a/extension/src/background/browser-actions.ts b/extension/src/background/browser-actions.ts index 482e1ba..0eea6a7 100644 --- a/extension/src/background/browser-actions.ts +++ b/extension/src/background/browser-actions.ts @@ -22,10 +22,15 @@ export interface BrowserTabsSeam { captureVisibleTab(windowId?: number, options?: { format?: "png" | "jpeg" }): Promise; } +export interface BrowserWindowsSeam { + update(windowId: number, updateInfo: Record): Promise; +} + export interface BrowserActionHandlerDeps { mainWorld: MainWorldExecutor; tabRuntime: Pick; tabs: BrowserTabsSeam; + windows: BrowserWindowsSeam; now?: () => number; isDebuggerScreenshotEnabled?: () => boolean | Promise; captureDebuggerScreenshot?: ( @@ -53,6 +58,7 @@ const ROUTED_BROWSER_ACTIONS: BrowserActionMap = { "tab.close": handleTabClose, "tab.pin": handleTabPin, "tab.unpin": handleTabUnpin, + "tab.activate": handleTabActivate, }; export function createBrowserActionHandler(deps: BrowserActionHandlerDeps): BrowserActionHandler { @@ -180,6 +186,19 @@ async function handleTabUnpin( return { data: {}, page: pageStateFromTab(updated) }; } +async function handleTabActivate( + deps: BrowserActionHandlerDeps, + request: BproxyForwardedRequest<"tab.activate">, +): Promise { + const tabId = requireTargetTabId(request, request.action); + const updated = await resolveTabResult(deps, tabId, deps.tabs.update(tabId, { active: true })); + if (typeof updated.windowId !== "number") { + throw scriptError(`Target tab ${updated.id} has no windowId for activation`); + } + await deps.windows.update(updated.windowId, { focused: true }); + return { data: { activated: true }, page: pageStateFromTab(updated) }; +} + async function buildRequireHumanError( deps: BrowserActionHandlerDeps, request: BproxyForwardedRequest<"require-human">, diff --git a/extension/src/background/dispatcher.ts b/extension/src/background/dispatcher.ts index 3a7033f..de7c080 100644 --- a/extension/src/background/dispatcher.ts +++ b/extension/src/background/dispatcher.ts @@ -1,9 +1,10 @@ -import type { - ActionResult, - BproxyError, - BproxyForwardedRequest, - BproxyResponse, - PageState, +import { + type ActionResult, + type BproxyError, + type BproxyForwardedRequest, + type BproxyResponse, + type PageState, + PROTOCOL_VERSION, } from "@bproxy/shared"; import type { Dedupe } from "./dedupe"; import { @@ -163,7 +164,7 @@ function emptyPageState(): PageState { function malformedRequest(id: string): BproxyForwardedRequest<"debug.log"> { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, action: "debug.log", params: {}, diff --git a/extension/src/background/forwarded-actions.ts b/extension/src/background/forwarded-actions.ts index 3ba2d6c..88a45c9 100644 --- a/extension/src/background/forwarded-actions.ts +++ b/extension/src/background/forwarded-actions.ts @@ -15,7 +15,14 @@ export type ForwardedAction = Exclude< export type BrowserAction = Extract< ForwardedAction, - "navigate" | "screenshot" | "require-human" | "tab.pin" | "tab.unpin" | "tab.open" | "tab.close" + | "navigate" + | "screenshot" + | "require-human" + | "tab.pin" + | "tab.unpin" + | "tab.open" + | "tab.close" + | "tab.activate" >; export type DomAction = Exclude; @@ -43,6 +50,7 @@ const FORWARDED_ACTIONS = [ "tab.unpin", "tab.open", "tab.close", + "tab.activate", "debug.log", ] as const satisfies readonly ForwardedAction[]; @@ -54,6 +62,7 @@ const BROWSER_ACTIONS = [ "tab.unpin", "tab.open", "tab.close", + "tab.activate", ] as const satisfies readonly BrowserAction[]; const DOM_ACTIONS = [ diff --git a/extension/src/background/forwarded-params.ts b/extension/src/background/forwarded-params.ts index cffe3aa..5279ca1 100644 --- a/extension/src/background/forwarded-params.ts +++ b/extension/src/background/forwarded-params.ts @@ -30,6 +30,7 @@ export function paramsValidForAction( "tab.unpin": isOptionalTabHandleParams, "tab.open": isNavigateParams, "tab.close": isOptionalTabHandleParams, + "tab.activate": isOptionalTabHandleParams, "debug.log": isDebugLogParams, }; return validators[action](value); diff --git a/extension/src/background/forwarded-request.ts b/extension/src/background/forwarded-request.ts index 442a6e8..51b14f6 100644 --- a/extension/src/background/forwarded-request.ts +++ b/extension/src/background/forwarded-request.ts @@ -1,4 +1,5 @@ import type { BproxyForwardedRequest } from "@bproxy/shared"; +import { PROTOCOL_VERSION } from "@bproxy/shared"; import { type ForwardedAction, isForwardedAction } from "./forwarded-actions"; import { isTarget, paramsValidForAction } from "./forwarded-params"; @@ -10,7 +11,7 @@ type EnvelopeValidation = | { success: true; data: { - protocol_version: 1; + protocol_version: typeof PROTOCOL_VERSION; id: string; action: ForwardedAction; params: unknown; @@ -42,7 +43,7 @@ export function parseForwardedRequest(raw: unknown): ParseResult { return { success: true, data: { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: env.id, action: env.action, params: env.params, @@ -84,7 +85,7 @@ function validateEnvelope(input: EnvelopeRecord): EnvelopeValidation { return { success: true, data: { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, action, params: input["params"], @@ -121,8 +122,8 @@ function validateEnvelopeShape( if (!hasExpectedKeys(input)) { return { success: false, id, error: "unexpected top-level keys" }; } - if (input["protocol_version"] !== 1) { - return { success: false, id, error: "protocol_version must be 1" }; + if (input["protocol_version"] !== PROTOCOL_VERSION) { + return { success: false, id, error: `protocol_version must be ${PROTOCOL_VERSION}` }; } return undefined; } diff --git a/extension/src/background/responses.ts b/extension/src/background/responses.ts index 4a46df3..c8fafdc 100644 --- a/extension/src/background/responses.ts +++ b/extension/src/background/responses.ts @@ -1,11 +1,12 @@ -import type { - Action, - ActionResult, - BproxyError, - BproxyErrorResponse, - BproxyForwardedRequest, - BproxySuccessResponse, - PageState, +import { + type Action, + type ActionResult, + type BproxyError, + type BproxyErrorResponse, + type BproxyForwardedRequest, + type BproxySuccessResponse, + type PageState, + PROTOCOL_VERSION, } from "@bproxy/shared"; export interface SuccessInput { @@ -29,7 +30,7 @@ export function successResponse( input: SuccessInput, ): BproxySuccessResponse { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: input.request.id, ok: true, data: input.data, @@ -40,7 +41,7 @@ export function successResponse( export function errorResponse(input: ErrorInput): BproxyErrorResponse { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: input.request.id, ok: false, error: input.error, diff --git a/extension/src/background/storage.ts b/extension/src/background/storage.ts index a942976..012d34a 100644 --- a/extension/src/background/storage.ts +++ b/extension/src/background/storage.ts @@ -1,4 +1,4 @@ -import type { TraceEntry } from "@bproxy/shared"; +import { PROTOCOL_VERSION, type TraceEntry } from "@bproxy/shared"; import { storage } from "wxt/utils/storage"; import type { DedupeEntry } from "./dedupe"; @@ -20,7 +20,7 @@ import type { DedupeEntry } from "./dedupe"; export interface PairingBootstrap { extensionToken: string; wsUrl: string; - protocolVersion: 1; + protocolVersion: typeof PROTOCOL_VERSION; issuedAt: number; expiresAt: number; nonce: string; diff --git a/extension/src/content/__tests__/reads.test.ts b/extension/src/content/__tests__/reads.test.ts index 88fa82f..d981551 100644 --- a/extension/src/content/__tests__/reads.test.ts +++ b/extension/src/content/__tests__/reads.test.ts @@ -86,19 +86,20 @@ describe("read actions", () => { Object.assign(page, { baseURI: "https://example.test/search?q=bproxy" }); Object.assign(page.defaultView, { innerWidth: 1280, innerHeight: 800 }); - const links = handleLinks( + const result = handleLinks( request("links", { selector: "#search", visibleOnly: true, limit: 4 }), withDocument(page), ); - expect(links).toHaveLength(4); - expect(links.map((link) => link.href)).toEqual([ + expect(result.links).toHaveLength(4); + expect(result.links.map((link) => link.href)).toEqual([ "https://example.test/result-1", "https://example.test/result-2", "https://example.test/result-1", "https://example.test/shadow", ]); - const firstLink = links[0]; + expect(result.total).toBeGreaterThanOrEqual(4); + const firstLink = result.links[0]; expect(firstLink).toMatchObject({ text: "Result One", title: "First result", @@ -106,22 +107,173 @@ describe("read actions", () => { targetAttr: "_blank", visible: true, }); - expect(links[3]).toMatchObject({ + expect(result.links[3]).toMatchObject({ target: { route: { hosts: [{ selector: "#shadow-host" }] } }, text: "Shadow result", visible: true, }); expect( - resolveElementTarget(links[3]!.target as ElementTarget, { + resolveElementTarget(result.links[3]!.target as ElementTarget, { document: page as unknown as Document, }), ).toBe(shadowLink); - const allLinks = handleLinks( + const allResult = handleLinks( request("links", { selector: "#search", limit: 10 }), withDocument(page), ); - expect(allLinks.map((link) => link.href)).toContain("https://example.test/hidden"); - expect(allLinks.map((link) => link.href)).toContain("https://example.test/offscreen"); + expect(allResult.links.map((link) => link.href)).toContain("https://example.test/hidden"); + expect(allResult.links.map((link) => link.href)).toContain("https://example.test/offscreen"); + }); + + it("links --href-contains filters by substring match on absolute href", () => { + const page = doc( + el("html", { + children: [ + el("body", { + children: [ + el("a", { attrs: { href: "https://linkedin.com/in/alice" }, text: "Alice" }), + el("a", { attrs: { href: "https://linkedin.com/in/bob" }, text: "Bob" }), + el("a", { attrs: { href: "https://linkedin.com/jobs/123" }, text: "Job" }), + el("a", { attrs: { href: "https://example.com/other" }, text: "Other" }), + ], + }), + ], + }), + ); + Object.assign(page, { baseURI: "https://linkedin.com/" }); + + // Matches substring + const profileLinks = handleLinks( + request("links", { hrefContains: "/in/" }), + withDocument(page), + ); + expect(profileLinks.links).toHaveLength(2); + expect(profileLinks.links.map((l) => l.text)).toEqual(["Alice", "Bob"]); + expect(profileLinks.total).toBe(2); + + // No match returns empty + const noMatch = handleLinks( + request("links", { hrefContains: "/nonexistent/" }), + withDocument(page), + ); + expect(noMatch.links).toHaveLength(0); + expect(noMatch.total).toBe(0); + + // Empty string matches everything + const allLinks = handleLinks(request("links", { hrefContains: "" }), withDocument(page)); + expect(allLinks.links).toHaveLength(4); + expect(allLinks.total).toBe(4); + + // undefined (omitted) means no filter + const noFilter = handleLinks(request("links", {}), withDocument(page)); + expect(noFilter.links).toHaveLength(4); + expect(noFilter.total).toBe(4); + + // Combined with limit: filters first, then caps + const limited = handleLinks( + request("links", { hrefContains: "linkedin.com", limit: 2 }), + withDocument(page), + ); + expect(limited.links).toHaveLength(2); + expect(limited.links.map((l) => l.text)).toEqual(["Alice", "Bob"]); + expect(limited.total).toBe(3); // 3 match linkedin.com, but only 2 returned due to limit + }); + + it("links --offset paginates through matching links", () => { + const page = doc( + el("html", { + children: [ + el("body", { + children: Array.from({ length: 10 }, (_, i) => + el("a", { + attrs: { href: `https://example.com/page/${i}` }, + text: `Link ${i}`, + }), + ), + }), + ], + }), + ); + Object.assign(page, { baseURI: "https://example.com/" }); + + // First page: offset 0, limit 3 + const page1 = handleLinks(request("links", { offset: 0, limit: 3 }), withDocument(page)); + expect(page1.links).toHaveLength(3); + expect(page1.total).toBe(10); + expect(page1.links.map((l) => l.text)).toEqual(["Link 0", "Link 1", "Link 2"]); + + // Second page: offset 3, limit 3 + const page2 = handleLinks(request("links", { offset: 3, limit: 3 }), withDocument(page)); + expect(page2.links).toHaveLength(3); + expect(page2.total).toBe(10); + expect(page2.links.map((l) => l.text)).toEqual(["Link 3", "Link 4", "Link 5"]); + + // Last partial page: offset 9, limit 3 + const lastPage = handleLinks(request("links", { offset: 9, limit: 3 }), withDocument(page)); + expect(lastPage.links).toHaveLength(1); + expect(lastPage.total).toBe(10); + expect(lastPage.links.map((l) => l.text)).toEqual(["Link 9"]); + + // Offset beyond total: empty result + const beyondEnd = handleLinks(request("links", { offset: 20, limit: 5 }), withDocument(page)); + expect(beyondEnd.links).toHaveLength(0); + expect(beyondEnd.total).toBe(10); + + // Offset with hrefContains + const filtered = handleLinks( + request("links", { hrefContains: "/page/", offset: 5, limit: 3 }), + withDocument(page), + ); + expect(filtered.links).toHaveLength(3); + expect(filtered.total).toBe(10); + expect(filtered.links.map((l) => l.text)).toEqual(["Link 5", "Link 6", "Link 7"]); + }); + + it("links returns capped: true when page has more than MAX_COLLECTION_CAP links", () => { + // MAX_COLLECTION_CAP is 2000 — create 2001 links to trigger the cap + const page = doc( + el("html", { + children: [ + el("body", { + children: Array.from({ length: 2001 }, (_, i) => + el("a", { + attrs: { href: `https://example.com/${i}` }, + text: `L${i}`, + }), + ), + }), + ], + }), + ); + Object.assign(page, { baseURI: "https://example.com/" }); + + const result = handleLinks(request("links", { limit: 500 }), withDocument(page)); + + // Total is capped at 2000 (MAX_COLLECTION_CAP), not the true 2001 + expect(result.total).toBe(2000); + expect(result.capped).toBe(true); + expect(result.links).toHaveLength(500); // limit applies to slice + + // Without capping, total is accurate when within cap + const smallPage = doc( + el("html", { + children: [ + el("body", { + children: Array.from({ length: 10 }, (_, i) => + el("a", { + attrs: { href: `https://example.com/${i}` }, + text: `L${i}`, + }), + ), + }), + ], + }), + ); + Object.assign(smallPage, { baseURI: "https://example.com/" }); + + const smallResult = handleLinks(request("links", {}), withDocument(smallPage)); + expect(smallResult.total).toBe(10); + expect(smallResult.capped).toBeUndefined(); }); it("images returns only visible images within the requested scope", () => { diff --git a/extension/src/content/__tests__/rpc.test.ts b/extension/src/content/__tests__/rpc.test.ts index 58b6a5d..01a4427 100644 --- a/extension/src/content/__tests__/rpc.test.ts +++ b/extension/src/content/__tests__/rpc.test.ts @@ -37,6 +37,7 @@ describe("createContentRpcHost", () => { visible: true, }, ], + total: 1, }) satisfies ActionResult["links"], }, getPageState: () => PAGE, @@ -63,6 +64,7 @@ describe("createContentRpcHost", () => { visible: true, }, ], + total: 1, }, page: PAGE, }); diff --git a/extension/src/content/actions/links.ts b/extension/src/content/actions/links.ts index df14e82..b164812 100644 --- a/extension/src/content/actions/links.ts +++ b/extension/src/content/actions/links.ts @@ -11,26 +11,52 @@ export interface LinkActionDeps { const DEFAULT_LINK_LIMIT = 100; const MAX_LINK_LIMIT = 500; +const MAX_COLLECTION_CAP = 2000; const NOISE_TAGS = new Set(["script", "style", "noscript", "template"]); const DEFAULT_BASE_URI = "https://example.test/"; +export type LinksResult = { + links: ActionResult["links"]["links"]; + total: number; + capped?: boolean; +}; + export function handleLinks( request: ContentRpcRequest<"links">, deps: LinkActionDeps = {}, -): ActionResult["links"]["links"] { +): LinksResult { const document = getDocument(deps); const root = resolveReadRoot(request.params.selector, document); const visibleOnly = request.params.visibleOnly === true; const limit = normalizeLimit(request.params.limit); - const links: ActionResult["links"]["links"] = []; + const offset = normalizeOffset(request.params.offset); + const hrefContains = request.params.hrefContains; + + // Phase 1: collect all matching links up to MAX_COLLECTION_CAP + const collected: ActionResult["links"]["links"] = []; + let capped = false; for (const element of walkComposedElements(root, { includeRoot: true })) { - if (links.length >= limit) break; + if (collected.length >= MAX_COLLECTION_CAP) { + capped = true; + break; + } const link = toLinkInfo(element, document, visibleOnly); - if (link) links.push(link); + if (!link) continue; + if (hrefContains !== undefined && !link.href.includes(hrefContains)) continue; + collected.push(link); } - return links; + const total = collected.length; + + // Phase 2: slice by offset and limit + const sliced = collected.slice(offset, offset + limit); + + const result: LinksResult = { links: sliced, total }; + if (capped) { + result.capped = true; + } + return result; } function toLinkInfo( @@ -157,3 +183,8 @@ function normalizeLimit(limit: number | undefined): number { if (typeof limit !== "number" || !Number.isFinite(limit)) return DEFAULT_LINK_LIMIT; return Math.min(MAX_LINK_LIMIT, Math.max(1, Math.floor(limit))); } + +function normalizeOffset(offset: number | undefined): number { + if (typeof offset !== "number" || !Number.isFinite(offset)) return 0; + return Math.max(0, Math.floor(offset)); +} diff --git a/extension/src/content/actions/reads.ts b/extension/src/content/actions/reads.ts index b375bf6..9891eb7 100644 --- a/extension/src/content/actions/reads.ts +++ b/extension/src/content/actions/reads.ts @@ -52,7 +52,7 @@ const MAX_DOM_DEPTH = 6; export function createReadHandlers(deps: ReadActionDeps = {}): ReadActionHandlers { return { text: (request) => ({ text: handleText(request, deps) }), - links: (request) => ({ links: handleLinks(request, deps) }), + links: (request) => handleLinks(request, deps), images: (request) => ({ images: handleImages(request, deps) }), elements: (request) => ({ elements: handleElements(request, deps) }), outline: (_request) => handleOutline(deps), diff --git a/extension/src/entrypoints/background.ts b/extension/src/entrypoints/background.ts index f33ced4..aa2db32 100644 --- a/extension/src/entrypoints/background.ts +++ b/extension/src/entrypoints/background.ts @@ -92,6 +92,9 @@ function makeDispatcher(client: WsClient, tabs: TabRuntime): Dispatcher { return options ? chrome.tabs.captureVisibleTab(options) : chrome.tabs.captureVisibleTab(); }, }, + windows: { + update: (windowId, updateInfo) => chrome.windows.update(windowId, updateInfo), + }, now: () => Date.now(), isDebuggerScreenshotEnabled: async () => (await configFlagsItem.getValue())["debuggerScreenshot"] === true, diff --git a/extension/src/entrypoints/popup/__tests__/pairing.test.ts b/extension/src/entrypoints/popup/__tests__/pairing.test.ts index ba3579b..e700ebc 100644 --- a/extension/src/entrypoints/popup/__tests__/pairing.test.ts +++ b/extension/src/entrypoints/popup/__tests__/pairing.test.ts @@ -1,3 +1,4 @@ +import { PROTOCOL_VERSION } from "@bproxy/shared"; import { describe, expect, it, vi } from "vitest"; import type { PairingBootstrap } from "../../../background/storage"; import { createFakeStorageItem } from "../../../test/fakes/storage"; @@ -25,7 +26,7 @@ function happyBody(overrides: Partial = {}) { const data: PairingBootstrap = { extensionToken: "tok-abc", wsUrl: "ws://127.0.0.1:9615/ws", - protocolVersion: 1, + protocolVersion: PROTOCOL_VERSION, issuedAt: 1000, expiresAt: 9999, nonce: "n-1", @@ -64,7 +65,7 @@ describe("runPairing", () => { expect(stored).toEqual({ extensionToken: "tok-abc", wsUrl: "ws://127.0.0.1:9615/ws", - protocolVersion: 1, + protocolVersion: PROTOCOL_VERSION, issuedAt: 1000, expiresAt: 9999, nonce: "n-1", @@ -140,11 +141,11 @@ describe("runPairing", () => { expect(deps.sendMessage).not.toHaveBeenCalled(); }); - it("protocolVersion !== 1 is rejected with UNSUPPORTED_PROTOCOL_VERSION", async () => { + it("protocolVersion !== 2 is rejected with UNSUPPORTED_PROTOCOL_VERSION", async () => { const deps = makeDeps({ fetch: makeFetch(200, { ok: true, - data: { ...happyBody().data, protocolVersion: 2 }, + data: { ...happyBody().data, protocolVersion: 99 }, }), }); diff --git a/extension/src/entrypoints/popup/pairing.ts b/extension/src/entrypoints/popup/pairing.ts index 92543fa..fc9e94b 100644 --- a/extension/src/entrypoints/popup/pairing.ts +++ b/extension/src/entrypoints/popup/pairing.ts @@ -1,3 +1,4 @@ +import { PROTOCOL_VERSION } from "@bproxy/shared"; import type { PairingBootstrap } from "../../background/storage"; import type { StorageItem } from "../../background/storage-item"; @@ -54,7 +55,7 @@ type ValidateErr = { ok: false; code: PairingErrorCode; message?: string }; * * 1. POST `{ code }` to `/pair/claim`. * 2. Validate the daemon's success envelope (`{ ok, data }`) and the - * bootstrap payload (loopback `wsUrl`, `protocolVersion === 1`, + * bootstrap payload (loopback `wsUrl`, `protocolVersion === 2`, * future `expiresAt`, non-empty nonce/token). * Daemon pairing failures, including rate limiting, pass through by code. * 3. Persist via the typed `bootstrapItem` storage seam. @@ -154,11 +155,11 @@ function validateShape(d: Record): ValidateOk | ValidateErr { return { ok: false, code: "INVALID_PAYLOAD_SHAPE", message: "wsUrl missing" }; } const protocolVersion = d["protocolVersion"]; - if (protocolVersion !== 1) { + if (protocolVersion !== PROTOCOL_VERSION) { return { ok: false, code: "UNSUPPORTED_PROTOCOL_VERSION", - message: `expected 1, got ${String(protocolVersion)}`, + message: `expected ${PROTOCOL_VERSION}, got ${String(protocolVersion)}`, }; } const issuedAt = d["issuedAt"]; @@ -176,7 +177,7 @@ function validateShape(d: Record): ValidateOk | ValidateErr { return { ok: true, - value: { extensionToken, wsUrl, protocolVersion: 1, issuedAt, expiresAt, nonce }, + value: { extensionToken, wsUrl, protocolVersion: PROTOCOL_VERSION, issuedAt, expiresAt, nonce }, }; } diff --git a/knip.json b/knip.json index e51eac9..37844a3 100644 --- a/knip.json +++ b/knip.json @@ -23,6 +23,6 @@ "project": ["src/**/*.ts", "scripts/**/*.ts"] } }, - "ignore": [".pi/extensions/**", "poc/**", "tools/pi/**"], + "ignore": ["poc/**"], "ignoreExportsUsedInFile": true } diff --git a/package.json b/package.json index 7e13a30..21debc4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "bproxy", "private": true, "type": "module", - "version": "0.8.0", + "version": "0.9.0", "description": "Browser proxy for code agents.", "license": "MIT", "packageManager": "pnpm@9.15.0", @@ -18,11 +18,7 @@ "sync-versions": "node scripts/sync-versions.js", "check-versions": "node scripts/check-versions.js", "check": "pnpm typecheck && pnpm format && pnpm lint && pnpm arch && pnpm deadcode && node scripts/check-versions.js", - "typecheck:pi-shim": "tsc -p .pi/extensions/context-grep/tsconfig.json --noEmit", - "typecheck:pi-tooling": "tsc -p tools/pi/context-grep/tsconfig.json --noEmit", - "lint:pi-shim": "eslint .pi/extensions/context-grep/index.ts --no-ignore", - "test": "pnpm -r test && pnpm test:pi-tooling", - "test:pi-tooling": "tsx --test tools/pi/context-grep/test/*.test.ts", + "test": "pnpm -r test", "docs:dev": "pnpm --filter views dev", "docs:build": "rm -rf views/public/views/auto && mkdir -p views/public/views/auto && cp docs/public/views/auto/*.svg views/public/views/auto/ && pnpm --filter views build && bash views/scripts/assert-no-md-links.sh && bash views/scripts/assert-component-svgs.sh", "views:audit": "pnpm --filter views run audit", @@ -42,7 +38,6 @@ "husky": "^9.1.7", "knip": "^6.13.1", "lint-staged": "^17.0.7", - "tsx": "^4.21.0", "typescript": "^6.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5ace14..03655b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,6 @@ importers: lint-staged: specifier: ^17.0.7 version: 17.0.7 - tsx: - specifier: ^4.21.0 - version: 4.21.0 typescript: specifier: ^6.0.3 version: 6.0.3 diff --git a/service/package.json b/service/package.json index 938a431..0fa3973 100644 --- a/service/package.json +++ b/service/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/service", - "version": "0.8.0", + "version": "0.9.0", "private": true, "type": "module", "bin": { diff --git a/service/src/__tests__/action-contract.test.ts b/service/src/__tests__/action-contract.test.ts index 4fb4d5e..304f0be 100644 --- a/service/src/__tests__/action-contract.test.ts +++ b/service/src/__tests__/action-contract.test.ts @@ -1,4 +1,10 @@ -import type { Action, BproxyRequest, BproxyResponse, TabHandle } from "@bproxy/shared"; +import { + type Action, + type BproxyRequest, + type BproxyResponse, + PROTOCOL_VERSION, + type TabHandle, +} from "@bproxy/shared"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { BuiltServer } from "../server"; import { @@ -51,7 +57,7 @@ function paramsFor(action: Action): BproxyRequest["params"] { function makeCmd(action: Action, overrides: Partial = {}): BproxyRequest { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? `01HZX${crypto.randomUUID().replaceAll("-", "").slice(0, 21).toUpperCase()}`, action, @@ -223,7 +229,7 @@ describe("action contract coverage — GAP A", () => { expect(built.sessions.list()).toHaveLength(before); }); - for (const action of ["tab.pin", "tab.unpin", "tab.close"] as const) { + for (const action of ["tab.pin", "tab.unpin", "tab.close", "tab.activate"] as const) { it(`${action}: returns TAB_NOT_FOUND without a selected tab even before WS forwarding`, async () => { const res = await postCommand(makeCmd(action)); expect(res.status).toBe(200); @@ -243,7 +249,7 @@ describe("action contract coverage — GAP A", () => { const req = JSON.parse(String(raw)) as BproxyRequest; receivedAction = req.action; const resp: BproxyResponse = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { entries: [] }, diff --git a/service/src/__tests__/auth-ordering.test.ts b/service/src/__tests__/auth-ordering.test.ts index 56b887b..6b535ac 100644 --- a/service/src/__tests__/auth-ordering.test.ts +++ b/service/src/__tests__/auth-ordering.test.ts @@ -1,4 +1,4 @@ -import type { BproxyRequest, BproxyResponse } from "@bproxy/shared"; +import { type BproxyRequest, type BproxyResponse, PROTOCOL_VERSION } from "@bproxy/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import WebSocket from "ws"; import type { BuiltServer } from "../server"; @@ -20,7 +20,7 @@ const DEFAULT_SESSION = "m4q8z2" as BproxyRequest["session"]; function makeCmd(overrides: Partial = {}): BproxyRequest { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? `auth-test-${crypto.randomUUID().slice(0, 8)}`, action: overrides.action ?? "text", nick: overrides.nick ?? TEST_NICK, diff --git a/service/src/__tests__/deadline-envelope.test.ts b/service/src/__tests__/deadline-envelope.test.ts index f6aab98..1d7ccd6 100644 --- a/service/src/__tests__/deadline-envelope.test.ts +++ b/service/src/__tests__/deadline-envelope.test.ts @@ -1,9 +1,10 @@ -import type { - BproxyErrorResponse, - BproxyRequest, - BproxyResponse, - SessionId, - TabHandle, +import { + type BproxyErrorResponse, + type BproxyRequest, + type BproxyResponse, + PROTOCOL_VERSION, + type SessionId, + type TabHandle, } from "@bproxy/shared"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { CapturedLogger } from "../logger"; @@ -34,7 +35,7 @@ function nextCommandId(): string { function makeCmd(overrides: Partial = {}): BproxyRequest { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? nextCommandId(), action: overrides.action ?? "text", nick: overrides.nick ?? TEST_NICK, @@ -280,7 +281,7 @@ describe("lifecycle log events for session-local and tab-mediated actions", () = ws.on("message", (raw: unknown) => { const req = JSON.parse(String(raw)) as BproxyRequest; const resp = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { tabId: 100, url: "https://example.com" }, @@ -316,7 +317,7 @@ describe("lifecycle log events for session-local and tab-mediated actions", () = ws.on("message", (raw: unknown) => { const req = JSON.parse(String(raw)) as BproxyRequest; const resp = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { tab: "t1", closed: true }, diff --git a/service/src/__tests__/dispatch.test.ts b/service/src/__tests__/dispatch.test.ts index c723178..4d3211d 100644 --- a/service/src/__tests__/dispatch.test.ts +++ b/service/src/__tests__/dispatch.test.ts @@ -1,4 +1,9 @@ -import type { BproxyForwardedRequest, BproxyRequest, BproxyResponse } from "@bproxy/shared"; +import { + type BproxyForwardedRequest, + type BproxyRequest, + type BproxyResponse, + PROTOCOL_VERSION, +} from "@bproxy/shared"; import { describe, expect, it, vi } from "vitest"; import { type ClientsRegistry, createClients } from "../clients"; import { createDispatch, type DispatchDeps } from "../dispatch"; @@ -12,7 +17,7 @@ const SESSION_B = "bbbbbb" as BproxyRequest["session"]; function req(id: string, session = DEFAULT_SESSION): BproxyRequest { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, action: "text", nick: "halbot" as BproxyRequest["nick"], @@ -25,7 +30,7 @@ function req(id: string, session = DEFAULT_SESSION): BproxyRequest { function ok(id: string): BproxyResponse { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, ok: true, data: { text: "x" }, @@ -111,7 +116,7 @@ describe("dispatch", () => { expect(onForwarded).toHaveBeenCalledWith({ id: forwarded.id, wsClient: "c1", tab: null }); expect(forwarded.target).toEqual({ tabId: null }); pending.resolveById(forwarded.id, { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: forwarded.id, ok: true, data: { tabId: 42, url: "https://google.com" }, diff --git a/service/src/__tests__/helpers/integration.ts b/service/src/__tests__/helpers/integration.ts index ebfb52f..b13fff1 100644 --- a/service/src/__tests__/helpers/integration.ts +++ b/service/src/__tests__/helpers/integration.ts @@ -5,7 +5,13 @@ * server lifecycle, makeCmd, postCommand) across action-contract, round-trip, * observability, nick-scoping, safety-ordering, etc. */ -import type { BproxyRequest, BproxyResponse, Nick, SessionId } from "@bproxy/shared"; +import { + type BproxyRequest, + type BproxyResponse, + type Nick, + PROTOCOL_VERSION, + type SessionId, +} from "@bproxy/shared"; import WebSocket from "ws"; import { buildCapturedLogger, type CapturedLogger } from "../../logger"; import { type BuildServerOptions, type BuiltServer, buildServer } from "../../server"; @@ -91,7 +97,7 @@ export function makeCmd( overrides: Partial = {}, ): BproxyRequest { const defaults: BproxyRequest = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: `${opts.idPrefix ?? "test"}-${crypto.randomUUID().slice(0, 8)}`, action: opts.defaultAction ?? "session.list", nick: TEST_NICK, diff --git a/service/src/__tests__/lifecycle-start-output.test.ts b/service/src/__tests__/lifecycle-start-output.test.ts index 70e0e79..3f03013 100644 --- a/service/src/__tests__/lifecycle-start-output.test.ts +++ b/service/src/__tests__/lifecycle-start-output.test.ts @@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process"; import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { VERSION } from "@bproxy/shared"; +import { PROTOCOL_VERSION, VERSION } from "@bproxy/shared"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { LifecycleStartResult, @@ -227,7 +227,11 @@ describe("status output JSON shape", () => { }); expect(out.status).toBe(0); const result = JSON.parse(out.stdout.trim()) as LifecycleStatusResult; - expect(result).toMatchObject({ running: false, version: VERSION, protocolVersion: 1 }); + expect(result).toMatchObject({ + running: false, + version: VERSION, + protocolVersion: PROTOCOL_VERSION, + }); }); it("status reports running:true with pid and port while daemon runs", { @@ -250,7 +254,7 @@ describe("status output JSON shape", () => { expect(result.pid).toBeGreaterThan(0); expect(result.port).toBeGreaterThan(0); expect(result.version).toBe(VERSION); - expect(result.protocolVersion).toBe(1); + expect(result.protocolVersion).toBe(PROTOCOL_VERSION); }); it("status is process-liveness based: stale files do not count as running", () => { diff --git a/service/src/__tests__/nick-scoping.test.ts b/service/src/__tests__/nick-scoping.test.ts index 64731da..e8bd081 100644 --- a/service/src/__tests__/nick-scoping.test.ts +++ b/service/src/__tests__/nick-scoping.test.ts @@ -1,4 +1,9 @@ -import type { BproxyRequest, BproxyResponse, TabHandle } from "@bproxy/shared"; +import { + type BproxyRequest, + type BproxyResponse, + PROTOCOL_VERSION, + type TabHandle, +} from "@bproxy/shared"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { CapturedLogger } from "../logger"; import { computeOwnerHash } from "../owner-hash"; @@ -143,7 +148,7 @@ describe("nick scoping", () => { if (req.action !== "debug.log") return; ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { @@ -232,7 +237,7 @@ describe("nick scoping", () => { forwardedHasNick = Object.hasOwn(req, "nick"); ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req["id"], ok: true, data: { tabId: 42, url: "https://example.com" }, diff --git a/service/src/__tests__/observability-contract.test.ts b/service/src/__tests__/observability-contract.test.ts index 2ff813d..31e88a2 100644 --- a/service/src/__tests__/observability-contract.test.ts +++ b/service/src/__tests__/observability-contract.test.ts @@ -1,4 +1,9 @@ -import type { BproxyRequest, BproxyResponse, TabHandle } from "@bproxy/shared"; +import { + type BproxyRequest, + type BproxyResponse, + PROTOCOL_VERSION, + type TabHandle, +} from "@bproxy/shared"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import WebSocket from "ws"; import type { CapturedLogger } from "../logger"; @@ -56,7 +61,7 @@ describe("observability contract — GAP D", () => { ws.on("message", (raw: unknown) => { const req = JSON.parse(String(raw)) as BproxyRequest; const resp: BproxyResponse = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { text: "hello" }, @@ -103,7 +108,7 @@ describe("observability contract — GAP D", () => { ws.on("message", (raw: unknown) => { const req = JSON.parse(String(raw)) as BproxyRequest; const resp: BproxyResponse = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { url: "https://a.com", title: "A", loadTime: 100 }, @@ -199,7 +204,7 @@ describe("observability contract — GAP D", () => { ws2.on("message", (raw: unknown) => { const req = JSON.parse(String(raw)) as BproxyRequest; const resp: BproxyResponse = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { text: "ok" }, diff --git a/service/src/__tests__/pending.test.ts b/service/src/__tests__/pending.test.ts index e7c076c..b78cd5b 100644 --- a/service/src/__tests__/pending.test.ts +++ b/service/src/__tests__/pending.test.ts @@ -1,4 +1,4 @@ -import type { BproxyForwardedRequest, BproxyResponse } from "@bproxy/shared"; +import { type BproxyForwardedRequest, type BproxyResponse, PROTOCOL_VERSION } from "@bproxy/shared"; import { describe, expect, it, vi } from "vitest"; import { createPending } from "../pending"; @@ -6,7 +6,7 @@ const BASE = 1_000_000; function req(id: string, deadline = BASE + 5000): BproxyForwardedRequest { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, action: "text", params: {}, @@ -19,7 +19,7 @@ function req(id: string, deadline = BASE + 5000): BproxyForwardedRequest { function okResponse(id: string): BproxyResponse { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id, ok: true, data: { text: "x" }, diff --git a/service/src/__tests__/round-trip.test.ts b/service/src/__tests__/round-trip.test.ts index 7a6275f..760dd0e 100644 --- a/service/src/__tests__/round-trip.test.ts +++ b/service/src/__tests__/round-trip.test.ts @@ -1,4 +1,4 @@ -import type { BproxyRequest, BproxyResponse } from "@bproxy/shared"; +import { type BproxyRequest, type BproxyResponse, PROTOCOL_VERSION } from "@bproxy/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import WebSocket from "ws"; import type { CapturedLogger } from "../logger"; @@ -23,7 +23,7 @@ let currentSession: BproxyRequest["session"]; function makeCmd(overrides: Partial = {}): BproxyRequest { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? `01HZX${crypto.randomUUID().replaceAll("-", "").slice(0, 21).toUpperCase()}`, action: "text", @@ -83,7 +83,7 @@ describe("round-trip — happy path", () => { ws.on("message", (raw: unknown) => { const req = JSON.parse(String(raw)) as BproxyRequest; const resp: BproxyResponse = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { text: "hello" }, @@ -142,7 +142,7 @@ describe("round-trip — happy path", () => { if (req.action === "elements") { ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { elements: [{ selector: "button.submit", tag: "button", label: "Submit" }] }, @@ -161,7 +161,7 @@ describe("round-trip — happy path", () => { clickTarget = req.params["target"]; ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { clicked: true, disappeared: false, stable: true }, @@ -214,7 +214,7 @@ describe("round-trip — happy path", () => { if (req.action === "elements") { ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { @@ -239,7 +239,7 @@ describe("round-trip — happy path", () => { forwardedFields = req.params["fields"]; ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { @@ -341,7 +341,7 @@ describe("round-trip — reconnect and replay", () => { ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: replayed.id, ok: true, data: { text: "from-client-2" }, diff --git a/service/src/__tests__/schemas.test.ts b/service/src/__tests__/schemas.test.ts index 9c71cbe..7841b12 100644 --- a/service/src/__tests__/schemas.test.ts +++ b/service/src/__tests__/schemas.test.ts @@ -1,4 +1,4 @@ -import type { BproxyRequest } from "@bproxy/shared"; +import { type BproxyRequest, PROTOCOL_VERSION } from "@bproxy/shared"; import { describe, expect, it } from "vitest"; import { ACTION_PARAM_SCHEMAS, ACTIONS, parseRequest } from "../schemas"; @@ -6,7 +6,7 @@ const SESSION = "m4q8z2" as BproxyRequest["session"]; function parse(action: string, params: unknown) { return parseRequest({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: `schema-${action}`, action, nick: "halbot", @@ -34,6 +34,18 @@ describe("request schemas", () => { ); }); + it("accepts links params with offset and hrefContains", () => { + expect(parse("links", { hrefContains: "/in/", offset: 50, limit: 25 }).success).toBe(true); + }); + + it("rejects links params with negative offset", () => { + expect(parse("links", { offset: -1 }).success).toBe(false); + }); + + it("rejects links params with non-integer offset", () => { + expect(parse("links", { offset: 3.5 }).success).toBe(false); + }); + it("accepts scroll params with an explicit element target", () => { expect( parse("scroll", { @@ -62,7 +74,7 @@ describe("request schemas", () => { it("accepts tab.open with an empty session placeholder for fresh bootstrap", () => { expect( parseRequest({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: "schema-tab-open-empty-session", action: "tab.open", nick: "halbot", diff --git a/service/src/__tests__/workflows.test.ts b/service/src/__tests__/workflows.test.ts index 9492179..8783a7c 100644 --- a/service/src/__tests__/workflows.test.ts +++ b/service/src/__tests__/workflows.test.ts @@ -1,4 +1,9 @@ -import type { BproxyRequest, BproxyResponse, TabHandle } from "@bproxy/shared"; +import { + type BproxyRequest, + type BproxyResponse, + PROTOCOL_VERSION, + type TabHandle, +} from "@bproxy/shared"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { BuiltServer } from "../server"; import { @@ -20,7 +25,7 @@ const T1 = "t1" as TabHandle; function makeCmd(overrides: Partial = {}): BproxyRequest { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: overrides.id ?? `01HZX${crypto.randomUUID().replaceAll("-", "").slice(0, 21).toUpperCase()}`, action: overrides.action ?? "text", @@ -81,7 +86,7 @@ describe("end-to-end workflows — Phase 5 task 3", () => { forwardedTarget = req.target?.tabId ?? null; ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { tabId: 42, url: "https://google.com" }, @@ -132,7 +137,7 @@ describe("end-to-end workflows — Phase 5 task 3", () => { openedUrls.push((req.params as { url: string }).url); ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { tabId: nextTabId++, url: (req.params as { url: string }).url }, @@ -192,7 +197,7 @@ describe("end-to-end workflows — Phase 5 task 3", () => { ws.on("message", (raw: unknown) => { const req = JSON.parse(String(raw)) as BproxyRequest; const resp: BproxyResponse = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { text: "Hello from extension" }, @@ -242,7 +247,7 @@ describe("end-to-end workflows — Phase 5 task 3", () => { const req = JSON.parse(String(raw)) as BproxyRequest; commandCount += 1; const resp = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { count: commandCount }, @@ -285,7 +290,7 @@ describe("end-to-end workflows — Phase 5 task 3", () => { if (req.action === "tab.close") { closedTargets.push(req.target?.tabId ?? -1); const resp = { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { tab: T1, closed: true }, @@ -330,7 +335,7 @@ describe("end-to-end workflows — Phase 5 task 3", () => { if (next === true) { ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: true, data: { tab: T1, closed: true }, @@ -342,7 +347,7 @@ describe("end-to-end workflows — Phase 5 task 3", () => { } ws.send( JSON.stringify({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: req.id, ok: false, error: { diff --git a/service/src/debug-actions.ts b/service/src/debug-actions.ts index 8cb53af..d5d063f 100644 --- a/service/src/debug-actions.ts +++ b/service/src/debug-actions.ts @@ -33,7 +33,7 @@ export function handleDaemonLocal(cmd: BproxyRequest, deps: DebugDeps): BproxyRe .filter((trace) => deps.sessions.getOwner(trace.session) === cmd.nick) .slice(-count); return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: cmd.id, ok: true, data: { requests }, @@ -47,7 +47,7 @@ export function handleDaemonLocal(cmd: BproxyRequest, deps: DebugDeps): BproxyRe tabs: deps.sessions.listTabs(session.id), })); return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: cmd.id, ok: true, data: { diff --git a/service/src/dispatch.ts b/service/src/dispatch.ts index 129f623..06f4e5b 100644 --- a/service/src/dispatch.ts +++ b/service/src/dispatch.ts @@ -1,11 +1,12 @@ -import type { - Action, - BproxyError, - BproxyForwardedRequest, - BproxyRequest, - BproxyResponse, - ElementTarget, - TabHandle, +import { + type Action, + type BproxyError, + type BproxyForwardedRequest, + type BproxyRequest, + type BproxyResponse, + type ElementTarget, + PROTOCOL_VERSION, + type TabHandle, } from "@bproxy/shared"; import type { ClientsRegistry } from "./clients"; import type { ElementHandleCache } from "./element-handles"; @@ -35,7 +36,7 @@ export interface DispatchEngine { const BACKGROUND_HANDLED_ACTIONS = new Set(["tab.open"]); function errorResponse(id: string, error: BproxyError): BproxyResponse { - return { protocol_version: 1, id, ok: false, error }; + return { protocol_version: PROTOCOL_VERSION, id, ok: false, error }; } // Per-tab FIFO serializer: runs one command at a time per tabId. diff --git a/service/src/pairing.ts b/service/src/pairing.ts index 00a0fc3..c635109 100644 --- a/service/src/pairing.ts +++ b/service/src/pairing.ts @@ -1,9 +1,10 @@ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { PROTOCOL_VERSION } from "@bproxy/shared"; export interface PairingBootstrap { extensionToken: string; wsUrl: string; - protocolVersion: 1; + protocolVersion: typeof PROTOCOL_VERSION; issuedAt: number; expiresAt: number; nonce: string; @@ -54,7 +55,7 @@ function defaultBootstrap(): Omit void; @@ -26,7 +27,7 @@ export interface PendingMap { } function errorResponse(id: string, error: BproxyError): BproxyResponse { - return { protocol_version: 1, id, ok: false, error }; + return { protocol_version: PROTOCOL_VERSION, id, ok: false, error }; } function timeoutResponse(id: string): BproxyResponse { diff --git a/service/src/routes/command.ts b/service/src/routes/command.ts index 9cf76cb..a9b0154 100644 --- a/service/src/routes/command.ts +++ b/service/src/routes/command.ts @@ -5,6 +5,7 @@ import { type ElementInfo, isValidNick, type LinkInfo, + PROTOCOL_VERSION, type SessionId, type TraceEntry, } from "@bproxy/shared"; @@ -48,16 +49,17 @@ function decorateReadHandles( ) as ElementInfo[]; return { ...response, data: { ...(response.data as object), elements } }; } + const linksData = response.data as { links: LinkInfo[]; total: number; capped?: boolean }; const links = deps.elementHandles.mint( cmd.session, bound.tab, bound.chromeTabId, "links", - (response.data as { links: LinkInfo[] }).links, + linksData.links, response.page.url, pageEpoch, ) as LinkInfo[]; - return { ...response, data: { ...(response.data as object), links } }; + return { ...response, data: { ...linksData, links } }; } function filterDebugLogEntries( @@ -145,7 +147,7 @@ export function commandRoute(deps: CommandRouteDeps) { return await finalizeResponse( cmd, deps, - { protocol_version: 1, id: cmd.id, ok: false, error: safetyError }, + { protocol_version: PROTOCOL_VERSION, id: cmd.id, ok: false, error: safetyError }, receivedAt, ); } diff --git a/service/src/routes/pair.ts b/service/src/routes/pair.ts index b0e83ba..03bfa81 100644 --- a/service/src/routes/pair.ts +++ b/service/src/routes/pair.ts @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { PROTOCOL_VERSION } from "@bproxy/shared"; import type { FastifyInstance } from "fastify"; import type { Logger } from "pino"; import { z } from "zod"; @@ -46,7 +47,7 @@ export function pairRoute(deps: PairRouteDeps) { const r = deps.pairing.claim(body.data.code, () => ({ extensionToken: randomBytes(32).toString("base64url"), wsUrl: deps.wsUrl(), - protocolVersion: 1, + protocolVersion: PROTOCOL_VERSION, })); if (!r.ok) { deps.rateLimiter.recordFailure(); diff --git a/service/src/routes/responses.ts b/service/src/routes/responses.ts index 2a2de4f..1ee9b70 100644 --- a/service/src/routes/responses.ts +++ b/service/src/routes/responses.ts @@ -1,4 +1,5 @@ import type { BproxyError, BproxyRequest, BproxyResponse, PageState } from "@bproxy/shared"; +import { PROTOCOL_VERSION } from "@bproxy/shared"; function pageOk(): PageState { return { url: "", title: "", state: "ready", busy: false }; @@ -10,7 +11,7 @@ export function success( page: PageState = pageOk(), ): BproxyResponse { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: cmd.id, ok: true, data, @@ -21,7 +22,7 @@ export function success( export function failure(cmd: BproxyRequest, error: BproxyError): BproxyResponse { return { - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: cmd.id, ok: false, error, diff --git a/service/src/routes/session-actions.ts b/service/src/routes/session-actions.ts index dc5f1ef..ee85b13 100644 --- a/service/src/routes/session-actions.ts +++ b/service/src/routes/session-actions.ts @@ -1,4 +1,5 @@ import type { BproxyError, BproxyRequest, BproxyResponse } from "@bproxy/shared"; +import { PROTOCOL_VERSION } from "@bproxy/shared"; import { createSessionTmpDir, removeSessionTmpDir } from "../session-tmp"; import { failure, success } from "./responses"; import type { CommandRouteDeps } from "./types"; @@ -161,7 +162,7 @@ async function handleSessionClose( for (const [index, handle] of handles.entries()) { deps.sessions.bind(cmd.session, handle); const closeResponse = await deps.dispatch.send({ - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, id: `${cmd.id}:close:${index + 1}`, action: "tab.close", nick: cmd.nick, diff --git a/service/src/routes/tab-actions.ts b/service/src/routes/tab-actions.ts index 2be9bda..4f2121f 100644 --- a/service/src/routes/tab-actions.ts +++ b/service/src/routes/tab-actions.ts @@ -13,7 +13,8 @@ export function isTabMediated(action: BproxyRequest["action"]): boolean { action === "tab.list" || action === "tab.pin" || action === "tab.unpin" || - action === "tab.close" + action === "tab.close" || + action === "tab.activate" ); } @@ -120,6 +121,7 @@ async function handleBoundTabAction( ): Promise { const resolved = resolveSessionTab(cmd, deps, (cmd.params as { tab?: string }).tab); if ("ok" in resolved) return resolved; + if (cmd.action === "tab.activate") return await handleTabActivate(cmd, deps, resolved); if (cmd.action === "tab.pin") return await handleTabPin(cmd, deps, resolved); if (cmd.action === "tab.unpin") return await handleTabUnpin(cmd, deps, resolved); return await handleTabClose(cmd, deps, resolved); @@ -164,6 +166,16 @@ function resolveRequestedTab( }); } +async function handleTabActivate( + cmd: BproxyRequest, + deps: CommandRouteDeps, + resolved: ResolvedTab, +): Promise { + const activated = await dispatchAndPause(cmd, deps, { targetTabId: resolved.chromeTabId }); + if (!activated.ok) return activated; + return success(cmd, { tab: resolved.tab, activated: true }, activated.page); +} + async function handleTabPin( cmd: BproxyRequest, deps: CommandRouteDeps, diff --git a/service/src/schemas.ts b/service/src/schemas.ts index 453c272..a4ebb94 100644 --- a/service/src/schemas.ts +++ b/service/src/schemas.ts @@ -1,4 +1,4 @@ -import { type Action, type BproxyRequest, HANDLE_PATTERN } from "@bproxy/shared"; +import { type Action, type BproxyRequest, HANDLE_PATTERN, PROTOCOL_VERSION } from "@bproxy/shared"; import { z } from "zod"; import { TAB_HANDLE_PATTERN } from "./sessions"; @@ -29,6 +29,7 @@ export const ACTIONS = [ "tab.unpin", "tab.open", "tab.close", + "tab.activate", "session.create", "session.list", "session.bind", @@ -77,6 +78,8 @@ export const ACTION_PARAM_SCHEMAS: Record = { selector: z.string().optional(), visibleOnly: z.boolean().optional(), limit: z.number().int().optional(), + hrefContains: z.string().optional(), + offset: z.number().int().min(0).optional(), }) .strict(), images: z.object({ selector: z.string().optional() }).strict(), @@ -148,6 +151,7 @@ export const ACTION_PARAM_SCHEMAS: Record = { "tab.unpin": z.object({ tab: tabHandle.optional() }).strict(), "tab.open": z.object({ url: z.string() }).strict(), "tab.close": z.object({ tab: tabHandle.optional() }).strict(), + "tab.activate": z.object({ tab: tabHandle.optional() }).strict(), "session.create": z.object({ label: z.string().optional() }).strict(), "session.list": z.object({}).strict(), "session.bind": z.object({ tab: tabHandle, pacing: pacingMode.optional() }).strict(), @@ -160,7 +164,7 @@ export const ACTION_PARAM_SCHEMAS: Record = { }; const ENVELOPE_BASE = z.object({ - protocol_version: z.literal(1), + protocol_version: z.literal(PROTOCOL_VERSION), id: z.string().min(1), action: z.string(), nick: z.string(), diff --git a/shared/package.json b/shared/package.json index 4c472c7..ffbfb63 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/shared", - "version": "0.8.0", + "version": "0.9.0", "private": true, "type": "module", "exports": { diff --git a/shared/src/actions.ts b/shared/src/actions.ts index 9c74e08..3f6d75c 100644 --- a/shared/src/actions.ts +++ b/shared/src/actions.ts @@ -27,6 +27,7 @@ export type Action = | "tab.unpin" | "tab.open" | "tab.close" + | "tab.activate" | "session.create" | "session.list" | "session.bind" @@ -131,7 +132,13 @@ export interface DaemonRequestTrace { export interface ActionParams { navigate: { url: string }; text: { selector?: string }; - links: { selector?: string; visibleOnly?: boolean; limit?: number }; + links: { + selector?: string; + visibleOnly?: boolean; + limit?: number; + hrefContains?: string; + offset?: number; + }; images: { selector?: string }; elements: { form?: boolean }; outline: Record; @@ -164,6 +171,7 @@ export interface ActionParams { "tab.unpin": { tab?: TabHandle }; "tab.open": { url: string }; "tab.close": { tab?: TabHandle }; + "tab.activate": { tab?: TabHandle }; "session.create": { label?: string }; "session.list": Record; "session.bind": { tab: TabHandle; pacing?: PacingMode }; @@ -180,7 +188,7 @@ export interface ActionParams { export interface ActionResult { navigate: { url: string; title: string; loadTime: number }; text: { text: string }; - links: { links: Array }; + links: { links: Array; total: number; capped?: boolean }; images: { images: Array<{ src: string; alt: string; width: number; height: number }> }; elements: { elements: Array }; outline: { landmarks: Array; headings: Array }; @@ -219,6 +227,7 @@ export interface ActionResult { ownerHash: string; }; "tab.close": { tab: TabHandle; closed: true }; + "tab.activate": { tab: TabHandle; activated: true }; "session.create": { session: SessionId; label?: string; tmpDir: string; ownerHash: string }; "session.list": { sessions: Array }; "session.bind": { session: SessionId; tab: TabHandle }; @@ -278,6 +287,7 @@ export interface ForwardedActionParams { "tab.unpin": ActionParams["tab.unpin"]; "tab.open": ActionParams["tab.open"]; "tab.close": ActionParams["tab.close"]; + "tab.activate": ActionParams["tab.activate"]; "session.create": ActionParams["session.create"]; "session.list": ActionParams["session.list"]; "session.bind": ActionParams["session.bind"]; diff --git a/shared/src/protocol-shape.assertions.ts b/shared/src/protocol-shape.assertions.ts index a22eb77..48b4580 100644 --- a/shared/src/protocol-shape.assertions.ts +++ b/shared/src/protocol-shape.assertions.ts @@ -5,6 +5,7 @@ import type { ActionResult, DaemonRequestTrace, ForwardedActionParams, + LinkInfo, TraceEntry, } from "./actions"; import type { ErrorCode } from "./errors"; @@ -22,9 +23,21 @@ type Expect = T; type _SessionCreateParams = Expect>; type _SessionCloseParams = Expect>>; type _LinksParams = Expect< - Equals + Equals< + ActionParams["links"], + { + selector?: string; + visibleOnly?: boolean; + limit?: number; + hrefContains?: string; + offset?: number; + } + > >; type _ClickParams = Expect>; +type _LinksResult = Expect< + Equals; total: number; capped?: boolean }> +>; type _HoverParams = Expect>; type _ForwardedClickParams = Expect< Equals @@ -56,6 +69,10 @@ type _TabPinResultUsesLogicalHandle = Expect< type _TabCloseResultUsesLogicalHandle = Expect< Equals >; +type _TabActivateParams = Expect>; +type _TabActivateResult = Expect< + Equals +>; type _SessionInfoUsesLogicalBinding = Expect< Equals & Equals >; diff --git a/shared/src/protocol.ts b/shared/src/protocol.ts index 1a4e474..13a2847 100644 --- a/shared/src/protocol.ts +++ b/shared/src/protocol.ts @@ -1,9 +1,10 @@ import type { Action, ActionParams, ActionResult, ForwardedActionParams } from "./actions"; import type { BproxyError } from "./errors"; import type { Nick, SessionId } from "./sessions"; +import type { PROTOCOL_VERSION } from "./version"; export interface BproxyRequest { - protocol_version: 1; + protocol_version: typeof PROTOCOL_VERSION; id: string; action: A; nick: Nick; @@ -35,7 +36,7 @@ export type BproxyForwardedRequest = Omit< }; export interface BproxySuccessResponse { - protocol_version: 1; + protocol_version: typeof PROTOCOL_VERSION; id: string; ok: true; data: ActionResult[A]; @@ -44,7 +45,7 @@ export interface BproxySuccessResponse { } export interface BproxyErrorResponse { - protocol_version: 1; + protocol_version: typeof PROTOCOL_VERSION; id: string; ok: false; error: BproxyError; diff --git a/shared/src/version.ts b/shared/src/version.ts index 31b8ec8..73029a6 100644 --- a/shared/src/version.ts +++ b/shared/src/version.ts @@ -6,7 +6,7 @@ */ /** Current bproxy package version (semver). */ -export const VERSION = "0.8.0"; +export const VERSION = "0.9.0"; /** Protocol version for the daemon↔CLI↔extension wire format. */ -export const PROTOCOL_VERSION = 1; +export const PROTOCOL_VERSION = 2; diff --git a/skills/bproxy/SKILL.md b/skills/bproxy/SKILL.md index b9118af..426180c 100644 --- a/skills/bproxy/SKILL.md +++ b/skills/bproxy/SKILL.md @@ -2,122 +2,126 @@ name: bproxy description: >- Control operator's real Chrome via CLI proxy. Use when agent needs to read - web pages, extract links/elements, fill forms, click, scroll, or screenshot — - through a localhost daemon bridging to a Chrome extension. Requires bproxy - daemon running and extension paired. Human stays in loop for login, CAPTCHA, + pages, extract links/elements, fill forms, click, scroll, activate tabs, or + screenshot through a localhost daemon + Chrome extension. Requires daemon + running and extension paired. Human stays in loop for login, CAPTCHA, consent, final submit. compatibility: Node >=24, bproxy installed (npm install -g @dimdasci/bproxy), daemon running, extension paired license: MIT metadata: - version: "0.8.0" + version: "0.9.0" --- # bproxy -CLI → daemon → Chrome extension → real browser page. -Operator handles: login, CAPTCHA, consent, final submit. -Agent handles: navigation, reading, form filling, clicking. +CLI → daemon → Chrome extension → operator's real Chrome. +Operator: login, CAPTCHA, consent, final submit. Agent: read, prepare, act only when explicit. -## Required: agent nickname (`--nick` / `-n`) +## Nick required -Every protocol command requires `-n `. Generate your nick **once at the start of your task** and reuse it for all commands. +Every protocol command needs `-n `. Generate once per task, reuse. -**How to generate:** pick 6 random lowercase alphanumeric characters, starting with a letter. Pattern: `/^[a-z][a-z0-9]{5}$/`. Example generation: pick a random letter a-z, then 5 random chars from a-z0-9. Do not reuse nicks from documentation or examples. +- Format: `/^[a-z][a-z0-9]{5}$/` (6 chars, starts letter). Example algorithm: random `a-z` + 5 random `a-z0-9`. +- Do not copy docs/examples nicks. +- Nick scopes sessions. Wrong nick → `SESSION_SCOPE_MISMATCH` or invisible session. +- No env default. Pass `-n` every time. -**Why:** sessions are scoped to nick. Your nick is your namespace — only you can see and command sessions you created. Using someone else's nick means you'll either fail with `SESSION_SCOPE_MISMATCH` or collide with their sessions. - -**Rules:** -- Generate once per task, reuse for all commands in that task. -- No environment variable — explicit on every call. -- Do not hardcode nicks from examples or documentation. - -## Flow: open → read → act → close +## Basic flow ```bash -# Generate your nick first (6 random lowercase alphanum, starts with letter) -# Then use it consistently: - bproxy tab open --url "https://example.com" -n -# → { session: "m4q7z2", tab: "t1", tmpDir: "...", ownerHash: "a3f7c012" } +# -> { session:"m4q7z2", tab:"t1", tmpDir:"...", ownerHash:"..." } bproxy text -n -s m4q7z2 -bproxy links -n -s m4q7z2 # → ln1, ln2, ln3... -bproxy elements -n -s m4q7z2 # → el1, el2, el3... +bproxy links -n -s m4q7z2 --limit 50 # -> ln1, ln2... +bproxy elements -n -s m4q7z2 --form # -> el1, el2... +bproxy tab activate -n -s m4q7z2 # foreground before destructive work if needed bproxy click -n -s m4q7z2 --element ln3 bproxy fill -n -s m4q7z2 --element el2 --value "hello" --method paste --world isolated bproxy session close -n -s m4q7z2 ``` -## Commands (quick ref) +## Quick commands -**Read** (non-destructive): -`text [--selector]` · `links [--selector] [--limit]` · `elements [--form]` · -`outline` · `dom [--selector] [--depth]` · `inspect --selector` · -`snapshot [--interactive-only]` · `screenshot [--activate] [--output-dir]` +**Read:** +`text [--selector] [--after MARKER] [--limit-chars N]` · +`links [--selector] [--visible-only] [--limit N] [--href-contains S] [--offset N]` · +`elements [--form]` · `outline` · `dom [--selector] [--depth]` · +`inspect --selector` · `snapshot [--interactive-only]` · +`screenshot [--activate] [--output-dir]` -**Act** (destructive): -`navigate --url` · `click --element/--selector` · `hover --element/--selector` · +**Act:** +`navigate --url` · `tab activate [--tab tN]` · +`click --element/--selector` · `hover --element/--selector` · `scroll [--element] [--direction up|down] [--by N]` · `fill --element --value --method --world` · `fill-form --json` · -`select --element --option-text` · `wait --strategy --target` +`select --element --option-text` · `wait --strategy --target` · +`require-human --reason` + +**Session/tab:** +`tab open --url` · `tab list` · `tab close [--tab]` · `tab pin [--tab]` · `tab unpin [--tab]` · +`session create` · `session list` · `session bind --tab tN [--pacing human|fast]` · +`session resume` · `session close` -**Session/Tab**: -`tab open --url` · `tab close` · `tab list` · -`session create` · `session close` · `session resume` · `session bind --tab tN` +`-s ` required for browser/session-bound commands. Exceptions: `tab open` may auto-create; `session create/list`, `debug status/last` need no `-s`. -**All commands require** `-n ` and `-s ` (except `tab open --url` which can auto-create session, and `session create`/`session list`/`debug status`/`debug last` which don't need `-s`). +Target exactly one: `--element ` preferred, or `--selector `, or `--route-json `. +Full table: `references/actions.md`. -**Target** (one of): `--element ` (preferred) · `--selector ` · `--route-json ` +## Phase 10 DX notes -Read `references/actions.md` for full params/responses. +- `tab activate` exists. Use it before destructive commands when tab is background. No hidden auto-activation elsewhere. +- `links --href-contains S` filters normalized absolute hrefs, case-sensitive, before limit. +- `links --offset N --limit M` paginates. Result includes `total` and maybe `capped:true`. +- For big pages, page links in chunks; stdout is still one valid JSON object. +- `text --after MARKER` slices CLI output from marker, inclusive. `--limit-chars N` caps text. If marker missing, full text plus `markerFound:false`. ## Fill method +Probe first: `elements --form`, check `runtimeHandle`. + | Target | Method | World | |--------|--------|-------| -| ``, `