Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4e734a0
Establish installed private-control source baseline
afif-reap Sep 22, 2026
edebaee
Stop tracking a machine-local node_modules symlink
afif-reap Sep 28, 2026
4df62a5
Add PDF upload and native fallback controls from the installed 0.2.0 …
afif-reap Sep 28, 2026
04bdcf2
Regenerate installable skill artifacts from a clean node_modules path
afif-reap Sep 28, 2026
9b0fcea
Rename the extension to Browser Control and bump to 0.2.1
afif-reap Sep 28, 2026
4a7c4c9
Cover native host reload, pause and resume in the service worker
afif-reap Sep 28, 2026
a88821b
Drop the unused history and downloads permissions
afif-reap Sep 28, 2026
1706c5f
Publish to the Chrome Web Store through keyless Workload Identity
afif-reap Sep 28, 2026
7097289
Add the Browser Control website, store listing and real-use images
afif-reap Sep 28, 2026
4378daa
Recover the native host after a crash and stop reporting a missing ho…
afif-reap Sep 28, 2026
60e4db9
Host the 0.2.1 helper on the website for reviewers
afif-reap Sep 28, 2026
7724087
Point the store description at the website download
afif-reap Sep 28, 2026
4e0a403
Serialize stale-socket recovery and drop unresponsive hosts
afif-reap Sep 28, 2026
5717395
Hold one startup lock for every host start and guard stale heartbeats
afif-reap Sep 28, 2026
3885b5b
Retry observation while a new tab is still on about:blank
afif-reap Sep 28, 2026
51da5b0
Say that interrupted host starts can leave pid-only files
afif-reap Sep 28, 2026
2771967
Record the 0.2.1 submission and the host lock's known limits
afif-reap Sep 28, 2026
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
74 changes: 46 additions & 28 deletions .github/workflows/chrome-web-store.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,93 +3,111 @@ name: Chrome Web Store
on:
workflow_dispatch:
inputs:
tag:
description: "Git tag to publish, for example vX.Y.Z"
ref:
description: "Git tag or branch to build, for example vX.Y.Z"
required: true
type: string
action:
description: "status reads the item state; upload replaces the draft package; submit uploads and submits for review"
required: true
default: status
type: choice
options:
- status
- upload
- submit

run-name: Publish ${{ inputs.tag }} to Chrome Web Store
run-name: Chrome Web Store ${{ inputs.action }} ${{ inputs.ref }}

permissions:
contents: read
id-token: write

jobs:
publish:
runs-on: ubuntu-latest
env:
EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
ref: ${{ inputs.ref }}

- uses: pnpm/action-setup@v4
if: ${{ inputs.action != 'status' }}
with:
version: 10.33.2

- uses: actions/setup-node@v4
if: ${{ inputs.action != 'status' }}
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile
- run: pnpm run check
- if: ${{ inputs.action != 'status' }}
run: pnpm install --frozen-lockfile

- if: ${{ inputs.action != 'status' }}
run: pnpm run check

- name: Get Chrome Web Store access token
id: token
id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
token_format: access_token
access_token_scopes: https://www.googleapis.com/auth/chromewebstore
create_credentials_file: false

- name: Fetch item status
shell: bash
env:
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }}
run: |
set -euo pipefail
response="$(curl -sS -w '\n%{http_code}' https://oauth2.googleapis.com/token \
-d "client_id=${CHROME_CLIENT_ID}" \
-d "client_secret=${CHROME_CLIENT_SECRET}" \
-d "refresh_token=${CHROME_REFRESH_TOKEN}" \
-d "grant_type=refresh_token")"
response="$(curl -sS -w '\n%{http_code}' \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
"https://chromewebstore.googleapis.com/v2/publishers/${CHROME_PUBLISHER_ID}/items/${EXTENSION_ID}:fetchStatus")"
status="$(printf '%s' "$response" | tail -n 1)"
body="$(printf '%s' "$response" | sed '$d')"
if [ "$status" != "200" ]; then
printf 'Chrome Web Store token request failed with HTTP %s:\n%s\n' "$status" "$body" >&2
printf 'Chrome Web Store status returned HTTP %s:\n%s\n' "$status" "$body"
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
exit 1
fi
token="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).access_token)' "$body")"
echo "::add-mask::$token"
echo "access_token=$token" >> "$GITHUB_OUTPUT"

- name: Upload extension package
if: ${{ inputs.action != 'status' }}
shell: bash
env:
ACCESS_TOKEN: ${{ steps.token.outputs.access_token }}
CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }}
ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }}
run: |
set -euo pipefail
response="$(curl -sS -w '\n%{http_code}' -X POST \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-T dist/release/opzero-chrome-extension.zip \
-T dist/release/browser-control-extension.zip \
"https://chromewebstore.googleapis.com/upload/v2/publishers/${CHROME_PUBLISHER_ID}/items/${EXTENSION_ID}:upload")"
status="$(printf '%s' "$response" | tail -n 1)"
body="$(printf '%s' "$response" | sed '$d')"
printf 'Chrome Web Store upload returned HTTP %s:\n%s\n' "$status" "$body"
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
printf 'Chrome Web Store upload failed with HTTP %s:\n%s\n' "$status" "$body" >&2
exit 1
fi

