diff --git a/README.md b/README.md index 0d152e2..99caabf 100644 --- a/README.md +++ b/README.md @@ -446,7 +446,7 @@ rejecting only cross-site `Origin`s. ``` The community driver has landed too: `LocalVaultDriver`, an AES-256-GCM file keyed by scrypt from a passphrase you hold, with `1claw-vault` to manage it. Three backends now ship, and the adversarial suite is green on all of them — which was always the real bar. -- **v0.2** — governed credential registration **(done, local backend)**; HITL approval queue, TOTP fill, and registration on the hosted backend still to come +- **v0.2** — governed credential registration **(done, local backend)** and governed credential **capture** — a fill in reverse: while logged in, the bridge reads a secret the site generates (an API key, a token) in a windowed page and stores it in the vault, without the agent seeing it **(done, local backend; see `examples/full-flow-capture.mjs`)**; HITL approval queue, TOTP fill, and both on the hosted backend still to come - **v0.3** — cloud-runtime sidecar (platform trust model) ## Security diff --git a/packages/browser-bridge/bin/1claw-vault.mjs b/packages/browser-bridge/bin/1claw-vault.mjs index 17a15a2..4216eea 100755 --- a/packages/browser-bridge/bin/1claw-vault.mjs +++ b/packages/browser-bridge/bin/1claw-vault.mjs @@ -31,9 +31,12 @@ const flag = (n) => { const i = rest.indexOf(`--${n}`); return i > -1 ? rest[i + function usage(code = 2) { console.error(`usage: 1claw-vault init - 1claw-vault add --id --url --hosts a.com,.b.com [--sso idp.com] + 1claw-vault add --id --url --hosts a.com,.b.com [--sso idp.com] [--username --user-sel ] 1claw-vault list 1claw-vault remove --id + 1claw-vault allow-capture --id --url --login \\ + --hosts a.com --value-sel [--generate-sel ] \\ + [--value-prop value|textContent] [--entry-id ] hosts: a bare entry matches only itself; a leading dot ('.example.com') matches that host and any subdomain. '*' is not a wildcard here and is refused. @@ -68,7 +71,7 @@ async function readStdin() { async function load(pass) { const doc = await openVault(JSON.parse(await readFile(file, "utf8")), pass); - return { entries: doc.entries ?? [], registrations: doc.registrations ?? [] }; + return { entries: doc.entries ?? [], registrations: doc.registrations ?? [], captures: doc.captures ?? [] }; } async function save(doc, pass) { // 0600: a vault the rest of the machine can read is not a vault. @@ -96,7 +99,7 @@ try { const pass = await passphrase(); const again = process.env.ONECLAW_BRIDGE_VAULT_PASSPHRASE ? pass : await promptHidden("again: "); if (pass !== again) { console.error("passphrases do not match"); process.exit(2); } - await save({ entries: [], registrations: [] }, pass); + await save({ entries: [], registrations: [], captures: [] }, pass); console.error(`created ${file}`); } else if (cmd === "add") { const id = flag("id"), url = flag("url"); @@ -114,7 +117,15 @@ try { if (doc.entries.some((e) => e.id === id)) { console.error(`${id} already exists`); process.exit(2); } const secret = await readStdin(); if (!secret) { console.error("empty secret"); process.exit(2); } - doc.entries.push({ id, secret, loginUrl: url, allowedHosts: hosts, ...(flag("sso") ? { ssoHosts: parseHosts(flag("sso"), "--sso") } : {}) }); + doc.entries.push({ + id, secret, loginUrl: url, allowedHosts: hosts, + ...(flag("sso") ? { ssoHosts: parseHosts(flag("sso"), "--sso") } : {}), + // Optional username, for login forms that do not pre-fill it. Not a + // secret — the bridge types it before the password. + ...(flag("username") ? { username: flag("username") } : {}), + ...(flag("user-sel") ? { usernameSelector: flag("user-sel") } : {}), + ...(flag("submit-sel") ? { submitSelector: flag("submit-sel") } : {}), + }); await save(doc, pass); console.error(`added ${id}`); } else if (cmd === "list") { @@ -177,6 +188,40 @@ try { }); await save(doc, pass); console.error(`allowed signup for ${id} as ${username}`); + } else if (cmd === "allow-capture") { + // Authorising an agent to capture a secret the site generates (an API key, + // a token). Everything it could otherwise choose is fixed here: the page, + // the control that generates the value, and where the value is read from. + // The agent supplies only --id and the tab it is logged in on. + const id = flag("id"), url = flag("url"), login = flag("login"); + if (!id || !url || !login) usage(); + const hosts = parseHosts(flag("hosts"), "--hosts"); + if (hosts.length === 0) { console.error("--hosts must name at least one host"); process.exit(2); } + const valueSel = flag("value-sel"); + if (!valueSel) { console.error("--value-sel is required (where the secret is read from)"); process.exit(2); } + for (const [name, u] of [["--url", url], ["--login", login]]) { + if (!u.startsWith("https://") && !u.startsWith("http://127.0.0.1")) { + console.error(`${name} must be https (http is allowed only for 127.0.0.1)`); + process.exit(2); + } + } + const valueProp = flag("value-prop"); + if (valueProp && valueProp !== "value" && valueProp !== "textContent") { + console.error("--value-prop must be 'value' or 'textContent'"); process.exit(2); + } + const pass = await passphrase(); + const doc = await load(pass); + if (doc.captures.some((c) => c.id === id)) { console.error(`${id} already allowed`); process.exit(2); } + const entryId = flag("entry-id") || id; + doc.captures.push({ + id, captureUrl: url, loginUrl: login, allowedHosts: hosts, valueSelector: valueSel, + ...(flag("generate-sel") ? { generateSelector: flag("generate-sel") } : {}), + ...(valueProp ? { valueProp } : {}), + ...(flag("value-attr") ? { valueAttr: flag("value-attr") } : {}), + ...(flag("entry-id") ? { entryId } : {}), + }); + await save(doc, pass); + console.error(`allowed capture for ${id} -> vault id ${entryId}`); } else usage(); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); diff --git a/packages/browser-bridge/examples/README.md b/packages/browser-bridge/examples/README.md index 6935dad..3a14642 100644 --- a/packages/browser-bridge/examples/README.md +++ b/packages/browser-bridge/examples/README.md @@ -14,7 +14,9 @@ node packages/browser-bridge/examples/register-login-act.mjs | --- | --- | | `demo.mjs` | A fill happens and the agent's tool result is printed, so you can check the password is not in it. An off-host fill is denied. | | `register-login-act.mjs` | The whole flow: the bridge signs up (generating the password), stores it encrypted, fills and submits a login so the **agent's own tab** ends up authenticated, and the agent then updates a profile as that user. The password is in none of it. | -| `agent.mjs` | The minimal CDP client the two above use to stand in for a framework. Not a demo on its own. | +| `full-flow-capture.mjs` | Adds the two remaining directions: **capture** (while logged in, the bridge generates an API key on the site, reads it, and stores it in the vault — a fill in reverse) and **execute** (the agent runs an intent that uses the captured key in a real request). The key is in none of the agent's output. | +| `agent.mjs` | The minimal CDP client the examples use to stand in for a framework. Not a demo on its own. | +| `intent-executor.mjs` | A local stand-in for the 1Claw Execution Intents API (`POST /v1/agents/{id}/execute`): a binding says which vaulted secret and how to inject it; the agent passes params, never the secret. Used by `full-flow-capture.mjs`. Not the production path — the hosted Intents API runs inside a TEE with guardrails and audit — but it demonstrates the same property. | `register-login-act.mjs` prints each step: diff --git a/packages/browser-bridge/examples/full-flow-capture.mjs b/packages/browser-bridge/examples/full-flow-capture.mjs new file mode 100644 index 0000000..5969ffc --- /dev/null +++ b/packages/browser-bridge/examples/full-flow-capture.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// Copyright (C) 2026 1Claw +// SPDX-License-Identifier: Apache-2.0 + +/** + * The whole thing, and the agent sees no secret at any step: + * + * 1. register — the bridge signs up and generates the password + * 2. log in — the bridge fills and submits; the agent's tab is authed + * 3. capture — while logged in, the bridge generates an API key on the + * site, reads it, and stores it in the vault + * 4. execute — the agent runs an intent that uses the captured key in a + * real request; a local executor injects it, the agent gets + * the response + * + * Steps 1-3 are browser-bridge. Step 4 is a stand-in for the 1Claw Execution + * Intents API (see examples/intent-executor.mjs): the credential is used + * without the agent ever holding it. + * + * node examples/full-flow-capture.mjs [--chrome /path/to/chrome] + */ +import { createServer } from "node:http"; +import { existsSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startBridge, LocalVaultDriver, sealVault, openVault } from "../dist/index.js"; +import { Agent } from "./agent.mjs"; +import { executeIntent } from "./intent-executor.mjs"; + +const argv = process.argv.slice(2); +const flag = (n) => { const i = argv.indexOf(`--${n}`); return i > -1 ? argv[i + 1] : undefined; }; +const CHROME = + flag("chrome") ?? process.env.ONECLAW_BRIDGE_CHROME ?? + { darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", linux: "/usr/bin/google-chrome" }[process.platform]; +if (!CHROME || !existsSync(CHROME)) { console.error(`No Chromium at ${CHROME ?? "(unknown)"}. Pass --chrome.`); process.exit(2); } +const PASSPHRASE = "a-long-enough-demo-passphrase"; + +// ── A site: signup, login, an API-keys page, and a key-protected endpoint ──── +const users = new Map(); +let issuedKey = ""; +const bodyOf = (req) => new Promise((r) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => r(b)); }); +const site = createServer(async (req, res) => { + const u = new URL(req.url, "http://x"); + const signedIn = (req.headers.cookie ?? "").includes("session=ada"); + if (u.pathname === "/signup" && req.method === "GET") + return void res.end(`
`); + if (u.pathname === "/signup" && req.method === "POST") { const p = new URLSearchParams(await bodyOf(req)); users.set(p.get("email"), p.get("password") || ""); return void res.writeHead(302, { location: "/welcome" }).end(); } + if (u.pathname === "/welcome") return void res.end("

Welcome

"); + if (u.pathname === "/login" && req.method === "GET") + return void res.end(`
`); + if (u.pathname === "/session" && req.method === "POST") { const p = new URLSearchParams(await bodyOf(req)); if (users.get(p.get("username")) !== p.get("password")) return void res.writeHead(302, { location: "/login?bad=1" }).end(); return void res.writeHead(302, { location: "/account", "set-cookie": "session=ada; Path=/" }).end(); } + if (u.pathname === "/account") return void res.writeHead(signedIn ? 200 : 401, { "content-type": "text/html" }).end(`
${signedIn ? "ada@example.com" : "anonymous"}
`); + if (u.pathname === "/settings/api") { + if (!signedIn) return void res.writeHead(401).end("no"); + return void res.writeHead(200, { "content-type": "text/html" }).end(``); + } + if (u.pathname === "/issue-key" && req.method === "POST") { if (!signedIn) return void res.writeHead(401).end("no"); issuedKey = "sk_live_" + Math.abs(Date.now() ^ (Math.random() * 1e9 | 0)).toString(36); return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ key: issuedKey })); } + // A key-protected "weather" endpoint — the thing step 4 calls with the key. + if (u.pathname === "/api/weather") { + if (u.searchParams.get("key") !== issuedKey) return void res.writeHead(401).end('{"error":"bad key"}'); + return void res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ city: u.searchParams.get("q"), tempC: 17 })); + } + res.writeHead(404).end(); +}); +await new Promise((r) => site.listen(0, "127.0.0.1", r)); +const origin = `http://127.0.0.1:${site.address().port}`; + +// ── Vault: a signup policy and a capture policy, both human-authored ───────── +const dir = mkdtempSync(join(tmpdir(), "1claw-fullcap-")); +const vaultPath = join(dir, "vault.json"); +writeFileSync(vaultPath, JSON.stringify(await sealVault({ + entries: [], + registrations: [{ id: "acme", signupUrl: `${origin}/signup`, loginUrl: `${origin}/login`, username: "ada@example.com", allowedHosts: ["127.0.0.1"], usernameSelector: "#email", passwordSelector: "#password", submitSelector: "#go", success: { urlChanges: true } }], + captures: [{ id: "acme-key", captureUrl: `${origin}/settings/api`, loginUrl: `${origin}/login`, allowedHosts: ["127.0.0.1"], generateSelector: "#generate", valueSelector: "#api-key", valueProp: "value" }], +}, PASSPHRASE))); + +const backend = new LocalVaultDriver({ path: vaultPath, passphrase: PASSPHRASE }); +await backend.open(); +const bridge = await startBridge({ executablePath: CHROME, backend, host: "127.0.0.1", port: 0, args: ["--headless=new"] }); +const observe = (frameId) => () => ({ tabOrigin: origin, frameOrigin: origin, formActionOrigin: origin, frameId, generation: 0 }); + +try { + console.log(`\n site: ${origin}\n bridge: ${bridge.url}\n tools: ${bridge.tools.map((t) => t.name).join(", ")}\n`); + + const reg = await bridge.callTool("begin_credential_registration", { site_id: "acme" }, observe("acme")); + console.log(` 1. register -> ${JSON.stringify(reg)}`); + + const agent = await Agent.connect(bridge.url); + const { targetId, sessionId } = await agent.openTab(`${origin}/account`); + const who = async () => { for (let i = 0; i < 60; i++) { const v = await agent.evaluate(sessionId, "document.querySelector('#who')?.textContent ?? ''"); if (v) return v; await new Promise((r) => setTimeout(r, 150)); } return ""; }; + console.log(` 2. before login -> ${await who()}`); + + const fill = await bridge.callTool("request_fill", { binding_id: "acme", target_id: targetId, selector: "#password" }, observe(targetId)); + await agent.reload(sessionId); + console.log(` login fill -> ${JSON.stringify(fill)}; agent tab now: ${await who()}`); + + const cap = await bridge.callTool("begin_credential_capture", { site_id: "acme-key", target_id: targetId }, observe(targetId)); + console.log(` 3. capture key -> ${JSON.stringify(cap)}`); + + // 4. Execution intent: the agent asks to run a request that needs the key. + // It passes params (the city), never the key. The executor injects it. + const result = await executeIntent({ + vaultPath, passphrase: PASSPHRASE, + binding: { method: "GET", url: `${origin}/api/weather?q={{city}}`, secretEntryId: "acme-key", inject: { as: "query", name: "key" } }, + params: { city: "London" }, + }); + console.log(` 4. execute intent -> HTTP ${result.status}, body ${result.body}`); + + const key = openVault(JSON.parse(readFileSync(vaultPath, "utf8")), PASSPHRASE).then((d) => d.entries.find((e) => e.id === "acme-key")?.secret); + const storedKey = await key; + const agentSaw = [reg, fill, cap, result].some((r) => JSON.stringify(r).includes(storedKey)); + const ok = reg.status === "registered" && fill.status === "filled" && cap.status === "captured" && result.status === 200 && JSON.parse(result.body).tempC === 17 && !agentSaw; + console.log(`\n agent ever saw the key: ${agentSaw ? "YES — BUG" : "no"}`); + console.log(` ${ok ? "OK" : "FAILED"}: registered, logged in, captured a key, and used it — the agent never saw it\n`); + agent.close(); + process.exitCode = ok ? 0 : 1; +} finally { + await bridge.close(); + await new Promise((r) => site.close(r)); +} diff --git a/packages/browser-bridge/examples/intent-executor.mjs b/packages/browser-bridge/examples/intent-executor.mjs new file mode 100644 index 0000000..ae52ab0 --- /dev/null +++ b/packages/browser-bridge/examples/intent-executor.mjs @@ -0,0 +1,55 @@ +// Copyright (C) 2026 1Claw +// SPDX-License-Identifier: Apache-2.0 + +/** + * A minimal local stand-in for the 1Claw Execution Intents API + * (`POST /v1/agents/{id}/execute`), so the capture examples can close the loop + * without a hosted account. + * + * The shape is the platform's: a human authors a binding — which vaulted secret, + * and how it is injected into one outbound request. The agent calls + * `executeIntent` with the binding id and some params, and gets the response + * back. It never sees the secret. The executor holds the vault passphrase, the + * way the hosted runtime holds the key inside the TEE, and injects the secret + * itself. + * + * This is a demonstration executor, not the production path — the hosted Intents + * API runs the request inside a TEE with guardrails, rate limits, and audit. + * What it shows is the same property: the credential is used without ever + * reaching the agent. + */ +import { readFileSync } from "node:fs"; +import { openVault } from "../dist/index.js"; + +/** + * @param {object} o + * @param {string} o.vaultPath path to the encrypted vault + * @param {string} o.passphrase its passphrase (the executor is trusted) + * @param {object} o.binding { method?, url, secretEntryId, inject } + * inject: { as: 'query', name } | { as: 'header', name, template? } + * `url` and header `template` may contain {{param}} and, for a header, + * {{secret}} — the only place the secret is ever substituted. + * @param {Record} [o.params] + * @returns {Promise<{status:number, body:string}>} + */ +export async function executeIntent({ vaultPath, passphrase, binding, params = {} }) { + const doc = await openVault(JSON.parse(readFileSync(vaultPath, "utf8")), passphrase); + const entry = doc.entries.find((e) => e.id === binding.secretEntryId); + if (!entry) throw new Error(`no vaulted secret "${binding.secretEntryId}"`); + const secret = entry.secret; + + const fill = (s) => s.replace(/\{\{(\w+)\}\}/g, (_, k) => encodeURIComponent(params[k] ?? "")); + let url = fill(binding.url); + const headers = {}; + + if (binding.inject.as === "query") { + const u = new URL(url); + u.searchParams.set(binding.inject.name, secret); // the secret goes on the wire, never to the agent + url = u.toString(); + } else { + headers[binding.inject.name] = (binding.inject.template ?? "{{secret}}").replace("{{secret}}", secret); + } + + const res = await fetch(url, { method: binding.method ?? "GET", headers }); + return { status: res.status, body: await res.text() }; +} diff --git a/packages/browser-bridge/src/adversarial.test.ts b/packages/browser-bridge/src/adversarial.test.ts index 3ea330f..f18c99c 100644 --- a/packages/browser-bridge/src/adversarial.test.ts +++ b/packages/browser-bridge/src/adversarial.test.ts @@ -51,6 +51,7 @@ function backend(): VaultBackend & { asked: FillRequest[] } { capabilities: () => ({ fills: true, registration: false, + capture: false, checkout: false, signing: false, hitl: false, diff --git a/packages/browser-bridge/src/bridge.ts b/packages/browser-bridge/src/bridge.ts index 343571c..26885c6 100644 --- a/packages/browser-bridge/src/bridge.ts +++ b/packages/browser-bridge/src/bridge.ts @@ -8,9 +8,11 @@ import { CdpGate } from "./cdp-policy.js"; import type { CdpMessage, CdpTransport } from "./cdp-transport.js"; import { FillEngine, type FillOutcome } from "./fill-engine.js"; import { RegistrationEngine } from "./registration-engine.js"; +import { CaptureEngine } from "./capture-engine.js"; import { buildToolset, dispatchTool, type ToolDefinition, type ToolResult } from "./mcp-tools.js"; import { PipeCdpTransport } from "./pipe-transport.js"; import { CdpProxyServer } from "./proxy-server.js"; +import type { SecretHandle } from "./secret-handle.js"; import type { VaultBackend } from "./vault-backend.js"; /** @@ -176,6 +178,70 @@ class RegistrationAdapter { } } +/** + * Binds a backend's capture methods to the core's capture engine. + * + * Capture is registration's mirror: the value is read off the page rather than + * generated by the backend, so the secret comes IN through `commitCapture`. The + * engine still knows nothing about which backend it is talking to. + */ +class CaptureAdapter { + readonly #engine: CaptureEngine; + readonly #backend: VaultBackend; + + constructor( + backend: VaultBackend, + transport: CdpTransport, + gate: CdpGate, + browserContextOf: (targetId: string) => string | undefined, + onError: (e: unknown) => void, + ) { + this.#backend = backend; + const b = backend as unknown as { + commitCapture?: (id: string, secret: SecretHandle) => Promise<{ entryId: string }>; + cancelCapture?: (id: string) => Promise; + }; + this.#engine = new CaptureEngine({ + transport, + gate, + browserContextOf, + commit: (id, secret) => { + if (!b.commitCapture) { + throw new Error("backend advertises capture but cannot commit"); + } + return b.commitCapture(id, secret); + }, + cancel: async (id) => { + await b.cancelCapture?.(id); + }, + onError, + }); + } + + async capture(siteId: string, targetId: string): Promise { + const b = this.#backend as unknown as { + beginCapture?: (req: { siteId: string }) => Promise>; + }; + if (!b.beginCapture) return { status: "error", message: "capture is not available" }; + + const decision = await b.beginCapture({ siteId }); + if (decision.kind !== "capture_grant") { + return { status: "denied", reason: String(decision.reason ?? "policy_denied") }; + } + const outcome = await this.#engine.capture(targetId, decision as never); + switch (outcome.status) { + case "captured": + return { status: "captured", entryId: outcome.entryId }; + case "denied": + return { status: "denied", reason: outcome.reason }; + case "rejected": + return { status: "rejected", reason: outcome.reason }; + default: + return { status: "error", message: outcome.message }; + } + } +} + /** Bumped by navigation; the fill re-checks it immediately before typing. */ class Generations { readonly #byTarget = new Map(); @@ -265,6 +331,12 @@ export async function startBridge(opts: BridgeOptions): Promise { } }); + // The context a target belongs to. The proxy is authoritative (it placed the + // agent's target); the event-derived map is a fallback for targets it did not + // open. Shared by the fill and capture engines: both open a windowed page in + // the agent's context so the site's logged-in session applies. + const browserContextOf = (t: string) => proxyContextForTarget?.(t) ?? targetContexts.get(t); + // Only constructed when the backend says it can register. const registrations = backend.capabilities().registration ? new RegistrationAdapter(backend, transport, gate, (e) => @@ -272,6 +344,13 @@ export async function startBridge(opts: BridgeOptions): Promise { ) : undefined; + // Only constructed when the backend says it can capture. + const captures = backend.capabilities().capture + ? new CaptureAdapter(backend, transport, gate, browserContextOf, (e) => + console.error("[browser-bridge] capture failed:", e), + ) + : undefined; + const fills = new FillEngine({ backend, transport, @@ -282,7 +361,7 @@ export async function startBridge(opts: BridgeOptions): Promise { // The proxy is authoritative: it placed the agent's target, so it knows the // context. The event-derived map is only a fallback for targets it did not // open, and needs setDiscoverTargets to be populated at all. - browserContextOf: (t) => proxyContextForTarget?.(t) ?? targetContexts.get(t), + browserContextOf, // stderr, not the tool result. The operator needs the reason; the agent // must not have it. onError: (e) => console.error("[browser-bridge] fill failed:", e), @@ -359,6 +438,7 @@ export async function startBridge(opts: BridgeOptions): Promise { // capability can differ from a method's existence on a driver, and the // tool must not work in that gap. ...(registrations ? { register: (siteId: string) => registrations.register(siteId) } : {}), + ...(captures ? { capture: (siteId: string, targetId: string) => captures.capture(siteId, targetId) } : {}), // The executor is the only path from a tool call to a credential, and // it returns a status. A tool result that carried the secret would be // the shortest way around every control in this package. diff --git a/packages/browser-bridge/src/capture-engine.ts b/packages/browser-bridge/src/capture-engine.ts new file mode 100644 index 0000000..7d36f38 --- /dev/null +++ b/packages/browser-bridge/src/capture-engine.ts @@ -0,0 +1,183 @@ +// Copyright (C) 2026 1Claw +// SPDX-License-Identifier: Apache-2.0 + +import type { CaptureGrant, CaptureOutcome } from "@1claw/browser-bridge-protocol"; +import type { CdpGate } from "./cdp-policy.js"; +import type { CdpTransport } from "./cdp-transport.js"; +import { SecretHandle } from "./secret-handle.js"; + +export type CaptureEngineDeps = { + readonly transport: CdpTransport; + readonly gate: CdpGate; + /** + * The browser context the agent's target lives in. + * + * A capture reads a secret the site shows only to a logged-in session, and + * that session lives in the agent's context (a fill put it there). So the + * windowed page is opened in that same context — authenticated — rather than + * a fresh one, exactly as a fill's typing page is. + */ + readonly browserContextOf?: (targetId: string) => string | undefined; + /** Store the captured secret. Consumes the handle. Returns the vault id. */ + readonly commit: (captureId: string, secret: SecretHandle) => Promise<{ entryId: string }>; + readonly cancel: (captureId: string) => Promise; + readonly onError?: (error: unknown) => void; + /** How long to wait for the value to appear before giving up. */ + readonly settleMs?: number; +}; + +const DEFAULT_SETTLE_MS = 15_000; +const POLL_MS = 250; + +/** + * Read a secret a site generates, and store it — without the agent seeing it. + * + * The mirror image of the fill engine, and it earns the same invariant the same + * way. The value is read in a target the agent has never scripted, while the + * agent's own target is windowed so it cannot observe the read, and the value + * is wrapped in a `SecretHandle` the instant it exists and handed to the + * backend — it never becomes a tool result, a log line, or a return value. + * + * The ordering matters, as it does for a fill: + * + * 1. Window the agent's target first, before any secret exists here — a gap + * before the window is a gap in which a listener the agent installed + * earlier could watch the read. + * 2. Open the read page in the agent's own context, so it is logged in. + * 3. Generate (if the policy names a control), then read the value. + * 4. Wrap and commit; the buffer is zeroed by the handle. + * 5. Close the window in `finally`, so a failure cannot strand it. + */ +export class CaptureEngine { + readonly #deps: CaptureEngineDeps; + + constructor(deps: CaptureEngineDeps) { + this.#deps = deps; + } + + async capture(agentTargetId: string, grant: CaptureGrant): Promise { + const { transport, gate, commit, cancel } = this.#deps; + const settleMs = this.#deps.settleMs ?? DEFAULT_SETTLE_MS; + + // 1. Block the agent's own target before anything is read. + gate.openFillWindow(agentTargetId); + + let target: string | undefined; + let handle: SecretHandle | undefined; + let committed = false; + try { + // 2. A page the agent has never scripted, in the agent's context so the + // site's logged-in session applies. + const contextId = this.#deps.browserContextOf?.(agentTargetId); + const created = (await transport.send({ + method: "Target.createTarget", + params: { url: "about:blank", ...(contextId !== undefined ? { browserContextId: contextId } : {}) }, + })) as { result?: { targetId?: string } }; + target = created.result?.targetId; + if (!target) return { status: "error", message: "could not open a page" }; + + // Window it immediately: getTargets and attachToTarget are allowlisted, so + // an agent that notices the new target must still be refused on it. + gate.openFillWindow(target); + + const attached = (await transport.send({ + method: "Target.attachToTarget", + params: { targetId: target, flatten: true }, + })) as { result?: { sessionId?: string } }; + const sessionId = attached.result?.sessionId; + if (!sessionId) return { status: "error", message: "could not attach" }; + + await transport.send({ sessionId, method: "Page.enable" }); + await transport.send({ sessionId, method: "Runtime.enable" }); + // The policy's URL, never the agent's. + await transport.send({ sessionId, method: "Page.navigate", params: { url: grant.captureUrl } }); + + // 3. Generate, if there is a control to click, then read. + if (grant.source.generateSelector) { + await this.#waitFor(sessionId, grant.source.generateSelector, settleMs); + await this.#click(sessionId, grant.source.generateSelector); + } + const value = await this.#readValue(sessionId, grant.source, settleMs); + if (value === undefined || value === "") { + await cancel(grant.captureId); + return { status: "rejected", reason: "no_value_found" }; + } + + // 4. Own the bytes before anything else touches them, then commit. + handle = SecretHandle.adopt(new TextEncoder().encode(value), `capture:${grant.entryId}`); + const { entryId } = await commit(grant.captureId, handle); + // commit consumed the handle; drop the reference so `finally` does not + // dispose an inert one. + handle = undefined; + committed = true; + return { status: "captured", entryId }; + } catch (err) { + // Not `err.message`: this reaches the agent, and the text comes from the + // transport and the backend. + this.#deps.onError?.(err); + return { status: "error", message: "the capture did not complete" }; + } finally { + handle?.dispose(); + if (!committed) await cancel(grant.captureId).catch(() => {}); + if (target !== undefined) { + await this.#deps.transport + .send({ method: "Target.closeTarget", params: { targetId: target } }) + .catch(() => {}); + this.#deps.gate.closeFillWindow(target); + } + // 5. Always. A stuck window locks the agent out of its own browser. + this.#deps.gate.closeFillWindow(agentTargetId); + } + } + + /** Poll the value selector until it holds something, or time out. */ + async #readValue( + sessionId: string, + source: CaptureGrant["source"], + settleMs: number, + ): Promise { + const sel = JSON.stringify(source.valueSelector); + const read = source.valueAttr + ? `document.querySelector(${sel})?.getAttribute(${JSON.stringify(source.valueAttr)}) ?? ""` + : source.valueProp === "value" + ? `document.querySelector(${sel})?.value ?? ""` + : source.valueProp === "textContent" + ? `(document.querySelector(${sel})?.textContent ?? "").trim()` + : // Take whichever is non-empty: an input exposes .value, a code block + // exposes .textContent, and reading the wrong one yields "". + `(el => el ? String(el.value || (el.textContent || "").trim()) : "")(document.querySelector(${sel}))`; + const deadline = Date.now() + settleMs; + while (Date.now() < deadline) { + const v = await this.#eval(sessionId, read); + if (typeof v === "string" && v !== "") return v; + await new Promise((r) => setTimeout(r, POLL_MS)); + } + return undefined; + } + + async #waitFor(sessionId: string, selector: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await this.#present(sessionId, selector)) return; + await new Promise((r) => setTimeout(r, POLL_MS)); + } + throw new Error(`the control never appeared (${selector})`); + } + + async #present(sessionId: string, selector: string): Promise { + return (await this.#eval(sessionId, `!!document.querySelector(${JSON.stringify(selector)})`)) === true; + } + + async #click(sessionId: string, selector: string): Promise { + await this.#eval(sessionId, `document.querySelector(${JSON.stringify(selector)})?.click()`); + } + + async #eval(sessionId: string, expression: string): Promise { + const out = (await this.#deps.transport.send({ + sessionId, + method: "Runtime.evaluate", + params: { expression, returnByValue: true }, + })) as { result?: { result?: { value?: unknown } } }; + return out.result?.result?.value; + } +} diff --git a/packages/browser-bridge/src/capture-real.test.ts b/packages/browser-bridge/src/capture-real.test.ts new file mode 100644 index 0000000..516b39d --- /dev/null +++ b/packages/browser-bridge/src/capture-real.test.ts @@ -0,0 +1,266 @@ +// Copyright (C) 2026 1Claw +// SPDX-License-Identifier: Apache-2.0 + +/** + * The whole no-see chain, against a real browser. + * + * A fill types a stored secret into a page; a capture reads a site-generated + * secret out of one. This drives both, end to end: the bridge registers an + * account, logs in with it, and then — while the agent's tab is authenticated — + * opens a page the agent has never scripted, generates an API key, reads it, and + * stores it in the vault. The agent's tool results carry a status and an id, and + * the key appears in neither. The site records the key it issued, and the test + * requires the vault to hold exactly that value. + */ +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WebSocket } from "ws"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { startBridge, type BridgeHandle } from "./bridge.js"; +import { LocalVaultDriver } from "./drivers/local.js"; +import { openVault, sealVault } from "./drivers/local-vault-file.js"; + +const CHROME = + process.env.ONECLAW_BRIDGE_CHROME ?? + { + darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + linux: "/usr/bin/google-chrome", + }[process.platform as "darwin" | "linux"] ?? + ""; +const HAVE_CHROME = CHROME !== "" && existsSync(CHROME); +const PASSPHRASE = "a-long-enough-passphrase"; +const LAUNCH_ARGS = [ + "--headless=new", + ...(process.env.CI && process.platform === "linux" ? ["--no-sandbox", "--disable-dev-shm-usage"] : []), +]; + +let server: Server; +let origin = ""; +let issuedKey = ""; // the key the site actually handed out + +function randomKey(): string { + // Deterministic-free but unique enough for one test run. + return "sk_live_" + Buffer.from(String(process.hrtime.bigint())).toString("hex"); +} + +beforeAll(async () => { + const users = new Map(); + const body = (req: import("node:http").IncomingMessage) => + new Promise((r) => { + let b = ""; + req.on("data", (c) => (b += c)); + req.on("end", () => r(b)); + }); + + server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://x"); + const signedIn = (req.headers.cookie ?? "").includes("session=ada"); + + if (url.pathname === "/signup" && req.method === "GET") { + res.end(`
+ +
`); + return; + } + if (url.pathname === "/signup" && req.method === "POST") { + const p = new URLSearchParams(await body(req)); + users.set(p.get("email") ?? "", p.get("password") ?? ""); + res.writeHead(302, { location: "/welcome" }).end(); + return; + } + if (url.pathname === "/welcome") return void res.end("

Welcome

"); + if (url.pathname === "/login" && req.method === "GET") { + res.end(`
+ +
`); + return; + } + if (url.pathname === "/session" && req.method === "POST") { + const p = new URLSearchParams(await body(req)); + if (users.get(p.get("username") ?? "") !== p.get("password")) { + res.writeHead(302, { location: "/login?bad=1" }).end(); + return; + } + res.writeHead(302, { location: "/account", "set-cookie": "session=ada; Path=/" }).end(); + return; + } + if (url.pathname === "/account") { + res + .writeHead(signedIn ? 200 : 401, { "content-type": "text/html" }) + .end(`
${signedIn ? "ada@example.com" : "anonymous"}
`); + return; + } + // The API-keys page: a Generate button that asks the server for a key and + // drops it into a field. Only for a logged-in session. + if (url.pathname === "/settings/api") { + if (!signedIn) return void res.writeHead(401).end("not signed in"); + res.writeHead(200, { "content-type": "text/html" }).end(` + + + `); + return; + } + if (url.pathname === "/issue-key" && req.method === "POST") { + if (!signedIn) return void res.writeHead(401).end("not signed in"); + issuedKey = randomKey(); + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ key: issuedKey })); + return; + } + res.writeHead(404).end(); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(() => new Promise((r) => server.close(() => r()))); + +class Agent { + #ws: WebSocket; + #next = 1; + readonly #pending = new Map) => void>(); + private constructor(ws: WebSocket) { + this.#ws = ws; + this.#ws.on("message", (data) => { + const msg = JSON.parse(String(data)) as { id?: number }; + if (typeof msg.id === "number") { + this.#pending.get(msg.id)?.(msg as Record); + this.#pending.delete(msg.id); + } + }); + } + static async connect(url: string): Promise { + const ws = new WebSocket(url); + await new Promise((res, rej) => { + ws.once("open", res); + ws.once("error", rej); + }); + return new Agent(ws); + } + send(msg: Record): Promise> { + const id = this.#next++; + return new Promise((resolve) => { + this.#pending.set(id, resolve); + this.#ws.send(JSON.stringify({ id, ...msg })); + }); + } + close(): void { + this.#ws.close(); + } +} + +let bridge: BridgeHandle | undefined; +afterEach(async () => { + await bridge?.close(); + bridge = undefined; +}); + +async function start(vaultPath: string) { + const backend = new LocalVaultDriver({ path: vaultPath, passphrase: PASSPHRASE }); + await backend.open(); + bridge = await startBridge({ executablePath: CHROME, backend, host: "127.0.0.1", port: 0, args: LAUNCH_ARGS }); + return bridge!; +} + +describe.skipIf(!HAVE_CHROME)("register, log in, and capture a generated key", () => { + it("stores the site's API key in the vault, and the agent never sees it", async () => { + issuedKey = ""; + const dir = mkdtempSync(join(tmpdir(), "1claw-capture-real-")); + const vaultPath = join(dir, "vault.json"); + writeFileSync( + vaultPath, + JSON.stringify( + await sealVault( + { + entries: [], + registrations: [ + { + id: "acme", + signupUrl: `${origin}/signup`, + loginUrl: `${origin}/login`, + username: "ada@example.com", + allowedHosts: ["127.0.0.1"], + usernameSelector: "#email", + passwordSelector: "#password", + submitSelector: "#go", + success: { urlChanges: true }, + }, + ], + captures: [ + { + id: "acme-key", + captureUrl: `${origin}/settings/api`, + loginUrl: `${origin}/login`, + allowedHosts: ["127.0.0.1"], + generateSelector: "#generate", + valueSelector: "#api-key", + valueProp: "value", + }, + ], + }, + PASSPHRASE, + ), + ), + ); + + const b = await start(vaultPath); + const observe = (frameId: string) => () => ({ + tabOrigin: origin, + frameOrigin: origin, + formActionOrigin: origin, + frameId, + generation: 0, + }); + + // 1. Register — creates the account, stores the generated password. + const reg = await b.callTool("begin_credential_registration", { site_id: "acme" }, observe("acme")); + expect(reg).toMatchObject({ status: "registered", bindingId: "acme" }); + + // 2. The agent opens its own tab and logs in via a fill. + const agent = await Agent.connect(b.url); + const made = await agent.send({ method: "Target.createTarget", params: { url: `${origin}/account` } }); + const target = (made as { result?: { targetId?: string } }).result?.targetId!; + await agent.send({ method: "Target.attachToTarget", params: { targetId: target, flatten: true } }); + + const fill = await b.callTool( + "request_fill", + { binding_id: "acme", target_id: target, selector: "#password" }, + observe(target), + ); + expect(fill).toMatchObject({ status: "filled" }); + + // 3. Capture — while logged in, generate an API key and store it. The agent + // passes its tab (for the session) and the site id, nothing else. + const cap = await b.callTool( + "begin_credential_capture", + { site_id: "acme-key", target_id: target }, + observe(target), + ); + expect(cap).toMatchObject({ status: "captured", entryId: "acme-key" }); + // The key is in no agent-visible output. + expect(issuedKey.length).toBeGreaterThan(10); + expect(JSON.stringify(cap)).not.toContain(issuedKey); + + // The vault holds exactly the key the site issued, encrypted at rest. + const raw = await readFile(vaultPath, "utf8"); + expect(raw).not.toContain(issuedKey); + const doc = await openVault(JSON.parse(raw), PASSPHRASE); + const entry = doc.entries.find((e) => e.id === "acme-key"); + expect(entry?.secret).toBe(issuedKey); + + agent.close(); + }, 120_000); +}); + +describe.skipIf(HAVE_CHROME)("register, log in, and capture a generated key", () => { + it("is skipped because no Chromium was found", () => { + expect(HAVE_CHROME).toBe(false); + }); +}); diff --git a/packages/browser-bridge/src/capture.test.ts b/packages/browser-bridge/src/capture.test.ts new file mode 100644 index 0000000..70282d5 --- /dev/null +++ b/packages/browser-bridge/src/capture.test.ts @@ -0,0 +1,134 @@ +// Copyright (C) 2026 1Claw +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtempSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { LocalVaultDriver } from "./drivers/local.js"; +import { openVault, sealVault, type CapturePolicy } from "./drivers/local-vault-file.js"; +import { SecretHandle } from "./secret-handle.js"; + +const PASSPHRASE = "a-long-enough-passphrase"; +const SLOW = 30_000; + +const POLICY: CapturePolicy = { + id: "acme-key", + captureUrl: "https://acme.example.com/settings/api", + loginUrl: "https://acme.example.com/login", + allowedHosts: ["acme.example.com"], + generateSelector: "#generate", + valueSelector: "#api-key", + valueProp: "value", +}; + +async function vault(captures: CapturePolicy[] = [POLICY]) { + const dir = mkdtempSync(join(tmpdir(), "1claw-capture-test-")); + const path = join(dir, "vault.json"); + writeFileSync(path, JSON.stringify(await sealVault({ entries: [], registrations: [], captures }, PASSPHRASE))); + const d = new LocalVaultDriver({ path, passphrase: PASSPHRASE }); + await d.open(); + await d.openSession({ clientId: "c", bridgeVersion: "0.1.0", protocolVersion: "0.1.0" }); + return { d, path }; +} + +describe("what the agent can and cannot choose", () => { + it("takes the URL, source and destination from the policy, not the request", async () => { + const { d } = await vault(); + const out = await d.beginCapture({ siteId: "acme-key" }); + expect(out).toMatchObject({ + kind: "capture_grant", + captureUrl: POLICY.captureUrl, + entryId: "acme-key", + source: { generateSelector: "#generate", valueSelector: "#api-key", valueProp: "value" }, + }); + // The request type has nowhere to put an alternative. + expect(Object.keys({ siteId: "acme-key" })).toEqual(["siteId"]); + }, SLOW); + + it("refuses a site nobody authorised, without saying it is unknown", async () => { + const { d } = await vault(); + expect(await d.beginCapture({ siteId: "evil" })).toMatchObject({ kind: "denied", reason: "policy_denied" }); + }, SLOW); + + it("never puts a secret in the grant", async () => { + const { d } = await vault(); + const grant = await d.beginCapture({ siteId: "acme-key" }); + // There is nothing to leak — the value does not exist yet — but the shape + // must have no place for one regardless. + expect(JSON.stringify(grant)).not.toMatch(/secret["']?\s*:/i); + expect(Object.keys(grant as object)).not.toContain("secret"); + }, SLOW); + + it("advertises the capability only when a policy exists", async () => { + const { d: withPolicy } = await vault(); + expect(withPolicy.capabilities().capture).toBe(true); + const { d: without } = await vault([]); + expect(without.capabilities().capture).toBe(false); + }, SLOW); + + it("refuses two captures for one site at once", async () => { + const { d } = await vault(); + expect((await d.beginCapture({ siteId: "acme-key" })).kind).toBe("capture_grant"); + expect(await d.beginCapture({ siteId: "acme-key" })).toMatchObject({ reason: "fill_in_progress" }); + }, SLOW); +}); + +describe("nothing is stored unless the value was read", () => { + it("writes no credential when the capture is cancelled", async () => { + const { d, path } = await vault(); + const grant = await d.beginCapture({ siteId: "acme-key" }); + if (grant.kind !== "capture_grant") throw new Error("expected a grant"); + await d.cancelCapture(grant.captureId); + const doc = await openVault(JSON.parse(await readFile(path, "utf8")), PASSPHRASE); + expect(doc.entries).toHaveLength(0); + }, SLOW); + + it("writes the captured secret on commit, encrypted, and usable as a binding", async () => { + const { d, path } = await vault(); + const grant = await d.beginCapture({ siteId: "acme-key" }); + if (grant.kind !== "capture_grant") throw new Error("expected a grant"); + + const KEY = "sk-live-abc123-the-captured-key"; + const handle = SecretHandle.adopt(new TextEncoder().encode(KEY), "test"); + const { entryId } = await d.commitCapture(grant.captureId, handle); + expect(entryId).toBe("acme-key"); + + const raw = await readFile(path, "utf8"); + expect(raw).not.toContain(KEY); // encrypted at rest + const doc = await openVault(JSON.parse(raw), PASSPHRASE); + expect(doc.entries).toHaveLength(1); + expect(doc.entries[0]).toMatchObject({ + id: "acme-key", + secret: KEY, + loginUrl: POLICY.loginUrl, + allowedHosts: POLICY.allowedHosts, + }); + }, SLOW); + + it("stores under a distinct entry-id when the policy names one", async () => { + const { d } = await vault([{ ...POLICY, entryId: "acme-api-token" }]); + const grant = await d.beginCapture({ siteId: "acme-key" }); + if (grant.kind !== "capture_grant") throw new Error("expected a grant"); + expect(grant.entryId).toBe("acme-api-token"); + const { entryId } = await d.commitCapture( + grant.captureId, + SecretHandle.adopt(new TextEncoder().encode("k"), "t"), + ); + expect(entryId).toBe("acme-api-token"); + }, SLOW); + + it("refuses to overwrite a credential that already exists", async () => { + const { d } = await vault(); + const g1 = await d.beginCapture({ siteId: "acme-key" }); + if (g1.kind !== "capture_grant") throw new Error("expected a grant"); + await d.commitCapture(g1.captureId, SecretHandle.adopt(new TextEncoder().encode("first"), "t")); + + const g2 = await d.beginCapture({ siteId: "acme-key" }); + if (g2.kind !== "capture_grant") throw new Error("expected a grant"); + await expect( + d.commitCapture(g2.captureId, SecretHandle.adopt(new TextEncoder().encode("second"), "t")), + ).rejects.toThrow(/already exists/); + }, SLOW); +}); diff --git a/packages/browser-bridge/src/drivers/local-vault-file.ts b/packages/browser-bridge/src/drivers/local-vault-file.ts index c999239..3813224 100644 --- a/packages/browser-bridge/src/drivers/local-vault-file.ts +++ b/packages/browser-bridge/src/drivers/local-vault-file.ts @@ -66,12 +66,47 @@ export type RegistrationPolicy = { readonly loginUrl: string; }; +/** + * Permission for an agent to capture a site-generated secret, authored by a + * human. The agent supplies only the `id`; everything that decides what gets + * read and stored is here. + * + * `allowedHosts` is the binding the captured secret is written under, so a + * capture and any later fill of the same credential are governed by one rule. + */ +export type CapturePolicy = { + readonly id: string; + /** The page where the secret is generated and shown. */ + readonly captureUrl: string; + readonly allowedHosts: readonly string[]; + /** A control the bridge clicks to make the secret appear. Omit if already shown. */ + readonly generateSelector?: string; + /** Where the value is read from once it exists. */ + readonly valueSelector: string; + /** Read `.value` or `.textContent`; omit to take whichever is non-empty. */ + readonly valueProp?: "value" | "textContent"; + /** Read a named attribute instead (e.g. `data-clipboard-text`); wins over valueProp. */ + readonly valueAttr?: string; + /** Vault id the captured secret is written under. Defaults to `id`. */ + readonly entryId?: string; + /** Login URL recorded on the resulting entry, so a later fill is governed too. */ + readonly loginUrl: string; +}; + export type VaultEntry = { readonly id: string; readonly secret: string; readonly loginUrl: string; readonly allowedHosts: readonly string[]; readonly ssoHosts?: readonly string[]; + /** + * A username to type before the password, for login forms that do not + * pre-fill it. Not a secret. Both must be present to be used. + */ + readonly username?: string; + readonly usernameSelector?: string; + /** A submit button to click, for forms that need the button's own click. */ + readonly submitSelector?: string; }; /** @@ -84,6 +119,7 @@ export type VaultEntry = { export type VaultContents = { readonly entries: VaultEntry[]; readonly registrations: RegistrationPolicy[]; + readonly captures: CapturePolicy[]; }; /** The on-disk shape. Everything outside `ciphertext` is public by design. */ @@ -131,10 +167,11 @@ export async function sealVault( passphrase: string, ): Promise { const doc: VaultContents = Array.isArray(contents) - ? { entries: [...(contents as readonly VaultEntry[])], registrations: [] } + ? { entries: [...(contents as readonly VaultEntry[])], registrations: [], captures: [] } : { entries: [...(contents as VaultContents).entries], registrations: [...((contents as VaultContents).registrations ?? [])], + captures: [...((contents as VaultContents).captures ?? [])], }; if (passphrase.length < 12) { // The file's only defence. A short passphrase makes the scrypt cost moot. @@ -180,10 +217,11 @@ export async function openVault(file: VaultFile, passphrase: string): Promise = {}): FillRequest => ({ describe("the vault file", () => { it("round-trips through seal and open", async () => { const file = await sealVault([ENTRY], PASSPHRASE); - expect(await openVault(file, PASSPHRASE)).toEqual({ entries: [ENTRY], registrations: [] }); + expect(await openVault(file, PASSPHRASE)).toEqual({ entries: [ENTRY], registrations: [], captures: [] }); }, SLOW); it("still reads a v1 file, which held a bare array", async () => { diff --git a/packages/browser-bridge/src/drivers/local.ts b/packages/browser-bridge/src/drivers/local.ts index e43b231..cf17659 100644 --- a/packages/browser-bridge/src/drivers/local.ts +++ b/packages/browser-bridge/src/drivers/local.ts @@ -5,6 +5,8 @@ import { randomUUID } from "node:crypto"; import { readFile, rename, writeFile } from "node:fs/promises"; import type { AuditEvent, + CaptureDecision, + CaptureRequest, RegistrationDecision, RegistrationRequest, Capabilities, @@ -22,6 +24,7 @@ import type { VaultBackend } from "../vault-backend.js"; import { openVault, sealVault, + type CapturePolicy, type RegistrationPolicy, type VaultContents, type VaultEntry, @@ -74,6 +77,8 @@ export class LocalVaultDriver implements VaultBackend { #index = new Map>(); /** Human-authored permission to create accounts. Never written by an agent. */ #registrations = new Map(); + /** Human-authored permission to capture a site-generated secret. */ + #captures = new Map(); readonly #grants = new Map< string, { entryId: string; generation: number; expiresAt: number } @@ -86,6 +91,8 @@ export class LocalVaultDriver implements VaultBackend { string, { siteId: string; password: string; expiresAt: number } >(); + /** In-flight captures: which policy a captureId is redeeming. */ + readonly #pendingCaptures = new Map(); constructor(opts: LocalVaultDriverOptions) { this.#opts = opts; @@ -101,8 +108,9 @@ export class LocalVaultDriver implements VaultBackend { * browser and believes the thing is working. */ async open(): Promise { - const { entries, registrations } = await this.#decrypt(); + const { entries, registrations, captures } = await this.#decrypt(); this.#registrations = new Map(registrations.map((r) => [r.id, r])); + this.#captures = new Map(captures.map((c) => [c.id, c])); this.#index = new Map( entries.map((e) => [ e.id, @@ -111,6 +119,9 @@ export class LocalVaultDriver implements VaultBackend { loginUrl: e.loginUrl, allowedHosts: e.allowedHosts, ...(e.ssoHosts ? { ssoHosts: e.ssoHosts } : {}), + ...(e.username ? { username: e.username } : {}), + ...(e.usernameSelector ? { usernameSelector: e.usernameSelector } : {}), + ...(e.submitSelector ? { submitSelector: e.submitSelector } : {}), }, ]), ); @@ -126,6 +137,9 @@ export class LocalVaultDriver implements VaultBackend { // backend with none should not advertise the tool: an agent that can see // it will call it, and be refused for a reason it cannot act on. registration: this.#registrations.size > 0, + // Same rule as registration: advertised only when a human has authored a + // policy, so an agent never sees a tool it would only be refused on. + capture: this.#captures.size > 0, checkout: false, signing: false, hitl: false, @@ -196,6 +210,9 @@ export class LocalVaultDriver implements VaultBackend { loginUrl: entry.loginUrl, expiresAt: new Date(now + this.#grantTtlMs).toISOString(), generation: req.generation, + ...(entry.username ? { username: entry.username } : {}), + ...(entry.usernameSelector ? { usernameSelector: entry.usernameSelector } : {}), + ...(entry.submitSelector ? { submitSelector: entry.submitSelector } : {}), }; } @@ -331,6 +348,89 @@ export class LocalVaultDriver implements VaultBackend { this.#pending.delete(registrationId); } + /** + * Begin one capture. + * + * The agent names a pre-authorised site and nothing else. Where the value is + * read from, and the id it is stored under, come from the policy a human + * wrote. No secret is returned — it does not exist yet, and when it does the + * engine hands it to `commitCapture`, never back to the agent. + */ + async beginCapture(req: CaptureRequest): Promise { + const policy = this.#captures.get(req.siteId); + // Same answer as "not allowed": which sites are pre-authorised is not + // something an agent gets to enumerate by probing ids. + if (!policy) { + return { kind: "denied", reason: "policy_denied", message: "not permitted" }; + } + if (!this.#session) { + return { kind: "denied", reason: "session_expired", message: "no open session" }; + } + if ([...this.#pendingCaptures.values()].some((p) => p.policyId === policy.id)) { + return { kind: "denied", reason: "fill_in_progress", message: "already capturing" }; + } + + const captureId = randomUUID(); + this.#pendingCaptures.set(captureId, { policyId: policy.id, expiresAt: Date.now() + 5 * 60 * 1000 }); + + return { + kind: "capture_grant", + captureId, + captureUrl: policy.captureUrl, + source: { + ...(policy.generateSelector ? { generateSelector: policy.generateSelector } : {}), + valueSelector: policy.valueSelector, + ...(policy.valueProp ? { valueProp: policy.valueProp } : {}), + ...(policy.valueAttr ? { valueAttr: policy.valueAttr } : {}), + }, + entryId: policy.entryId ?? policy.id, + }; + } + + /** + * Store the captured secret, now that the bridge has read it off the page. + * + * The value came in from the engine, not the vault, so this is where a + * captured secret first touches disk. Consumes the handle: it is read once, + * under the same passphrase the rest of the file uses, and the buffer is + * zeroed. + */ + async commitCapture(captureId: string, secret: SecretHandle): Promise<{ entryId: string }> { + const pending = this.#pendingCaptures.get(captureId); + if (!pending) throw new Error("no such capture"); + const policy = this.#captures.get(pending.policyId); + if (!policy) throw new Error("capture policy has gone"); + const entryId = policy.entryId ?? policy.id; + + const contents = await this.#decrypt(); + if (contents.entries.some((e) => e.id === entryId)) { + this.#pendingCaptures.delete(captureId); + throw new Error(`a credential for ${entryId} already exists; remove it first`); + } + // Read the handle only once we are committed to storing it. + const value = secret.use((b) => new TextDecoder().decode(b)); + contents.entries.push({ + id: entryId, + secret: value, + loginUrl: policy.loginUrl, + allowedHosts: policy.allowedHosts, + }); + await this.#write(contents); + + this.#pendingCaptures.delete(captureId); + this.#index.set(entryId, { + id: entryId, + loginUrl: policy.loginUrl, + allowedHosts: policy.allowedHosts, + }); + return { entryId }; + } + + /** Discard a capture. Nothing is written. */ + async cancelCapture(captureId: string): Promise { + this.#pendingCaptures.delete(captureId); + } + async audit(event: AuditEvent): Promise { this.#opts.onAudit?.(event); } diff --git a/packages/browser-bridge/src/drivers/mock.ts b/packages/browser-bridge/src/drivers/mock.ts index 216b255..36fcaa4 100644 --- a/packages/browser-bridge/src/drivers/mock.ts +++ b/packages/browser-bridge/src/drivers/mock.ts @@ -90,6 +90,7 @@ export class MockVaultDriver implements VaultBackend { // Everything else is absent rather than present-and-refusing. A tool that // exists and always fails teaches an agent to retry. registration: false, + capture: false, checkout: false, signing: false, hitl: false, diff --git a/packages/browser-bridge/src/drivers/saas.ts b/packages/browser-bridge/src/drivers/saas.ts index 79ed774..816d081 100644 --- a/packages/browser-bridge/src/drivers/saas.ts +++ b/packages/browser-bridge/src/drivers/saas.ts @@ -60,6 +60,7 @@ export class SaasDriver implements VaultBackend { // v0.2. Off until the registration flow and its adversarial suite land; // the tool is absent rather than present-and-refusing. registration: false, + capture: false, // Off until the vault routes exist. A capability advertised ahead of its // endpoint registers a tool that fails on first call, which teaches an // agent to retry against a 404. diff --git a/packages/browser-bridge/src/fill-engine.ts b/packages/browser-bridge/src/fill-engine.ts index 233b6e6..1f2bc3f 100644 --- a/packages/browser-bridge/src/fill-engine.ts +++ b/packages/browser-bridge/src/fill-engine.ts @@ -122,6 +122,19 @@ export class FillEngine { return { status: "aborted", reason: "generation_stale" }; } + // Username first, when the binding carries one. Many real login forms do + // not pre-fill it, and a form submitted with only a password fails. Not a + // secret, so it is typed plainly — but by the bridge in this windowed + // page, so the agent still never scripts the login. + if (grant.usernameSelector && grant.username) { + if (!(await this.#typeInto(sessionId, grant.usernameSelector, grant.username))) { + return { status: "error", message: "the username field never appeared" }; + } + if (currentGeneration(targetId) !== grant.generation) { + return { status: "aborted", reason: "navigated" }; + } + } + // Wait for the field. Page.navigate resolves before the document exists, // so without this the focus below runs against an empty page. if (!(await this.#waitForSelector(sessionId, selector, this.#deps.readyTimeoutMs ?? 10_000))) { @@ -161,7 +174,7 @@ export class FillEngine { // // Without this the ceremony typed a password into a throwaway page and // closed it, so nothing ever logged in — and it reported "filled". - await this.#submit(sessionId, selector); + await this.#submit(sessionId, selector, grant.submitSelector); // 8. Let the submission land before `finally` closes this page. // @@ -223,6 +236,21 @@ export class FillEngine { } } + /** Type a non-secret value into a field: wait for it, focus it, type. */ + async #typeInto(sessionId: string, selector: string, value: string): Promise { + if (!(await this.#waitForSelector(sessionId, selector, this.#deps.readyTimeoutMs ?? 10_000))) { + return false; + } + const focused = await this.#eval( + sessionId, + `(() => { const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) return false; el.focus(); return document.activeElement === el; })()`, + ); + if (focused !== true) return false; + await this.#send({ sessionId, method: "Input.insertText", params: { text: value } }); + return true; + } + /** Poll until a selector exists, or give up. */ async #waitForSelector(sessionId: string, selector: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; @@ -246,7 +274,19 @@ export class FillEngine { * validation and fires the submit handler where `submit()` would skip both. * The dataset guard stops the two paths submitting twice. */ - async #submit(sessionId: string, selector: string): Promise { + async #submit(sessionId: string, selector: string, submitSelector?: string): Promise { + // A named submit button is the reliable path for forms that need the + // button's own click — an ASP.NET WebForms login posts back through the + // button, and a bare form.submit() omits it and never authenticates. Click + // it and stop; fall through to the generic path only if it is not there. + if (submitSelector) { + const clicked = await this.#eval( + sessionId, + `(() => { const b = document.querySelector(${JSON.stringify(submitSelector)}); + if (!b) return false; b.click(); return true; })()`, + ); + if (clicked === true) return; + } for (const type of ["keyDown", "keyUp"] as const) { await this.#send({ sessionId, diff --git a/packages/browser-bridge/src/fill-login-fields.test.ts b/packages/browser-bridge/src/fill-login-fields.test.ts new file mode 100644 index 0000000..a30e2d2 --- /dev/null +++ b/packages/browser-bridge/src/fill-login-fields.test.ts @@ -0,0 +1,153 @@ +// Copyright (C) 2026 1Claw +// SPDX-License-Identifier: Apache-2.0 + +/** + * A fill against a real-shaped login form: the username is not pre-filled, and + * the form submits only through a named button's click (a bare form.submit() + * does nothing — the shape of an ASP.NET postback or a JS-bound button). + * + * The login-session test cheated on both counts — the username was a server-set + * `value` and a form submit worked — so it could not have caught the two gaps a + * live site (weatherapi.com) exposed: the fill typed only the password, and + * submitted generically. This drives both `username`/`usernameSelector` and + * `submitSelector` on the binding, and requires the agent's tab to end up + * authenticated. + */ +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WebSocket } from "ws"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { startBridge, type BridgeHandle } from "./bridge.js"; +import { LocalVaultDriver } from "./drivers/local.js"; +import { sealVault } from "./drivers/local-vault-file.js"; + +const CHROME = + process.env.ONECLAW_BRIDGE_CHROME ?? + { darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", linux: "/usr/bin/google-chrome" }[ + process.platform as "darwin" | "linux" + ] ?? + ""; +const HAVE_CHROME = CHROME !== "" && existsSync(CHROME); +const PASSPHRASE = "a-long-enough-passphrase"; +const USER = "ada@example.com"; +const PASSWORD = "hunter2-fields-test!"; +const ARGS = ["--headless=new", ...(process.env.CI && process.platform === "linux" ? ["--no-sandbox", "--disable-dev-shm-usage"] : [])]; + +let server: Server; +let origin = ""; + +beforeAll(async () => { + server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://x"); + const signedIn = (req.headers.cookie ?? "").includes("session=ada"); + if (url.pathname === "/login") { + // No
. The button carries the whole login in a click handler, so a + // generic form submit cannot work — only clicking #signin does. + res.writeHead(200, { "content-type": "text/html" }).end(` + + + + `); + return; + } + if (url.pathname === "/session") { + const ok = url.searchParams.get("u") === USER && url.searchParams.get("p") === PASSWORD; + if (!ok) return void res.writeHead(302, { location: "/login?bad=1" }).end(); + return void res.writeHead(302, { location: "/account", "set-cookie": "session=ada; Path=/" }).end(); + } + if (url.pathname === "/account") { + return void res + .writeHead(signedIn ? 200 : 401, { "content-type": "text/html" }) + .end(`
${signedIn ? USER : "anonymous"}
`); + } + res.writeHead(404).end(); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); +afterAll(() => new Promise((r) => server.close(() => r()))); + +class Agent { + #ws: WebSocket; + #n = 1; + readonly #p = new Map) => void>(); + private constructor(ws: WebSocket) { + this.#ws = ws; + this.#ws.on("message", (d) => { + const m = JSON.parse(String(d)) as { id?: number }; + if (typeof m.id === "number") { this.#p.get(m.id)?.(m as Record); this.#p.delete(m.id); } + }); + } + static async connect(url: string) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.once("open", res); ws.once("error", rej); }); + return new Agent(ws); + } + send(m: Record): Promise> { + const id = this.#n++; + return new Promise((r) => { this.#p.set(id, r); this.#ws.send(JSON.stringify({ id, ...m })); }); + } + close() { this.#ws.close(); } +} + +let bridge: BridgeHandle | undefined; +afterEach(async () => { await bridge?.close(); bridge = undefined; }); + +describe.skipIf(!HAVE_CHROME)("a fill on a form with an empty username and a button-only submit", () => { + it("types the username, clicks the button, and logs the agent in", async () => { + const dir = mkdtempSync(join(tmpdir(), "1claw-fields-")); + const path = join(dir, "vault.json"); + writeFileSync( + path, + JSON.stringify( + await sealVault( + [{ + id: "acme", secret: PASSWORD, loginUrl: `${origin}/login`, allowedHosts: ["127.0.0.1"], + username: USER, usernameSelector: "#user", submitSelector: "#signin", + }], + PASSPHRASE, + ), + ), + ); + const backend = new LocalVaultDriver({ path, passphrase: PASSPHRASE }); + await backend.open(); + bridge = await startBridge({ executablePath: CHROME, backend, host: "127.0.0.1", port: 0, args: ARGS }); + + const agent = await Agent.connect(bridge.url); + const made = await agent.send({ method: "Target.createTarget", params: { url: `${origin}/account` } }); + const target = (made as { result?: { targetId?: string } }).result?.targetId!; + const att = await agent.send({ method: "Target.attachToTarget", params: { targetId: target, flatten: true } }); + const session = (att as { result?: { sessionId?: string } }).result?.sessionId!; + const who = async () => { + for (let i = 0; i < 60; i++) { + const out = await agent.send({ sessionId: session, method: "Runtime.evaluate", params: { expression: "document.querySelector('#who')?.textContent ?? ''", returnByValue: true } }); + const v = (out as { result?: { result?: { value?: string } } }).result?.result?.value ?? ""; + if (v) return v; + await new Promise((r) => setTimeout(r, 150)); + } + return ""; + }; + expect(await who()).toBe("anonymous"); + + const fill = await bridge.callTool( + "request_fill", + { binding_id: "acme", target_id: target, selector: "#pass" }, + () => ({ tabOrigin: origin, frameOrigin: origin, formActionOrigin: origin, frameId: target, generation: 0 }), + ); + expect(fill).toMatchObject({ status: "filled" }); + expect(JSON.stringify(fill)).not.toContain(PASSWORD); + + await agent.send({ sessionId: session, method: "Page.reload" }); + expect(await who()).toBe(USER); + agent.close(); + }, 120_000); +}); + +describe.skipIf(HAVE_CHROME)("a fill on a form with an empty username and a button-only submit", () => { + it("is skipped because no Chromium was found", () => { expect(HAVE_CHROME).toBe(false); }); +}); diff --git a/packages/browser-bridge/src/mcp-tools.test.ts b/packages/browser-bridge/src/mcp-tools.test.ts index 426d2f3..3629fce 100644 --- a/packages/browser-bridge/src/mcp-tools.test.ts +++ b/packages/browser-bridge/src/mcp-tools.test.ts @@ -10,11 +10,11 @@ import type { VaultBackend } from "./vault-backend.js"; const PASSWORD = "hunter2-correct-horse"; const SAAS: Capabilities = { - fills: true, registration: false, checkout: true, signing: true, + fills: true, registration: false, capture: false, checkout: true, signing: true, hitl: true, centralAudit: true, shadowReports: true, }; const COMMUNITY: Capabilities = { - fills: true, registration: false, checkout: false, signing: false, + fills: true, registration: false, capture: false, checkout: false, signing: false, hitl: false, centralAudit: false, shadowReports: false, }; diff --git a/packages/browser-bridge/src/mcp-tools.ts b/packages/browser-bridge/src/mcp-tools.ts index 8e3efc7..38554f4 100644 --- a/packages/browser-bridge/src/mcp-tools.ts +++ b/packages/browser-bridge/src/mcp-tools.ts @@ -45,6 +45,8 @@ export type ToolResult = | { readonly status: "aborted"; readonly reason: string } /** An account now exists and its credential is in the vault. Never a password. */ | { readonly status: "registered"; readonly bindingId: string } + /** A site-generated secret was read and stored in the vault. Never the secret. */ + | { readonly status: "captured"; readonly entryId: string } /** The site did not accept it, or did not say so. Nothing was stored. */ | { readonly status: "rejected"; readonly reason: string } | { readonly status: "error"; readonly message: string }; @@ -81,6 +83,23 @@ const SCHEMAS: Record>> = { // set to redirect where the credential ends up. additionalProperties: false, }, + begin_credential_capture: { + type: "object", + properties: { + site_id: { + type: "string", + description: + "Which pre-authorised site to capture a secret from. The only input that names a policy: the URL, the control that generates the value, and where it is read from all come from a policy a human wrote. An agent that could name the source would be choosing what gets stored.", + }, + target_id: { + type: "string", + description: + "The tab the agent is logged in on. A capture reads a secret the site shows only to a logged-in session, so it runs in this tab's browser context; the agent is blocked from observing the read.", + }, + }, + required: ["site_id", "target_id"], + additionalProperties: false, + }, get_registration_status: { type: "object", properties: { binding_id: { type: "string" } }, @@ -106,6 +125,8 @@ const DESCRIPTIONS: Record = { begin_credential_registration: "Begin a governed account registration.", get_registration_status: "Check a registration in progress.", cancel_registration: "Cancel a registration in progress.", + begin_credential_capture: + "Capture a secret the site generates (an API key, a token) into the vault. Returns only whether it happened — never the secret.", }; /** Tool definitions for a backend, in a stable order. */ @@ -136,6 +157,8 @@ export async function dispatchTool( readonly execute: FillExecutor; /** Present only when the backend advertises `registration`. */ readonly register?: (siteId: string) => Promise; + /** Present only when the backend advertises `capture`. */ + readonly capture?: (siteId: string, targetId: string) => Promise; readonly observe: () => { tabOrigin: string; frameOrigin: string; @@ -161,9 +184,13 @@ export async function dispatchTool( } const bindingId = typeof args.binding_id === "string" ? args.binding_id : ""; - // Registration is identified by site. There is no binding yet — that is the - // whole point of it — so it is exempt from this requirement. - if (!bindingId && name !== "begin_credential_registration") { + // Registration and capture are identified by site, not a binding — a + // registration has none yet, and a capture creates one — so both are exempt. + if ( + !bindingId && + name !== "begin_credential_registration" && + name !== "begin_credential_capture" + ) { return { status: "error", message: "binding_id is required" }; } @@ -201,6 +228,20 @@ export async function dispatchTool( return ctx.register(siteId); } + case "begin_credential_capture": { + // Like registration, the agent names a pre-authorised site. It also names + // the tab it is logged in on, because a capture reads a secret behind that + // login — but it chooses nothing about what is read or where it is stored. + if (!ctx.capture) { + return { status: "error", message: "capture is not available on this backend" }; + } + const siteId = typeof args.site_id === "string" ? args.site_id : ""; + const targetId = typeof args.target_id === "string" ? args.target_id : ""; + if (!siteId) return { status: "error", message: "site_id is required" }; + if (!targetId) return { status: "error", message: "target_id is required" }; + return ctx.capture(siteId, targetId); + } + case "get_registration_status": case "cancel_registration": return { status: "in_progress", bindingId }; diff --git a/packages/browser-bridge/src/vault-backend.test.ts b/packages/browser-bridge/src/vault-backend.test.ts index c3c84ad..ae8cfe4 100644 --- a/packages/browser-bridge/src/vault-backend.test.ts +++ b/packages/browser-bridge/src/vault-backend.test.ts @@ -7,12 +7,12 @@ import { CAPABILITY_TOOLS, toolsFor } from "./vault-backend.js"; import { SaasDriver } from "./drivers/saas.js"; const SAAS: Capabilities = { - fills: true, registration: false, checkout: true, signing: true, + fills: true, registration: false, capture: false, checkout: true, signing: true, hitl: true, centralAudit: true, shadowReports: true, }; const COMMUNITY: Capabilities = { - fills: true, registration: false, checkout: false, signing: false, + fills: true, registration: false, capture: false, checkout: false, signing: false, hitl: false, centralAudit: false, shadowReports: false, }; diff --git a/packages/browser-bridge/src/vault-backend.ts b/packages/browser-bridge/src/vault-backend.ts index dff2a95..e4a7f5b 100644 --- a/packages/browser-bridge/src/vault-backend.ts +++ b/packages/browser-bridge/src/vault-backend.ts @@ -60,6 +60,12 @@ export interface VaultBackend { beginRegistration?(req: unknown): Promise; commitRegistration?(grant: unknown): Promise; cancelRegistration?(id: string): Promise; + // Capture is registration's mirror: the value is read off the page by the + // engine, so the secret comes IN via `commitCapture` rather than being + // generated by the backend. `commitCapture` consumes the handle. + beginCapture?(req: unknown): Promise; + commitCapture?(id: string, secret: SecretHandle): Promise; + cancelCapture?(id: string): Promise; authorizeCheckout?(req: unknown): Promise; authorizeSignature?(req: unknown): Promise; } @@ -73,6 +79,7 @@ export interface VaultBackend { export const CAPABILITY_TOOLS: Readonly> = { fills: ["request_fill", "get_fill_status"], registration: ["begin_credential_registration", "get_registration_status", "cancel_registration"], + capture: ["begin_credential_capture"], checkout: ["request_checkout"], signing: ["request_signature"], hitl: ["get_approval_status"], diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 9895af9..c09c065 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -27,6 +27,12 @@ export type Capabilities = { readonly fills: true; /** v0.2, and gated by backend *and* policy. */ readonly registration: boolean; + /** + * Capture a secret a site generates (an API key, a token) into the vault, + * without the agent seeing it. Gated by backend *and* policy, like + * registration. + */ + readonly capture: boolean; readonly checkout: boolean; readonly signing: boolean; /** Human-in-the-loop approvals are available and enforceable. */ @@ -96,6 +102,20 @@ export type Grant = { readonly loginUrl: string; readonly expiresAt: string; readonly generation: number; + /** + * A username to type before the password, for login forms that do not + * pre-fill it. Not a secret — but typed by the bridge in the windowed page, + * not by the agent, so the agent still never scripts the login. Both must be + * present for the username to be typed. + */ + readonly username?: string; + readonly usernameSelector?: string; + /** + * A specific submit button to click, for forms that need the button's own + * click rather than a bare form submit (an ASP.NET postback, a JS handler + * bound to the button). Omit to submit generically. + */ + readonly submitSelector?: string; }; export type Denied = { @@ -191,6 +211,67 @@ export type RegistrationOutcome = | { readonly status: "rejected"; readonly reason: "site_rejected_password" | "no_success_signal" } | { readonly status: "error"; readonly message: string }; +/** + * A request to capture a secret the site generates — an API key, an access + * token — and store it in the vault, without the agent ever seeing it. + * + * The mirror image of a fill: a fill types a stored secret *into* a page; a + * capture reads a site-generated secret *out* of a page and into the vault. It + * carries the same invariant. Like a registration, the agent names only a + * pre-authorised site. It does not choose the URL, the control that generates + * the value, or where the value is read from — a human authored those, because + * whatever is read off the page becomes a stored credential, and an agent that + * chose the source would be choosing what gets stored. + */ +export type CaptureRequest = { + readonly siteId: string; +}; + +/** How the bridge produces and then reads the value, all human-authored. */ +export type CaptureSource = { + /** A control the bridge clicks to make the secret appear. Omit if it is already shown. */ + readonly generateSelector?: string; + /** Where the value is read from once it exists. */ + readonly valueSelector: string; + /** + * Read the element's `.value` (an input) or `.textContent` (a code block). + * Omit to take whichever is non-empty. + */ + readonly valueProp?: "value" | "textContent"; + /** + * Read a named attribute instead — for a copy button that carries the secret + * in `data-clipboard-text`, say, next to a label the textContent would drag + * in. Wins over `valueProp` when set. + */ + readonly valueAttr?: string; +}; + +/** + * Permission to capture one secret. + * + * Carries no secret — the value does not exist when this is issued, and when it + * does the bridge reads it in a windowed page and hands it straight to the + * backend, never back through an object the agent could be given. + */ +export type CaptureGrant = { + readonly kind: "capture_grant"; + readonly captureId: string; + /** From the policy, never the agent. */ + readonly captureUrl: string; + readonly source: CaptureSource; + /** The vault id the captured secret is written under. */ + readonly entryId: string; +}; + +export type CaptureDecision = CaptureGrant | Denied; + +/** What the agent gets back. An id, never the captured secret. */ +export type CaptureOutcome = + | { readonly status: "captured"; readonly entryId: string } + | { readonly status: "denied"; readonly reason: DenyReason } + | { readonly status: "rejected"; readonly reason: "no_value_found" } + | { readonly status: "error"; readonly message: string }; + export type AuditEvent = { readonly type: string; readonly sessionId: string;