diff --git a/cli/package.json b/cli/package.json index f85b6f7..6a28a87 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/cli", - "version": "0.7.5", + "version": "0.8.0", "private": true, "type": "module", "bin": { diff --git a/cli/src/__tests__/client.test.ts b/cli/src/__tests__/client.test.ts index 4cf085d..145c08b 100644 --- a/cli/src/__tests__/client.test.ts +++ b/cli/src/__tests__/client.test.ts @@ -12,6 +12,7 @@ const T1 = "t1" as TabHandle; function makeGlobals(overrides: Partial = {}): ClientGlobalArgs { return { + nick: "halbot" as ClientGlobalArgs["nick"], session: "m4q7z2", timeout: "5000", home: "/home/testuser/.bproxy", diff --git a/cli/src/__tests__/command-test-helpers.ts b/cli/src/__tests__/command-test-helpers.ts index 1be6414..0d42e1c 100644 --- a/cli/src/__tests__/command-test-helpers.ts +++ b/cli/src/__tests__/command-test-helpers.ts @@ -23,6 +23,7 @@ export function makeGlobals( overrides: Partial = {}, ): ClientGlobalArgs { return { + nick: "halbot" as ClientGlobalArgs["nick"], session: "m4q7z2", timeout: "5000", home, diff --git a/cli/src/__tests__/commands-read-parsing.test.ts b/cli/src/__tests__/commands-read-parsing.test.ts index b5bcf53..b54d2f2 100644 --- a/cli/src/__tests__/commands-read-parsing.test.ts +++ b/cli/src/__tests__/commands-read-parsing.test.ts @@ -12,12 +12,14 @@ import { extractGlobals, parseSessionId, parseTabHandle } from "../globals.js"; describe("extractGlobals", () => { it("extracts all global args when present", () => { const result = extractGlobals({ + nick: "halbot", session: "m4q7z2", timeout: "5000", home: "/home/testuser/.bproxy", verbose: true, }); expect(result).toEqual({ + nick: "halbot", session: "m4q7z2", timeout: "5000", home: "/home/testuser/.bproxy", @@ -25,30 +27,48 @@ describe("extractGlobals", () => { }); }); - it("returns undefined for missing string args", () => { - const result = extractGlobals({}); - expect(result).toEqual({ - session: undefined, - timeout: undefined, - home: undefined, - verbose: undefined, - }); + it("fails when nick is missing", () => { + expect(() => + extractGlobals( + {}, + { + onUsageError: (message) => { + throw new Error(message); + }, + }, + ), + ).toThrow("Missing required --nick (-n). Every command requires an agent nickname."); }); - it("ignores non-string values for string args", () => { + it("ignores non-string values for non-nick args", () => { const result = extractGlobals({ + nick: "halbot", session: 123, timeout: true, home: null, verbose: "yes", }); expect(result).toEqual({ + nick: "halbot", session: undefined, timeout: undefined, home: undefined, verbose: undefined, }); }); + + it("fails when nick format is invalid", () => { + expect(() => + extractGlobals( + { nick: "bad" }, + { + onUsageError: (message) => { + throw new Error(message); + }, + }, + ), + ).toThrow("Invalid --nick value: bad. Must match /^[a-z][a-z0-9]{5}$/."); + }); }); describe("Phase 5 id parsing", () => { diff --git a/cli/src/__tests__/design-assertions.test.ts b/cli/src/__tests__/design-assertions.test.ts index b56bbcd..a34ae02 100644 --- a/cli/src/__tests__/design-assertions.test.ts +++ b/cli/src/__tests__/design-assertions.test.ts @@ -29,11 +29,23 @@ function setupTempHome(): string { } function makeGlobals(home: string): ClientGlobalArgs { - return { session: "m4q7z2", timeout: "5000", home, verbose: false }; + return { + nick: "halbot" as ClientGlobalArgs["nick"], + session: "m4q7z2", + timeout: "5000", + home, + verbose: false, + }; } function makeVerboseGlobals(home: string): ClientGlobalArgs { - return { session: "m4q7z2", timeout: "5000", home, verbose: true }; + return { + nick: "halbot" as ClientGlobalArgs["nick"], + session: "m4q7z2", + timeout: "5000", + home, + verbose: true, + }; } function successResponse(id: string) { diff --git a/cli/src/__tests__/screenshot-file.test.ts b/cli/src/__tests__/screenshot-file.test.ts index 20cc5f2..cce52cd 100644 --- a/cli/src/__tests__/screenshot-file.test.ts +++ b/cli/src/__tests__/screenshot-file.test.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { type SendOptions, sendAction } from "../client.js"; +import { type ClientGlobalArgs, type SendOptions, sendAction } from "../client.js"; import { writeScreenshotFile } from "../screenshot-file.js"; import type { BproxyResponse } from "../types.js"; import { createTestStateDir } from "./helpers/test-state-dir.js"; @@ -122,23 +122,28 @@ function mockFetch(responseBody: unknown): typeof globalThis.fetch { }; } +async function takeScreenshot(requestId: string) { + const home = setupHome(); + const opts: SendOptions = { + fetch: mockFetch(screenshotResponse(requestId)), + requestId, + }; + const plan = await sendAction( + "screenshot", + {}, + { home, nick: "halbot" as ClientGlobalArgs["nick"], session: "abc234" }, + opts, + ); + expect(plan.code).toBe(0); + const response = plan.stdout as BproxyResponse<"screenshot">; + expect(response.ok).toBe(true); + return response; +} + describe("screenshot --output-dir integration", () => { it("command writes file and stdout has file path instead of base64", async () => { - const home = setupHome(); const outputDir = join(makeTempDir(), "screens"); - const requestId = "ss-file-test-1"; - - const opts: SendOptions = { - fetch: mockFetch(screenshotResponse(requestId)), - requestId, - }; - - const plan = await sendAction("screenshot", {}, { home, session: "abc234" }, opts); - - // Simulate what screenshot command does with --output-dir - expect(plan.code).toBe(0); - const response = plan.stdout as BproxyResponse<"screenshot">; - expect(response.ok).toBe(true); + const response = await takeScreenshot("ss-file-test-1"); if (!response.ok) return; const result = writeScreenshotFile(outputDir, response.data.base64, response.data.format); @@ -159,19 +164,7 @@ describe("screenshot --output-dir integration", () => { }); it("without --output-dir, base64 remains in stdout", async () => { - const home = setupHome(); - const requestId = "ss-no-dir-1"; - - const opts: SendOptions = { - fetch: mockFetch(screenshotResponse(requestId)), - requestId, - }; - - const plan = await sendAction("screenshot", {}, { home, session: "abc234" }, opts); - - expect(plan.code).toBe(0); - const response = plan.stdout as BproxyResponse<"screenshot">; - expect(response.ok).toBe(true); + const response = await takeScreenshot("ss-no-dir-1"); if (!response.ok) return; expect(response.data.base64).toBe(INT_TINY_PNG); }); diff --git a/cli/src/__tests__/smoke.integration.test.ts b/cli/src/__tests__/smoke.integration.test.ts index 91c5a99..151cced 100644 --- a/cli/src/__tests__/smoke.integration.test.ts +++ b/cli/src/__tests__/smoke.integration.test.ts @@ -16,7 +16,7 @@ * - Stop through CLI, verify status becomes running:false */ import { execSync, spawn } from "node:child_process"; -import { existsSync, rmSync, statSync } from "node:fs"; +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"; @@ -120,6 +120,32 @@ describe("CLI integration smoke", () => { beforeEach(() => { tempHome = createTestStateDir("bproxy-smoke-"); + // Relax safety guards so sequential CLI calls don't hit minInterval rejection + writeFileSync( + join(tempHome, "config.json"), + JSON.stringify({ + pacing: { + human: { + navigate: { min: 1, max: 2 }, + scroll: { min: 1, max: 2 }, + interaction: { min: 1, max: 2 }, + fill: { min: 1, max: 2 }, + }, + fast: { + navigate: { min: 1, max: 2 }, + scroll: { min: 1, max: 2 }, + interaction: { min: 1, max: 2 }, + fill: { min: 1, max: 2 }, + }, + }, + safety: { + minInterval: { ms: 1 }, + rateCap: { requestsPerMinute: 600 }, + errorDelay: { minMs: 1, maxMs: 1 }, + metronome: { tolerance: 0.1, consecutiveEqual: 100, maxIntervalMs: 60000 }, + }, + }), + ); }); afterEach(async () => { @@ -176,7 +202,7 @@ describe("CLI integration smoke", () => { it("session list returns valid JSON without extension", () => { runCli(["service", "start"], tempHome); - const result = runCli(["session", "list"], tempHome); + const result = runCli(["session", "list", "-n", "halbot"], tempHome); expect(result.exitCode).toBe(0); const parsed = parseJson(result.stdout) as Record; @@ -189,7 +215,10 @@ describe("CLI integration smoke", () => { it("session create and close work against running daemon", () => { runCli(["service", "start"], tempHome); - const createResult = runCli(["session", "create", "--label", "research"], tempHome); + const createResult = runCli( + ["session", "create", "-n", "halbot", "--label", "research"], + tempHome, + ); expect(createResult.exitCode).toBe(0); const createParsed = parseJson(createResult.stdout) as Record; expect(createParsed["ok"]).toBe(true); @@ -198,7 +227,7 @@ describe("CLI integration smoke", () => { expect(createData["label"]).toBe("research"); const sessionId = createData["session"] as string; - const closeResult = runCli(["session", "close", "-s", sessionId], tempHome); + const closeResult = runCli(["session", "close", "-n", "halbot", "-s", sessionId], tempHome); expect(closeResult.exitCode).toBe(0); const closeParsed = parseJson(closeResult.stdout) as Record; expect(closeParsed["ok"]).toBe(true); @@ -209,7 +238,7 @@ describe("CLI integration smoke", () => { it("debug status returns daemon info without extension", () => { runCli(["service", "start"], tempHome); - const result = runCli(["status"], tempHome); + const result = runCli(["status", "-n", "halbot"], tempHome); expect(result.exitCode).toBe(0); const parsed = parseJson(result.stdout) as Record; @@ -224,7 +253,7 @@ describe("CLI integration smoke", () => { it("debug last returns valid response structure", () => { runCli(["service", "start"], tempHome); - const result = runCli(["debug", "last", "--count", "5"], tempHome); + const result = runCli(["debug", "last", "-n", "halbot", "--count", "5"], tempHome); expect(result.exitCode).toBe(0); const parsed = parseJson(result.stdout) as Record; @@ -315,20 +344,24 @@ describe("CLI integration smoke", () => { ); }); - const createResult = runCli(["session", "create"], tempHome); + const createResult = runCli(["session", "create", "-n", "halbot"], tempHome); expect(createResult.exitCode).toBe(0); const createParsed = parseJson(createResult.stdout) as Record; const sessionId = (createParsed["data"] as Record)["session"] as string; const openResult = await runCliAsync( - ["tab", "open", "-s", sessionId, "--url", "https://example.com"], + ["tab", "open", "-n", "halbot", "-s", sessionId, "--url", "https://example.com"], tempHome, 10_000, ); expect(openResult.exitCode).toBe(0); // Send a forwarded read command (text) using async spawn - const textResult = await runCliAsync(["text", "-s", sessionId], tempHome, 10_000); + const textResult = await runCliAsync( + ["text", "-n", "halbot", "-s", sessionId], + tempHome, + 10_000, + ); expect(textResult.exitCode).toBe(0); const textParsed = parseJson(textResult.stdout) as Record; diff --git a/cli/src/bproxy.ts b/cli/src/bproxy.ts index 7015fc5..ba211aa 100644 --- a/cli/src/bproxy.ts +++ b/cli/src/bproxy.ts @@ -14,6 +14,11 @@ const main = defineCommand({ description: "Browser proxy CLI for code agents", }, args: { + nick: { + type: "string", + alias: "n", + description: "Agent nickname for request scoping", + }, session: { type: "string", alias: "s", diff --git a/cli/src/client.ts b/cli/src/client.ts index 8d2682a..59c4db2 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -59,6 +59,7 @@ const ABORT_BUFFER_MS = 2_000; interface RequestContext { requestId: string; action: string; + nick: ClientGlobalArgs["nick"]; session: string; port: number; token: string; @@ -119,6 +120,7 @@ function resolveContext( return { requestId: opts.requestId ?? generateRequestId(), action, + nick: globals.nick, session, port, token: tokenResult.token, @@ -170,6 +172,7 @@ function buildRequest( protocol_version: 1, id: ctx.requestId, action, + nick: ctx.nick, params, session: ctx.session as BproxyRequest["session"], deadline: Date.now() + ctx.deadlineMs, diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 900ec3e..ab8153b 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -84,6 +84,7 @@ async function probeDaemonHttp( protocol_version: CLI_PROTOCOL_VERSION, id: "doctor-check", action: "debug.status", + nick: "doctor", params: {}, session: "", deadline: Date.now() + 5000, diff --git a/cli/src/globals.ts b/cli/src/globals.ts index 8c82db3..ca1ea12 100644 --- a/cli/src/globals.ts +++ b/cli/src/globals.ts @@ -7,7 +7,8 @@ * - `extractGlobals`: extract ClientGlobalArgs from parsed citty args */ -import type { ClientGlobalArgs, SessionId, TabHandle } from "./types.js"; +import { executeExitPlan, exitUsageError } from "./exit.js"; +import { type ClientGlobalArgs, isValidNick, type SessionId, type TabHandle } from "./types.js"; /** * Global arg definitions for leaf commands. @@ -25,6 +26,11 @@ export function parseTabHandle(value: string): TabHandle | null { } export const globalArgs = { + nick: { + type: "string" as const, + alias: "n", + description: "Agent nickname for request scoping", + }, session: { type: "string" as const, alias: "s", @@ -49,8 +55,31 @@ export const globalArgs = { /** * Extract ClientGlobalArgs from a citty parsed-args object. */ -export function extractGlobals(args: Record): ClientGlobalArgs { +export interface ExtractGlobalsDeps { + onUsageError?: (message: string) => never; +} + +export function extractGlobals( + args: Record, + deps: ExtractGlobalsDeps = {}, +): ClientGlobalArgs { + const fail = + deps.onUsageError ?? + ((message: string): never => { + executeExitPlan(exitUsageError(message)); + throw new Error("unreachable"); + }); + + const nick = typeof args["nick"] === "string" ? args["nick"] : undefined; + if (!nick) { + return fail("Missing required --nick (-n). Every command requires an agent nickname."); + } + if (!isValidNick(nick)) { + return fail(`Invalid --nick value: ${nick}. Must match /^[a-z][a-z0-9]{5}$/.`); + } + return { + nick, session: typeof args["session"] === "string" ? args["session"] : undefined, timeout: typeof args["timeout"] === "string" ? args["timeout"] : undefined, home: typeof args["home"] === "string" ? args["home"] : undefined, diff --git a/cli/src/types.ts b/cli/src/types.ts index 68a6eb7..e62f5e3 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -3,7 +3,10 @@ * This module exists so command implementations can import from a * single CLI-local path without reaching into shared internals. */ +import type { Nick } from "@bproxy/shared"; + export interface ClientGlobalArgs { + nick: Nick; session?: string; timeout?: string; home?: string; @@ -23,7 +26,8 @@ export type { ElementTarget, ExecutionWorld, FillMethod, + Nick, SessionId, TabHandle, } from "@bproxy/shared"; -export { PROTOCOL_VERSION, VERSION } from "@bproxy/shared"; +export { isValidNick, PROTOCOL_VERSION, VERSION } from "@bproxy/shared"; diff --git a/docs/internal/architecture.md b/docs/internal/architecture.md index 2f714db..b0b0562 100644 --- a/docs/internal/architecture.md +++ b/docs/internal/architecture.md @@ -29,8 +29,8 @@ Escape hatches (`--trusted`, network shim, chrome.debugger) are opt-in when real - **Read mode covers most work** — URL-driven navigation + ISOLATED-world text extraction + explicit scroll/click/hover actuators when the agent chooses them. - **DOM polling beats MutationObserver** as the default "is page settled" mechanism. Polling is **jittered** (randomized intervals) and **visibility-aware** (destructive actions bail on hidden tabs unless user-initiated) [ADR-006](./decisions.md#adr-006-dom-polling-over-mutationobserver). -- **Pacing is daemon-enforced** — per-session, applied to navigations, scrolls, and per-field fill delay. -- **Session authority lives in the daemon** — session ids are daemon-generated capability handles, labels are display-only, logical tabs (`t1`, `t2`, ...) are session-scoped, raw Chrome tab ids stay internal, and pacing / pause state remains daemon-owned in-memory state. +- **Pacing is daemon-enforced** — per-session, applied to navigations, scrolls, and per-field fill delay, with an absolute per-agent ingress safety floor (`minInterval`) that pacing mode cannot bypass. +- **Session authority lives in the daemon** — session ids are daemon-generated capability handles, labels are display-only, logical tabs (`t1`, `t2`, ...) are session-scoped, raw Chrome tab ids stay internal, session ownership is nick-scoped, raw nicks never appear in persisted logs or API responses, and pacing / pause state remains daemon-owned in-memory state. - **Auth is transport-boundary first-fail (header-auth routes)** — `POST /` and `GET /ws` are rejected at request ingress (before body parsing/validation and before any route logic). `POST /pair/claim` keeps Host/Origin checks at ingress and validates pairing code after body parse. - **Lifecycle is single-instance per `BPROXY_HOME`** — daemon startup must fail cleanly when the lockfile PID is alive; stale PID files are recoverable; `status` truth is process-liveness based. - **Three explicit write methods** — `direct` | `paste` | `runtime-api`, no `auto` [ADR-007](./decisions.md#adr-007-three-method-write-contract). Method and world choice are agent-owned per call. @@ -71,7 +71,7 @@ Implementation: `docs/public/solution/extension.md` ### CLI -One invocation = one command = one HTTP POST to the daemon = one JSON response on stdout. Browser-control commands accept `-s, --session ` where `` is a daemon-generated 6-character handle matching `/^[a-z2-7]{6}$/`. `tab open --url ...` is the sole bootstrap exception that may omit `-s`; it returns the generated `session` id plus logical `tab` handle. Exits 0/1/2. +One invocation = one command = one HTTP POST to the daemon = one JSON response on stdout. Every protocol command requires `-n, --nick ` where `` matches `/^[a-z][a-z0-9]{5}$/`. Browser-control commands accept `-s, --session ` where `` is a daemon-generated 6-character handle matching `/^[a-z2-7]{6}$/`. `tab open --url ...` is the sole bootstrap exception that may omit `-s`; it returns the generated `session` id plus logical `tab` handle, `tmpDir`, and `ownerHash`. Exits 0/1/2. Implementation: `docs/public/solution/cli.md` @@ -108,6 +108,7 @@ The shared contract between all three components. Every message uses the same JS "protocol_version": 1, "id": "01HZX9C2K8R7Q3VG9MNPYJVZ4D", "action": "fill", + "nick": "halbot", "params": { "target": { "handle": "el1" }, "value": "user@example.com", "method": "paste", "world": "isolated" }, "session": "m4q7z2", "deadline": 1714000030000, @@ -148,7 +149,7 @@ Errors use a single RFC 9457-aligned envelope: - **Generated sessions:** daemon-created only, 6-character base32 lowercase ids (`SessionId` branded type); no implicit shared `default` session for browser-control flows. - **Logical tabs:** normal CLI/protocol responses expose session-scoped handles such as `t1` (`TabHandle` branded type); raw Chrome tab ids remain daemon/extension internals. -- **Fresh bootstrap:** `tab open --url ...` is the only command that may auto-create a session when `-s` is omitted. It returns `{ session, tab, bound: true, url, tmpDir }`. +- **Fresh bootstrap:** `tab open --url ...` is the only command that may auto-create a session when `-s` is omitted. The daemon stamps the new session with the request nick and returns `{ session, tab, bound: true, url, tmpDir, ownerHash }`. - **Session temp directory:** every session receives a pre-created artifact directory at `BPROXY_HOME/tmp/sessions//`, returned as `tmpDir` in session bootstrap and `session.create` responses. Agents use it for file output (screenshots, exports) without managing directory lifecycle. Cleaned on `session close` or daemon stop. - **Scoped privacy:** `tab list` returns only tabs owned by the supplied session. Operator-opened tabs are not exposed through the normal agent surface. - **Bind/close rules:** `session bind --tab tN` accepts logical tab handles only; `session close -s ` closes all session-owned Chrome tabs. @@ -190,7 +191,7 @@ Routing details and contract: `docs/public/solution/service.md` § Action routin ## Reliability - **At-most-once** for destructive actions (client-supplied `id`, proxy pending map, extension dedupe table). -- **Pacing** - daemon-enforced human-pace delays. Agent cannot bypass. +- **Pacing + safety guards** - daemon-enforced human-pace delays plus per-nick ingress guards (`minInterval`, rate cap, metronome detection, jittered error delay). Agent cannot bypass them. - **`HUMAN_REQUIRED`** - structured stop signal on interstitials. No retry through bot detection. ## Related Documents diff --git a/docs/internal/decisions.md b/docs/internal/decisions.md index e890d59..b5d6a36 100644 --- a/docs/internal/decisions.md +++ b/docs/internal/decisions.md @@ -2,7 +2,7 @@ title: Architecture Decision Records --- -**Edition: 2026-06-14 (temp file confinement)** — Authoritative current ADR set. +**Edition: 2026-06-19 (agent nick session scoping)** — Authoritative current ADR set. --- @@ -569,3 +569,62 @@ The `tmpDir` field is part of the session bootstrap contract: **Deferred:** - *Custom domain.* Revisit when the project has a public name or launch event. Migration is a DNS record plus a Pages setting (or `CNAME` file); no code change beyond updating `site` in `views/astro.config.mjs`. - *PR preview deployments.* GitHub Pages does not natively support per-PR previews on the same repository without significant workflow gymnastics. If previews become valuable, that becomes the trigger to migrate hosting to Cloudflare Pages or Netlify. + +--- + +## ADR-030: Agent nickname session scoping +**Date:** 2026-06-19 +**Status:** Accepted + +**Decision:** Add a required agent namespace — the **nick** — that scopes session visibility and command authority within a single-user `BPROXY_HOME`. The nick is a short identifier declared explicitly on every CLI invocation via `--nick` / `-n`. Sessions are stamped with their creator's nick and only accessible to commands bearing the same nick. + +**Motivation:** Issue #22 revealed that any CLI caller sharing `BPROXY_HOME` can enumerate (`session list`) and command any active session. In multi-agent orchestration (supervisor + sub-agents), a buggy agent can accidentally interfere with another agent's session — sending commands to a closed or foreign session indefinitely. The current auth model (single daemon token per OS user) provides authentication but no inter-agent isolation. + +**Rules:** + +1. **Flag:** `--nick` / `-n`, required on every protocol command (browser-control and session/tab lifecycle). Service lifecycle commands (`service start/stop/status/restart`) do not require it. +2. **Format:** `/^[a-z][a-z0-9]{5}$/` — starts with a letter, exactly 6 lowercase alphanumeric characters. Validated at CLI arg parse (exit 2) and at daemon ingress (400). +3. **No environment variable.** The nick must be explicit on every invocation. No implicit default, no shared pool. Prevents ambient inheritance in process trees from creating silent shared scopes. +4. **Wire field:** `nick` added to `BproxyRequest` envelope (required for all protocol actions). +5. **Session ownership:** Daemon stamps `owner: nick` on session creation (`session.create`, `tab.open` auto-create). Ownership is immutable for the session lifetime. +6. **Enforcement:** Every command referencing a session validates `session.owner === request.nick`. Mismatch → `SESSION_SCOPE_MISMATCH` error (`retry: "never"`). +7. **Listing:** `session list` returns only sessions where `owner === request.nick`. +8. **`debug.*` commands are nick-scoped.** `debug.status`, `debug.last`, `debug.log` filter output to the requesting nick's sessions only. `debug.last` and `debug.log` are bounded live diagnostic surfaces, not durable history APIs: once a session is closed and removed from daemon memory, entries for that session are excluded from these API responses. No unscoped operator surface via API — the operator uses daemon log files for full visibility. +9. **No raw nick in persisted output.** Daemon logs emit `ownerHash` (8 hex chars, `sha256(instanceSalt + nick)`). Instance salt is random bytes generated at daemon startup, held in memory only, never persisted. Agents reading logs can correlate entries but cannot reverse other agents' nicks. +10. **Responses include `ownerHash`, not raw nick.** `session.create` and `tab.open` responses return `ownerHash` so the agent knows its own hash for log correlation. Raw nick never appears in any API response or log file. +11. **Minimum interval floor.** Daemon enforces an absolute per-nick minimum inter-request interval (default 900ms). Requests arriving sooner are rejected with `RATE_LIMITED` (`retry: "safe"`, includes `retryAfter`). This is an ingress safety guard, not a suggestion. +12. **Metronome detection.** Daemon tracks inter-request arrival times per nick. If three consecutive intervals are equal (±10% tolerance), the third command is rejected with `METRONOME_DETECTED` (`retry: "never"`). Two equal intervals are allowed; three are a script. The error instructs the agent to control bproxy directly without writing programmatic loops. +13. **Error-path delay.** Error responses include a jittered delay (500ms–2s) before the daemon responds, making runaway loops self-throttling. +14. **Per-nick rate cap.** Sliding window (60 requests/minute per nick). Exceeded → `RATE_LIMITED` (`retry: "safe"`, includes `retryAfter`). +15. **No transfer.** No grant/transfer verb. Delegation = spawn sub-agent with the same nick. +16. **Extension unaware.** Nick is consumed and enforced at the daemon only. Stripped before WS dispatch. +17. **Configuration file.** Safety guard parameters and pacing presets live in `BPROXY_HOME/config.json` (optional; defaults applied when absent). Loaded once at daemon startup; restart to apply. No hot-reload. Daemon logs active configuration at startup. +18. **Safety floor beats pacing mode.** Session pacing mode (`human` / `fast`) may add delay above the ingress floor, but may not reduce any request interval below `safety.minInterval.ms`. `fast` means lower added delay than `human`, not sub-floor execution. +19. **Fail closed on invalid pacing config.** Daemon startup fails with a meaningful configuration error if any pacing range has `min > max` or any pacing min/max is configured below the absolute `minInterval` floor. + +**Error contract additions:** + +| Code | Category | Retry | When | +|------|----------|-------|------| +| `SESSION_SCOPE_MISMATCH` | `policy` | `never` | Session exists but `owner !== nick` | +| `METRONOME_DETECTED` | `policy` | `never` | Three consecutive equal-interval requests from same nick | +| `RATE_LIMITED` | `policy` | `safe` | Nick exceeds 60 requests/minute | + +`SESSION_NOT_FOUND` is updated: `retry` changes from `"conditional"` to `"never"`, and gains `suggestedAction: "Session is permanently closed or never existed. Do not retry. Create a new session with 'bproxy tab open --url ... -n {nick}'. If you need historical diagnostics, inspect BPROXY_HOME/logs/ and correlate entries with your ownerHash."` — well-formed session IDs that don't resolve will never come back (IDs are not recycled). + +**Scope boundary:** +- Nick is **authorization** (namespace), not authentication. The daemon token remains the auth boundary. +- Single-user model preserved. Nick adds namespace within one OS user's trust domain. +- Not cryptographic. Prevents cooperative-but-buggy agents from accidental interference; not designed to stop a malicious agent that already holds the daemon token. + +**Rejected alternatives:** + +| Alternative | Why rejected | +|-------------|-------------| +| Environment variable (`BPROXY_NICK`) | Silently inherited by child processes; creates ambient shared scopes that reproduce #22. | +| Per-agent tokens | Heavy ceremony, token lifecycle/revocation overkill for cooperative threat model. | +| Per-session secrets | Better security but worse DX; doesn't prevent enumeration without also scoping list. | +| Optional nick with shared default pool | Defeats the purpose — agents that skip it share a pool and #22 recurs. | +| Longer nick (UUID) | Unnecessary; adversarial agents already hold the daemon token. 6 chars balances uniqueness with CLI ergonomics. | + +**Implementation:** Phase plan at [`docs/internal/plans/phases/08-agent-nick-session-scoping.md`](./plans/phases/08-agent-nick-session-scoping.md). diff --git a/docs/internal/plans/phases/08-agent-nick-session-scoping.md b/docs/internal/plans/phases/08-agent-nick-session-scoping.md new file mode 100644 index 0000000..eabcac0 --- /dev/null +++ b/docs/internal/plans/phases/08-agent-nick-session-scoping.md @@ -0,0 +1,419 @@ +--- +title: "Phase 8: Agent nickname session scoping" +status: Active +date: 2026-06-19 +issue: "#22" +adr: ADR-030 +--- + +## Phase 8: Agent nickname session scoping + +**Motivation:** Issue #22 revealed that any CLI caller sharing `BPROXY_HOME` can enumerate and command any active session. In multi-agent orchestration (supervisor + sub-agents), a buggy agent can accidentally interfere with another agent's session — sending commands to a closed or wrong session indefinitely. The current auth model (single daemon token per OS user) provides no isolation between concurrent agents. + +**Decision:** [ADR-030](../decisions.md#adr-030-agent-nickname-session-scoping). Add a required agent namespace — the **nick** — that scopes session visibility and command authority. + +--- + +## Design (resolved) + +### Nick format and flag + +- **Flag:** `--nick` / `-n`, required on every protocol command. +- **Format:** `/^[a-z][a-z0-9]{5}$/` — starts with a letter, exactly 6 lowercase alphanumeric characters. +- **No environment variable.** Explicit on every invocation. No implicit defaults. + +### Wire + +New required field `nick` in `BproxyRequest` envelope: +```json +{ + "protocol_version": 1, + "id": "...", + "action": "scroll", + "nick": "halbot", + "session": "m4q7z2", + "params": { ... }, + "deadline": ..., + "destructive": true +} +``` + +**Breaking change:** Adding `nick` as required to `BproxyRequest` is a protocol-level breaking change. All existing tests across cli, service, and shared that construct or validate requests must be updated. The `protocol_version` remains `1` (this is a pre-1.0 project; semver applies at the package level, not the wire protocol version). + +### Session ownership + +- Daemon stamps `owner: nick` on session creation (`session.create`, `tab.open` auto-create). +- Ownership is immutable for the session lifetime. +- Every command referencing a session validates `session.owner === request.nick`. + +### Scoping rules + +- `session list` returns only sessions where `owner === request.nick`. +- `debug.status`, `debug.last` are nick-scoped — return only the requesting nick's data. +- `debug.log` is nick-scoped — daemon filters entries by `entry.session` → session owner match (see "debug.log scoping" below). +- `debug.last` and `debug.log` are **bounded live diagnostic surfaces**, not durable history APIs. Once a session is closed and removed from daemon memory, entries for that session are excluded from these API responses. +- Historical visibility lives in daemon structured log files under `BPROXY_HOME/logs/`, correlated via `ownerHash`. +- No unscoped operator surface via API. Operator uses daemon log files for full visibility. + +### Nick privacy — ownerHash + +- Raw nick **never** appears in persisted output (log files) or API responses. +- Daemon logs emit `ownerHash`: 8 hex chars from `sha256(instanceSalt + nick)`. +- `instanceSalt`: random bytes generated at daemon startup, held in memory only, never persisted, regenerated on restart. +- `session.create` and `tab.open` responses include `ownerHash` (not raw nick) so the agent knows its own hash for log correlation. + +```json +{"session":"m4q7z2","tab":"t1","ownerHash":"a3f7c012","tmpDir":"..."} +``` + +### debug.log scoping + +The extension's `TraceEntry` currently lacks a `session` field. The extension already receives `session` in every `BproxyForwardedRequest` but discards it at trace-append time. + +**Fix:** Add `session?: string` to `TraceEntry` (shared type). Extension stores the session from the forwarded request when appending. Optional field for backward compatibility with old ring buffer entries. + +**Daemon filtering:** When `debug.log` response returns from the extension, daemon filters entries: +- Entry has `session` → check `sessions.get(entry.session)?.owner === request.nick` → include if match +- Entry has `session` but session is closed → exclude +- Entry lacks `session` (old format) → exclude + +This is intentional. `debug.log` is a bounded live trace surface. Closing a session closes API visibility into that session's extension trace as well. Historical investigation happens through daemon structured log files using `ownerHash`, not through `debug.log`. + +### debug.last scoping + +`debug.last` is also a bounded live surface, not a durable history API. The daemon filters trace entries by live session ownership: +- Entry session resolves to a live session whose `owner === request.nick` → include +- Entry session is closed / no longer resolvable → exclude + +This keeps the API surface strictly scoped to currently-owned live sessions. Historical cross-session diagnostics remain available only through daemon structured log files. + +### Error contract + +**New error: `SESSION_SCOPE_MISMATCH`** + +| Field | Value | +|-------|-------| +| `code` | `SESSION_SCOPE_MISMATCH` | +| `category` | `policy` | +| `retry` | `never` | +| `message` | `"Session '{id}' does not belong to this agent"` | +| `suggestedAction` | `"This session belongs to another agent. Create your own session with 'bproxy tab open --url ... -n {nick}' or check that you are using the correct --nick value."` | + +**Enriched `SESSION_NOT_FOUND`** + +| Field | Current | New | +|-------|---------|-----| +| `retry` | `"conditional"` | `"never"` | +| `suggestedAction` | *(absent)* | `"Session '{id}' is permanently closed or never existed. Do not retry. Create a new session with 'bproxy tab open --url ... -n {nick}'. If you need historical diagnostics, inspect BPROXY_HOME/logs/ and correlate entries with your ownerHash."` | + +**New error: `RATE_LIMITED`** + +| Field | Value | +|-------|-------| +| `code` | `RATE_LIMITED` | +| `category` | `policy` | +| `retry` | `safe` | +| `message` | `"Rate limit exceeded for this agent."` | +| `suggestedAction` | `"Slow down. Wait at least {retryAfter}ms before the next command."` | +| `details` | `{ "retryAfter": }` | + +Used for both minimum interval violations and per-minute rate cap exceeded. + +**New error: `METRONOME_DETECTED`** + +| Field | Value | +|-------|-------| +| `code` | `METRONOME_DETECTED` | +| `category` | `policy` | +| `retry` | `never` | +| `message` | `"Request timing is too regular. Three consecutive commands arrived at equal intervals (~{N}ms). This pattern is detectable as automation."` | +| `suggestedAction` | `"Do not write scripts or programs to call bproxy in a loop. Control each bproxy command directly — read the result, decide what to do next, then act. If you absolutely must use programmatic control, add random variance to timing. A human does not operate a browser with fixed intervals."` | + +### No transfer, no sharing + +No grant/transfer verb. Delegation = spawn sub-agent with the same nick. + +--- + +## Daemon-side safety guards + +Four mechanisms address the runaway-agent failure mode that nick scoping alone does not cover (an agent hammering its own session): + +### Minimum interval (burst guard) + +Hard floor on inter-request timing per nick. Any command arriving less than the configured minimum (default 900ms) after the previous command from the same nick is rejected immediately. Humans cannot think, read output, and decide in under 900ms. + +Returns `RATE_LIMITED` with `retryAfter` set to remaining milliseconds until the minimum interval passes. + +### Metronome detection + +The daemon tracks inter-request arrival times per nick. If N consecutive command intervals are equal (within ±tolerance), the Nth command is rejected. This prevents agents from writing scripts that call bproxy in a loop with fixed delays. + +**Rules:** +- Track last N+1 arrival timestamps per nick (N = `consecutiveEqual` from config, default 3) +- Intervals compared pairwise: `|interval[i] - interval[i-1]| / interval[i-1] < tolerance` +- Only checked for intervals between `minInterval` (900ms) and `maxIntervalMs` (default 60s). Intervals above `maxIntervalMs` are not suspicious at human timescales. +- **Reset rule:** Streak clears after a rejection OR after any interval that breaks the pattern (non-equal or outside the checked range). The next request starts a fresh tracking window. + +### Error-path rate limiting + +When the daemon returns an error (any error), it injects a jittered delay (configurable min/max, default 500ms–2s) before responding. This makes runaway loops self-throttling — an agent ignoring `retry: "never"` physically cannot exceed ~1 req/s on error paths. + +### Per-nick activity rate cap + +Sliding window counter at request ingress. If a nick exceeds the configured requests/minute (default 60), respond with `RATE_LIMITED` error including `retryAfter`. + +### Ingress check ordering + +All guards execute in this order at request ingress: + +``` +1. Nick validation (format check) → 400 on malformed +2. Minimum interval check → RATE_LIMITED if too fast +3. Per-nick rate cap → RATE_LIMITED if exceeded +4. Metronome detection → METRONOME_DETECTED if pattern found +5. Session validation (exists, owner matches) → SESSION_NOT_FOUND / SESSION_SCOPE_MISMATCH +6. Pacing (existing, for forwarded actions only) → jittered delay +7. Dispatch +``` + +Error-path delay is applied at response time (step 7 failure or steps 2–5 rejection), not at ingress. + +### Safety guard precedence over pacing mode + +`minInterval` is an **absolute ingress floor**. Session pacing mode (`human` / `fast`) may add delay above that floor, but it may not reduce the interval below it. + +Concretely: +- `fast` means lower daemon-added pacing than `human` +- `fast` does **not** mean sub-`minInterval` +- no command may execute faster than `safety.minInterval.ms`, regardless of pacing mode + +--- + +## Configuration + +All daemon policy parameters are configurable via `BPROXY_HOME/config.json`. The file is optional — daemon uses hardcoded defaults if absent. Loaded once at startup; restart to apply changes. + +```json +{ + "pacing": { + "human": { + "navigate": { "min": 1500, "max": 4000 }, + "scroll": { "min": 4000, "max": 8000 }, + "interaction": { "min": 1200, "max": 2500 }, + "fill": { "min": 1200, "max": 2500 } + }, + "fast": { + "navigate": { "min": 900, "max": 1400 }, + "scroll": { "min": 900, "max": 1600 }, + "interaction": { "min": 900, "max": 1200 }, + "fill": { "min": 900, "max": 1200 } + } + }, + "safety": { + "minInterval": { + "ms": 900 + }, + "rateCap": { + "requestsPerMinute": 60 + }, + "errorDelay": { + "minMs": 500, + "maxMs": 2000 + }, + "metronome": { + "tolerance": 0.10, + "consecutiveEqual": 3, + "maxIntervalMs": 60000 + } + } +} +``` + +### Config semantics + +| Parameter | Default | Meaning | +|-----------|---------|---------| +| `pacing.human.navigate` | `1500–4000` | Delay range for `navigate` in human mode | +| `pacing.human.scroll` | `4000–8000` | Delay range for `scroll` in human mode | +| `pacing.human.interaction` | `1200–2500` | Delay range for `click` / `hover` in human mode | +| `pacing.human.fill` | `1200–2500` | Delay range for `fill` / `fill-form` in human mode | +| `pacing.fast.navigate` | `900–1400` | Delay range for `navigate` in fast mode | +| `pacing.fast.scroll` | `900–1600` | Delay range for `scroll` in fast mode | +| `pacing.fast.interaction` | `900–1200` | Delay range for `click` / `hover` in fast mode | +| `pacing.fast.fill` | `900–1200` | Delay range for `fill` / `fill-form` in fast mode | +| `safety.minInterval.ms` | `900` | Absolute minimum ms between consecutive requests from same nick | +| `safety.rateCap.requestsPerMinute` | `60` | Max requests per nick per sliding minute | +| `safety.errorDelay.minMs` | `500` | Minimum jittered delay before error response | +| `safety.errorDelay.maxMs` | `2000` | Maximum jittered delay before error response | +| `safety.metronome.tolerance` | `0.10` | Interval equality threshold (±10%) | +| `safety.metronome.consecutiveEqual` | `3` | How many equal intervals before rejection | +| `safety.metronome.maxIntervalMs` | `60000` | Skip metronome check for intervals above this | + +### Startup validation + +Daemon startup fails with a meaningful configuration error if: +- config shape contains unknown keys +- any value has the wrong type +- any `{ min, max }` pacing pair has `min > max` +- any pacing minimum is below `safety.minInterval.ms` +- any pacing maximum is below `safety.minInterval.ms` +- `safety.errorDelay.minMs > safety.errorDelay.maxMs` +- safety numeric values are non-positive or otherwise nonsensical + +This fail-closed rule is intentional: misconfigured pacing must not silently undercut the ingress safety floor. + +The daemon logs the active configuration at startup (`info` level) so the operator can verify what's in effect. + +--- + +## CLI UX examples + +```bash +# Bootstrap: open a tab, session auto-created under "halbot" +bproxy tab open --url https://example.com -n halbot +# → {"session":"m4q7z2","tab":"t1","ownerHash":"a3f7c012","tmpDir":"..."} + +# Create session explicitly +bproxy session create -n halbot --label research +# → {"session":"p7k2qm","label":"research","ownerHash":"a3f7c012","tmpDir":"..."} + +# Normal commands +bproxy text -n halbot -s m4q7z2 +bproxy scroll -n halbot -s m4q7z2 --direction down +bproxy click -n halbot -s m4q7z2 --element el3 +bproxy session list -n halbot +# → only sessions owned by "halbot" + +# Lower-added-delay mode; still cannot bypass safety.minInterval.ms +bproxy session bind -n halbot -s m4q7z2 --tab t1 --pacing fast + +# Wrong nick → immediate terminal error +bproxy text -n bobcat -s m4q7z2 +# → {"ok":false,"error":{"code":"SESSION_SCOPE_MISMATCH","retry":"never", +# "suggestedAction":"This session belongs to another agent..."}} + +# Session closed → terminal error with guidance +bproxy scroll -n halbot -s m4q7z2 +# → {"ok":false,"error":{"code":"SESSION_NOT_FOUND","retry":"never", +# "suggestedAction":"Session 'm4q7z2' is permanently closed..."}} + +# Too fast (< 900ms since last command) → rate limited +bproxy text -n halbot -s m4q7z2 +# → {"ok":false,"error":{"code":"RATE_LIMITED","retry":"safe", +# "details":{"retryAfter":450}}} + +# Metronome pattern detected +bproxy scroll -n halbot -s m4q7z2 # at t=0 +bproxy scroll -n halbot -s m4q7z2 # at t=5000 +bproxy scroll -n halbot -s m4q7z2 # at t=10000 +bproxy scroll -n halbot -s m4q7z2 # at t=15000 → rejected +# → {"ok":false,"error":{"code":"METRONOME_DETECTED","retry":"never", +# "suggestedAction":"Do not write scripts or programs to call bproxy..."}} + +# Missing nick → exit 2 (CLI validation, never reaches daemon) +bproxy text -s m4q7z2 +# stderr: "Missing required --nick (-n). Every command requires an agent nickname." +``` + +--- + +## Implementation tasks + +### 1. Shared types (`shared/`) + +- [x] Add `Nick` branded type + `isValidNick()` validation (`/^[a-z][a-z0-9]{5}$/`) +- [x] Add `nick` field to `BproxyRequest` type (required) +- [x] Add `session?: string` field to `TraceEntry` type +- [x] Add `SESSION_SCOPE_MISMATCH`, `METRONOME_DETECTED`, `RATE_LIMITED` to `ErrorCode` +- [x] Update `SESSION_NOT_FOUND` documentation/comments: retry is now `"never"` + +### 2. CLI (`cli/`) + +- [x] Add `--nick` / `-n` to `globalArgs` (required, string) +- [x] Validate nick format at `extractGlobals()` → exit 2 on invalid/missing +- [x] Wire `nick` into `sendAction` → `BproxyRequest` envelope +- [x] Update all tests that construct requests to include `nick` + +### 3. Daemon — nick scoping (`service/`) + +- [x] Generate `instanceSalt` (32 random bytes) at daemon startup, hold in memory +- [x] Add `computeOwnerHash(salt, nick)` utility: `sha256(salt + nick).hex().slice(0, 8)` +- [x] Add `owner: string` field to internal session state +- [x] Stamp `owner` on `session.create` and `tab.open` auto-create +- [x] Add nick validation at request ingress (400 on malformed) +- [x] Add scope check in `validateSession`: session exists but `owner !== nick` → `SESSION_SCOPE_MISMATCH` +- [x] Filter `session list` by `request.nick` +- [x] Filter `debug.status` by `request.nick` +- [x] Filter `debug.last` by live session owner match; exclude entries for closed/non-resolvable sessions +- [x] Filter `debug.log` response from extension by `entry.session` → owner match; exclude entries for closed sessions +- [x] Enrich `SESSION_NOT_FOUND` error: `retry: "never"`, add `suggestedAction` +- [x] Include `ownerHash` in `session.create` / `tab.open` responses +- [x] Emit `ownerHash` (not raw nick) in structured log entries +- [x] Update request schema validation to require `nick` field +- [x] Update all tests + +### 4. Daemon — configuration (`service/`) + +- [x] Define daemon config types for both `pacing` and `safety` +- [x] Move hard-coded pacing preset defaults out of `shared/` and into daemon config/defaults +- [x] Load `BPROXY_HOME/config.json` at startup (optional file, missing = all defaults) +- [x] Validate config shape (reject unknown keys, wrong types) → fail startup on invalid +- [x] Validate pacing ranges (`min <= max`) and enforce `pacing.*.*.min/max >= safety.minInterval.ms` → fail startup on invalid +- [x] Log active configuration at startup (`info` level) +- [x] Wire config values into pacing and safety modules + +### 5. Daemon — safety guards (`service/`) + +- [x] Minimum interval tracker: per-nick last-request timestamp +- [x] Minimum interval check: reject with `RATE_LIMITED` + `retryAfter` if below threshold +- [x] Metronome detector: track last N+1 arrival timestamps per nick +- [x] Metronome detector: compute interval equality within configurable tolerance +- [x] Metronome detector: skip intervals above `maxIntervalMs` +- [x] Metronome detector: reject on Nth consecutive equal interval with `METRONOME_DETECTED` +- [x] Metronome detector: reset streak on rejection or pattern break +- [x] Error-path delay: inject jittered sleep (config min/max) before returning any error response +- [x] Per-nick rate cap: sliding window counter (config requests/min) +- [x] Per-nick rate cap: return `RATE_LIMITED` with `retryAfter` in details +- [x] Ingress ordering: nick validation → min interval → rate cap → metronome → session validation → pacing → dispatch +- [x] Enforce `minInterval` as absolute precedence over `human` / `fast` pacing mode + +### 6. Extension (`extension/`) + +- [x] Store `session` from forwarded request in trace entry (one field addition in dispatcher trace append) +- [x] Verify `nick` is NOT included in forwarded WS messages (stripped at daemon before dispatch) + +### 7. Documentation + +- [x] Update `docs/internal/architecture.md` — mention nick scoping in session authority +- [x] Update `docs/public/solution/cli.md` — add `--nick` to global flags table, update examples +- [x] Update `docs/public/solution/service.md` — session ownership, scope validation, safety guards, config file +- [x] Update `docs/public/solution/shared.md` — new types, new error codes, TraceEntry change +- [x] Update `docs/public/views/04-session-state.md` — ownership on session creation +- [x] Update `docs/public/views/06-threat-model.md` — inter-agent isolation via nick, ownerHash in logs + +--- + +## What this does NOT change + +- Auth model stays single-token (nick is authorization/namespace, not authentication) +- Single-user scope preserved +- Session ID format unchanged (`/^[a-z2-7]{6}$/`) +- No daemon persistence of nicks (in-memory, cleared on restart) +- Extension receives no new logic or strategy — just stores one additional field in trace + +--- + +## Validation + +- `pnpm check` passes (typecheck + format + lint + arch + deadcode) +- `pnpm test` passes (all packages — tests updated for required `nick` field) +- Unit tests: nick validation, scope mismatch, ownership stamp, listing filter, hash computation +- Unit tests: minimum interval rejection, metronome detection + reset, rate cap, error delay +- Unit tests: config loading (valid, invalid, missing file), pacing config validation, debug.log/debug.last closed-session filtering +- Integration: daemon startup fails with meaningful error when pacing config undercuts `safety.minInterval.ms` +- Integration: two agents with different nicks cannot see or touch each other's sessions +- Integration: agent with correct nick can operate its sessions normally +- Integration: `SESSION_NOT_FOUND` returns `retry: "never"` with guidance +- Integration: daemon logs show `ownerHash`, not raw nick +- Integration: metronome detection triggers on equal-interval commands, resets after break diff --git a/docs/public/solution/cli.md b/docs/public/solution/cli.md index 92b0f41..34fa136 100644 --- a/docs/public/solution/cli.md +++ b/docs/public/solution/cli.md @@ -84,11 +84,14 @@ Every leaf command defines these via `globalArgs` spread: | Flag | Alias | Type | Default | Description | |------|-------|------|---------|-------------| -| `--session` | `-s` | string | *(required for browser commands)* | Session ID for the request | +| `--nick` | `-n` | string | *(required for protocol commands)* | Agent nickname for request scoping; must match `/^[a-z][a-z0-9]{5}$/` | +| `--session` | `-s` | string | *(required for most browser commands)* | Session ID for the request | | `--timeout` | | string (ms) | `30000` | Protocol deadline in milliseconds | | `--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. + ## Exit Codes | Code | Meaning | @@ -135,7 +138,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. Build `BproxyRequest` with `protocol_version: 1`, session, deadline, destructive flag +5. Validate `--nick` and build `BproxyRequest` with `protocol_version: 1`, 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) @@ -195,8 +198,8 @@ Composition: stop then start. Produces the same JSON as start. Daemon-local (no extension required): -- `session create [--label TEXT]` — creates a new session, returns generated id -- `session list` — returns all active sessions +- `session create [--label TEXT]` — creates a new nick-owned session, returns generated id, `tmpDir`, and `ownerHash` +- `session list` — returns only active sessions owned by the supplied nick - `session bind --tab tN [--pacing human|fast]` — binds session to logical tab - `session unbind` — unbinds session from tab (destructive) - `session resume` — clears paused state (destructive) @@ -210,18 +213,18 @@ Forwarded to extension (require connected WS client): - `tab pin [--tab tN]` — pin a tab (destructive) - `tab unpin` — unpin current tab (destructive) -- `tab open --url ` — open new tab, auto-create session if `-s` omitted (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) ## Debug Commands -- `debug log [--id ID] [--limit N]` — forwarded to extension (ring buffer) -- `debug last [--count N]` — daemon-local request history -- `debug status` — daemon-local full status +- `debug log [--id ID] [--limit N]` — forwarded to extension (ring buffer), filtered to the requesting nick's live sessions +- `debug last [--count N]` — daemon-local request history for the requesting nick's live sessions only +- `debug status` — daemon-local full status scoped to the requesting nick ## Top-level `status` -`bproxy status` is a protocol-backed alias for `debug.status`. It requires token preflight. It does **not** fall back to `service status` on auth failure — that's a config/security failure (exit `2`). +`bproxy status` is a protocol-backed alias for `debug.status`. It requires token preflight and `--nick`. It does **not** fall back to `service status` on auth failure — that's a config/security failure (exit `2`). ## Write Commands @@ -266,6 +269,16 @@ There is intentionally no `eval` command. Arbitrary page/runtime investigation b **Non-destructive:** text, links, images, elements, outline, dom, inspect, snapshot, screenshot, wait, tab.list, session.list, debug.log, debug.last, debug.status. +## Examples + +```bash +bproxy tab open --url https://example.com -n halbot +bproxy session create -n halbot --label research +bproxy text -n halbot -s m4q7z2 +bproxy session list -n halbot +bproxy debug status -n halbot +``` + ## Verbose Mode (`--verbose`) Writes structured JSON to stderr. Each entry includes: `requestId`, `action`, `session`, `url`, `elapsed`, `httpStatus`, `errorCode`. Token values are never included. diff --git a/docs/public/solution/service.md b/docs/public/solution/service.md index 276ac1c..221fe06 100644 --- a/docs/public/solution/service.md +++ b/docs/public/solution/service.md @@ -26,11 +26,13 @@ service/ │ └── types.ts # CommandRouteDeps interface ├── clients.ts # WS client registry ├── config.ts # env-based configuration + ├── daemon-config.ts # BPROXY_HOME/config.json load + validation ├── debug-actions.ts # debug.status / debug.last handlers ├── dispatch.ts # route command to correct WS client + tab ├── element-handles.ts # daemon-owned handle cache + page-epoch tracking ├── logger.ts # structured JSON logger (pino-compatible) ├── pacing.ts # per-session delay enforcement + ├── safety.ts # per-nick ingress guards + error-path delay ├── pairing.ts # pairing code generation and validation ├── pairing-rate-limit.ts # global in-memory failed-attempt throttle for /pair/claim ├── pairing-file.ts # pairing.json file I/O @@ -111,26 +113,23 @@ Failure at any layer → 401, connection closed. Receives CLI commands. Single route, single method. ```typescript -app.post('/', { - schema: { - body: BproxyRequestSchema, // JSON Schema from shared types - response: { 200: BproxyResponseSchema } - } -}, async (request, reply) => { - const cmd = request.body as BproxyRequest; +app.post('/', async (request, reply) => { + const cmd = parseAndValidateRequest(request.body); // includes required `nick` - // 1. Enforce pacing (may delay before proceeding) - await pacing.waitForSlot(cmd.session, cmd.action); + // 1. Per-nick ingress safety guards + const safetyError = safety.checkIngress(cmd.nick); + if (safetyError) return delayedErrorReply(safetyError); - // 2. Find target WS client for this session's tab - const client = dispatch.resolveClient(cmd.session); - if (!client) return reply.code(502).send(noExtensionError(cmd)); + // 2. Session validation + nick scope check + const sessionError = validateSession(cmd); + if (sessionError) return delayedErrorReply(sessionError); - // 3. Forward to extension, await response (with deadline) - const result = await dispatch.send(client, cmd); + // 3. Daemon pacing (cannot bypass safety.minInterval.ms) + await pacing.waitForSlot(cmd.session, cmd.action); - // 4. Return to CLI - return result; + // 4. Execute daemon-local or forwarded action + const result = await executeCommand(cmd); + return finalize(result); // applies jittered error delay on error responses }); ``` @@ -138,12 +137,31 @@ The route is synchronous from the CLI's perspective: POST blocks until the exten **Status precedence (normative):** for `POST /`, auth failure (`401`) takes precedence over request-body parse/schema failures (`400`). +### Ingress safety ordering + +For every authenticated protocol request, the daemon evaluates ingress policy in this order: + +1. Nick validation (`400` on malformed) +2. Minimum interval guard (`RATE_LIMITED`) +3. Per-nick sliding-window rate cap (`RATE_LIMITED`) +4. Metronome detection (`METRONOME_DETECTED`) +5. Session existence + owner-scope validation (`SESSION_NOT_FOUND` / `SESSION_SCOPE_MISMATCH`) +6. Daemon pacing (`human` / `fast`) +7. Dispatch / daemon-local execution + +Any error response produced at steps 2–7 is delayed by a jittered `errorDelay` sleep before being returned. `safety.minInterval.ms` is an absolute ingress floor: pacing mode may add delay above it, but may not reduce execution below it. + ## Action Routing and Session Contract ### Session authority -Daemon is the source of truth for session state (tab ownership, pacing, paused, pauseReason). -This state is in-memory only and resets on daemon restart. +Daemon is the source of truth for session state (owner nick, tab ownership, pacing, paused, pauseReason). This state is in-memory only and resets on daemon restart. + +Ownership rules: +- every session is stamped with immutable `owner = request.nick` at `session.create` and `tab.open` auto-create time +- every session-referencing command validates `session.owner === request.nick` +- `session.list`, `debug.status`, `debug.last`, and `debug.log` are nick-scoped surfaces +- raw nick never appears in persisted log files or API responses; log correlation uses `ownerHash` ### Routing matrix @@ -157,8 +175,8 @@ This state is in-memory only and resets on daemon restart. ### `session.*` semantics -- `session.create` — generates a new 6-character base32 `SessionId` with default pacing; returns `{ session, label?, tmpDir }`. -- `session.list` — returns daemon's current in-memory session snapshot. +- `session.create` — generates a new 6-character base32 `SessionId`, stamps `owner = nick`, creates the session temp directory, and returns `{ session, label?, tmpDir, ownerHash }`. +- `session.list` — returns only the requesting nick's current in-memory sessions. - `session.bind` — binds a session to a logical `TabHandle`. Rebinding is immediate: the very next forwarded command resolves the new tab. - `session.unbind` — clears tab binding; idempotent. - `session.resume` — clears paused state/reason; idempotent. @@ -176,7 +194,7 @@ This state is in-memory only and resets on daemon restart. ### Forwarded request shape -Daemon→extension messages carry a daemon-owned `target.tabId`. The CLI HTTP input is the bare `BproxyRequest` (no target); the daemon wraps it as `BproxyForwardedRequest = BproxyRequest & { target: { tabId: number } }` at the dispatch site. `session.*`, `debug.last`, and `debug.status` are handled daemon-locally and never carry `target`. Rebinding (`session.bind` with a new `tabId`) is immediate: the very next forwarded request picks up the new tab. +Daemon→extension messages carry a daemon-owned `target.tabId`. The CLI HTTP input is the bare `BproxyRequest`; the daemon consumes `nick`, resolves `session → tabId`, narrows any handle-based params, and forwards `BproxyForwardedRequest` with `target.tabId`. `nick` is **not** present on the daemon→extension wire. `session.*`, `debug.last`, and `debug.status` are handled daemon-locally and never carry `target`. Rebinding (`session.bind` with a new `tabId`) is immediate: the very next forwarded request picks up the new tab. ### Pause/resume contract @@ -364,10 +382,29 @@ async function waitForSlot(session: string, action: string): Promise { Default pacing (human mode): - Navigate: 1500–4000ms - Scroll: 4000–8000ms -- Interaction (`click` / `hover`): 500–2000ms -- Fill (per field): 500–2000ms +- Interaction (`click` / `hover`): 1200–2500ms +- Fill (per field): 1200–2500ms + +Default fast mode: +- Navigate: 900–1400ms +- Scroll: 900–1600ms +- Interaction (`click` / `hover`): 900–1200ms +- Fill (per field): 900–1200ms + +Configurable per session via `bproxy session bind --pacing human|fast`, but all pacing buckets are bounded by the daemon-wide `safety.minInterval.ms` floor loaded from `BPROXY_HOME/config.json`. + +## Daemon Configuration + +The daemon loads `BPROXY_HOME/config.json` once at startup. The file is optional; when absent, hardcoded defaults are used. + +Top-level sections: +- `pacing.human` / `pacing.fast` — per-action delay ranges +- `safety.minInterval.ms` — absolute minimum inter-request interval per nick +- `safety.rateCap.requestsPerMinute` — sliding-window per-nick cap +- `safety.errorDelay.minMs/maxMs` — jittered delay applied before any error response +- `safety.metronome.tolerance/consecutiveEqual/maxIntervalMs` — regular-cadence detection -Configurable per session via `bproxy session bind --pacing human|fast`. Per-session config overrides (arbitrary `PacingConfig` literal) are deferred to a later phase. +Startup is fail-closed: unknown keys, wrong types, inverted ranges, pacing values below `minInterval`, or nonsensical safety values abort daemon startup with a meaningful configuration error. The daemon logs the active config at startup. ## Pending Request Map @@ -510,7 +547,10 @@ These use the shared `BproxyError` envelope (`{ code, category, retry, message, | `OVERLOADED` | transport | Pending map full | | `SESSION_REQUIRED` | policy | Browser-control command sent without `-s` | | `INVALID_SESSION_ID` | target | Session id doesn't match `/^[a-z2-7]{6}$/` | -| `SESSION_NOT_FOUND` | target | Session id not in daemon registry | +| `SESSION_NOT_FOUND` | target | Session id not in daemon registry; retry is `never` with create-new-session guidance | +| `SESSION_SCOPE_MISMATCH` | policy | Session exists but belongs to another nick | +| `RATE_LIMITED` | policy | Nick violated the minimum interval floor or per-minute cap | +| `METRONOME_DETECTED` | policy | Nick sent three equal-interval requests within the suspicious range | | `TAB_NOT_FOUND` | target | Session has no bound tab | | `TAB_HANDLE_NOT_FOUND` | target | Logical tab handle not registered in any session | | `TAB_NOT_IN_SESSION` | target | Tab exists but belongs to another session | @@ -547,20 +587,21 @@ The daemon is the central point of visibility — all requests flow through it. Structured JSON via Fastify's pino logger. Every log line includes the request `id` when applicable. ``` -{"level":"info","id":"01HZX9C2K8","action":"scroll","session":"m4q7z2","event":"received","ts":1714000027000} +{"level":"info","id":"01HZX9C2K8","action":"scroll","session":"m4q7z2","event":"received","ownerHash":"a3f7c012","ts":1714000027000} {"level":"info","id":"01HZX9C2K8","event":"pacing_wait","delay_ms":2400} {"level":"info","id":"01HZX9C2K8","event":"forwarded","ws_client":"client-1","tab":1234} -{"level":"info","id":"01HZX9C2K8","event":"response","ok":true,"elapsed_ms":377} +{"level":"info","id":"01HZX9C2K8","event":"response","ok":true,"elapsed_ms":377,"ownerHash":"a3f7c012"} ``` ### Lifecycle Events Logged | Event | When | Fields | |---|---|---| -| `received` | HTTP POST arrives | `id`, `action`, `session`, `destructive` | +| `received` | HTTP POST arrives | `id`, `action`, `session`, `destructive`, `ownerHash` | | `pacing_wait` | Before forwarding, delay enforced | `id`, `delay_ms` | +| `error_delay` | Before returning an error response | `id`, `delay_ms` | | `forwarded` | Sent to extension via WS | `id`, `ws_client`, `tab` | -| `response` | Extension replied | `id`, `ok`, `elapsed_ms`, `error_code?` | +| `response` | Extension replied or daemon-local action completed | `id`, `ok`, `elapsed_ms`, `error_code?`, `ownerHash` | | `timeout` | Deadline expired | `id`, `elapsed_ms` | | `replay` | Re-sent after WS reconnect | `id`, `ws_client` | | `ws_connect` | Extension WS client connected | `ws_client`, `remote` | diff --git a/docs/public/solution/shared.md b/docs/public/solution/shared.md index 7462158..900cf4f 100644 --- a/docs/public/solution/shared.md +++ b/docs/public/solution/shared.md @@ -18,21 +18,22 @@ 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 # session/tab identifiers, pacing config + └── sessions.ts # nick/session/tab identifiers, validation helpers, pacing mode ``` ## Protocol Envelope ```typescript // src/protocol.ts -import type { Action, ActionParams, ActionResult } from './actions'; +import type { Action, ActionParams, ActionResult, ForwardedActionParams } from './actions'; import type { BproxyError } from './errors'; -import type { SessionId } from './sessions'; +import type { Nick, SessionId } from './sessions'; export interface BproxyRequest { protocol_version: 1; id: string; action: A; + nick: Nick; params: ActionParams[A]; session: SessionId; deadline: number; // unix ms @@ -40,13 +41,16 @@ export interface BproxyRequest { } // Daemon → extension wire shape. The CLI's HTTP input is `BproxyRequest`; -// the daemon owns the mapping session → tabId and wraps the request with -// `target.tabId` at the dispatch site. Only forwarded actions use this shape — -// daemon-local actions (session.*, debug.last, debug.status) never carry a target. +// the daemon consumes `nick`, resolves session → tabId, narrows params from +// client targets/handles to forwarded explicit targets, and wraps the request +// with `target.tabId` at the dispatch site. Only forwarded actions use this +// shape — daemon-local actions (session.*, debug.last, debug.status) never +// carry a target. // // `target.tabId` may be `null` for background-handled actions that do not -// require an existing tab (tab.open, tab.list, tab.close). -export type BproxyForwardedRequest = BproxyRequest & { +// require an existing tab (tab.open, tab.close). +export type BproxyForwardedRequest = Omit, 'nick' | 'params'> & { + params: ForwardedActionParams[A]; target: { tabId: number | null }; }; @@ -206,9 +210,9 @@ export interface ActionResult { 'tab.list': { session: SessionId; tabs: Array }; 'tab.pin': { tab: TabHandle; pinned: true }; 'tab.unpin': { tab: TabHandle; pinned: false }; - 'tab.open': { session: SessionId; tab: TabHandle; bound: boolean; url: string }; + 'tab.open': { session: SessionId; tab: TabHandle; bound: boolean; url: string; tmpDir: string; ownerHash: string }; 'tab.close': { tab: TabHandle; closed: true }; - 'session.create': { session: SessionId; label?: string }; + 'session.create': { session: SessionId; label?: string; tmpDir: string; ownerHash: string }; 'session.list': { sessions: Array }; 'session.bind': { session: SessionId; tab: TabHandle }; 'session.unbind': Record; @@ -254,6 +258,9 @@ export type ErrorCode = | 'HUMAN_REQUIRED' | 'DEBUGGER_DISABLED' | 'SESSION_REQUIRED' + | 'SESSION_SCOPE_MISMATCH' + | 'METRONOME_DETECTED' + | 'RATE_LIMITED' // Execution | 'SCRIPT_ERROR' | 'NAVIGATION_FAILED' @@ -279,9 +286,14 @@ export interface BproxyError { // src/sessions.ts // Branded types — prevent accidental string/number interchange +declare const nickBrand: unique symbol; declare const sessionIdBrand: unique symbol; declare const tabHandleBrand: unique symbol; +// 6-character lowercase alphanumeric nick, e.g. "halbot" +export type Nick = string & { readonly [nickBrand]: 'Nick' }; +export function isValidNick(value: string): value is Nick; // /^[a-z][a-z0-9]{5}$/ + // 6-character base32 lowercase, e.g. "m4q7z2" export type SessionId = string & { readonly [sessionIdBrand]: 'SessionId' }; @@ -297,21 +309,6 @@ export interface PacingConfig { fill: { min: number; max: number }; } -export const PACING_PRESETS: Record = { - human: { - navigate: { min: 1500, max: 4000 }, - scroll: { min: 4000, max: 8000 }, - interaction: { min: 500, max: 2000 }, - fill: { min: 500, max: 2000 }, - }, - fast: { - navigate: { min: 300, max: 800 }, - scroll: { min: 500, max: 1500 }, - interaction: { min: 100, max: 400 }, - fill: { min: 100, max: 400 }, - }, -}; - export interface SessionInfo { id: SessionId; label?: string; @@ -391,6 +388,7 @@ export interface Heading { export interface TraceEntry { id: string; action: Action; + session?: string; // optional for backward compatibility with pre-ADR-030 entries tab: number; timestamp: number; elapsed: number; diff --git a/docs/public/views/04-session-state.md b/docs/public/views/04-session-state.md index 39717f2..d88f3b9 100644 --- a/docs/public/views/04-session-state.md +++ b/docs/public/views/04-session-state.md @@ -51,7 +51,7 @@ Figure 4. State machine the daemon maintains for each session — the four state The daemon is the only place this state lives. Nothing on the CLI side carries a "session is bound" flag — an agent cannot fabricate a bound session by sending different headers. Every transition above is a daemon-side mutation, and every session is forgotten when the daemon stops. Restarting the service clears all sessions; only the extension token survives across restarts. -Sessions are created **explicitly** — either via `session.create` (returns a fresh `SessionId`) or as a side-effect of `tab open --url ...` when `-s` is omitted (which auto-creates a session, opens a tab, and binds them together). Session ids are daemon-generated 6-character base32 strings matching `/^[a-z2-7]{6}$/`; agents cannot choose or reuse them. +Sessions are created **explicitly** — either via `session.create` (returns a fresh `SessionId`) or as a side-effect of `tab open --url ...` when `-s` is omitted (which auto-creates a session, opens a tab, and binds them together). Session ids are daemon-generated 6-character base32 strings matching `/^[a-z2-7]{6}$/`; agents cannot choose or reuse them. At creation time the daemon also stamps immutable ownership from the request `--nick` value. Session visibility and command authority are scoped to that nick for the session lifetime. `session.bind --tab tN` moves the session to `bound` by resolving a logical `TabHandle` (like `t1`) to the internal Chrome tab id. Only tabs registered to the same session can be bound. Calling `session.bind` again with a different tab (or just a new pacing setting) is the self-loop on `bound`: the very next forwarded action picks up the new target. @@ -59,6 +59,8 @@ The session moves to `paused` when the extension reports that the page needs hum `session.close` is a terminal transition from any state. It closes all Chrome tabs owned by the session (forwarding `tab.close` for each one), then destroys the session. The closed session id cannot be reused. +A session handle alone is therefore not sufficient authority: the daemon checks both the session id and the matching nick on every session-bound command. `session list`, `debug.status`, `debug.last`, and `debug.log` are all filtered to the requesting nick's live sessions. + ## Logical tab handles Tabs within a session are referenced by logical handles like `t1`, `t2`, etc. (`TabHandle` branded type). These are session-scoped: `t1` in session `m4q7z2` is a different tab than `t1` in session `p7k2qm`. Raw Chrome tab ids never appear in CLI output, protocol responses, or `debug.status`/`debug.last` data — they exist only in daemon-internal state and operator-level daemon logs. diff --git a/docs/public/views/06-threat-model.md b/docs/public/views/06-threat-model.md index 6216eef..7e2c715 100644 --- a/docs/public/views/06-threat-model.md +++ b/docs/public/views/06-threat-model.md @@ -84,12 +84,16 @@ Figure 5. Data-flow diagram with trust boundaries — the three dashed-red enclo | Tampering | Daemon token file is replaced by another user | Owner check rejects tokens whose `st.uid` differs from `process.getuid()` | `lifecycle.ts:assertOwnerMode600` | | Repudiation | "Did command X run? When? Through which client?" | Every lifecycle event carries request `id`: `received` → `pacing_wait?` → `forwarded` → `response` (or `timeout` / `replay`) | daemon observability suites | | Information disclosure | Daemon API exposed beyond localhost | Bind host fixed to `127.0.0.1`; Host header verified at the auth gate even if a proxy rewrites it | `config.ts`; `auth.ts:checkHost` | +| Information disclosure | One agent sharing `BPROXY_HOME` enumerates another agent's live sessions or debug surfaces | Required `--nick`; daemon filters `session.list`, `debug.status`, `debug.last`, and `debug.log` by live session ownership | ADR-030; `sessions.ts`; `debug-actions.ts`; `routes/command.ts` | +| Information disclosure | Persisted logs reveal raw agent nicknames | Daemon never persists raw nick; logs use short per-instance `ownerHash = sha256(instanceSalt + nick).slice(0, 8)` instead | ADR-030; `owner-hash.ts`; daemon logger wiring | | Information disclosure | Token leaks via insecure file mode | Read-side preflight on every token load fails closed with `INSECURE_TOKEN_FILE` / `INSECURE_EXTENSION_TOKEN_FILE` | `lifecycle.ts:assertOwnerMode600`; Gap E file-semantics tests | | Denial of service | Unbounded pending-request map | Hard cap of 100 in-flight requests → `OVERLOADED` | `pending.ts`; `pending.test.ts` | | Denial of service | Repeated read commands grow daemon memory without bound | Element-handle cache is bounded (TTL 120s, per-scope cap 200, global cap 1000) | `element-handles.ts`; handle-cache tests | | Denial of service | Head-of-line blocking across tabs | Per-tab FIFO queue, parallel across tabs | `dispatch.ts:withTabLock`; `dispatch.test.ts` | | Denial of service | Pairing-code brute force | One-time consumption + 5-min TTL + constant-time compare + `chrome-extension://` Origin gate + global failed-attempt throttle on `/pair/claim` (5 failures / 60s). The throttle is localhost-scoped best-effort, not per-source attribution or DDoS-grade protection. | `pairing.ts`; `pairing-rate-limit.ts` | +| Denial of service | Runaway agent loops hammer its own session | Per-nick `minInterval` floor, sliding-window rate cap, metronome detection, and jittered error-path delay throttle request ingress and error loops | ADR-030; `safety.ts`; `routes/command.ts` | | Elevation of privilege | Extension token grants command issuance | Two-token model: bearer auth only valid on `POST /`; subprotocol auth only valid on `GET /ws`; tokens never cross routes | `auth.ts:checkCommandAuth/checkWsAuth` | +| Elevation of privilege | Buggy agent commands another agent's live session after learning its id | Session ownership is stamped at creation and enforced on every session-bound command; mismatches fail with `SESSION_SCOPE_MISMATCH` | ADR-030; `sessions.ts`; `routes/session-actions.ts` | | Elevation of privilege | Pairing endpoint accepts CLI bearer | `POST /pair/claim` is body-auth only (pairing code) and requires `chrome-extension://` Origin | [service spec § Auth Gate](../solution/service.md#auth-gate) | ## Extension surface @@ -99,7 +103,7 @@ Figure 5. Data-flow diagram with trust boundaries — the three dashed-red enclo | Bootstrap token in extension storage | Long-lived WS auth material could leak through loose storage handling | Popup validates payload shape before write; bootstrap is stored as one atomic `chrome.storage.local` record; daemon still limits auth to localhost WS with subprotocol token | | Runtime content script | Page-visible extension presence or broad ambient listeners | No declarative `content_scripts`; runtime script is injected only on first command per tab; content host keeps one `chrome.runtime.onMessage` listener and no page-global hooks | | MAIN-world execution | Page learns about the extension or receives raw extension errors/stacks | MAIN world is one-shot only for narrow product actions such as `runtime-api` writes via `chrome.scripting.executeScript({ world: "MAIN" })`; injected functions catch/normalize errors and contain no identifying literals (ADR-013, ADR-015, ADR-024) | -| Trace / dedupe ring buffer | `debug.log` could expose stale or over-broad extension data | Trace is bounded in `chrome.storage.session`, queryable only through authenticated daemon forwarding, filtered by `id`/`limit`, and stamped with `extensionVersion` | +| Trace / dedupe ring buffer | `debug.log` could expose stale or over-broad extension data | Trace is bounded in `chrome.storage.session`, queryable only through authenticated daemon forwarding, stamped with `session` + `extensionVersion`, and daemon-filtered to the requesting nick's live sessions only | | 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) | diff --git a/extension/package.json b/extension/package.json index 7a455f3..3e8b5dc 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/extension", - "version": "0.7.5", + "version": "0.8.0", "private": true, "type": "module", "scripts": { diff --git a/extension/scripts/smoke/command.ts b/extension/scripts/smoke/command.ts index fc942f5..49279e6 100644 --- a/extension/scripts/smoke/command.ts +++ b/extension/scripts/smoke/command.ts @@ -3,7 +3,7 @@ import type { Action, ActionParams } from "@bproxy/shared"; import { parseJsonObject, printJson, sendCommand, trimPnpmDoubleDash } from "./common.ts"; const usageLines = [ - "usage: smoke:command [--home ] [--session ] [--id ] [--timeout ] [--destructive] [--raw] [params-json]", + "usage: smoke:command [--home ] [--nick ] [--session ] [--id ] [--timeout ] [--destructive] [--raw] [params-json]", "example: smoke:command --home ./.tmp/bproxy-smoke-demo debug.status", 'example: smoke:command --home ./.tmp/bproxy-smoke-demo --session m4q7z2 text \'{"selector":"main"}\'', ] as const; @@ -13,6 +13,7 @@ const { values, positionals } = parseArgs({ allowPositionals: true, options: { home: { type: "string" }, + nick: { type: "string", default: "smoke1" }, session: { type: "string" }, id: { type: "string" }, timeout: { type: "string", default: "30000" }, @@ -35,6 +36,7 @@ const action = actionInput as Action; const params = (paramsInput ? parseJsonObject(paramsInput, "params") : {}) as ActionParams[Action]; const result = await sendCommand(action, params, { home: values.home, + nick: values.nick, session: values.session, id: values.id, timeoutMs, diff --git a/extension/scripts/smoke/common.ts b/extension/scripts/smoke/common.ts index 428ca3a..9595074 100644 --- a/extension/scripts/smoke/common.ts +++ b/extension/scripts/smoke/common.ts @@ -26,6 +26,7 @@ const DESTRUCTIVE_ACTIONS = new Set([ export interface SendCommandOptions { home?: string; + nick?: string; session?: string; id?: string; timeoutMs?: number; @@ -88,6 +89,7 @@ export function buildRequest( protocol_version: 1, id: options.id ?? randomUUID(), action, + nick: (options.nick ?? "smoke1") as BproxyRequest["nick"], params, session: (options.session ?? "") as BproxyRequest["session"], deadline: Date.now() + (options.timeoutMs ?? 30_000), diff --git a/extension/scripts/smoke/workflow.ts b/extension/scripts/smoke/workflow.ts index c4c0f77..8b2c132 100644 --- a/extension/scripts/smoke/workflow.ts +++ b/extension/scripts/smoke/workflow.ts @@ -11,6 +11,7 @@ const { values } = parseArgs({ navigateUrl: { type: "string" }, searchSelector: { type: "string", default: "#search" }, linkLimit: { type: "string", default: "10" }, + nick: { type: "string", default: "smoke1" }, waitForWs: { type: "string", default: "30000" }, }, }); @@ -24,9 +25,10 @@ if (!openUrl || !navigateUrl) { throw new Error("Provide --baseUrl or both --openUrl and --navigateUrl."); } -const wsStatus = await waitForWsClient({ home: values.home, timeoutMs: waitForWsMs }); +const baseSmokeOptions = { home: values.home, nick: values.nick }; +const wsStatus = await waitForWsClient({ ...baseSmokeOptions, timeoutMs: waitForWsMs }); -const open = await sendCommand("tab.open", { url: openUrl }, { home: values.home }); +const open = await sendCommand("tab.open", { url: openUrl }, baseSmokeOptions); const openBody = expectOk(open, "tab.open"); if (!/^[a-z2-7]{6}$/.test(openBody.data.session)) { throw new Error(`tab.open returned invalid session id: ${openBody.data.session}`); @@ -40,7 +42,7 @@ if (!openBody.data.bound) { } const session = openBody.data.session; -const baseOptions = { home: values.home, session }; +const baseOptions = { ...baseSmokeOptions, session }; const text = await sendCommand("text", { selector: "main" }, baseOptions); const textBody = expectOk(text, "text(main)"); diff --git a/extension/src/background/__tests__/dispatcher.test.ts b/extension/src/background/__tests__/dispatcher.test.ts index a6900d0..4947cb2 100644 --- a/extension/src/background/__tests__/dispatcher.test.ts +++ b/extension/src/background/__tests__/dispatcher.test.ts @@ -139,6 +139,20 @@ describe("parseForwardedRequest", () => { ); expect(parsed).toMatchObject({ success: false, id: "req-1" }); }); + + it("rejects forwarded requests that still include nick", () => { + const parsed = parseForwardedRequest( + JSON.stringify({ + ...makeRequest({ action: "text", params: { selector: "main" } }), + nick: "halbot", + }), + ); + expect(parsed).toMatchObject({ + success: false, + id: "req-1", + error: "unexpected top-level keys", + }); + }); }); describe("dispatcher", () => { @@ -181,8 +195,18 @@ describe("dispatcher", () => { expect(h.responses[1]).toMatchObject({ ok: true, replay: true, id: request.id }); const entries = await h.trace.query({ id: request.id }); expect(entries).toHaveLength(2); - expect(entries[0]).toMatchObject({ action: "navigate", replay: false, result: "ok" }); - expect(entries[1]).toMatchObject({ action: "navigate", replay: true, result: "ok" }); + expect(entries[0]).toMatchObject({ + action: "navigate", + session: request.session, + replay: false, + result: "ok", + }); + expect(entries[1]).toMatchObject({ + action: "navigate", + session: request.session, + replay: true, + result: "ok", + }); }); it("routes DOM actions through the content handler and dedupes non-destructive reads", async () => { diff --git a/extension/src/background/__tests__/trace.test.ts b/extension/src/background/__tests__/trace.test.ts index 8418e9a..e281e29 100644 --- a/extension/src/background/__tests__/trace.test.ts +++ b/extension/src/background/__tests__/trace.test.ts @@ -23,11 +23,12 @@ describe("trace ring buffer", () => { const store = createFakeStorageItem("session:trace", []); const trace = createTrace({ store, maxSize: 10, extensionVersion: () => "0.1.0" }); - await trace.append(entry("a")); + await trace.append(entry("a", { session: "m4q8z2" })); const all = await trace.query(); expect(all).toHaveLength(1); expect(all[0]?.extensionVersion).toBe("0.1.0"); + expect(all[0]?.session).toBe("m4q8z2"); }); it("evicts the oldest entry when appending beyond capacity", async () => { diff --git a/extension/src/background/dispatcher.ts b/extension/src/background/dispatcher.ts index 0ba9488..3a7033f 100644 --- a/extension/src/background/dispatcher.ts +++ b/extension/src/background/dispatcher.ts @@ -135,6 +135,7 @@ async function appendTrace( await deps.trace.append({ id: request.id, action: request.action, + session: request.session, tab: request.target.tabId ?? 0, timestamp: startedAt, elapsed: Math.max(0, endedAt - startedAt), diff --git a/package.json b/package.json index 9dfd830..bed3206 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "bproxy", "private": true, "type": "module", - "version": "0.7.5", + "version": "0.8.0", "description": "Browser proxy for code agents.", "license": "MIT", "packageManager": "pnpm@9.15.0", diff --git a/service/package.json b/service/package.json index 6b700be..938a431 100644 --- a/service/package.json +++ b/service/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/service", - "version": "0.7.5", + "version": "0.8.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 0b04998..4fb4d5e 100644 --- a/service/src/__tests__/action-contract.test.ts +++ b/service/src/__tests__/action-contract.test.ts @@ -4,6 +4,7 @@ import type { BuiltServer } from "../server"; import { connectWsClient, setupTestServer, + TEST_NICK, type TestServerContext, teardownTestServer, } from "./helpers/integration"; @@ -54,6 +55,7 @@ function makeCmd(action: Action, overrides: Partial = {}): Bproxy id: overrides.id ?? `01HZX${crypto.randomUUID().replaceAll("-", "").slice(0, 21).toUpperCase()}`, action, + nick: overrides.nick ?? TEST_NICK, params: paramsFor(action), session: overrides.session ?? currentSession, deadline: Date.now() + 5000, diff --git a/service/src/__tests__/auth-ordering.test.ts b/service/src/__tests__/auth-ordering.test.ts index f3b94b4..56b887b 100644 --- a/service/src/__tests__/auth-ordering.test.ts +++ b/service/src/__tests__/auth-ordering.test.ts @@ -5,6 +5,7 @@ import type { BuiltServer } from "../server"; import { connectWsClient, setupTestServer, + TEST_NICK, type TestServerContext, teardownTestServer, } from "./helpers/integration"; @@ -22,6 +23,7 @@ function makeCmd(overrides: Partial = {}): BproxyRequest { protocol_version: 1, id: overrides.id ?? `auth-test-${crypto.randomUUID().slice(0, 8)}`, action: overrides.action ?? "text", + nick: overrides.nick ?? TEST_NICK, params: overrides.params ?? {}, session: overrides.session ?? DEFAULT_SESSION, deadline: Date.now() + 5000, @@ -203,7 +205,7 @@ describe("auth ordering — GAP C", () => { }); it("session state is not modified when auth fails", async () => { - const sessionId = built.sessions.create().id; + const sessionId = built.sessions.create(TEST_NICK).id; built.sessions.pause(sessionId, "captcha"); const sessionBefore = built.sessions.internal(sessionId); const beforePaused = sessionBefore.paused; diff --git a/service/src/__tests__/daemon-config.test.ts b/service/src/__tests__/daemon-config.test.ts new file mode 100644 index 0000000..d4bfe57 --- /dev/null +++ b/service/src/__tests__/daemon-config.test.ts @@ -0,0 +1,85 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadConfig } from "../config"; +import { configFilePath, DEFAULT_DAEMON_CONFIG, loadDaemonConfig } from "../daemon-config"; +import { createTestStateDir, removeTestStateDir } from "./helpers/test-state-dir"; + +const homes: string[] = []; + +function createHome(): string { + const home = createTestStateDir("daemon-config-"); + homes.push(home); + return home; +} + +function writeConfig(home: string, config: unknown): void { + writeFileSync(configFilePath(home), JSON.stringify(config, null, 2)); +} + +afterEach(() => { + while (homes.length > 0) { + removeTestStateDir(homes.pop()!); + } +}); + +describe("daemon config", () => { + it("uses defaults when config.json is missing", () => { + const home = createHome(); + expect(loadDaemonConfig(home)).toEqual(DEFAULT_DAEMON_CONFIG); + }); + + it("loads config.json from BPROXY_HOME", () => { + const home = createHome(); + const custom = { + ...DEFAULT_DAEMON_CONFIG, + pacing: { + ...DEFAULT_DAEMON_CONFIG.pacing, + fast: { + ...DEFAULT_DAEMON_CONFIG.pacing.fast, + navigate: { min: 1000, max: 1100 }, + }, + }, + safety: { + ...DEFAULT_DAEMON_CONFIG.safety, + rateCap: { requestsPerMinute: 42 }, + }, + }; + writeConfig(home, custom); + + const loaded = loadConfig({ BPROXY_HOME: home }); + expect(loaded.daemon).toEqual(custom); + }); + + it("rejects unknown keys", () => { + const home = createHome(); + writeConfig(home, { ...DEFAULT_DAEMON_CONFIG, extra: true }); + + expect(() => loadDaemonConfig(home)).toThrow(/extra/); + }); + + it("rejects pacing ranges that undercut the minInterval floor", () => { + const home = createHome(); + const invalid = { + ...DEFAULT_DAEMON_CONFIG, + pacing: { + ...DEFAULT_DAEMON_CONFIG.pacing, + fast: { + ...DEFAULT_DAEMON_CONFIG.pacing.fast, + fill: { min: 800, max: 1200 }, + }, + }, + }; + writeConfig(home, invalid); + + expect(() => loadDaemonConfig(home)).toThrow(/pacing\.fast\.fill\.min/); + }); + + it("rejects invalid JSON with the config path in the error", () => { + const home = createHome(); + const path = join(home, "config.json"); + writeFileSync(path, "{not json"); + + expect(() => loadDaemonConfig(home)).toThrow(new RegExp(path.replaceAll(".", String.raw`\.`))); + }); +}); diff --git a/service/src/__tests__/deadline-envelope.test.ts b/service/src/__tests__/deadline-envelope.test.ts index dcc56a0..f6aab98 100644 --- a/service/src/__tests__/deadline-envelope.test.ts +++ b/service/src/__tests__/deadline-envelope.test.ts @@ -11,6 +11,7 @@ import type { BuiltServer } from "../server"; import { connectWsClient, setupTestServer, + TEST_NICK, type TestServerContext, teardownTestServer, } from "./helpers/integration"; @@ -36,6 +37,7 @@ function makeCmd(overrides: Partial = {}): BproxyRequest { protocol_version: 1, id: overrides.id ?? nextCommandId(), action: overrides.action ?? "text", + nick: overrides.nick ?? TEST_NICK, params: overrides.params ?? {}, session: overrides.session ?? currentSession, deadline: overrides.deadline ?? Date.now() + 5000, @@ -199,7 +201,7 @@ describe("BproxyError envelope completeness", () => { }); it("TAB_NOT_IN_SESSION — tab from another session", async () => { - const otherSession = built.sessions.create().id; + const otherSession = built.sessions.create(TEST_NICK).id; built.sessions.registerTab(otherSession, 999); const ws = await connectWsClient(port, extensionToken); const cmd = makeCmd({ diff --git a/service/src/__tests__/dispatch.test.ts b/service/src/__tests__/dispatch.test.ts index 94a6262..c723178 100644 --- a/service/src/__tests__/dispatch.test.ts +++ b/service/src/__tests__/dispatch.test.ts @@ -15,6 +15,7 @@ function req(id: string, session = DEFAULT_SESSION): BproxyRequest { protocol_version: 1, id, action: "text", + nick: "halbot" as BproxyRequest["nick"], params: {}, session, deadline: Date.now() + 5000, @@ -36,7 +37,7 @@ function ok(id: string): BproxyResponse { function createSeededRegistry(...sessionIds: BproxyRequest["session"][]) { const sessions = createSessionRegistry(); for (const sessionId of sessionIds) { - sessions.getOrCreate(sessionId); + sessions.getOrCreate(sessionId, "halbot"); } return sessions; } diff --git a/service/src/__tests__/helpers/integration.ts b/service/src/__tests__/helpers/integration.ts index f98ab6a..ebfb52f 100644 --- a/service/src/__tests__/helpers/integration.ts +++ b/service/src/__tests__/helpers/integration.ts @@ -2,9 +2,10 @@ * Shared integration test utilities for service route tests. * * Extracted to eliminate Sonar-flagged duplication (connectClient, waitUntil, - * server lifecycle) across action-contract, round-trip, observability, etc. + * server lifecycle, makeCmd, postCommand) across action-contract, round-trip, + * observability, nick-scoping, safety-ordering, etc. */ -import type { SessionId } from "@bproxy/shared"; +import type { BproxyRequest, BproxyResponse, Nick, SessionId } from "@bproxy/shared"; import WebSocket from "ws"; import { buildCapturedLogger, type CapturedLogger } from "../../logger"; import { type BuildServerOptions, type BuiltServer, buildServer } from "../../server"; @@ -33,6 +34,8 @@ export function waitUntil(fn: () => boolean, timeoutMs = 2000): Promise { }); } +export const TEST_NICK = "halbot" as Nick; + export interface TestServerContext { built: BuiltServer; stateDir: string; @@ -47,17 +50,26 @@ export async function setupTestServer( const stateDir = createTestStateDir(); const captured = buildCapturedLogger(); const { daemonToken, extensionToken, ...serverOpts } = opts; + let safetyTick = 0; + let safetyCalls = 0; const built = await buildServer({ port: 0, stateDir, daemonToken, extensionToken, logger: captured.logger, + safetyNow: () => { + safetyCalls += 1; + safetyTick += 1000 + (safetyCalls % 3) * 137; + return safetyTick; + }, + safetySleep: async () => {}, + safetyRandom: () => 0, ...serverOpts, }); const addr = await built.app.listen({ host: "127.0.0.1", port: 0 }); const port = Number.parseInt(addr.split(":").pop() ?? "0", 10); - const currentSession = built.sessions.create().id; + const currentSession = built.sessions.create(TEST_NICK).id; return { built, stateDir, port, captured, currentSession }; } @@ -65,3 +77,49 @@ export async function teardownTestServer(ctx: TestServerContext): Promise await ctx.built.app.close(); removeTestStateDir(ctx.stateDir); } + +// ─── Shared request helpers ──────────────────────────────────────────── + +export interface MakeCmdOptions { + idPrefix?: string; + defaultAction?: BproxyRequest["action"]; + defaultSession: () => BproxyRequest["session"]; +} + +export function makeCmd( + opts: MakeCmdOptions, + overrides: Partial = {}, +): BproxyRequest { + const defaults: BproxyRequest = { + protocol_version: 1, + id: `${opts.idPrefix ?? "test"}-${crypto.randomUUID().slice(0, 8)}`, + action: opts.defaultAction ?? "session.list", + nick: TEST_NICK, + params: {}, + session: opts.defaultSession(), + deadline: Date.now() + 5000, + destructive: false, + }; + return { ...defaults, ...overrides }; +} + +export async function postCommand( + port: number, + token: string, + cmd: BproxyRequest, +): Promise { + const res = await fetch(`http://127.0.0.1:${port}/`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify(cmd), + }); + return (await res.json()) as BproxyResponse; +} + +export async function postRaw(port: number, token: string, cmd: BproxyRequest): Promise { + return fetch(`http://127.0.0.1:${port}/`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify(cmd), + }); +} diff --git a/service/src/__tests__/lifecycle.test.ts b/service/src/__tests__/lifecycle.test.ts index 588c133..f5fb23f 100644 --- a/service/src/__tests__/lifecycle.test.ts +++ b/service/src/__tests__/lifecycle.test.ts @@ -3,6 +3,7 @@ import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "no import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_DAEMON_CONFIG } from "../daemon-config"; import { readExtensionToken, writeExtensionToken, writeToken } from "../lifecycle"; import { createTestStateDir } from "./helpers/test-state-dir"; @@ -81,6 +82,31 @@ describe("lifecycle smoke", () => { const parsed = JSON.parse(out.stdout) as { running: boolean }; expect(parsed.running).toBe(false); }); + + it("start fails with a meaningful config error when pacing undercuts minInterval", () => { + expect(existsSync(BIN)).toBe(true); + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + ...DEFAULT_DAEMON_CONFIG, + pacing: { + ...DEFAULT_DAEMON_CONFIG.pacing, + fast: { + ...DEFAULT_DAEMON_CONFIG.pacing.fast, + interaction: { min: 850, max: 1200 }, + }, + }, + }), + ); + const out = spawnSync(process.execPath, [BIN, "start"], { + env: { ...process.env, BPROXY_HOME: home }, + encoding: "utf8", + }); + expect(out.status).toBe(1); + expect(out.stderr).toContain("config.json"); + expect(out.stderr).toContain("pacing.fast.interaction.min"); + expect(existsSync(join(home, "bproxy.pid"))).toBe(false); + }); }); describe("token-file security (auth-gate invariant)", () => { diff --git a/service/src/__tests__/logger.test.ts b/service/src/__tests__/logger.test.ts index 5d8eb97..52de3d2 100644 --- a/service/src/__tests__/logger.test.ts +++ b/service/src/__tests__/logger.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { loadConfig } from "../config"; +import { loadBaseConfig } from "../config"; -describe("loadConfig", () => { +describe("loadBaseConfig", () => { it("uses defaults when env is empty", () => { - const config = loadConfig({}); + const config = loadBaseConfig({}); expect(config.port).toBe(9615); expect(config.host).toBe("127.0.0.1"); expect(config.logLevel).toBe("info"); @@ -11,17 +11,20 @@ describe("loadConfig", () => { }); it("honours BPROXY_PORT and BPROXY_HOME", () => { - const config = loadConfig({ BPROXY_PORT: "12345", BPROXY_HOME: "/home/testuser/.bproxy-alt" }); + const config = loadBaseConfig({ + BPROXY_PORT: "12345", + BPROXY_HOME: "/home/testuser/.bproxy-alt", + }); expect(config.port).toBe(12345); expect(config.stateDir).toBe("/home/testuser/.bproxy-alt"); }); it("falls back to default port for invalid BPROXY_PORT", () => { - expect(loadConfig({ BPROXY_PORT: "garbage" }).port).toBe(9615); - expect(loadConfig({ BPROXY_PORT: "-1" }).port).toBe(9615); + expect(loadBaseConfig({ BPROXY_PORT: "garbage" }).port).toBe(9615); + expect(loadBaseConfig({ BPROXY_PORT: "-1" }).port).toBe(9615); }); it("rejects unknown log level", () => { - expect(loadConfig({ BPROXY_LOG_LEVEL: "shout" }).logLevel).toBe("info"); + expect(loadBaseConfig({ BPROXY_LOG_LEVEL: "shout" }).logLevel).toBe("info"); }); }); diff --git a/service/src/__tests__/nick-scoping.test.ts b/service/src/__tests__/nick-scoping.test.ts new file mode 100644 index 0000000..64731da --- /dev/null +++ b/service/src/__tests__/nick-scoping.test.ts @@ -0,0 +1,255 @@ +import type { BproxyRequest, BproxyResponse, TabHandle } from "@bproxy/shared"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { CapturedLogger } from "../logger"; +import { computeOwnerHash } from "../owner-hash"; +import type { BuiltServer } from "../server"; +import { + connectWsClient, + type MakeCmdOptions, + makeCmd as makeTestCmd, + postCommand as post, + setupTestServer, + TEST_NICK, + type TestServerContext, + teardownTestServer, +} from "./helpers/integration"; + +const daemonToken = "test-nick-token"; +const extensionToken = "test-ext-token"; +const OTHER_NICK = "bobcat" as BproxyRequest["nick"]; +const T1 = "t1" as TabHandle; +const SALT = new Uint8Array(32).fill(7); + +let ctx: TestServerContext; +let built: BuiltServer; +let port: number; +let captured: CapturedLogger; +let currentSession: BproxyRequest["session"]; +let cmdOpts: MakeCmdOptions; + +function cmd(overrides: Partial = {}): BproxyRequest { + return makeTestCmd(cmdOpts, overrides); +} + +async function postCommand(request: BproxyRequest): Promise { + return post(port, daemonToken, request); +} + +beforeEach(async () => { + ctx = await setupTestServer({ daemonToken, extensionToken, instanceSalt: SALT }); + ({ built, port, captured, currentSession } = ctx); + cmdOpts = { idPrefix: "nick", defaultSession: () => currentSession }; +}); + +afterEach(async () => { + await teardownTestServer(ctx); +}); + +describe("nick scoping", () => { + it("filters session.list and debug.status by owner nick", async () => { + const halbotSession2 = built.sessions.create(TEST_NICK).id; + const bobcatSession = built.sessions.create(OTHER_NICK).id; + built.sessions.registerTab(currentSession, 42, { bind: true, url: "https://own.test/1" }); + built.sessions.registerTab(halbotSession2, 43, { bind: true, url: "https://own.test/2" }); + built.sessions.registerTab(bobcatSession, 99, { bind: true, url: "https://other.test/" }); + built.sessions.pause(currentSession, "captcha-own"); + built.sessions.pause(bobcatSession, "captcha-other"); + + const listed = (await postCommand( + cmd({ action: "session.list", params: {} }), + )) as BproxyResponse<"session.list">; + expect(listed.ok).toBe(true); + if (!listed.ok) return; + const cmp = (a: string, b: string) => a.localeCompare(b); + expect(listed.data.sessions.map((session) => session.id).sort(cmp)).toEqual( + [currentSession, halbotSession2].sort(cmp), + ); + + const status = (await postCommand( + cmd({ action: "debug.status", params: {} }), + )) as BproxyResponse<"debug.status">; + expect(status.ok).toBe(true); + if (!status.ok) return; + expect(status.data.sessions.map((session) => session.id).sort(cmp)).toEqual( + [currentSession, halbotSession2].sort(cmp), + ); + expect(status.data.sessionTabs.map((entry) => entry.session).sort(cmp)).toEqual( + [currentSession, halbotSession2].sort(cmp), + ); + expect(status.data.pausedSessions).toEqual([ + { session: currentSession, reason: "captcha-own" }, + ]); + }); + + it("returns SESSION_SCOPE_MISMATCH for a foreign live session", async () => { + const foreignSession = built.sessions.create(OTHER_NICK).id; + const response = await postCommand( + cmd({ action: "text", session: foreignSession, nick: TEST_NICK }), + ); + expect(response).toMatchObject({ ok: false, error: { code: "SESSION_SCOPE_MISMATCH" } }); + if (!response.ok) { + expect(response.error.retry).toBe("never"); + expect(response.error.suggestedAction).toContain("--nick"); + } + }); + + it("filters debug.last to live sessions owned by the requesting nick", async () => { + const foreignSession = built.sessions.create(OTHER_NICK).id; + built.sessions.registerTab(currentSession, 42); + built.sessions.registerTab(foreignSession, 99); + + const ownBind = await postCommand( + cmd({ id: "trace-own", action: "session.bind", params: { tab: T1 } }), + ); + expect(ownBind.ok).toBe(true); + + const foreignBind = await postCommand( + cmd({ + id: "trace-foreign", + action: "session.bind", + nick: OTHER_NICK, + session: foreignSession, + params: { tab: T1 }, + }), + ); + expect(foreignBind.ok).toBe(true); + + const live = (await postCommand( + cmd({ action: "debug.last", params: { count: 10 } }), + )) as BproxyResponse<"debug.last">; + expect(live.ok).toBe(true); + if (!live.ok) return; + expect(live.data.requests.map((trace) => trace.id)).toContain("trace-own"); + expect(live.data.requests.map((trace) => trace.id)).not.toContain("trace-foreign"); + + built.sessions.close(currentSession); + const afterClose = (await postCommand( + cmd({ action: "debug.last", params: { count: 10 } }), + )) as BproxyResponse<"debug.last">; + expect(afterClose.ok).toBe(true); + if (!afterClose.ok) return; + expect(afterClose.data.requests.map((trace) => trace.id)).not.toContain("trace-own"); + }); + + it("filters debug.log by live session owner and excludes entries without session metadata", async () => { + const foreignSession = built.sessions.create(OTHER_NICK).id; + const closedSession = built.sessions.create(TEST_NICK).id; + built.sessions.close(closedSession); + built.sessions.bind(currentSession, 42); + const ws = await connectWsClient(port, extensionToken); + + ws.on("message", (raw: unknown) => { + const req = JSON.parse(String(raw)) as BproxyRequest; + if (req.action !== "debug.log") return; + ws.send( + JSON.stringify({ + protocol_version: 1, + id: req.id, + ok: true, + data: { + entries: [ + { + id: "own", + action: "text", + session: currentSession, + tab: 42, + timestamp: 1, + elapsed: 2, + result: "ok", + replay: false, + extensionVersion: "1", + }, + { + id: "foreign", + action: "text", + session: foreignSession, + tab: 99, + timestamp: 1, + elapsed: 2, + result: "ok", + replay: false, + extensionVersion: "1", + }, + { + id: "closed", + action: "text", + session: closedSession, + tab: 77, + timestamp: 1, + elapsed: 2, + result: "ok", + replay: false, + extensionVersion: "1", + }, + { + id: "legacy", + action: "text", + tab: 42, + timestamp: 1, + elapsed: 2, + result: "ok", + replay: false, + extensionVersion: "1", + }, + ], + }, + page: { url: "", title: "", state: "ready", busy: false }, + replay: false, + }), + ); + }); + + const response = (await postCommand( + cmd({ action: "debug.log", params: {}, destructive: false }), + )) as BproxyResponse<"debug.log">; + expect(response.ok).toBe(true); + if (!response.ok) return; + expect(response.data.entries.map((entry) => entry.id)).toEqual(["own"]); + ws.close(); + }); + + it("returns ownerHash in bootstrap responses, strips nick from WS messages, and logs ownerHash only", async () => { + const expectedHash = computeOwnerHash(SALT, TEST_NICK); + + captured.clear(); + const created = (await postCommand( + cmd({ action: "session.create", params: { label: "research" } }), + )) as BproxyResponse<"session.create">; + expect(created.ok).toBe(true); + if (created.ok) { + expect(created.data.ownerHash).toBe(expectedHash); + } + const sessionLogs = captured.lines.filter((line) => line["id"] === created.id); + for (const line of sessionLogs) { + expect(line["ownerHash"]).toBe(expectedHash); + expect(Object.hasOwn(line, "nick")).toBe(false); + } + + const ws = await connectWsClient(port, extensionToken); + let forwardedHasNick = true; + ws.on("message", (raw: unknown) => { + const req = JSON.parse(String(raw)) as Record; + forwardedHasNick = Object.hasOwn(req, "nick"); + ws.send( + JSON.stringify({ + protocol_version: 1, + id: req["id"], + ok: true, + data: { tabId: 42, url: "https://example.com" }, + page: { url: "https://example.com", title: "Example", state: "ready", busy: false }, + replay: false, + }), + ); + }); + + const opened = (await postCommand( + cmd({ action: "tab.open", params: { url: "https://example.com" }, session: "" as never }), + )) as BproxyResponse<"tab.open">; + expect(opened.ok).toBe(true); + if (opened.ok) { + expect(opened.data.ownerHash).toBe(expectedHash); + } + expect(forwardedHasNick).toBe(false); + ws.close(); + }); +}); diff --git a/service/src/__tests__/observability-contract.test.ts b/service/src/__tests__/observability-contract.test.ts index f11d68b..2ff813d 100644 --- a/service/src/__tests__/observability-contract.test.ts +++ b/service/src/__tests__/observability-contract.test.ts @@ -5,6 +5,9 @@ import type { CapturedLogger } from "../logger"; import type { BuiltServer } from "../server"; import { connectWsClient, + type MakeCmdOptions, + makeCmd as makeTestCmd, + postRaw, setupTestServer, type TestServerContext, teardownTestServer, @@ -20,26 +23,18 @@ let port: number; let captured: CapturedLogger; let currentSession: BproxyRequest["session"]; const T1 = "t1" as TabHandle; - -function makeCmd(overrides: Partial = {}): BproxyRequest { - return { - protocol_version: 1, - id: overrides.id ?? `obs-${crypto.randomUUID().slice(0, 8)}`, - action: overrides.action ?? "text", - params: overrides.params ?? {}, - session: overrides.session ?? currentSession, - deadline: overrides.deadline ?? Date.now() + 5000, - destructive: false, - ...overrides, - }; +const cmdOpts: MakeCmdOptions = { + idPrefix: "obs", + defaultAction: "text", + defaultSession: () => currentSession, +}; + +function buildCmd(overrides: Partial = {}): BproxyRequest { + return makeTestCmd(cmdOpts, overrides); } -async function postCommand(cmd: BproxyRequest, token = daemonToken): Promise { - return fetch(`http://127.0.0.1:${port}/`, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, - body: JSON.stringify(cmd), - }); +async function postCommand(request: BproxyRequest, token = daemonToken): Promise { + return postRaw(port, token, request); } beforeEach(async () => { @@ -71,7 +66,7 @@ describe("observability contract — GAP D", () => { ws.send(JSON.stringify(resp)); }); - const cmd = makeCmd({ id: "obs-seq-test-1", action: "text" }); + const cmd = buildCmd({ id: "obs-seq-test-1", action: "text" }); await postCommand(cmd); const events = captured.lines.filter((line) => line["id"] === cmd.id); @@ -119,11 +114,11 @@ describe("observability contract — GAP D", () => { }); await postCommand( - makeCmd({ id: "obs-pacing-1", action: "navigate", params: { url: "https://a.com" } }), + buildCmd({ id: "obs-pacing-1", action: "navigate", params: { url: "https://a.com" } }), ); captured.clear(); - const cmd2 = makeCmd({ + const cmd2 = buildCmd({ id: "obs-pacing-2", action: "navigate", params: { url: "https://b.com" }, @@ -142,7 +137,7 @@ describe("observability contract — GAP D", () => { it("emits response with error_code when forward fails", async () => { captured.clear(); const ws = await connectWsClient(port, extensionToken); - const cmd = makeCmd({ id: "obs-err-1", action: "text" }); + const cmd = buildCmd({ id: "obs-err-1", action: "text" }); await postCommand(cmd); const events = captured.lines.filter((line) => line["id"] === cmd.id); @@ -163,7 +158,7 @@ describe("observability contract — GAP D", () => { // Intentionally hang. }); - const cmd = makeCmd({ id: "obs-timeout-1", action: "text", deadline: Date.now() + 500 }); + const cmd = buildCmd({ id: "obs-timeout-1", action: "text", deadline: Date.now() + 500 }); await postCommand(cmd); await waitUntil(() => { @@ -189,7 +184,7 @@ describe("observability contract — GAP D", () => { ws.once("message", (raw: unknown) => resolve(JSON.parse(String(raw)) as BproxyRequest)); }); - const cmd = makeCmd({ id: "obs-replay-1", action: "text", deadline: Date.now() + 10000 }); + const cmd = buildCmd({ id: "obs-replay-1", action: "text", deadline: Date.now() + 10000 }); const postPromise = postCommand(cmd); await seenByClient1; ws.close(); @@ -239,7 +234,7 @@ describe("observability contract — GAP D", () => { captured.clear(); built.sessions.registerTab(currentSession, 42); - const cmd = makeCmd({ + const cmd = buildCmd({ id: "obs-config-1", action: "session.bind", params: { tab: T1, pacing: "fast" }, @@ -286,7 +281,7 @@ describe("observability contract — GAP D", () => { describe("error_code field presence", () => { it("includes error_code in response event on failure", async () => { captured.clear(); - const cmd = makeCmd({ id: "obs-errcode-1", action: "text" }); + const cmd = buildCmd({ id: "obs-errcode-1", action: "text" }); await postCommand(cmd); const events = captured.lines.filter((line) => line["id"] === cmd.id); @@ -296,7 +291,7 @@ describe("observability contract — GAP D", () => { it("omits error_code when response is successful", async () => { captured.clear(); - const cmd = makeCmd({ id: "obs-success-1", action: "debug.status" }); + const cmd = buildCmd({ id: "obs-success-1", action: "debug.status" }); await postCommand(cmd); const events = captured.lines.filter((line) => line["id"] === cmd.id); diff --git a/service/src/__tests__/pacing.test.ts b/service/src/__tests__/pacing.test.ts index 547226b..60aa426 100644 --- a/service/src/__tests__/pacing.test.ts +++ b/service/src/__tests__/pacing.test.ts @@ -1,6 +1,6 @@ import type { BproxyRequest } from "@bproxy/shared"; -import { PACING_PRESETS } from "@bproxy/shared"; import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_DAEMON_CONFIG } from "../daemon-config"; import { createPacing } from "../pacing"; import { createSessionRegistry } from "../sessions"; @@ -15,6 +15,7 @@ function createTestHarness(opts: { random?: () => number; session?: string } = { sessions.getOrCreate(sid); const pacing = createPacing({ sessions, + config: DEFAULT_DAEMON_CONFIG.pacing, now: () => clock, sleep: async (ms) => { sleeps.push(ms); @@ -63,7 +64,7 @@ describe("pacing engine", () => { await h.pacing.waitForSlot(SESSION, "navigate"); await h.pacing.waitForSlot(SESSION, "navigate"); expect(h.sleeps.length).toBe(1); - const { min, max } = PACING_PRESETS.human.navigate; + const { min, max } = DEFAULT_DAEMON_CONFIG.pacing.human.navigate; expect(h.sleeps[0]).toBeGreaterThanOrEqual(min - 1); expect(h.sleeps[0]).toBeLessThanOrEqual(max); }); @@ -74,13 +75,14 @@ describe("pacing engine", () => { await h.pacing.waitForSlot(SESSION, "click"); h.advance(10); await h.pacing.waitForSlot(SESSION, "hover"); - expect(h.sleeps).toEqual([1250 - 10]); + expect(h.sleeps).toEqual([1850 - 10]); }); it("passes through unpaced actions immediately", async () => { const sleep = vi.fn(); const pacing = createPacing({ sessions: createSessionRegistry(), + config: DEFAULT_DAEMON_CONFIG.pacing, now: () => 0, sleep, random: () => 0, @@ -97,7 +99,7 @@ describe("pacing engine", () => { await h.pacing.waitForSlot(FAST_SESSION, "fill"); h.advance(10); await h.pacing.waitForSlot(FAST_SESSION, "fill"); - expect(h.sleeps).toEqual([250 - 10]); + expect(h.sleeps).toEqual([1050 - 10]); }); it("never sleeps when elapsed already exceeds the configured delay", async () => { diff --git a/service/src/__tests__/round-trip.test.ts b/service/src/__tests__/round-trip.test.ts index 0ea3402..7a6275f 100644 --- a/service/src/__tests__/round-trip.test.ts +++ b/service/src/__tests__/round-trip.test.ts @@ -6,6 +6,7 @@ import type { BuiltServer } from "../server"; import { connectWsClient, setupTestServer, + TEST_NICK, type TestServerContext, teardownTestServer, waitUntil, @@ -26,6 +27,7 @@ function makeCmd(overrides: Partial = {}): BproxyRequest { id: overrides.id ?? `01HZX${crypto.randomUUID().replaceAll("-", "").slice(0, 21).toUpperCase()}`, action: "text", + nick: overrides.nick ?? TEST_NICK, params: {}, session: currentSession, deadline: Date.now() + 5000, diff --git a/service/src/__tests__/safety-ordering.test.ts b/service/src/__tests__/safety-ordering.test.ts new file mode 100644 index 0000000..6804c13 --- /dev/null +++ b/service/src/__tests__/safety-ordering.test.ts @@ -0,0 +1,83 @@ +import type { BproxyRequest, BproxyResponse } from "@bproxy/shared"; +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULT_DAEMON_CONFIG } from "../daemon-config"; +import type { BuiltServer } from "../server"; +import { + type MakeCmdOptions, + makeCmd as makeTestCmd, + postCommand as post, + setupTestServer, + type TestServerContext, + teardownTestServer, +} from "./helpers/integration"; + +const daemonToken = "test-safety-token"; +const extensionToken = "test-safety-ext-token"; +const OTHER_NICK = "bobcat" as BproxyRequest["nick"]; + +let ctx: TestServerContext; +let built: BuiltServer; +let port: number; +let currentSession: BproxyRequest["session"]; +let cmdOpts: MakeCmdOptions; + +function cmd(overrides: Partial = {}): BproxyRequest { + return makeTestCmd(cmdOpts, overrides); +} + +async function postCommand(request: BproxyRequest): Promise { + return post(port, daemonToken, request); +} + +afterEach(async () => { + if (ctx) await teardownTestServer(ctx); +}); + +describe("safety guard ordering", () => { + it("applies minimum-interval rejection before session scope validation", async () => { + const arrivals = [0, 500]; + ctx = await setupTestServer({ + daemonToken, + extensionToken, + safetyNow: () => arrivals.shift() ?? 10_000, + safetySleep: async () => {}, + safetyRandom: () => 0, + }); + ({ built, port, currentSession } = ctx); + cmdOpts = { idPrefix: "safe", defaultSession: () => currentSession }; + const foreignSession = built.sessions.create(OTHER_NICK).id; + + expect((await postCommand(cmd({ action: "session.list", params: {} }))).ok).toBe(true); + const second = await postCommand(cmd({ action: "text", session: foreignSession })); + expect(second).toMatchObject({ ok: false, error: { code: "RATE_LIMITED" } }); + }); + + it("delays session-validation errors before responding", async () => { + const sleeps: number[] = []; + const daemonConfig = { + pacing: DEFAULT_DAEMON_CONFIG.pacing, + safety: { + ...DEFAULT_DAEMON_CONFIG.safety, + minInterval: { ms: 100 }, + errorDelay: { minMs: 500, maxMs: 500 }, + }, + }; + ctx = await setupTestServer({ + daemonToken, + extensionToken, + daemonConfig, + safetyNow: () => 10_000, + safetySleep: async (ms) => { + sleeps.push(ms); + }, + safetyRandom: () => 0, + }); + ({ built, port, currentSession } = ctx); + cmdOpts = { idPrefix: "safe", defaultSession: () => currentSession }; + const foreignSession = built.sessions.create(OTHER_NICK).id; + + const response = await postCommand(cmd({ action: "text", session: foreignSession })); + expect(response).toMatchObject({ ok: false, error: { code: "SESSION_SCOPE_MISMATCH" } }); + expect(sleeps).toEqual([500]); + }); +}); diff --git a/service/src/__tests__/safety.test.ts b/service/src/__tests__/safety.test.ts new file mode 100644 index 0000000..037a982 --- /dev/null +++ b/service/src/__tests__/safety.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_DAEMON_CONFIG, type SafetyConfig } from "../daemon-config"; +import { createSafetyGuards } from "../safety"; + +interface HarnessOptions { + config?: Partial; + random?: () => number; +} + +function buildSafetyConfig(config?: Partial): SafetyConfig { + return { + minInterval: { ...DEFAULT_DAEMON_CONFIG.safety.minInterval, ...config?.minInterval }, + rateCap: { ...DEFAULT_DAEMON_CONFIG.safety.rateCap, ...config?.rateCap }, + errorDelay: { ...DEFAULT_DAEMON_CONFIG.safety.errorDelay, ...config?.errorDelay }, + metronome: { ...DEFAULT_DAEMON_CONFIG.safety.metronome, ...config?.metronome }, + }; +} + +function createHarness(opts: HarnessOptions = {}) { + let now = 0; + const sleeps: number[] = []; + const safety = createSafetyGuards({ + config: buildSafetyConfig(opts.config), + now: () => now, + sleep: async (ms) => { + sleeps.push(ms); + now += ms; + }, + random: opts.random ?? (() => 0), + }); + return { + safety, + sleeps, + setNow(value: number) { + now = value; + }, + }; +} + +function metronomeHarness() { + const h = createHarness({ + config: { + minInterval: { ms: 100 }, + metronome: { tolerance: 0.1, consecutiveEqual: 3, maxIntervalMs: 60_000 }, + }, + }); + // Establish two equal intervals (at 0, 1000, 2000) — not yet rejected + h.setNow(0); + expect(h.safety.checkIngress("halbot")).toBeNull(); + h.setNow(1_000); + expect(h.safety.checkIngress("halbot")).toBeNull(); + h.setNow(2_000); + expect(h.safety.checkIngress("halbot")).toBeNull(); + return h; +} + +describe("safety guards", () => { + it("rejects requests below the minimum interval with RATE_LIMITED", () => { + const h = createHarness({ + config: { minInterval: { ms: 900 } }, + }); + + h.setNow(0); + expect(h.safety.checkIngress("halbot")).toBeNull(); + + h.setNow(500); + expect(h.safety.checkIngress("halbot")).toMatchObject({ + code: "RATE_LIMITED", + details: { retryAfter: 400 }, + }); + }); + + it("enforces the per-nick sliding-window rate cap", () => { + const h = createHarness({ + config: { + minInterval: { ms: 1 }, + rateCap: { requestsPerMinute: 2 }, + }, + }); + + h.setNow(0); + expect(h.safety.checkIngress("halbot")).toBeNull(); + h.setNow(1_000); + expect(h.safety.checkIngress("halbot")).toBeNull(); + h.setNow(2_000); + expect(h.safety.checkIngress("halbot")).toMatchObject({ + code: "RATE_LIMITED", + details: { retryAfter: 58_000 }, + }); + }); + + it("detects metronomic request timing", () => { + const h = metronomeHarness(); + h.setNow(3_000); + expect(h.safety.checkIngress("halbot")).toMatchObject({ + code: "METRONOME_DETECTED", + message: expect.stringContaining("~1000ms"), + }); + }); + + it("resets the metronome streak after a pattern break", () => { + const h = metronomeHarness(); + // Break the pattern with a non-equal interval + h.setNow(3_300); + expect(h.safety.checkIngress("halbot")).toBeNull(); + // Start a new streak (4300, 5300, 6300 = equal intervals again) + h.setNow(4_300); + expect(h.safety.checkIngress("halbot")).toBeNull(); + h.setNow(5_300); + expect(h.safety.checkIngress("halbot")).toBeNull(); + h.setNow(6_300); + expect(h.safety.checkIngress("halbot")).toMatchObject({ + code: "METRONOME_DETECTED", + }); + }); + + it("delays error responses with configured jitter", async () => { + const h = createHarness({ + config: { + errorDelay: { minMs: 500, maxMs: 500 }, + }, + }); + + await expect(h.safety.delayForError()).resolves.toBe(500); + expect(h.sleeps).toEqual([500]); + }); +}); diff --git a/service/src/__tests__/schemas.test.ts b/service/src/__tests__/schemas.test.ts index 668ec63..9c71cbe 100644 --- a/service/src/__tests__/schemas.test.ts +++ b/service/src/__tests__/schemas.test.ts @@ -9,6 +9,7 @@ function parse(action: string, params: unknown) { protocol_version: 1, id: `schema-${action}`, action, + nick: "halbot", params, session: SESSION, deadline: Date.now() + 1000, @@ -64,6 +65,7 @@ describe("request schemas", () => { protocol_version: 1, id: "schema-tab-open-empty-session", action: "tab.open", + nick: "halbot", params: { url: "https://example.com" }, session: "", deadline: Date.now() + 1000, diff --git a/service/src/__tests__/sessions.test.ts b/service/src/__tests__/sessions.test.ts index 0ebf3be..343bcaf 100644 --- a/service/src/__tests__/sessions.test.ts +++ b/service/src/__tests__/sessions.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { createSessionRegistry, SESSION_ID_PATTERN } from "../sessions"; +const OWNER = "halbot"; + describe("session registry", () => { it("generates 6-char base32 session ids", () => { const reg = createSessionRegistry(); - const session = reg.create("research"); + const session = reg.create(OWNER, "research"); expect(session.id).toMatch(SESSION_ID_PATTERN); expect(session.label).toBe("research"); expect(session.tab).toBeNull(); @@ -17,8 +19,8 @@ describe("session registry", () => { const reg = createSessionRegistry({ generateId: () => issued.shift() ?? "cccccc", }); - const first = reg.create(); - const second = reg.create(); + const first = reg.create(OWNER); + const second = reg.create(OWNER); expect(first.id).toBe("aaaaaa"); expect(second.id).toBe("bbbbbb"); expect(reg.list().map((session) => session.id)).toEqual(["aaaaaa", "bbbbbb"]); @@ -26,7 +28,7 @@ describe("session registry", () => { it("registers session-scoped logical tab handles", () => { const reg = createSessionRegistry({ generateId: () => "m4q8z2" }); - const session = reg.create(); + const session = reg.create(OWNER); reg.bind(session.id, 42, "fast"); reg.bind(session.id, 99); @@ -41,7 +43,7 @@ describe("session registry", () => { it("can rebind to an existing logical handle without losing pacing", () => { const reg = createSessionRegistry({ generateId: () => "m4q8z2" }); - const session = reg.create(); + const session = reg.create(OWNER); reg.bind(session.id, 42, "fast"); reg.bind(session.id, 99); @@ -53,7 +55,7 @@ describe("session registry", () => { it("pauses, resumes, and unbind clears the tab plus pause flag", () => { const reg = createSessionRegistry({ generateId: () => "m4q8z2" }); - const session = reg.create(); + const session = reg.create(OWNER); reg.bind(session.id, 42); reg.pause(session.id, "captcha"); @@ -69,7 +71,7 @@ describe("session registry", () => { it("closes a session and returns owned Chrome tab ids", () => { const reg = createSessionRegistry({ generateId: () => "m4q8z2" }); - const session = reg.create(); + const session = reg.create(OWNER); reg.bind(session.id, 42); reg.bind(session.id, 99); @@ -83,8 +85,8 @@ describe("session registry", () => { it("tracks tab handles per session", () => { const issued = ["aaaaaa", "bbbbbb"]; const reg = createSessionRegistry({ generateId: () => issued.shift() ?? "cccccc" }); - const first = reg.create(); - const second = reg.create(); + const first = reg.create(OWNER); + const second = reg.create(OWNER); reg.bind(first.id, 11); reg.bind(second.id, 22); @@ -96,7 +98,7 @@ describe("session registry", () => { it("exposes mutable internal state only for existing sessions", () => { const reg = createSessionRegistry({ generateId: () => "m4q8z2" }); - const created = reg.create(); + const created = reg.create(OWNER); const internal = reg.internal(created.id); expect(internal).toBe(reg.getOrCreate(created.id)); expect(internal.lastActionAt).toEqual({}); diff --git a/service/src/__tests__/workflows.test.ts b/service/src/__tests__/workflows.test.ts index d064b42..9492179 100644 --- a/service/src/__tests__/workflows.test.ts +++ b/service/src/__tests__/workflows.test.ts @@ -4,6 +4,7 @@ import type { BuiltServer } from "../server"; import { connectWsClient, setupTestServer, + TEST_NICK, type TestServerContext, teardownTestServer, } from "./helpers/integration"; @@ -23,6 +24,7 @@ function makeCmd(overrides: Partial = {}): BproxyRequest { id: overrides.id ?? `01HZX${crypto.randomUUID().replaceAll("-", "").slice(0, 21).toUpperCase()}`, action: overrides.action ?? "text", + nick: overrides.nick ?? TEST_NICK, params: overrides.params ?? {}, session: overrides.session ?? currentSession, deadline: Date.now() + 5000, @@ -216,7 +218,7 @@ describe("end-to-end workflows — Phase 5 task 3", () => { }); it("rejects logical handles owned by another session", async () => { - const otherSession = built.sessions.create().id; + const otherSession = built.sessions.create(TEST_NICK).id; built.sessions.registerTab(currentSession, 42); const res = await postCommand( makeCmd({ diff --git a/service/src/config.ts b/service/src/config.ts index 6360dc7..f7265f1 100644 --- a/service/src/config.ts +++ b/service/src/config.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; +import { type DaemonConfig, loadDaemonConfig } from "./daemon-config"; export interface ServiceConfig { port: number; @@ -8,11 +9,15 @@ export interface ServiceConfig { logLevel: "trace" | "debug" | "info" | "warn" | "error"; } +export interface LoadedServiceConfig extends ServiceConfig { + daemon: DaemonConfig; +} + const DEFAULT_PORT = 9615; const DEFAULT_HOST = "127.0.0.1"; const VALID_LEVELS = new Set(["trace", "debug", "info", "warn", "error"]); -export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServiceConfig { +export function loadBaseConfig(env: NodeJS.ProcessEnv = process.env): ServiceConfig { const port = Number.parseInt(env["BPROXY_PORT"] ?? "", 10); const level = env["BPROXY_LOG_LEVEL"] ?? "info"; return { @@ -23,6 +28,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServiceConfig }; } +export function loadConfig(env: NodeJS.ProcessEnv = process.env): LoadedServiceConfig { + const base = loadBaseConfig(env); + return { ...base, daemon: loadDaemonConfig(base.stateDir) }; +} + export function stateFile( stateDir: string, name: "bproxy.pid" | "port" | "token" | "extension-token" | "pairing.json", diff --git a/service/src/daemon-config.ts b/service/src/daemon-config.ts new file mode 100644 index 0000000..838713f --- /dev/null +++ b/service/src/daemon-config.ts @@ -0,0 +1,182 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { type PacingConfig, type PacingMode } from "@bproxy/shared"; +import { z } from "zod"; + +export interface SafetyConfig { + minInterval: { ms: number }; + rateCap: { requestsPerMinute: number }; + errorDelay: { minMs: number; maxMs: number }; + metronome: { tolerance: number; consecutiveEqual: number; maxIntervalMs: number }; +} + +export interface DaemonConfig { + pacing: DaemonPacingConfig; + safety: SafetyConfig; +} + +export type DaemonPacingConfig = Record; + +const PACING_MODES = ["human", "fast"] as const satisfies readonly PacingMode[]; +const PACING_BUCKETS = [ + "navigate", + "scroll", + "interaction", + "fill", +] as const satisfies readonly (keyof PacingConfig)[]; + +const positiveInt = z.number().int().positive(); +const positiveFinite = z.number().finite().positive(); +const rangeSchema = z.object({ min: positiveInt, max: positiveInt }).strict(); +const pacingSchema = z + .object({ + navigate: rangeSchema, + scroll: rangeSchema, + interaction: rangeSchema, + fill: rangeSchema, + }) + .strict(); +const daemonConfigSchema = z + .object({ + pacing: z.object({ human: pacingSchema, fast: pacingSchema }).strict(), + safety: z + .object({ + minInterval: z.object({ ms: positiveInt }).strict(), + rateCap: z.object({ requestsPerMinute: positiveInt }).strict(), + errorDelay: z.object({ minMs: positiveInt, maxMs: positiveInt }).strict(), + metronome: z + .object({ + tolerance: z.number().finite().gt(0).lt(1), + consecutiveEqual: z.number().int().gte(2), + maxIntervalMs: positiveFinite, + }) + .strict(), + }) + .strict(), + }) + .strict(); + +export const DEFAULT_DAEMON_CONFIG: DaemonConfig = { + pacing: { + human: { + navigate: { min: 1500, max: 4000 }, + scroll: { min: 4000, max: 8000 }, + interaction: { min: 1200, max: 2500 }, + fill: { min: 1200, max: 2500 }, + }, + fast: { + navigate: { min: 900, max: 1400 }, + scroll: { min: 900, max: 1600 }, + interaction: { min: 900, max: 1200 }, + fill: { min: 900, max: 1200 }, + }, + }, + safety: { + minInterval: { ms: 900 }, + rateCap: { requestsPerMinute: 60 }, + errorDelay: { minMs: 500, maxMs: 2000 }, + metronome: { tolerance: 0.1, consecutiveEqual: 3, maxIntervalMs: 60_000 }, + }, +}; + +export function configFilePath(stateDir: string): string { + return resolve(stateDir, "config.json"); +} + +export function loadDaemonConfig(stateDir: string): DaemonConfig { + const path = configFilePath(stateDir); + if (!existsSync(path)) return cloneDaemonConfig(DEFAULT_DAEMON_CONFIG); + const raw = readConfigFile(path); + const parsed = daemonConfigSchema.safeParse(raw); + if (!parsed.success) { + throw new Error(`Invalid daemon config at ${path}: ${formatZodError(parsed.error)}`); + } + validateDaemonConfig(parsed.data, path); + return cloneDaemonConfig(parsed.data); +} + +function readConfigFile(path: string): unknown { + try { + return JSON.parse(readFileSync(path, "utf8")) as unknown; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid daemon config at ${path}: ${message}`); + } +} + +function formatZodError(error: z.ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path.length > 0 ? issue.path.join(".") : ""; + return `${path}: ${issue.message}`; + }) + .join("; "); +} + +function validateDaemonConfig(config: DaemonConfig, path: string): void { + const minIntervalMs = config.safety.minInterval.ms; + for (const mode of PACING_MODES) { + for (const bucket of PACING_BUCKETS) { + const range = config.pacing[mode][bucket]; + const key = `pacing.${mode}.${bucket}`; + if (range.min > range.max) { + throw new Error(`Invalid daemon config at ${path}: ${key}.min must be <= ${key}.max`); + } + if (range.min < minIntervalMs) { + throw new Error( + `Invalid daemon config at ${path}: ${key}.min must be >= safety.minInterval.ms (${minIntervalMs})`, + ); + } + if (range.max < minIntervalMs) { + throw new Error( + `Invalid daemon config at ${path}: ${key}.max must be >= safety.minInterval.ms (${minIntervalMs})`, + ); + } + } + } + if (config.safety.errorDelay.minMs > config.safety.errorDelay.maxMs) { + throw new Error( + `Invalid daemon config at ${path}: safety.errorDelay.minMs must be <= safety.errorDelay.maxMs`, + ); + } + if (config.safety.metronome.maxIntervalMs < minIntervalMs) { + throw new Error( + `Invalid daemon config at ${path}: safety.metronome.maxIntervalMs must be >= safety.minInterval.ms (${minIntervalMs})`, + ); + } +} + +function cloneRange(range: { min: number; max: number }): { min: number; max: number } { + return { min: range.min, max: range.max }; +} + +function clonePacingConfig(config: PacingConfig): PacingConfig { + return { + navigate: cloneRange(config.navigate), + scroll: cloneRange(config.scroll), + interaction: cloneRange(config.interaction), + fill: cloneRange(config.fill), + }; +} + +function cloneDaemonConfig(config: DaemonConfig): DaemonConfig { + return { + pacing: { + human: clonePacingConfig(config.pacing.human), + fast: clonePacingConfig(config.pacing.fast), + }, + safety: { + minInterval: { ms: config.safety.minInterval.ms }, + rateCap: { requestsPerMinute: config.safety.rateCap.requestsPerMinute }, + errorDelay: { + minMs: config.safety.errorDelay.minMs, + maxMs: config.safety.errorDelay.maxMs, + }, + metronome: { + tolerance: config.safety.metronome.tolerance, + consecutiveEqual: config.safety.metronome.consecutiveEqual, + maxIntervalMs: config.safety.metronome.maxIntervalMs, + }, + }, + }; +} diff --git a/service/src/debug-actions.ts b/service/src/debug-actions.ts index 6056322..8cb53af 100644 --- a/service/src/debug-actions.ts +++ b/service/src/debug-actions.ts @@ -28,16 +28,20 @@ export function handleDaemonLocal(cmd: BproxyRequest, deps: DebugDeps): BproxyRe if (cmd.action === "debug.last") { const params = cmd.params as { count?: number }; const count = params.count ?? 50; + const requests = deps + .traces() + .filter((trace) => deps.sessions.getOwner(trace.session) === cmd.nick) + .slice(-count); return { protocol_version: 1, id: cmd.id, ok: true, - data: { requests: deps.traces().slice(-count) }, + data: { requests }, page: pageOk(), replay: false, }; } - const sessions = deps.sessions.list(); + const sessions = deps.sessions.listByOwner(cmd.nick); const sessionTabs = sessions.map((session) => ({ session: session.id, tabs: deps.sessions.listTabs(session.id), diff --git a/service/src/dispatch.ts b/service/src/dispatch.ts index 03aa12d..129f623 100644 --- a/service/src/dispatch.ts +++ b/service/src/dispatch.ts @@ -101,8 +101,9 @@ export function createDispatch(deps: DispatchDeps): DispatchEngine { return errorResponse(cmd.id, { code: "SESSION_NOT_FOUND", category: "target", - retry: "conditional", + retry: "never", message: `Session '${cmd.session}' was not found`, + suggestedAction: `Session '${cmd.session}' is permanently closed or never existed. Do not retry. Create a new session with 'bproxy tab open --url ... -n ${cmd.nick}'. If you need historical diagnostics, inspect BPROXY_HOME/logs/ and correlate entries with your ownerHash.`, details: { session: cmd.session }, }); } @@ -129,8 +130,13 @@ export function createDispatch(deps: DispatchDeps): DispatchEngine { } const forwarded: BproxyForwardedRequest = { - ...cmd, + protocol_version: cmd.protocol_version, + id: cmd.id, + action: cmd.action, params: resolved.params, + session: cmd.session, + deadline: cmd.deadline, + destructive: cmd.destructive, target: { tabId: resolved.tabId }, }; const registerPending = () => diff --git a/service/src/index.ts b/service/src/index.ts index dfb08da..9790721 100644 --- a/service/src/index.ts +++ b/service/src/index.ts @@ -1,26 +1,29 @@ -import { loadConfig } from "./config"; +import { loadBaseConfig, loadConfig } from "./config"; import type { LifecycleStartResult, LifecycleStatusResult, LifecycleStopResult } from "./lifecycle"; import { startDetached, startForeground, status, stop } from "./lifecycle"; async function main(): Promise { const cmd = process.argv[2]; - const config = loadConfig(); switch (cmd) { case "start": { + const config = loadConfig(); const result: LifecycleStartResult = await startDetached(config); process.stdout.write(`${JSON.stringify(result)}\n`); return 0; } case "daemonize": { + const config = loadConfig(); await startForeground(config); return 0; } case "stop": { + const config = loadBaseConfig(); const result: LifecycleStopResult = await stop(config); process.stdout.write(`${JSON.stringify(result)}\n`); return 0; } case "status": { + const config = loadBaseConfig(); const s: LifecycleStatusResult = status(config); process.stdout.write(`${JSON.stringify(s)}\n`); return 0; diff --git a/service/src/lifecycle-state.ts b/service/src/lifecycle-state.ts new file mode 100644 index 0000000..682a2bd --- /dev/null +++ b/service/src/lifecycle-state.ts @@ -0,0 +1,180 @@ +/** + * State-file utilities for daemon lifecycle management. + * + * Extracted from lifecycle.ts to keep it under max-lines while satisfying + * the export-from re-export rule (S7763). + */ + +import { randomBytes } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import type { ServiceConfig } from "./config"; +import { stateFile } from "./config"; + +// ─── State directory ─────────────────────────────────────────────────── + +export function ensureStateDir(config: ServiceConfig): void { + mkdirSync(config.stateDir, { recursive: true, mode: 0o700 }); + const st = statSync(config.stateDir); + if ((st.mode & 0o777) !== 0o700) { + chmodSync(config.stateDir, 0o700); + } +} + +// ─── PID / port / alive ──────────────────────────────────────────────── + +interface PidState { + exists: boolean; + pid: number | null; +} + +export function readPidState(config: ServiceConfig): PidState { + const path = stateFile(config.stateDir, "bproxy.pid"); + if (!existsSync(path)) return { exists: false, pid: null }; + const raw = readFileSync(path, "utf8").trim(); + const pid = Number.parseInt(raw, 10); + return { + exists: true, + pid: Number.isFinite(pid) && pid > 0 ? pid : null, + }; +} + +export function readPid(config: ServiceConfig): number | null { + return readPidState(config).pid; +} + +export function readPort(config: ServiceConfig): number | undefined { + const path = stateFile(config.stateDir, "port"); + if (!existsSync(path)) return undefined; + const port = Number.parseInt(readFileSync(path, "utf8").trim(), 10); + return Number.isFinite(port) && port > 0 && port <= 65_535 ? port : undefined; +} + +export function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === "ESRCH") return false; + if (code === "EPERM") return true; + return false; + } +} + +// ─── File permissions ────────────────────────────────────────────────── + +export function assertOwnerMode600( + path: string, + errCode: "INSECURE_TOKEN_FILE" | "INSECURE_EXTENSION_TOKEN_FILE", +): void { + const st = statSync(path); + if ((st.mode & 0o777) !== 0o600) { + throw new Error(`${errCode}: mode is ${(st.mode & 0o777).toString(8)}, expected 600`); + } + const uid = process.getuid?.(); + if (uid !== undefined && st.uid !== uid) { + throw new Error(`${errCode}: owned by uid ${st.uid}, expected ${uid}`); + } +} + +// ─── State file CRUD ─────────────────────────────────────────────────── + +export function removeStateFiles( + config: ServiceConfig, + names: readonly ("bproxy.pid" | "port" | "token" | "pairing.json")[], +): void { + for (const name of names) { + try { + rmSync(stateFile(config.stateDir, name), { force: true }); + } catch { + /* best effort */ + } + } +} + +export function cleanupRuntimeState(config: ServiceConfig): void { + removeStateFiles(config, ["bproxy.pid", "port", "pairing.json"]); +} + +// ─── Token / port / pid writes ───────────────────────────────────────── + +export function writeToken(config: ServiceConfig): string { + ensureStateDir(config); + const path = stateFile(config.stateDir, "token"); + if (existsSync(path)) assertOwnerMode600(path, "INSECURE_TOKEN_FILE"); + const token = randomBytes(32).toString("hex"); + writeFileSync(path, token, { mode: 0o600 }); + return token; +} + +export function readExtensionToken(config: ServiceConfig): string | null { + ensureStateDir(config); + const path = stateFile(config.stateDir, "extension-token"); + if (!existsSync(path)) return null; + assertOwnerMode600(path, "INSECURE_EXTENSION_TOKEN_FILE"); + const token = readFileSync(path, "utf8").trim(); + return token.length > 0 ? token : null; +} + +export function writeExtensionToken(config: ServiceConfig, token: string): void { + ensureStateDir(config); + const path = stateFile(config.stateDir, "extension-token"); + if (existsSync(path)) assertOwnerMode600(path, "INSECURE_EXTENSION_TOKEN_FILE"); + writeFileSync(path, token, { mode: 0o600 }); +} + +export function clearToken(config: ServiceConfig): void { + try { + rmSync(stateFile(config.stateDir, "token"), { force: true }); + } catch { + /* best effort */ + } +} + +export function writePort(config: ServiceConfig, port: number): void { + writeFileSync(stateFile(config.stateDir, "port"), String(port)); +} + +export function writePidFile(config: ServiceConfig, pid: number): void { + writeFileSync(stateFile(config.stateDir, "bproxy.pid"), String(pid)); +} + +// ─── Timing helpers ──────────────────────────────────────────────────── + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (!isAlive(pid)) return true; + await sleep(50); + } + return !isAlive(pid); +} + +export async function waitForDaemonReady( + config: ServiceConfig, + pid: number, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (!isAlive(pid)) { + throw new Error("daemon failed during startup"); + } + const port = readPort(config); + if (port !== undefined) return; + await sleep(50); + } + throw new Error("startup timeout waiting for daemon readiness"); +} diff --git a/service/src/lifecycle.ts b/service/src/lifecycle.ts index 59d9d82..650661d 100644 --- a/service/src/lifecycle.ts +++ b/service/src/lifecycle.ts @@ -1,218 +1,88 @@ import { spawn } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; import { PROTOCOL_VERSION, VERSION } from "@bproxy/shared"; -import type { ServiceConfig } from "./config"; -import { stateFile } from "./config"; +import type { LoadedServiceConfig, ServiceConfig } from "./config"; +import { + cleanupRuntimeState, + clearToken, + ensureStateDir, + isAlive, + readExtensionToken, + readPid, + readPidState, + readPort, + waitForDaemonReady, + waitForProcessExit, + writeExtensionToken, + writePidFile, + writePort, + writeToken, +} from "./lifecycle-state"; import { buildLogger } from "./logger"; import { createPairingStore } from "./pairing"; -import type { +import { readPairingFile, removePairingFile, writePairingFile } from "./pairing-file"; +import { buildServer } from "./server"; +import { removeOrphanedTmpFiles, wipeTmpDir } from "./session-tmp"; +import { createSessionRegistry } from "./sessions"; + +export type { LifecycleStartResult, LifecycleStatusResult, LifecycleStopResult, PairingMetadata, } from "./pairing-file"; -import { readPairingFile, removePairingFile, writePairingFile } from "./pairing-file"; -import { buildServer } from "./server"; -import { removeOrphanedTmpFiles, wipeTmpDir } from "./session-tmp"; -import { createSessionRegistry } from "./sessions"; -export type { LifecycleStartResult, LifecycleStatusResult, LifecycleStopResult, PairingMetadata }; +import type { + LifecycleStartResult, + LifecycleStatusResult, + LifecycleStopResult, +} from "./pairing-file"; + +export { + clearToken, + ensureStateDir, + isAlive, + readExtensionToken, + readPid, + writeExtensionToken, + writePidFile, + writePort, + writeToken, +} from "./lifecycle-state"; export { readPairingFile, removePairingFile, writePairingFile }; const STARTUP_TIMEOUT_MS = 5_000; const STOP_TIMEOUT_MS = 5_000; -const POLL_MS = 50; -export function ensureStateDir(config: ServiceConfig): void { - mkdirSync(config.stateDir, { recursive: true, mode: 0o700 }); - // Tighten pre-existing dir that may have been created with a permissive umask - const st = statSync(config.stateDir); - if ((st.mode & 0o777) !== 0o700) { - chmodSync(config.stateDir, 0o700); - } +function persistClaimedExtensionToken(config: ServiceConfig, token: string): void { + writeExtensionToken(config, token); + removePairingFile(config); } -interface PidState { - exists: boolean; - pid: number | null; -} - -function readPidState(config: ServiceConfig): PidState { - const path = stateFile(config.stateDir, "bproxy.pid"); - if (!existsSync(path)) return { exists: false, pid: null }; - const raw = readFileSync(path, "utf8").trim(); - const pid = Number.parseInt(raw, 10); - return { - exists: true, - pid: Number.isFinite(pid) && pid > 0 ? pid : null, - }; -} - -export function readPid(config: ServiceConfig): number | null { - return readPidState(config).pid; -} - -function readPort(config: ServiceConfig): number | undefined { - const path = stateFile(config.stateDir, "port"); - if (!existsSync(path)) return undefined; - const port = Number.parseInt(readFileSync(path, "utf8").trim(), 10); - return Number.isFinite(port) && port > 0 && port <= 65_535 ? port : undefined; -} - -export function isAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (e) { - const code = (e as NodeJS.ErrnoException).code; - if (code === "ESRCH") return false; - if (code === "EPERM") return true; - return false; - } -} - -function assertOwnerMode600( - path: string, - errCode: "INSECURE_TOKEN_FILE" | "INSECURE_EXTENSION_TOKEN_FILE", -): void { - const st = statSync(path); - if ((st.mode & 0o777) !== 0o600) { - throw new Error(`${errCode}: mode is ${(st.mode & 0o777).toString(8)}, expected 600`); - } - const uid = process.getuid?.(); - if (uid !== undefined && st.uid !== uid) { - throw new Error(`${errCode}: owned by uid ${st.uid}, expected ${uid}`); - } -} - -function removeStateFiles( - config: ServiceConfig, - names: readonly ("bproxy.pid" | "port" | "token" | "pairing.json")[], -): void { - for (const name of names) { - try { - rmSync(stateFile(config.stateDir, name), { force: true }); - } catch { - /* best effort */ - } - } -} - -function cleanupRuntimeState(config: ServiceConfig): void { - removeStateFiles(config, ["bproxy.pid", "port", "pairing.json"]); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitForProcessExit(pid: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - if (!isAlive(pid)) return true; - await sleep(POLL_MS); - } - return !isAlive(pid); -} - -async function waitForDaemonReady( - config: ServiceConfig, - pid: number, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - if (!isAlive(pid)) { - throw new Error("daemon failed during startup"); - } - const port = readPort(config); - if (port !== undefined) return; - await sleep(POLL_MS); - } - throw new Error("startup timeout waiting for daemon readiness"); -} - -export function writeToken(config: ServiceConfig): string { - ensureStateDir(config); - const path = stateFile(config.stateDir, "token"); - if (existsSync(path)) assertOwnerMode600(path, "INSECURE_TOKEN_FILE"); - const token = randomBytes(32).toString("hex"); - writeFileSync(path, token, { mode: 0o600 }); - return token; -} - -export function readExtensionToken(config: ServiceConfig): string | null { - ensureStateDir(config); - const path = stateFile(config.stateDir, "extension-token"); - if (!existsSync(path)) return null; - assertOwnerMode600(path, "INSECURE_EXTENSION_TOKEN_FILE"); - const token = readFileSync(path, "utf8").trim(); - return token.length > 0 ? token : null; -} - -export function writeExtensionToken(config: ServiceConfig, token: string): void { - ensureStateDir(config); - const path = stateFile(config.stateDir, "extension-token"); - if (existsSync(path)) assertOwnerMode600(path, "INSECURE_EXTENSION_TOKEN_FILE"); - writeFileSync(path, token, { mode: 0o600 }); -} - -export function clearToken(config: ServiceConfig): void { - try { - rmSync(stateFile(config.stateDir, "token"), { force: true }); - } catch { - /* best effort */ - } -} - -export function writePort(config: ServiceConfig, port: number): void { - writeFileSync(stateFile(config.stateDir, "port"), String(port)); -} - -export function writePidFile(config: ServiceConfig, pid: number): void { - writeFileSync(stateFile(config.stateDir, "bproxy.pid"), String(pid)); -} - -export async function startForeground(config: ServiceConfig): Promise { +export async function startForeground(config: LoadedServiceConfig): Promise { ensureStateDir(config); wipeTmpDir(config.stateDir); removeOrphanedTmpFiles(config.stateDir); const logger = buildLogger(config); + logger.info({ event: "active_config", daemon: config.daemon }); const daemonToken = writeToken(config); const extensionToken = readExtensionToken(config) ?? ""; const pairing = createPairingStore({ ttlMs: 300_000, now: () => Date.now() }); const issued = pairing.issue(); - - // Write pairing metadata atomically for the detached parent to read - const pairingMeta: PairingMetadata = { + writePairingFile(config, { pairingCode: issued.code, pairingExpiresAt: issued.expiresAt, issuedAt: Date.now(), - }; - writePairingFile(config, pairingMeta); - + }); const built = await buildServer({ port: config.port, stateDir: config.stateDir, daemonToken, extensionToken, logger, + daemonConfig: config.daemon, pairing, sessions: createSessionRegistry(), - onExtensionTokenChanged: (token) => { - writeExtensionToken(config, token); - // Pairing claim succeeded — remove pairing.json - removePairingFile(config); - }, + onExtensionTokenChanged: (token) => persistClaimedExtensionToken(config, token), }); let resolveShutdown!: () => void; const shutdownPromise = new Promise((resolve) => { @@ -238,7 +108,6 @@ export async function startForeground(config: ServiceConfig): Promise { }; process.once("SIGTERM", () => shutdown("SIGTERM")); process.once("SIGINT", () => shutdown("SIGINT")); - const addr = await built.app.listen({ host: config.host, port: config.port }); const boundPort = Number.parseInt(addr.split(":").pop() ?? String(config.port), 10); writePort(config, boundPort); diff --git a/service/src/owner-hash.ts b/service/src/owner-hash.ts new file mode 100644 index 0000000..1e42230 --- /dev/null +++ b/service/src/owner-hash.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto"; + +export function computeOwnerHash(salt: Uint8Array, nick: string): string { + return createHash("sha256").update(salt).update(nick).digest("hex").slice(0, 8); +} diff --git a/service/src/pacing.ts b/service/src/pacing.ts index 6b1770d..3e32946 100644 --- a/service/src/pacing.ts +++ b/service/src/pacing.ts @@ -1,4 +1,5 @@ -import { type Action, PACING_PRESETS, type PacingConfig } from "@bproxy/shared"; +import { type Action, type PacingConfig } from "@bproxy/shared"; +import type { DaemonPacingConfig } from "./daemon-config"; import type { SessionRegistry } from "./sessions"; function pacingKey(action: Action): keyof PacingConfig | null { @@ -10,6 +11,7 @@ function pacingKey(action: Action): keyof PacingConfig | null { export interface PacingDeps { sessions: SessionRegistry; + config: DaemonPacingConfig; now: () => number; sleep: (ms: number) => Promise; random: () => number; @@ -25,7 +27,7 @@ export function createPacing(deps: PacingDeps): PacingEngine { const key = pacingKey(action); if (!key) return 0; const s = deps.sessions.internal(session); - const preset = PACING_PRESETS[s.pacing][key]; + const preset = deps.config[s.pacing][key]; const target = preset.min + deps.random() * (preset.max - preset.min); const lastEntry = s.lastActionAt[key]; if (lastEntry === undefined) { diff --git a/service/src/routes/command.ts b/service/src/routes/command.ts index 334ab20..9cf76cb 100644 --- a/service/src/routes/command.ts +++ b/service/src/routes/command.ts @@ -1,10 +1,12 @@ -import type { - BproxyRequest, - BproxyResponse, - DaemonRequestTrace, - ElementInfo, - LinkInfo, - SessionId, +import { + type BproxyRequest, + type BproxyResponse, + type DaemonRequestTrace, + type ElementInfo, + isValidNick, + type LinkInfo, + type SessionId, + type TraceEntry, } from "@bproxy/shared"; import type { FastifyInstance } from "fastify"; import { handleDaemonLocal, isDaemonLocal } from "../debug-actions"; @@ -20,7 +22,8 @@ async function executeCommand(cmd: BproxyRequest, deps: CommandRouteDeps): Promi if (isSessionLocal(cmd.action)) return await handleSessionLocal(cmd, deps); if (isTabMediated(cmd.action)) return await handleTabMediated(cmd, deps); const response = await dispatchAndPause(cmd, deps); - return decorateReadHandles(cmd, deps, response); + const filtered = filterDebugLogEntries(cmd, deps, response); + return decorateReadHandles(cmd, deps, filtered); } function decorateReadHandles( @@ -57,6 +60,18 @@ function decorateReadHandles( return { ...response, data: { ...(response.data as object), links } }; } +function filterDebugLogEntries( + cmd: BproxyRequest, + deps: CommandRouteDeps, + response: BproxyResponse, +): BproxyResponse { + if (cmd.action !== "debug.log" || !response.ok) return response; + const entries = (response.data as { entries: TraceEntry[] }).entries.filter( + (entry) => entry.session !== undefined && deps.sessions.getOwner(entry.session) === cmd.nick, + ); + return { ...response, data: { entries } }; +} + function logResponse( cmd: BproxyRequest, deps: CommandRouteDeps, @@ -71,6 +86,7 @@ function logResponse( ok: response.ok, elapsed_ms: elapsedMs, error_code: errorCode, + ownerHash: deps.computeOwnerHash(cmd.nick), }); deps.trace?.({ id: cmd.id, @@ -83,6 +99,20 @@ function logResponse( } satisfies DaemonRequestTrace); } +async function finalizeResponse( + cmd: BproxyRequest, + deps: CommandRouteDeps, + response: BproxyResponse, + receivedAt: number, +): Promise { + if (!response.ok) { + const delayMs = await deps.safety.delayForError(); + if (delayMs > 0) deps.logger.info({ id: cmd.id, event: "error_delay", delay_ms: delayMs }); + } + logResponse(cmd, deps, response, receivedAt); + return response; +} + export function commandRoute(deps: CommandRouteDeps) { return async function (app: FastifyInstance): Promise { app.post("/", async (request, reply) => { @@ -94,6 +124,12 @@ export function commandRoute(deps: CommandRouteDeps) { }); } const cmd = parsed.data; + if (!isValidNick(cmd.nick)) { + return reply.code(400).send({ + ok: false, + error: { code: "BAD_REQUEST", message: "nick must match /^[a-z][a-z0-9]{5}$/" }, + }); + } const receivedAt = Date.now(); deps.logger.info({ id: cmd.id, @@ -101,20 +137,27 @@ export function commandRoute(deps: CommandRouteDeps) { session: cmd.session, destructive: cmd.destructive, event: "received", + ownerHash: deps.computeOwnerHash(cmd.nick), }); - const sessionError = validateSession(cmd, deps); - if (sessionError) { - logResponse(cmd, deps, sessionError, receivedAt); - return sessionError; + const safetyError = deps.safety.checkIngress(cmd.nick); + if (safetyError) { + return await finalizeResponse( + cmd, + deps, + { protocol_version: 1, id: cmd.id, ok: false, error: safetyError }, + receivedAt, + ); } + const sessionError = validateSession(cmd, deps); + if (sessionError) return await finalizeResponse(cmd, deps, sessionError, receivedAt); + const waited = await deps.pacing.waitForSlot(cmd.session, cmd.action); if (waited > 0) deps.logger.info({ id: cmd.id, event: "pacing_wait", delay_ms: waited }); const response = await executeCommand(cmd, deps); - logResponse(cmd, deps, response, receivedAt); - return response; + return await finalizeResponse(cmd, deps, response, receivedAt); }); }; } diff --git a/service/src/routes/session-actions.ts b/service/src/routes/session-actions.ts index 91b5bc0..dc5f1ef 100644 --- a/service/src/routes/session-actions.ts +++ b/service/src/routes/session-actions.ts @@ -31,6 +31,7 @@ export function validateSession(cmd: BproxyRequest, deps: CommandRouteDeps): Bpr } if (!deps.sessions.isValidSessionId(cmd.session)) return invalidSession(cmd); if (!deps.sessions.has(cmd.session)) return sessionNotFound(cmd); + if (deps.sessions.getOwner(cmd.session) !== cmd.nick) return sessionScopeMismatch(cmd); return null; } @@ -39,7 +40,8 @@ export async function handleSessionLocal( deps: CommandRouteDeps, ): Promise { if (cmd.action === "session.create") return handleSessionCreate(cmd, deps); - if (cmd.action === "session.list") return success(cmd, { sessions: deps.sessions.list() }); + if (cmd.action === "session.list") + return success(cmd, { sessions: deps.sessions.listByOwner(cmd.nick) }); if (cmd.action === "session.bind") return handleSessionBind(cmd, deps); if (cmd.action === "session.unbind") return handleSessionUnbind(cmd, deps); if (cmd.action === "session.resume") return handleSessionResume(cmd, deps); @@ -70,16 +72,33 @@ function sessionNotFound(cmd: BproxyRequest): BproxyResponse { return failure(cmd, { code: "SESSION_NOT_FOUND", category: "target", - retry: "conditional", + retry: "never", message: `Session '${cmd.session}' was not found`, + suggestedAction: `Session '${cmd.session}' is permanently closed or never existed. Do not retry. Create a new session with 'bproxy tab open --url ... -n ${cmd.nick}'. If you need historical diagnostics, inspect BPROXY_HOME/logs/ and correlate entries with your ownerHash.`, + details: { session: cmd.session }, + }); +} + +function sessionScopeMismatch(cmd: BproxyRequest): BproxyResponse { + return failure(cmd, { + code: "SESSION_SCOPE_MISMATCH", + category: "policy", + retry: "never", + message: `Session '${cmd.session}' does not belong to this agent`, + suggestedAction: `This session belongs to another agent. Create your own session with 'bproxy tab open --url ... -n ${cmd.nick}' or check that you are using the correct --nick value.`, details: { session: cmd.session }, }); } function handleSessionCreate(cmd: BproxyRequest, deps: CommandRouteDeps): BproxyResponse { - const created = deps.sessions.create((cmd.params as { label?: string }).label); + const created = deps.sessions.create(cmd.nick, (cmd.params as { label?: string }).label); const tmpDir = createSessionTmpDir(deps.stateDir, created.id); - return success(cmd, { session: created.id, label: created.label, tmpDir }); + return success(cmd, { + session: created.id, + label: created.label, + tmpDir, + ownerHash: deps.computeOwnerHash(cmd.nick), + }); } function handleSessionBind(cmd: BproxyRequest, deps: CommandRouteDeps): BproxyResponse { @@ -88,7 +107,12 @@ function handleSessionBind(cmd: BproxyRequest, deps: CommandRouteDeps): BproxyRe return sessionBindTargetError(cmd, deps, params.tab); deps.sessions.bind(cmd.session, params.tab, params.pacing); if (params.pacing) { - deps.logger.info({ event: "pacing_config", session: cmd.session, mode: params.pacing }); + deps.logger.info({ + event: "pacing_config", + session: cmd.session, + mode: params.pacing, + ownerHash: deps.computeOwnerHash(cmd.nick), + }); } return success(cmd, { session: cmd.session, tab: params.tab }); } @@ -140,6 +164,7 @@ async function handleSessionClose( protocol_version: 1, id: `${cmd.id}:close:${index + 1}`, action: "tab.close", + nick: cmd.nick, params: {}, session: cmd.session, deadline: cmd.deadline, diff --git a/service/src/routes/tab-actions.ts b/service/src/routes/tab-actions.ts index 56c1a57..2be9bda 100644 --- a/service/src/routes/tab-actions.ts +++ b/service/src/routes/tab-actions.ts @@ -51,7 +51,7 @@ async function handleTabOpen( deps: CommandRouteDeps, ): Promise { const createdSession = !cmd.session; - const session = cmd.session || deps.sessions.create().id; + const session = cmd.session || deps.sessions.create(cmd.nick).id; const request = { ...cmd, session } as BproxyRequest<"tab.open">; const opened = await dispatchAndPause(request, deps, { targetTabId: null }); if (!opened.ok) return tabOpenFailure(opened, deps, createdSession, session); @@ -69,7 +69,14 @@ async function handleTabOpen( const tmpDir = createSessionTmpDir(deps.stateDir, session); return success( request, - { session, tab: tab.tab, bound: true, url: tab.url, tmpDir }, + { + session, + tab: tab.tab, + bound: true, + url: tab.url, + tmpDir, + ownerHash: deps.computeOwnerHash(cmd.nick), + }, opened.page, ); } diff --git a/service/src/routes/types.ts b/service/src/routes/types.ts index e0e7539..1ee4405 100644 --- a/service/src/routes/types.ts +++ b/service/src/routes/types.ts @@ -4,15 +4,18 @@ import type { DebugDeps } from "../debug-actions"; import type { DispatchEngine } from "../dispatch"; import type { ElementHandleCache } from "../element-handles"; import type { PacingEngine } from "../pacing"; +import type { SafetyGuards } from "../safety"; import type { SessionRegistry } from "../sessions"; export interface CommandRouteDeps { dispatch: DispatchEngine; pacing: PacingEngine; + safety: SafetyGuards; logger: Logger; debug: DebugDeps; sessions: SessionRegistry; elementHandles: ElementHandleCache; stateDir: string; + computeOwnerHash: (nick: string) => string; trace?: (entry: DaemonRequestTrace) => void; } diff --git a/service/src/safety.ts b/service/src/safety.ts new file mode 100644 index 0000000..67cb175 --- /dev/null +++ b/service/src/safety.ts @@ -0,0 +1,184 @@ +import type { BproxyError } from "@bproxy/shared"; +import type { SafetyConfig } from "./daemon-config"; + +interface NickSafetyState { + lastRequestAt?: number; + rateWindow: number[]; + metronome: MetronomeState; +} + +interface MetronomeState { + lastArrival?: number; + lastInterval?: number; + equalCount: number; +} + +export interface SafetyDeps { + config: SafetyConfig; + now: () => number; + sleep: (ms: number) => Promise; + random: () => number; +} + +export interface SafetyGuards { + checkIngress(nick: string): BproxyError | null; + delayForError(): Promise; +} + +export function createSafetyGuards(deps: SafetyDeps): SafetyGuards { + const states = new Map(); + + return { + checkIngress(nick) { + const now = deps.now(); + const state = getState(states, nick); + + const minIntervalError = checkMinInterval(state, now, deps.config.minInterval.ms); + if (minIntervalError) return rateLimitedError(minIntervalError); + + const rateCapError = checkRateCap(state, now, deps.config.rateCap.requestsPerMinute); + if (rateCapError) return rateLimitedError(rateCapError); + + const metronomeInterval = checkMetronome(state, now, deps.config); + if (metronomeInterval !== null) { + return metronomeDetectedError( + deps.config.metronome.consecutiveEqual, + Math.round(metronomeInterval), + ); + } + + return null; + }, + + async delayForError() { + const { minMs, maxMs } = deps.config.errorDelay; + const wait = jitterMs(minMs, maxMs, deps.random); + if (wait > 0) await deps.sleep(wait); + return wait; + }, + }; +} + +function getState(states: Map, nick: string): NickSafetyState { + const existing = states.get(nick); + if (existing) return existing; + const created: NickSafetyState = { + rateWindow: [], + metronome: { equalCount: 0 }, + }; + states.set(nick, created); + return created; +} + +function checkMinInterval( + state: NickSafetyState, + now: number, + minIntervalMs: number, +): number | null { + const previous = state.lastRequestAt; + state.lastRequestAt = now; + if (previous === undefined) return null; + const elapsed = Math.max(0, now - previous); + if (elapsed >= minIntervalMs) return null; + return minIntervalMs - elapsed; +} + +function checkRateCap(state: NickSafetyState, now: number, limit: number): number | null { + pruneWindow(state.rateWindow, now); + if (state.rateWindow.length >= limit) { + const oldest = state.rateWindow[0]; + if (oldest === undefined) return 60_000; + return Math.max(1, oldest + 60_000 - now); + } + state.rateWindow.push(now); + return null; +} + +function pruneWindow(window: number[], now: number): void { + const cutoff = now - 60_000; + while (window[0] !== undefined && window[0] <= cutoff) { + window.shift(); + } +} + +function checkMetronome(state: NickSafetyState, now: number, config: SafetyConfig): number | null { + const tracker = state.metronome; + const lastArrival = tracker.lastArrival; + if (lastArrival === undefined) { + tracker.lastArrival = now; + return null; + } + + const interval = Math.max(0, now - lastArrival); + const { minInterval, metronome } = config; + if (interval < minInterval.ms || interval > metronome.maxIntervalMs) { + resetMetronomeTo(tracker, now); + return null; + } + + if (tracker.lastInterval === undefined) { + tracker.lastArrival = now; + tracker.lastInterval = interval; + tracker.equalCount = 1; + return null; + } + + if (!isWithinTolerance(interval, tracker.lastInterval, metronome.tolerance)) { + resetMetronomeTo(tracker, now); + return null; + } + + const nextCount = tracker.equalCount + 1; + if (nextCount >= metronome.consecutiveEqual) { + resetMetronome(tracker); + return interval; + } + + tracker.lastArrival = now; + tracker.lastInterval = interval; + tracker.equalCount = nextCount; + return null; +} + +function resetMetronome(state: MetronomeState): void { + delete state.lastArrival; + delete state.lastInterval; + state.equalCount = 0; +} + +function resetMetronomeTo(state: MetronomeState, now: number): void { + state.lastArrival = now; + delete state.lastInterval; + state.equalCount = 0; +} + +function isWithinTolerance(current: number, previous: number, tolerance: number): boolean { + return Math.abs(current - previous) / previous < tolerance; +} + +function jitterMs(minMs: number, maxMs: number, random: () => number): number { + if (minMs === maxMs) return minMs; + return Math.round(minMs + random() * (maxMs - minMs)); +} + +function rateLimitedError(retryAfter: number): BproxyError { + return { + code: "RATE_LIMITED", + category: "policy", + retry: "safe", + message: "Rate limit exceeded for this agent.", + suggestedAction: `Slow down. Wait at least ${retryAfter}ms before the next command.`, + details: { retryAfter }, + }; +} + +function metronomeDetectedError(consecutiveEqual: number, approxIntervalMs: number): BproxyError { + return { + code: "METRONOME_DETECTED", + category: "policy", + retry: "never", + message: `Request timing is too regular. ${consecutiveEqual} consecutive commands arrived at equal intervals (~${approxIntervalMs}ms). This pattern is detectable as automation.`, + suggestedAction: + "Do not write scripts or programs to call bproxy in a loop. Control each bproxy command directly — read the result, decide what to do next, then act. If you absolutely must use programmatic control, add random variance to timing. A human does not operate a browser with fixed intervals.", + }; +} diff --git a/service/src/schemas.ts b/service/src/schemas.ts index fed4b9e..453c272 100644 --- a/service/src/schemas.ts +++ b/service/src/schemas.ts @@ -163,6 +163,7 @@ const ENVELOPE_BASE = z.object({ protocol_version: z.literal(1), id: z.string().min(1), action: z.string(), + nick: z.string(), params: z.unknown(), session: z.string(), deadline: z.number().int(), diff --git a/service/src/server.ts b/service/src/server.ts index 5aa48f0..adbf89c 100644 --- a/service/src/server.ts +++ b/service/src/server.ts @@ -5,8 +5,10 @@ import Fastify, { type FastifyInstance } from "fastify"; import type { Logger } from "pino"; import { makeHeaderAuthHook } from "./auth"; import { createClients } from "./clients"; +import { type DaemonConfig, DEFAULT_DAEMON_CONFIG } from "./daemon-config"; import { createDispatch } from "./dispatch"; import { ElementHandleCache } from "./element-handles"; +import { computeOwnerHash } from "./owner-hash"; import { createPacing } from "./pacing"; import { createPairingStore, type PairingStore } from "./pairing"; import { createPairingRateLimiter, type PairingRateLimiter } from "./pairing-rate-limit"; @@ -14,6 +16,7 @@ import { createPending } from "./pending"; import { commandRoute } from "./routes/command"; import { pairRoute } from "./routes/pair"; import { wsRoute } from "./routes/ws"; +import { createSafetyGuards } from "./safety"; import { createSessionRegistry, type SessionRegistry } from "./sessions"; export interface BuildServerOptions { @@ -22,9 +25,14 @@ export interface BuildServerOptions { daemonToken: string; extensionToken: string; logger: Logger; + daemonConfig?: DaemonConfig; + instanceSalt?: Uint8Array; pairing?: PairingStore; pairingRateLimiter?: PairingRateLimiter; pairingRateLimitNow?: () => number; + safetyNow?: () => number; + safetySleep?: (ms: number) => Promise; + safetyRandom?: () => number; sessions?: SessionRegistry; traces?: () => readonly DaemonRequestTrace[]; onExtensionTokenChanged?: (token: string) => void; @@ -48,10 +56,12 @@ interface ObjectGraph { dispatch: ReturnType; elementHandles: ElementHandleCache; pacing: ReturnType; + safety: ReturnType; newClientId: () => string; startedAt: number; traces: () => readonly DaemonRequestTrace[]; pushTrace: (entry: DaemonRequestTrace) => void; + instanceSalt: Uint8Array; } interface TraceRing { @@ -80,6 +90,33 @@ function createTraceRing(capacity = 200): TraceRing { }; } +function randomUnit(): number { + return randomBytes(4).readUInt32BE() / 0x100000000; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createPacingEngine(opts: BuildServerOptions, sessions: SessionRegistry) { + return createPacing({ + sessions, + config: opts.daemonConfig?.pacing ?? DEFAULT_DAEMON_CONFIG.pacing, + now: () => Date.now(), + sleep, + random: randomUnit, + }); +} + +function createSafetyEngine(opts: BuildServerOptions) { + return createSafetyGuards({ + config: opts.daemonConfig?.safety ?? DEFAULT_DAEMON_CONFIG.safety, + now: opts.safetyNow ?? (() => Date.now()), + sleep: opts.safetySleep ?? sleep, + random: opts.safetyRandom ?? randomUnit, + }); +} + function createDeps(opts: BuildServerOptions): ObjectGraph { const sessions = opts.sessions ?? createSessionRegistry(); const clients = createClients(); @@ -103,18 +140,11 @@ function createDeps(opts: BuildServerOptions): ObjectGraph { opts.logger.info({ id, event: "forwarded", ws_client: wsClient, tab }); }, }); - const pacing = createPacing({ - sessions, - now: () => Date.now(), - sleep: (ms) => new Promise((r) => setTimeout(r, ms)), - random: () => randomBytes(4).readUInt32BE() / 0x100000000, - }); const pairing = opts.pairing ?? createPairingStore({ ttlMs: 300_000, now: () => Date.now() }); const pairingRateLimiter = opts.pairingRateLimiter ?? createPairingRateLimiter({ now: opts.pairingRateLimitNow ?? (() => Date.now()) }); let clientCounter = 0; - const newClientId = (): string => `client-${++clientCounter}`; const ring = createTraceRing(); const traces = opts.traces ?? (() => ring.read()); @@ -124,13 +154,15 @@ function createDeps(opts: BuildServerOptions): ObjectGraph { sessions, dispatch, elementHandles, - pacing, + pacing: createPacingEngine(opts, sessions), + safety: createSafetyEngine(opts), pairing, pairingRateLimiter, - newClientId, + newClientId: () => `client-${++clientCounter}`, startedAt: Date.now(), traces, pushTrace: ring.push, + instanceSalt: opts.instanceSalt ?? randomBytes(32), }; } @@ -145,10 +177,12 @@ async function registerRoutes( commandRoute({ dispatch: deps.dispatch, pacing: deps.pacing, + safety: deps.safety, logger: opts.logger, sessions: deps.sessions, elementHandles: deps.elementHandles, stateDir: opts.stateDir, + computeOwnerHash: (nick) => computeOwnerHash(deps.instanceSalt, nick), trace: deps.pushTrace, debug: { clients: deps.clients, diff --git a/service/src/session-registry-support.ts b/service/src/session-registry-support.ts new file mode 100644 index 0000000..0c3f77a --- /dev/null +++ b/service/src/session-registry-support.ts @@ -0,0 +1,104 @@ +import type { PacingMode, SessionId, SessionInfo, TabHandle, TabInfo } from "@bproxy/shared"; +import type { InternalSession, InternalTabInfo } from "./session-registry-types"; + +export function createInternalSession(id: string, owner: string, label?: string): InternalSession { + return { + id: id as SessionId, + owner, + label, + tab: null, + pacing: "human", + paused: false, + lastActionAt: {}, + tabs: new Map(), + nextTabOrdinal: 1, + }; +} + +export function registerChromeTab( + session: InternalSession, + chromeTabId: number, + config?: { url?: string; title?: string; bind?: boolean }, +): InternalTabInfo { + const existing = findTabByChromeId(session, chromeTabId); + if (existing) return updateExistingTab(session, existing, config); + return createChromeTab(session, chromeTabId, config); +} + +export function setPacing(session: InternalSession, pacing?: PacingMode): void { + if (pacing) session.pacing = pacing; +} + +export function syncBoundFlags(session: InternalSession): void { + for (const [handle, tab] of session.tabs) { + tab.bound = session.tab === handle; + } +} + +export function toSessionInfo(session: InternalSession): SessionInfo { + return { + id: session.id, + label: session.label, + tab: session.tab, + pacing: session.pacing, + paused: session.paused, + pauseReason: session.pauseReason, + }; +} + +export function toTabInfo(tab: InternalTabInfo): TabInfo { + return { + tab: tab.tab, + url: tab.url, + title: tab.title, + bound: tab.bound, + }; +} + +export function toInternalTabInfo(tab: InternalTabInfo): InternalTabInfo { + return { + tab: tab.tab, + url: tab.url, + title: tab.title, + bound: tab.bound, + chromeTabId: tab.chromeTabId, + }; +} + +function updateExistingTab( + session: InternalSession, + tab: InternalTabInfo, + config?: { url?: string; title?: string; bind?: boolean }, +): InternalTabInfo { + if (config?.url !== undefined) tab.url = config.url; + if (config?.title !== undefined) tab.title = config.title; + if (config?.bind) session.tab = tab.tab; + syncBoundFlags(session); + return tab; +} + +function createChromeTab( + session: InternalSession, + chromeTabId: number, + config?: { url?: string; title?: string; bind?: boolean }, +): InternalTabInfo { + const handle = `t${session.nextTabOrdinal++}` as TabHandle; + const created: InternalTabInfo = { + tab: handle, + url: config?.url ?? "", + title: config?.title ?? "", + bound: false, + chromeTabId, + }; + session.tabs.set(handle, created); + if (config?.bind) session.tab = handle; + syncBoundFlags(session); + return created; +} + +function findTabByChromeId(session: InternalSession, chromeTabId: number): InternalTabInfo | null { + for (const tab of session.tabs.values()) { + if (tab.chromeTabId === chromeTabId) return tab; + } + return null; +} diff --git a/service/src/session-registry-types.ts b/service/src/session-registry-types.ts new file mode 100644 index 0000000..84e8724 --- /dev/null +++ b/service/src/session-registry-types.ts @@ -0,0 +1,12 @@ +import type { SessionInfo, TabHandle, TabInfo } from "@bproxy/shared"; + +export interface InternalTabInfo extends TabInfo { + chromeTabId: number; +} + +export interface InternalSession extends SessionInfo { + owner: string; + lastActionAt: Record; + tabs: Map; + nextTabOrdinal: number; +} diff --git a/service/src/sessions.ts b/service/src/sessions.ts index 96572b6..4e8daeb 100644 --- a/service/src/sessions.ts +++ b/service/src/sessions.ts @@ -1,23 +1,26 @@ -import type { PacingMode, SessionId, SessionInfo, TabHandle, TabInfo } from "@bproxy/shared"; +import type { PacingMode, SessionInfo, TabHandle, TabInfo } from "@bproxy/shared"; import { randomSessionId, SESSION_ID_PATTERN, TAB_HANDLE_PATTERN } from "./session-identifiers"; +import { + createInternalSession, + registerChromeTab, + setPacing, + syncBoundFlags, + toInternalTabInfo, + toSessionInfo, + toTabInfo, +} from "./session-registry-support"; +import type { InternalSession, InternalTabInfo } from "./session-registry-types"; export { SESSION_ID_PATTERN, TAB_HANDLE_PATTERN } from "./session-identifiers"; -export interface InternalTabInfo extends TabInfo { - chromeTabId: number; -} - -export interface InternalSession extends SessionInfo { - lastActionAt: Record; - tabs: Map; - nextTabOrdinal: number; -} +export type { InternalSession, InternalTabInfo } from "./session-registry-types"; export interface SessionRegistry { - create(label?: string): SessionInfo; + create(owner: string, label?: string): SessionInfo; get(id: string): SessionInfo | null; + getOwner(id: string): string | null; has(id: string): boolean; - getOrCreate(id: string): InternalSession; + getOrCreate(id: string, owner?: string): InternalSession; bind(id: string, tab: number | string, pacing?: PacingMode): void; registerTab( id: string, @@ -33,6 +36,7 @@ export interface SessionRegistry { pause(id: string, reason?: string): void; resume(id: string): void; list(): SessionInfo[]; + listByOwner(owner: string): SessionInfo[]; close(id: string): { session: SessionInfo; tabs: Array } | null; internal(id: string): InternalSession; isValidSessionId(id: string): boolean; @@ -57,10 +61,11 @@ export function createSessionRegistry(options: SessionRegistryOptions = {}): Ses }; return { - create: (label) => createGeneratedSession(state, label), + create: (owner, label) => createGeneratedSession(state, owner, label), get: (id) => getSessionInfo(state, id), + getOwner: (id) => state.sessions.get(id)?.owner ?? null, has: (id) => state.sessions.has(id), - getOrCreate: (id) => getOrCreateSession(state, id), + getOrCreate: (id, owner) => getOrCreateSession(state, id, owner), bind: (id, tab, pacing) => bindSession(state, id, tab, pacing), registerTab: (id, chromeTabId, config) => registerTabForSession(state, id, chromeTabId, config), removeTab: (id, tab) => removeTabFromSession(state, id, tab), @@ -72,6 +77,8 @@ export function createSessionRegistry(options: SessionRegistryOptions = {}): Ses pause: (id, reason) => pauseSession(state, id, reason), resume: (id) => resumeSession(state, id), list: () => [...state.sessions.values()].map(toSessionInfo), + listByOwner: (owner) => + [...state.sessions.values()].filter((session) => session.owner === owner).map(toSessionInfo), close: (id) => closeSession(state, id), internal: (id) => requireSession(state, id), isValidSessionId: (id) => SESSION_ID_PATTERN.test(id), @@ -79,12 +86,12 @@ export function createSessionRegistry(options: SessionRegistryOptions = {}): Ses }; } -function createGeneratedSession(state: RegistryState, label?: string): SessionInfo { +function createGeneratedSession(state: RegistryState, owner: string, label?: string): SessionInfo { let id = state.generateId(); while (state.sessions.has(id)) { id = state.generateId(); } - const session = createInternalSession(id, label); + const session = createInternalSession(id, owner, label); state.sessions.set(id, session); return toSessionInfo(session); } @@ -102,10 +109,10 @@ function requireSession(state: RegistryState, id: string): InternalSession { return session; } -function getOrCreateSession(state: RegistryState, id: string): InternalSession { +function getOrCreateSession(state: RegistryState, id: string, owner = ""): InternalSession { const existing = state.sessions.get(id); if (existing) return existing; - const created = createInternalSession(id); + const created = createInternalSession(id, owner); state.sessions.set(id, created); return created; } @@ -229,104 +236,3 @@ function closeSession( tabs: [...session.tabs.values()].map(toInternalTabInfo), }; } - -function createInternalSession(id: string, label?: string): InternalSession { - return { - id: id as SessionId, - label, - tab: null, - pacing: "human", - paused: false, - lastActionAt: {}, - tabs: new Map(), - nextTabOrdinal: 1, - }; -} - -function registerChromeTab( - session: InternalSession, - chromeTabId: number, - config?: { url?: string; title?: string; bind?: boolean }, -): InternalTabInfo { - const existing = findTabByChromeId(session, chromeTabId); - if (existing) return updateExistingTab(session, existing, config); - return createChromeTab(session, chromeTabId, config); -} - -function updateExistingTab( - session: InternalSession, - tab: InternalTabInfo, - config?: { url?: string; title?: string; bind?: boolean }, -): InternalTabInfo { - if (config?.url !== undefined) tab.url = config.url; - if (config?.title !== undefined) tab.title = config.title; - if (config?.bind) session.tab = tab.tab; - syncBoundFlags(session); - return tab; -} - -function createChromeTab( - session: InternalSession, - chromeTabId: number, - config?: { url?: string; title?: string; bind?: boolean }, -): InternalTabInfo { - const handle = `t${session.nextTabOrdinal++}` as TabHandle; - const created: InternalTabInfo = { - tab: handle, - url: config?.url ?? "", - title: config?.title ?? "", - bound: false, - chromeTabId, - }; - session.tabs.set(handle, created); - if (config?.bind) session.tab = handle; - syncBoundFlags(session); - return created; -} - -function setPacing(session: InternalSession, pacing?: PacingMode): void { - if (pacing) session.pacing = pacing; -} - -function findTabByChromeId(session: InternalSession, chromeTabId: number): InternalTabInfo | null { - for (const tab of session.tabs.values()) { - if (tab.chromeTabId === chromeTabId) return tab; - } - return null; -} - -function syncBoundFlags(session: InternalSession): void { - for (const [handle, tab] of session.tabs) { - tab.bound = session.tab === handle; - } -} - -function toSessionInfo(session: InternalSession): SessionInfo { - return { - id: session.id, - label: session.label, - tab: session.tab, - pacing: session.pacing, - paused: session.paused, - pauseReason: session.pauseReason, - }; -} - -function toTabInfo(tab: InternalTabInfo): TabInfo { - return { - tab: tab.tab, - url: tab.url, - title: tab.title, - bound: tab.bound, - }; -} - -function toInternalTabInfo(tab: InternalTabInfo): InternalTabInfo { - return { - tab: tab.tab, - url: tab.url, - title: tab.title, - bound: tab.bound, - chromeTabId: tab.chromeTabId, - }; -} diff --git a/shared/package.json b/shared/package.json index 679d9fb..4c472c7 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@bproxy/shared", - "version": "0.7.5", + "version": "0.8.0", "private": true, "type": "module", "exports": { diff --git a/shared/src/actions.ts b/shared/src/actions.ts index 395a447..9c74e08 100644 --- a/shared/src/actions.ts +++ b/shared/src/actions.ts @@ -103,6 +103,7 @@ export interface InspectElement { export interface TraceEntry { id: string; action: Action; + session?: string; tab: number; timestamp: number; elapsed: number; @@ -209,9 +210,16 @@ export interface ActionResult { "tab.list": { session: SessionId; tabs: Array }; "tab.pin": { tab: TabHandle; pinned: true }; "tab.unpin": { tab: TabHandle; pinned: false }; - "tab.open": { session: SessionId; tab: TabHandle; bound: boolean; url: string; tmpDir: string }; + "tab.open": { + session: SessionId; + tab: TabHandle; + bound: boolean; + url: string; + tmpDir: string; + ownerHash: string; + }; "tab.close": { tab: TabHandle; closed: true }; - "session.create": { session: SessionId; label?: string; tmpDir: string }; + "session.create": { session: SessionId; label?: string; tmpDir: string; ownerHash: string }; "session.list": { sessions: Array }; "session.bind": { session: SessionId; tab: TabHandle }; "session.unbind": Record; diff --git a/shared/src/errors.ts b/shared/src/errors.ts index 438f93c..ebebd02 100644 --- a/shared/src/errors.ts +++ b/shared/src/errors.ts @@ -10,6 +10,8 @@ export type ErrorCode = | "ELEMENT_NOT_ACTIONABLE" | "SELECTOR_AMBIGUOUS" | "INVALID_SESSION_ID" + // Well-formed session ids that do not resolve are terminal: callers must + // create a new session rather than retrying the old id. | "SESSION_NOT_FOUND" | "TAB_HANDLE_NOT_FOUND" | "TAB_NOT_IN_SESSION" @@ -20,6 +22,9 @@ export type ErrorCode = | "HUMAN_REQUIRED" | "DEBUGGER_DISABLED" | "SESSION_REQUIRED" + | "SESSION_SCOPE_MISMATCH" + | "METRONOME_DETECTED" + | "RATE_LIMITED" // Execution | "SCRIPT_ERROR" | "NAVIGATION_FAILED" diff --git a/shared/src/index.ts b/shared/src/index.ts index b5d28e5..1d91468 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -34,6 +34,7 @@ export type { PageState, } from "./protocol"; export type { + Nick, PacingConfig, PacingMode, SessionId, @@ -41,6 +42,6 @@ export type { TabHandle, TabInfo, } from "./sessions"; -export { PACING_PRESETS } from "./sessions"; +export { isValidNick } from "./sessions"; export type { ElementRoute, ElementTarget } from "./targets"; export { PROTOCOL_VERSION, VERSION } from "./version"; diff --git a/shared/src/protocol-shape.assertions.ts b/shared/src/protocol-shape.assertions.ts index 642ccdf..a22eb77 100644 --- a/shared/src/protocol-shape.assertions.ts +++ b/shared/src/protocol-shape.assertions.ts @@ -10,7 +10,7 @@ import type { import type { ErrorCode } from "./errors"; import type { ClientElementTarget } from "./handles"; import type { BproxyForwardedRequest, BproxyRequest } from "./protocol"; -import type { PacingMode, SessionId, SessionInfo, TabHandle, TabInfo } from "./sessions"; +import type { Nick, PacingMode, SessionId, SessionInfo, TabHandle, TabInfo } from "./sessions"; import type { ElementTarget } from "./targets"; type Equals = ((() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 @@ -37,7 +37,14 @@ type _BindUsesLogicalTab = Expect< type _TabOpenUsesLogicalHandles = Expect< Equals< ActionResult["tab.open"], - { session: SessionId; tab: TabHandle; bound: boolean; url: string; tmpDir: string } + { + session: SessionId; + tab: TabHandle; + bound: boolean; + url: string; + tmpDir: string; + ownerHash: string; + } > >; type _TabListIsScoped = Expect< @@ -52,9 +59,11 @@ type _TabCloseResultUsesLogicalHandle = Expect< type _SessionInfoUsesLogicalBinding = Expect< Equals & Equals >; +type _RequestUsesNick = Expect>; type _RequestUsesSessionId = Expect>; type _TraceUsesNarrowActionAndErrorCode = Expect< Equals & + Equals & Equals & Equals & Equals @@ -63,6 +72,9 @@ type _TabInfoUsesLogicalHandle = Expect>; type _ForwardedTargetAllowsNull = Expect< Equals >; +type _ForwardedStripsNick = Expect< + Equals<"nick" extends keyof BproxyForwardedRequest ? true : false, false> +>; type _DebugStatusExposesSessionTabs = Expect< Equals< ActionResult["debug.status"]["sessionTabs"][number], diff --git a/shared/src/protocol.ts b/shared/src/protocol.ts index fce0b0f..1a4e474 100644 --- a/shared/src/protocol.ts +++ b/shared/src/protocol.ts @@ -1,11 +1,12 @@ import type { Action, ActionParams, ActionResult, ForwardedActionParams } from "./actions"; import type { BproxyError } from "./errors"; -import type { SessionId } from "./sessions"; +import type { Nick, SessionId } from "./sessions"; export interface BproxyRequest { protocol_version: 1; id: string; action: A; + nick: Nick; params: ActionParams[A]; session: SessionId; deadline: number; // unix ms @@ -25,7 +26,10 @@ export interface BproxyRequest { * `debug.last`, and `debug.status` remain daemon-local and never carry a * `target`. */ -export type BproxyForwardedRequest = Omit, "params"> & { +export type BproxyForwardedRequest = Omit< + BproxyRequest, + "nick" | "params" +> & { params: ForwardedActionParams[A]; target: { tabId: number | null }; }; diff --git a/shared/src/sessions.ts b/shared/src/sessions.ts index 0838949..ec00b2c 100644 --- a/shared/src/sessions.ts +++ b/shared/src/sessions.ts @@ -1,11 +1,19 @@ export type PacingMode = "human" | "fast"; +declare const nickBrand: unique symbol; declare const sessionIdBrand: unique symbol; declare const tabHandleBrand: unique symbol; +const NICK_PATTERN = /^[a-z][a-z0-9]{5}$/; + +export type Nick = string & { readonly [nickBrand]: "Nick" }; export type SessionId = string & { readonly [sessionIdBrand]: "SessionId" }; export type TabHandle = `t${number}` & { readonly [tabHandleBrand]: "TabHandle" }; +export function isValidNick(value: string): value is Nick { + return NICK_PATTERN.test(value); +} + export interface PacingConfig { navigate: { min: number; max: number }; scroll: { min: number; max: number }; @@ -13,21 +21,6 @@ export interface PacingConfig { fill: { min: number; max: number }; } -export const PACING_PRESETS: Record = { - human: { - navigate: { min: 1500, max: 4000 }, - scroll: { min: 4000, max: 8000 }, - interaction: { min: 500, max: 2000 }, - fill: { min: 500, max: 2000 }, - }, - fast: { - navigate: { min: 300, max: 800 }, - scroll: { min: 500, max: 1500 }, - interaction: { min: 100, max: 400 }, - fill: { min: 100, max: 400 }, - }, -}; - export interface SessionInfo { id: SessionId; label?: string; diff --git a/shared/src/version.ts b/shared/src/version.ts index 4b8eb27..31b8ec8 100644 --- a/shared/src/version.ts +++ b/shared/src/version.ts @@ -6,7 +6,7 @@ */ /** Current bproxy package version (semver). */ -export const VERSION = "0.7.5"; +export const VERSION = "0.8.0"; /** Protocol version for the daemon↔CLI↔extension wire format. */ export const PROTOCOL_VERSION = 1; diff --git a/skills/bproxy/SKILL.md b/skills/bproxy/SKILL.md index cb63ccd..b9118af 100644 --- a/skills/bproxy/SKILL.md +++ b/skills/bproxy/SKILL.md @@ -9,7 +9,7 @@ description: >- compatibility: Node >=24, bproxy installed (npm install -g @dimdasci/bproxy), daemon running, extension paired license: MIT metadata: - version: "0.7.5" + version: "0.8.0" --- # bproxy @@ -18,20 +18,36 @@ CLI → daemon → Chrome extension → real browser page. Operator handles: login, CAPTCHA, consent, final submit. Agent handles: navigation, reading, form filling, clicking. +## Required: agent nickname (`--nick` / `-n`) + +Every protocol command requires `-n `. Generate your nick **once at the start of your task** and reuse it for all commands. + +**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. + +**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 ```bash -bproxy tab open --url "https://example.com" -# → { session: "m4q7z2", tab: "t1", tmpDir: "..." } +# 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" } -bproxy text -s m4q7z2 -bproxy links -s m4q7z2 # → ln1, ln2, ln3... -bproxy elements -s m4q7z2 # → el1, el2, el3... +bproxy text -n -s m4q7z2 +bproxy links -n -s m4q7z2 # → ln1, ln2, ln3... +bproxy elements -n -s m4q7z2 # → el1, el2, el3... -bproxy click -s m4q7z2 --element ln3 -bproxy fill -s m4q7z2 --element el2 --value "hello" --method paste --world isolated +bproxy click -n -s m4q7z2 --element ln3 +bproxy fill -n -s m4q7z2 --element el2 --value "hello" --method paste --world isolated -bproxy session close -s m4q7z2 +bproxy session close -n -s m4q7z2 ``` ## Commands (quick ref) @@ -49,7 +65,9 @@ bproxy session close -s m4q7z2 **Session/Tab**: `tab open --url` · `tab close` · `tab list` · -`session close` · `session resume` · `session bind --tab tN` +`session create` · `session close` · `session resume` · `session bind --tab tN` + +**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** (one of): `--element ` (preferred) · `--selector ` · `--route-json ` @@ -71,10 +89,12 @@ Read `references/fill-methods.md` when handling rich editors or shadow DOM. When page shows cookie banner, login wall, or CAPTCHA: - Cookie banners: read `references/consent.md` for CMP selector catalog + decision tree. - Login/CAPTCHA/age gates: `require-human --reason "..."` immediately. -- After operator resolves: `session resume -s `, continue. +- After operator resolves: `session resume -n -s `, continue. ## Gotchas +- **`--nick` is mandatory**: missing or invalid nick → exit 2, command never reaches daemon. +- **Generate your own nick**: do not copy nicks from docs/examples. Generate a random one. - **`TAB_NOT_VISIBLE`**: destructive actions fail on background tabs. Tab must be Chrome's active foreground tab. No standalone `tab activate` command yet — ask operator to switch, or use `navigate` to the same URL which may bring it forward. - **Handles expire**: `el1`/`ln1` are valid only for current page state. After navigation or page change, re-read. - **`-s` required**: every browser command needs session id. Exception: `tab open --url` auto-creates session. @@ -83,6 +103,7 @@ When page shows cookie banner, login wall, or CAPTCHA: - **Screenshot needs visible tab**: use `--activate` to bring tab to foreground, or ask operator. - **Cross-origin iframes**: content inside them is unreachable. Use `require-human`. - **`SELECTOR_AMBIGUOUS`**: bproxy refuses if selector matches >1 element. Narrow the selector or use handles. +- **Pacing is enforced**: daemon adds delay between commands (900ms minimum). Don't try to batch rapidly — it will be rejected. ## Error recovery @@ -90,7 +111,10 @@ When page shows cookie banner, login wall, or CAPTCHA: |-------|-----| | `HUMAN_REQUIRED` | Stop. Tell operator. Wait. `session resume`. | | `ELEMENT_NOT_FOUND` / `HANDLE_STALE` | Re-read (`elements`/`links`) for fresh handles. | -| `SESSION_NOT_FOUND` | Daemon restarted. `tab open --url` for new session. | +| `SESSION_NOT_FOUND` | Session permanently gone. `tab open --url` for new session. **Do not retry.** | +| `SESSION_SCOPE_MISMATCH` | Session belongs to another agent. Check your `--nick`. **Do not retry.** | +| `RATE_LIMITED` | Too fast. Wait the `retryAfter` ms from details, then retry. | +| `METRONOME_DETECTED` | Fixed-interval pattern detected. **Do not retry.** Vary your timing. | | `TIMEOUT` | `wait --strategy selector --target `, then retry. | | `TAB_NOT_VISIBLE` | Ask operator to foreground tab, retry. | | `NO_EXTENSION` | Extension not connected. Ask operator to check. | diff --git a/skills/bproxy/references/actions.md b/skills/bproxy/references/actions.md index 4d26c2f..23abc40 100644 --- a/skills/bproxy/references/actions.md +++ b/skills/bproxy/references/actions.md @@ -1,50 +1,54 @@ # Actions Reference +All commands require `-n ` (agent nickname, 6 chars, e.g. `halbot`). +All browser commands require `-s ` except where noted. + ## Read (non-destructive) | Action | Syntax | Key params | Returns | |--------|--------|------------|---------| -| `text` | `text [-s] [--selector css]` | selector (default: body) | `{ text }` | -| `links` | `links [-s] [--selector] [--limit N]` | selector?, limit? | `{ links: [{ text, href, handle?, ... }] }` | -| `images` | `images [-s] [--selector]` | selector? | `{ images: [{ src, alt, width, height }] }` | -| `elements` | `elements [-s] [--form]` | form? | `{ elements: [{ tag, type?, label?, value?, handle?, selector, runtimeHandle?, ... }] }` | -| `outline` | `outline [-s]` | — | `{ landmarks, headings }` | -| `dom` | `dom [-s] [--selector] [--depth N]` | selector?, depth (default:3) | `{ html }` | -| `inspect` | `inspect [-s] --selector [--properties] [--limit]` | selector, properties?, limit? | `{ elements: [{ rect, scroll, styles }] }` | -| `snapshot` | `snapshot [-s] [--selector] [--max-depth] [--interactive-only]` | selector?, maxDepth?, interactiveOnly? | `{ tree }` | -| `screenshot` | `screenshot [-s] [--activate] [--output-dir] [--debugger]` | activate?, outputDir? | `{ format, file, size }` | +| `text` | `text -n -s [--selector css]` | selector (default: body) | `{ text }` | +| `links` | `links -n -s [--selector] [--limit N]` | selector?, limit? | `{ links: [{ text, href, handle?, ... }] }` | +| `images` | `images -n -s [--selector]` | selector? | `{ images: [{ src, alt, width, height }] }` | +| `elements` | `elements -n -s [--form]` | form? | `{ elements: [{ tag, type?, label?, value?, handle?, selector, runtimeHandle?, ... }] }` | +| `outline` | `outline -n -s ` | — | `{ landmarks, headings }` | +| `dom` | `dom -n -s [--selector] [--depth N]` | selector?, depth (default:3) | `{ html }` | +| `inspect` | `inspect -n -s --selector [--properties] [--limit]` | selector, properties?, limit? | `{ elements: [{ rect, scroll, styles }] }` | +| `snapshot` | `snapshot -n -s [--selector] [--max-depth] [--interactive-only]` | selector?, maxDepth?, interactiveOnly? | `{ tree }` | +| `screenshot` | `screenshot -n -s [--activate] [--output-dir] [--debugger]` | activate?, outputDir? | `{ format, file, size }` | ## Write (destructive) | Action | Syntax | Key params | Returns | |--------|--------|------------|---------| -| `navigate` | `navigate [-s] --url ` | url | `{ url, title, loadTime }` | -| `click` | `click [-s] --element/--selector` | target | `{ clicked, disappeared, stable }` | -| `hover` | `hover [-s] --element/--selector` | target | `{ hovered, stable, elapsed }` | -| `scroll` | `scroll [-s] [--element] [--direction] [--by]` | target?, direction?, by? | `{ moved, before, after, scrolledPx }` | -| `fill` | `fill [-s] --element --value --method --world` | target, value, method, world | `{ filled, verifiedValue }` | -| `fill-form` | `fill-form [-s] --json '{fields:[...]}'` | fields[] | `{ results[] }` | -| `select` | `select [-s] --element --option-text` | trigger, optionText | `{ selected, optionText }` | -| `wait` | `wait [-s] --strategy --target [--timeout]` | strategy (selector/url/navigation), target | `{ matched, elapsed }` | -| `require-human` | `require-human [-s] --reason "..."` | reason | pauses session | +| `navigate` | `navigate -n -s --url ` | url | `{ url, title, loadTime }` | +| `click` | `click -n -s --element/--selector` | target | `{ clicked, disappeared, stable }` | +| `hover` | `hover -n -s --element/--selector` | target | `{ hovered, stable, elapsed }` | +| `scroll` | `scroll -n -s [--element] [--direction] [--by]` | target?, direction?, by? | `{ moved, before, after, scrolledPx }` | +| `fill` | `fill -n -s --element --value --method --world` | target, value, method, world | `{ filled, verifiedValue }` | +| `fill-form` | `fill-form -n -s --json '{fields:[...]}'` | fields[] | `{ results[] }` | +| `select` | `select -n -s --element --option-text` | trigger, optionText | `{ selected, optionText }` | +| `wait` | `wait -n -s --strategy --target [--timeout]` | strategy (selector/url/navigation), target | `{ matched, elapsed }` | +| `require-human` | `require-human -n -s --reason "..."` | reason | pauses session | ## Tab/Session | Action | Syntax | Notes | |--------|--------|-------| -| `tab open` | `tab open --url [-s]` | Auto-creates session if `-s` omitted. Returns `{ session, tab, tmpDir }` | -| `tab close` | `tab close [-s] [--tab tN]` | | -| `tab list` | `tab list [-s]` | Session-scoped, daemon-local | -| `tab pin` | `tab pin [-s] [--tab tN]` | | -| `session create` | `session create [--label]` | Returns `{ session, tmpDir }` | -| `session bind` | `session bind [-s] --tab tN [--pacing human\|fast]` | Switch active tab | -| `session resume` | `session resume [-s]` | Clear paused state | -| `session close` | `session close [-s]` | Closes all tabs, destroys session | +| `tab open` | `tab open -n --url [-s]` | Auto-creates session if `-s` omitted. Returns `{ session, tab, tmpDir, ownerHash }` | +| `tab close` | `tab close -n -s [--tab tN]` | | +| `tab list` | `tab list -n -s ` | Session-scoped, daemon-local | +| `tab pin` | `tab pin -n -s [--tab tN]` | | +| `session create` | `session create -n [--label]` | Returns `{ session, tmpDir, ownerHash }`. No `-s` needed. | +| `session list` | `session list -n ` | Returns only sessions owned by this nick. No `-s` needed. | +| `session bind` | `session bind -n -s --tab tN [--pacing human\|fast]` | Switch active tab | +| `session resume` | `session resume -n -s ` | Clear paused state | +| `session close` | `session close -n -s ` | Closes all tabs, destroys session | ## Debug | Action | Syntax | Notes | |--------|--------|-------| -| `debug status` | `debug status` | Full daemon/WS/session state | -| `debug last` | `debug last [--count N]` | Daemon ring buffer (last 200) | -| `debug log` | `debug log [--id] [--limit]` | Extension ring buffer | +| `debug status` | `debug status -n ` | Daemon/WS/session state (nick-scoped). No `-s` needed. | +| `debug last` | `debug last -n [--count N]` | Daemon ring buffer, nick-scoped. No `-s` needed. | +| `debug log` | `debug log -n -s [--id] [--limit]` | Extension ring buffer, nick-scoped | diff --git a/skills/bproxy/references/consent.md b/skills/bproxy/references/consent.md index 65db369..5d50165 100644 --- a/skills/bproxy/references/consent.md +++ b/skills/bproxy/references/consent.md @@ -15,7 +15,7 @@ | GDPR generic | `[data-testid*="accept"], [id*="accept-cookies"]` | ```bash -bproxy click -s --selector "#onetrust-accept-btn-handler" +bproxy click -n -s --selector "#onetrust-accept-btn-handler" ``` If `ELEMENT_NOT_FOUND` → next CMP. If clicked → done. @@ -23,7 +23,7 @@ If `ELEMENT_NOT_FOUND` → next CMP. If clicked → done. ### 2. Check for cross-origin consent iframe ```bash -bproxy dom -s --selector "iframe[src*='consent'], iframe[src*='sourcepoint'], iframe[src*='onetrust'], iframe[title*='consent']" +bproxy dom -n -s --selector "iframe[src*='consent'], iframe[src*='sourcepoint'], iframe[src*='onetrust'], iframe[title*='consent']" ``` If iframe found → **unreachable** (browser security boundary) → `require-human`. @@ -32,7 +32,7 @@ If iframe found → **unreachable** (browser security boundary) → `require-hum Probe structure: ```bash -bproxy dom -s --selector "[role='dialog'], [data-testid*='BottomBar'], [id*='consent'], [class*='banner']" --depth 4 +bproxy dom -n -s --selector "[role='dialog'], [data-testid*='BottomBar'], [id*='consent'], [class*='banner']" --depth 4 ``` Find accept button by DOM position — usually first/most prominent button in the container. @@ -45,6 +45,6 @@ Build a positional selector: ` > div > button:first-child` or similar ## After human resolves ```bash -bproxy session resume -s +bproxy session resume -n -s # continue automation ``` diff --git a/skills/bproxy/references/errors.md b/skills/bproxy/references/errors.md index 7734f6a..80f1c93 100644 --- a/skills/bproxy/references/errors.md +++ b/skills/bproxy/references/errors.md @@ -8,9 +8,9 @@ ## Retry semantics -- `safe` → retry immediately (2-3x max) +- `safe` → retry after waiting (respect `retryAfter` if present) - `conditional` → fix the condition first, then retry -- `never` → request is wrong, fix params +- `never` → request is wrong, fix params. **Do not retry.** ## By category @@ -29,7 +29,7 @@ | `ELEMENT_NOT_FOUND` | conditional | Re-read (`elements`/`links`). | | `ELEMENT_NOT_ACTIONABLE` | conditional | Hidden/disabled/covered. `inspect` → scroll or wait. | | `SELECTOR_AMBIGUOUS` | never | Matched >1. Use handle or narrow selector. | -| `SESSION_NOT_FOUND` | conditional | `tab open --url` for new session. | +| `SESSION_NOT_FOUND` | never | Session permanently closed or never existed. **Do not retry.** `tab open --url` for new session. | | `INVALID_SESSION_ID` | never | Must match `/^[a-z2-7]{6}$/`. | | `TAB_HANDLE_NOT_FOUND` | conditional | `tab list` to see available. | | `TAB_NOT_IN_SESSION` | never | Wrong session. | @@ -43,6 +43,9 @@ |------|-------|----------| | `HUMAN_REQUIRED` | conditional | **Stop.** Tell operator. Wait. `session resume`. | | `SESSION_REQUIRED` | never | Add `-s ` or bootstrap with `tab open --url`. | +| `SESSION_SCOPE_MISMATCH` | never | Session belongs to another agent. Check your `--nick` value. Create your own session with `tab open --url ... -n `. | +| `RATE_LIMITED` | safe | Too fast. Wait `details.retryAfter` ms before next command. | +| `METRONOME_DETECTED` | never | Fixed-interval pattern detected. **Stop scripting.** Vary timing between commands. Do not use programmatic loops with fixed delays. | | `DEBUGGER_DISABLED` | never | Remove `--debugger` flag. | ### Execution @@ -61,4 +64,8 @@ **Human intervention** → `require-human` → operator resolves → `session resume` → continue. -**Session lost** → `tab open --url "..."` → new session id → continue. +**Session lost** → `tab open --url "..." -n ` → new session id → continue. Do not retry the old session. + +**Rate limited** → wait `retryAfter` ms from `error.details`, then retry the same command. + +**Scope mismatch** → you're using the wrong `--nick` or trying to access another agent's session. Use only sessions you created. diff --git a/skills/bproxy/references/fill-methods.md b/skills/bproxy/references/fill-methods.md index 8dc4392..6e98b6f 100644 --- a/skills/bproxy/references/fill-methods.md +++ b/skills/bproxy/references/fill-methods.md @@ -28,7 +28,7 @@ elements --form → inspect target: Use `--route-json` for elements inside open shadow roots: ```bash -bproxy fill -s --route-json '{"hosts":[{"selector":"#shadow-host"}],"target":"input.inner"}' \ +bproxy fill -n -s --route-json '{"hosts":[{"selector":"#shadow-host"}],"target":"input.inner"}' \ --value "text" --method paste --world isolated ``` diff --git a/views/package.json b/views/package.json index 168206e..d0eebd8 100644 --- a/views/package.json +++ b/views/package.json @@ -1,7 +1,7 @@ { "name": "@bproxy/views", "private": true, - "version": "0.7.5", + "version": "0.8.0", "type": "module", "scripts": { "dev": "astro dev",