- name: Publish extension
- name: Submit for review
if: ${{ inputs.action == 'submit' }}
shell: bash
env:
ACCESS_TOKEN: ${{ steps.token.outputs.access_token }}
CHROME_PUBLISHER_ID: ${{ secrets.CHROME_PUBLISHER_ID }}
ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }}
run: |
set -euo pipefail
response="$(curl -sS -w '\n%{http_code}' -X POST \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
"https://chromewebstore.googleapis.com/v2/publishers/${CHROME_PUBLISHER_ID}/items/${EXTENSION_ID}:publish")"
status="$(printf '%s' "$response" | tail -n 1)"
body="$(printf '%s' "$response" | sed '$d')"
printf 'Chrome Web Store publish returned HTTP %s:\n%s\n' "$status" "$body"
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
printf 'Chrome Web Store publish failed with HTTP %s:\n%s\n' "$status" "$body" >&2
exit 1
fi
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: |
dist/release/opzero-chrome-extension.zip
dist/release/browser-control-extension.zip
dist/release/chrome-control-skill.zip
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ node_modules/
coverage/
dist/
scripts/extension-id.json
experiments/artifacts/
experiments/chrome-jev/ready.json
site/download/
72 changes: 34 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,69 +1,65 @@
# Opzero Chrome
# Browser Control

Chrome automation extension for Opzero agents.
Browser Control is a Chrome extension and native messaging host that let an AI agent on your own computer work in your browser tabs. The extension does nothing until you install the local host.

## Install For Users
- Homepage: <https://browser-control.pages.dev/>
- Privacy policy: <https://browser-control.pages.dev/privacy/>
- Support: <https://browser-control.pages.dev/support/>

Install Opzero Chrome from the Chrome Web Store:
## Identifiers

```text
https://chromewebstore.google.com/detail/opzero-chrome/dcnjjnecbhipdbngkhjppkckpkellmld
```
| What | Value |
| --- | --- |
| Chrome Web Store item ID | `dcnjjnecbhipdbngkhjppkckpkellmld` |
| Native messaging host name | `com.opzero.chrome` |
| Host and page protocol | version 2 |
| Unpacked extension ID | Derived by Chrome from the absolute path of the loaded folder. The manifest has no `key` field. |

For local development or manual testing, you can still download `opzero-chrome-extension.zip` from the latest GitHub Release, unzip it, then load the folder from `chrome://extensions` with Developer mode enabled.
Chrome derives an unpacked extension's ID from its folder path, so every checkout or install location has its own ID. For example, the fast-chrome controller loads `~/.config/opencode/mcp/fast-chrome/op-chrome/dist/extension`, which gives the ID `pncpgnbanebkeopjghjleodgmphmmmcp`. The native host manifest must allow the ID of the extension that connects to it.

## Install The Agent Skill
## Install for users

