Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,9 @@ local Node relay.
sandbox before releasing its execute permit, preventing a non-settling prompt
action from mutating the page later. Cancel the waiter if WAIT presentation or
action startup fails.
- `TargetRegistry` is the sole production live target-ownership authority.
Session state keeps one durable default-target identity and owner. Adoption reserves,
- Each browser profile has an isolated `TargetRegistry`, the sole live
target-ownership authority for that profile. Session state keeps one durable
profile ID, default-target identity, and owner. Adoption reserves,
commits, or rolls back registry ownership transactionally and reconciles CDP
visibility, grouping, and page status for every changed target.
- `CdpClientPool` owns client announcements and aliases as private state. Its
Expand Down Expand Up @@ -320,7 +321,7 @@ browser-control skill

- Load `extension/dist` as the unpacked extension.
- The relay listens on `127.0.0.1:19989` by default.
- Current shim version is `0.0.24` and extension protocol version is `2`.
- Current shim version is `0.0.25` and extension protocol version is `2`.
- Store and npm versions may differ while their extension protocol versions remain compatible.
- The source and unpacked-build manifest carries the public key for stable id
`eibhgjafffkigblngnhafgbcipofaeon`. Store packaging must strip that key so the
Expand Down
7 changes: 7 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ The user's already-running Chromium-family browser with the Browser Control
extension installed.
_Avoid_: Chrome-only, managed browser

**Browser Profile**:
One extension installation, identified by a persistent locally generated profile
ID. Each profile has isolated live transport, targets, and sessions within the
same daemon. An optional user-chosen label is not a detected account identity.
Browser profiles are distinct from secret profiles used for captured credentials.
_Avoid_: Browser Control session, secret profile, Google account

**Local Driver Daemon**:
The persistent Node process that owns Playwright execution, Browser Control
sessions, target ownership, cross-process serialization, artifacts, and the
Expand Down
40 changes: 31 additions & 9 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,15 +296,37 @@ require a new extension capture protocol and permission model.

## Session And Tab Model

A relay serves one browser/profile connection at a time. The first compatible
OPEN connection keeps ownership through inventory reconciliation and normal
operation; another connection is rejected with close code 4004 instead of
destroying existing targets, handoffs, or pending commands. Contention starts a
bounded websocket liveness probe so a dead incumbent cannot hold ownership
indefinitely. Genuine disconnect/reconnect still rebuilds the inventory.
`status` and `doctor` report rejected connection attempts, not a browser count.
This does not add simultaneous multi-browser routing or persistent browser
selection: switching requires disconnecting the incumbent extension.
A relay serves multiple browser/profile connections simultaneously. Each extension
installation stores a random profile ID locally and announces it in `hello`.
Connections, RPCs, target registries, CDP clients, handoffs, and recording state
are isolated per profile, including when Chrome tab/session IDs collide.
Sessions persist their profile ID in the shared catalog and never fall back to
another profile when their own disconnects. New sessions require `--profile`
(or MCP `profileId`) when selection is ambiguous. `profile list` discovers IDs;
`profile name <id> <label>` persists a user-chosen label in the extension.
Labels are conveniences, not account identity or credentials.

A duplicate connection for the same profile is rejected with close code 4004;
it cannot erase the incumbent's targets or pending commands. Older extensions
without identity remain confined to one legacy runtime and must be reloaded for
multi-profile use. Catalog entries predating profile IDs bind only to a ready
profile containing the exact saved target; targetless entries need explicit
selection. Disconnected legacy identities migrate by the same rule after reload.
No session adopts a browser merely because it connects first.
Disconnect/reconnect inventory reconciliation is profile-local. The shared
`RelayShutdown` controller drains every profile's admitted and queued session
work, native operations, root reconciliation, and catalog writes. Raw CDP
clients or active recordings/captures in any profile block restart. Each profile
retains upstream `RootTargetLifecycle` and `CdpRuntime` guards; a cancelled drain
resumes admission for all profiles.