Install the `chrome-control` skill from the repository path with any agent runtime that supports repository/path-based skill installs:
1. Install Browser Control from the [Chrome Web Store](https://chromewebstore.google.com/detail/dcnjjnecbhipdbngkhjppkckpkellmld).
2. Install the native host. It needs Node.js 18 or later on macOS or Linux:

```text
https://github.com/opzero1/op-chrome/tree/main/skills/chrome-control
```
```sh
mkdir -p ~/.config/opencode/skills/chrome-control
curl -fsSL https://github.com/opzero1/browser-control/releases/latest/download/chrome-control-skill.zip -o /tmp/chrome-control-skill.zip
unzip -o /tmp/chrome-control-skill.zip -d ~/.config/opencode/skills/chrome-control
cd ~/.config/opencode/skills/chrome-control
node scripts/install-native-host.js --extension-id dcnjjnecbhipdbngkhjppkckpkellmld
```

Alternatively, install the latest release zip into opencode's global skills directory with one command:
3. Open the Browser Control popup and click **Reload host**. It shows **Connected**.

```sh
curl -fsSL https://raw.githubusercontent.com/opzero1/op-chrome/main/scripts/install-chrome-control-skill.sh | sh
```
The popup also has **Pause host**, which disconnects the host and ends every agent session until you click **Resume host**. The pause setting survives browser restarts. If the host exits, the extension reconnects automatically within about 30 seconds.

Manual install:
To install the agent skill with one command instead:

```sh
mkdir -p ~/.config/opencode/skills/chrome-control
curl -fsSL https://github.com/opzero1/op-chrome/releases/latest/download/chrome-control-skill.zip -o /tmp/chrome-control-skill.zip
unzip -o /tmp/chrome-control-skill.zip -d ~/.config/opencode/skills/chrome-control
```

Trigger it in agents with `@chrome-control`, or by asking for Chrome/browser automation, Chrome setup checks, native host repair, tab/session control, or CDP access.

After installing the skill, run the native host installer from the installed skill directory:

```sh
node scripts/install-native-host.js --extension-id "$(node -p 'require("./scripts/extension-id.json").extensionId')"
curl -fsSL https://raw.githubusercontent.com/opzero1/browser-control/main/scripts/install-chrome-control-skill.sh | sh
```

## Verify

```sh
pnpm run check
pnpm run check-native-host -- --extension-id <your-extension-id> --json
pnpm run check-extension -- --extension-id <your-extension-id> --json
pnpm run client -- ping
pnpm run client -- getInfo
node native-host/client.js ping
node native-host/client.js getInfo
```

If `ping` and `getInfo` work, Chrome, the extension, and the native host are connected.
`ping` returns `pong` from the extension. `getInfo` reports `protocolVersion: 2`.

## Development

See [docs/DEVELOPER.md](docs/DEVELOPER.md) for local setup and native host installation.
See [docs/DEVELOPER.md](docs/DEVELOPER.md) to load an unpacked build and connect a local host.

```sh
pnpm install
pnpm run build
pnpm run check
```

`pnpm run check` builds the extension, native host and installable skill, then runs type checks, tests and the project checks. The headless-browser tests for private input run only when `OPZERO_SYNTHETIC_CHROME` names a Chrome for Testing binary.

## Release

See [docs/RELEASE.md](docs/RELEASE.md).
See [docs/RELEASE.md](docs/RELEASE.md). The store listing text is in [store/listing.md](store/listing.md), the store images are in [store/assets](store/assets), and the website is in [site](site).
38 changes: 31 additions & 7 deletions docs/DEVELOPER.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# Developer Setup

## Local Extension Install
## Local extension install

1. Download `opzero-chrome-extension.zip` from the latest GitHub Release, or run `pnpm run build` and use `dist/extension`.
1. Run `pnpm install && pnpm run build`, or download `browser-control-extension.zip` from the latest GitHub Release and unzip it.
2. Open `chrome://extensions`.
3. Enable **Developer mode**.
4. Click **Load unpacked**.
5. Select the unzipped extension folder or `dist/extension`.
6. Copy the generated extension ID.
5. Select `dist/extension` or the unzipped folder.
6. Copy the extension ID that Chrome shows.

## Native Host
The manifest has no `key` field, so Chrome derives the unpacked ID from the folder's absolute path. Loading the same folder always gives the same ID; moving it gives a new one.

## Native host

From a local checkout:

Expand All @@ -18,9 +20,25 @@ pnpm install
pnpm run install-native-host -- --extension-id <your-extension-id>
```

Reload the extension in `chrome://extensions` after installing the native host.
Open the extension popup and click **Reload host** after you install the native host.

The installer writes `com.opzero.chrome.json` into Chrome's per-user `NativeMessagingHosts` directory, and it saves the extension ID to `dist/scripts/extension-id.json` so follow-up checks can run without `--extension-id`. Pass `--socket-path <path>` to give the host a private socket other than `~/.opzero-chrome/default.sock`.

For a disposable Chrome for Testing profile, write the host manifest to `<user-data-dir>/NativeMessagingHosts/` instead, so the default Chrome profile keeps its own host. `store/capture/launch.sh` shows the full sequence.

## Host controls

The popup has three controls:

- **Reload host** closes the native port and opens a new one. Chrome restarts the host process, and every connected agent session ends.
- **Pause host** disconnects the host, stops the reconnect and heartbeat alarms, and ends every session. The paused state is stored and survives service worker and browser restarts.
- **Resume host** clears the paused state and reconnects.

The installer saves the extension ID to `dist/scripts/extension-id.json` so follow-up checks can run without passing `--extension-id` again.
The popup shows **Connecting** until the host sends its first message, because `connectNative` returns a port even when no host is installed. A host that does not answer within 15 seconds, or that fails the 30-second heartbeat, is dropped. When the host exits or is dropped, the extension shows **Disconnected** and its reconnect alarm reconnects within 30 seconds.

Every Unix host startup holds the lock file `<socket>.lock`, from its first look at the socket path until its own socket is listening. The lock holds the owner's process ID. A new host treats it as stale only if that process is dead, or if the lock is older than five minutes, which only happens when a process ID has been reused. While the lock is held, the host removes a socket left by a crashed host only if a connection to it is refused. At shutdown it removes the socket only if it is still the one it bound. A host that cannot take the lock exits, and the extension retries within 30 seconds.

Known limits of this lock: Node.js has no crash-released file lock, so ownership rests on the lock file. Two hosts can still both believe they own the endpoint in three rare cases. The first is an orphaned lock left by an interrupted start, combined with three hosts starting at once. The second is the same orphaned lock, two hosts starting at once, and the filesystem reusing the freed inode. The third is a host suspended for more than five minutes mid-start. A single Chrome profile cannot reach any of these. Use one Chrome profile per socket path.

## Verify

Expand All @@ -31,3 +49,9 @@ pnpm run check-extension -- --extension-id <your-extension-id> --json
pnpm run client -- ping
pnpm run client -- getInfo
```

To also run the headless-browser private-input tests, point `OPZERO_SYNTHETIC_CHROME` at a Chrome for Testing binary:

```sh
OPZERO_SYNTHETIC_CHROME="/path/to/Google Chrome for Testing" pnpm exec vitest run tests/security/private-input.browser.test.ts
```
Loading
Loading