Follow-up: profile-specific startup waiting. On local 0.5.1 / extension 0.0.25,
restarting the relay while two profiles are open can make an immediate named
execute return `Browser profile not found` before that profile reconnects (the
other profile has already satisfied the generic readiness wait). Reproduce with
a managed-relay upgrade followed immediately by `execute --profile <label>`.
Expected: wait for the selected profile; current recovery: inspect `profile list`
and retry once its connection appears. No command is routed to the wrong profile.

Password-manager extension UI is a Chromium permission boundary, not a missing
page. A cross-extension failure keeps the session's page and returns an explicit
Expand Down
4 changes: 2 additions & 2 deletions extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"manifest_version": 3,
"name": "Browser Control",
"version": "0.0.24",
"version": "0.0.25",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzTg+VNnMmpavX6z5WKu4FYh9xa2J+jjxTS/EmxYdgbzh33amUxawAVWV3p5NF58Y9yZxBEv11M1BUPWVVdAYyTsHYj70+xpCXGCnljQF6igo8hKBPoNNvg/+/IyI+FPbj34Mv2P1u+ZUCR9cAhFcqmD5mSvMbB8zFVSa+Rgknwwz5Uegu1bKzpI5Z9nO2EI6KOgjeJV/YEjl6NecC743FlCVztFZ1NIiJKl7DFmTSUa1/Piu7WHucBNfNDnoc6A9tmLTTLYnOH+KinXV3MsakWGNhGQTPCBRGLlGQ3SAa6HN7SPzZIkqHABBdMbf/1nO9Vj7dWAKIhX3Zud3GHxObwIDAQAB",
"description": "Connect browser tabs to the local Browser Control driver.",
"minimum_chrome_version": "120",
"permissions": ["activeTab", "alarms", "debugger", "offscreen", "tabCapture", "tabGroups"],
"permissions": ["activeTab", "alarms", "debugger", "offscreen", "storage", "tabCapture", "tabGroups"],
"homepage_url": "https://github.com/anomalyco/browser-control",
"background": {
"service_worker": "background.js",
Expand Down
54 changes: 43 additions & 11 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ const relayPort = 19989
const offscreenDocumentPath = "offscreen.html"
const maxRecordingSocketBufferedBytes = 16 * 1024 * 1024

type ProfileIdentity = {
readonly profileId: string
readonly profileName?: string
}

let socket: WebSocket | undefined
let connectionPromise: Promise<void> | undefined
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
Expand Down Expand Up @@ -90,7 +95,7 @@ function connect(): void {
void ensureConnection().catch(() => {})
}

function startConnection(): WebSocket {
function startConnection(profile: ProfileIdentity): WebSocket {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = undefined
Expand All @@ -100,7 +105,7 @@ function startConnection(): WebSocket {
let stopKeepAlive: (() => void) | undefined
socket = currentSocket
currentSocket.onopen = () => {
void announceHelloAndAttachedTabs(currentSocket, currentGeneration).then(
void announceHelloAndAttachedTabs(currentSocket, currentGeneration, profile).then(
() => {
if (socket === currentSocket && currentSocket.readyState === WebSocket.OPEN) {
stopKeepAlive = startSocketKeepAlive(() => sendOnCurrentSocket(currentSocket, { method: "pong" }))
Expand Down Expand Up @@ -131,8 +136,27 @@ async function ensureConnection(): Promise<void> {
if (connectionPromise) {
return connectionPromise
}
const current = socket?.readyState === WebSocket.CONNECTING ? socket : startConnection()
const pending = new Promise<void>((resolve, reject) => {
const pending = connectToRelay()
connectionPromise = pending
try {
await pending
} finally {
if (connectionPromise === pending) connectionPromise = undefined
}
}

async function connectToRelay(): Promise<void> {
// Persist before opening the socket so reconnects and worker restarts cannot
// announce a transient identity, and hello remains the first socket message.
const stored = await chrome.storage.local.get(["profileId", "profileName"])
const profileId = typeof stored.profileId === "string" && stored.profileId.trim()
? stored.profileId
: crypto.randomUUID()
if (profileId !== stored.profileId) await chrome.storage.local.set({ profileId })
const profileName = typeof stored.profileName === "string" ? stored.profileName.trim() : ""
const profile: ProfileIdentity = { profileId, ...(profileName ? { profileName } : {}) }
const current = socket?.readyState === WebSocket.CONNECTING ? socket : startConnection(profile)
await new Promise<void>((resolve, reject) => {
if (current.readyState === WebSocket.OPEN) {
resolve()
return
Expand Down Expand Up @@ -165,20 +189,15 @@ async function ensureConnection(): Promise<void> {
current.addEventListener("error", onError, { once: true })
current.addEventListener("close", onClose, { once: true })
})
connectionPromise = pending
try {
await pending
} finally {
if (connectionPromise === pending) connectionPromise = undefined
}
}

async function announceHelloAndAttachedTabs(currentSocket: WebSocket, currentGeneration: number): Promise<void> {
async function announceHelloAndAttachedTabs(currentSocket: WebSocket, currentGeneration: number, profile: ProfileIdentity): Promise<void> {
sendOnSocket(currentSocket, {
method: "hello",
params: {
version: chrome.runtime.getManifest().version,
protocolVersion: extensionProtocolVersion,
...profile,
},
})
await completeExtensionHandshake({
Expand Down Expand Up @@ -345,6 +364,19 @@ async function handleCommand(command: ShimCommand, currentSocket: WebSocket): Pr
await sendPageStatusMessage(tabId, { action: "page-status.clear" })
return {}
}
if (command.method === "profile.rename") {
const profileName = stringParam(command.params, "name").trim()
if (profileName.length === 0 || profileName.length > 100) {
throw new Error("Profile name must contain 1–100 characters after trimming")
}
const stored = await chrome.storage.local.get("profileId")
if (typeof stored.profileId !== "string" || !stored.profileId.trim()) {
throw new Error("Profile identity is not initialized")
}
assertCurrentSocket(currentSocket)
await chrome.storage.local.set({ profileName })
return { profileId: stored.profileId, profileName }
}
if (command.method === "runtime.reload") {
chrome.runtime.reload()
return {}
Expand Down
6 changes: 6 additions & 0 deletions scripts/check-runtime-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ const check = Effect.fn("LifecycleCheck.run")(function* (options: { previous: st
socket.send(JSON.stringify({ method: "hello", params: { version: "lifecycle-fixture", protocolVersion: 2 } }))
socket.send(JSON.stringify({ method: "debugger.attached", params: { tabId: 1 } }))
socket.send(JSON.stringify({ method: "ready" }))
yield* wait(relay.extensionStatus.pipe(Effect.flatMap((status) => Effect.try(() => {
assert(status.connected, "Extension inventory has not settled")
}))))
// Old catalogs have no profile identity. Restoration is deliberately lazy:
// an explicit session request binds only to its exact, ready target inventory.
yield* relay.sessionEnsure(fixtureSession)
yield* wait(relay.extensionStatus.pipe(Effect.flatMap((status) => Effect.try(() => {
assert(status.connected && status.protocolCompatible === true && status.protocolVersion === 2)
assert.equal(status.targets?.length, 1)
Expand Down
3 changes: 2 additions & 1 deletion scripts/package-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ async function validateStoreExtension(dist: string): Promise<string> {
throw new Error("Extension manifest must target Manifest V3 and Chrome 120 or later")
}
const permissions = manifest.permissions
const expectedPermissions = ["activeTab", "alarms", "debugger", "offscreen", "tabCapture", "tabGroups"]
// storage persists the profile's random installation ID and user-assigned label.
const expectedPermissions = ["activeTab", "alarms", "debugger", "offscreen", "storage", "tabCapture", "tabGroups"]
if (!Array.isArray(permissions) || permissions.join(",") !== expectedPermissions.join(",")) {
throw new Error("Extension manifest permissions differ from the reviewed allowlist")
}
Expand Down
6 changes: 4 additions & 2 deletions scripts/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1634,7 +1634,7 @@ const scopedOwnerCdpPage = Effect.fnUntraced(function* (options: {

async function makeOwnerCdpPage(options: { readonly sessionId: string; readonly urlIncludes: string }): Promise<OwnerCdpPage> {
const [versionResponse, targetsResponse] = await Promise.all([
fetch(new URL("/json/version", endpointUrl)),
fetch(new URL("/json/version", endpointUrl), { headers: { "browser-control-session-id": options.sessionId } }),
fetch(new URL("/json/list", endpointUrl)),
])
const version = await versionResponse.json() as { readonly webSocketDebuggerUrl?: unknown }
Expand Down Expand Up @@ -1773,7 +1773,9 @@ async function makeOwnerCdpPage(options: { readonly sessionId: string; readonly

const scopedBrowser = Effect.fnUntraced(function* () {
return yield* Effect.acquireRelease(
playwright("connect over CDP", () => chromium.connectOverCDP(endpointUrl)),
playwright("connect over CDP", () => chromium.connectOverCDP(endpointUrl, {
...(process.env.BROWSER_CONTROL_PROFILE ? { headers: { "browser-control-profile-id": process.env.BROWSER_CONTROL_PROFILE } } : {}),
})),
(browser) => boundedCleanup("close browser", () => browser.close()),
)
})
Expand Down
37 changes: 29 additions & 8 deletions skills/browser-control/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,31 @@ session. Reset or delete releases an adopted user tab without closing it.
Prefer adoption for authenticated browser state rather than reproducing login
in a fresh page.

Each relay controls one browser/profile at a time. A second extension connection
cannot replace a healthy active connection. If `status` or `doctor` reports
rejected competing connections, keep the extension enabled only in the intended
browser/profile. To switch browsers, disconnect the incumbent extension first;
creating a new execute session does not switch browsers.
A relay can control several browser profiles simultaneously. Reload the updated
extension once in each profile so it advertises its persistent installation ID.
Inspect `browser-control profile list` (or MCP `status` → `profiles`), then select
an ID or unique label when creating a session:

```bash
browser-control profile list
browser-control profile name <profile-id> "Rebase Accounts"
browser-control execute --profile "Rebase Accounts" 'return page.url()'
```

CLI `execute`, `session new`, `session adopt`, and recording commands accept
`--profile` (or `BROWSER_CONTROL_PROFILE`); MCP counterparts accept `profileId`.
Recording selectors are profile-local: pass `--profile` when multiple profiles
are known, including a profile that has disconnected. MCP `profile_name`
assigns a label. Labels are user-chosen, not Chrome account names discovered by
the extension. An existing session remains pinned to its profile; continue with
its session ID. Selecting a different profile for that session is rejected.
When more than one profile is available, new sessions without a profile fail
closed. A disconnected profile never falls back to another connected account.
Old catalog entries bind only where their exact saved target reappears; entries
without a target require explicit selection. Disconnected legacy connections
can migrate by the same exact-target rule after the extension is reloaded.
Two connections claiming the same ID are still rejected; reload older extensions
rather than disabling the other profile.

Completion: the selected page URL is the intended page, and later work either
retains the returned session id or intentionally uses the MCP process session.
Expand Down Expand Up @@ -403,9 +423,10 @@ Common diagnoses:
does not recover.
- Incompatible extension protocol: update either the extension or npm package;
exact extension and relay release versions do not need to match.
- Competing browser/profile connections: the active browser is preserved and
additional connections are rejected. Use one browser/profile per relay;
repeatedly creating sessions or resetting tabs does not switch browsers.
- Duplicate or legacy profile connections: a connection claiming an already
active identity is rejected. Reload older extensions in each profile, inspect
`profile list`, and select the intended profile explicitly. Do not reset a
pinned session to switch accounts; create a new session for the other profile.
- Stale relay build: inspect `doctor`, then coordinate an explicit
`browser-control relay restart`. It requires an exact managed instance and
safe shutdown protocol 2. Legacy relays need a one-time coordinated manual
Expand Down
Loading