Add the Browser Control MCP server in TypeScript - #4
Merged
Merged
Conversation
Sets up @op1/browser-control: the server source and test layout, bundled dependencies, a self-contained CLI and native-host build, and the parity checker, so pnpm run check covers the server. Ports opchrome.py as host-connection.ts, sites.py, and native_captures.py, with Python-semantics helpers (urlsplit, ipaddress, IDNA 2003, str and json behavior) checked against captured CPython and Pillow output. SQLite locks replace fcntl.flock. runStdioServer implements the bounded shutdown; app, cli, pool operator and clipboard guard are stubs for later slices. docs/server/DESIGN.md records the design, the coordinator decisions (a fixed isolated-extension key, no pool lease on paste_1password_field) and the retirement of the opchrome name (D19): opchrome-* codes become browser-control-*, OPZERO_CHROME_HOST_SOCKET becomes BROWSER_CONTROL_HOST_SOCKET, and the wrapper is browser-control-host.
Merge the installed chrome-control skill with the repo's old skill into skills/browser-control, the directory package.json ships. Describe setup through npx @op1/browser-control install and doctor, the session-identity fallback, and a whole-section act_steps example. Point native control at the skill that cua-driver installs. Leave skills/chrome-control untouched.
The MCP instructions' "Load chrome-control" is compared as "Load browser-control", and the server test harness reads BROWSER_CONTROL_TEST_TMPDIR. DESIGN.md records the user's decision to rename the skill, host and scripts on afif/browser-control-rename, the one merge conflict in scripts/build.mjs, and removing the host-variable shim once that branch is merged.
Ports clipboard_guard.py, onepassword.py and private_input.py as private/clipboard-guard.ts, private/onepassword.ts, private/cua-mcp.ts and private/private-input.ts, with their tests (test_clipboard_guard.py, test_onepassword.py, test_private_input.py) mapped in tests/server/parity/private.md. The vault reader opens a private `cua-driver mcp` connection through the MCP SDK stdio client per read, serializes reads with the bounded lock at <state>/locks/onepassword.lock, and copies only inside the clipboard guard. The guard trusts only an owner-owned, non-writable, executable regular file, and buildClipboardGuard compiles the Swift source into <state>/bin/clipboard-guard-<sha12>. The private transfer no longer takes a Direct account-pool lease (C6, Q2). PasteRequest carries the caller session instead, and paste refuses fast-chrome-tab-not-owned unless it owns the tab. The pool-lease test is not ported; new tests prove ownership is still required. The other deviations are listed in the parity file.
Each lock attempt opened and closed its own descriptor on the lock file to check it. Closing any descriptor drops every POSIX lock the process holds on that file, so a second attempt in a process that already held the lock (a second tab's pin, a status read beside a pin) silently released the first holder's lock for other processes, while SQLite still counted it as held. Another process could then release, reap or claim the controller exclusively. An existing lock file is now only lstat'ed; a missing one is created with O_EXCL, so the descriptor closed there is on a new inode that nobody locks. SQLite opens the file with mode=rw and can no longer create it with 0644.
Ports browser_pool.py (pool/registry.ts, pool/operator.ts), browser_preferences.py (pool/preferences.ts) and browser_start.py (pool/start.ts, pool/provision.ts, pool/cua-cli.ts) with their tests: shared and exclusive leases, the cookie-site sharing rule with FAST_CHROME_UNSHARED_SITES, controller and tenant limits, pins, crash-retained markers, exact release, reap and reset, serialized startup that never replays an unconfirmed launch, and lossless Preferences. Provisioning writes a wrapper that execs process.execPath on the stable host copy under the state root, and per-profile manifests that allow only the isolated extension copy's fixed ID. Chrome for Testing starts through cua-driver with self-activation suppressed and the key-injected stable extension. `browser-control pool` reproduces the Python argparse CLI without `migrate`. The multi-process tests run real Node children on one private state root: concurrent claims, same-site gates, pins racing releases, SIGKILLed holders and a reap that excludes other reapers. tests/server/parity/pool.md maps all 81 Python tests (79 ported; migrate and the legacy wrapper are not ported).
install copies the native host to a versioned directory under the state root, writes the user wrapper (exec process.execPath) and the user Chrome manifest for com.opzero.chrome with the store and isolated extension IDs, and never replaces a manifest that names another host without --force. It checks the extension (a port of check-extension-installed), cua-driver and Chrome for Testing, builds the clipboard guard through buildClipboardGuard and verifies it, links the bundled skills to stable copies, and prints MCP snippets for OpenCode, Claude Code and Codex. doctor runs the same checks read-only with fixed messages. --smoke starts the server with a temporary state root and a user socket that does not exist, serves a loopback fixture with FAST_CHROME_ALLOW_LOOPBACK=1, runs claim_browser, open_tab, one act_steps batch and the releases, then reaps the temporary profile. package.json drops "private". docs/server/INSTALL.md documents setup. Tests run only in temporary directories, including a packed tarball, and fail if a default path under the real home directory changes.
…d cleanup and private transfers
…nvelopes and origins
…e the it.each case tables
…tdown deadlines exact Load bare builtins that bundled dependencies require as node: builtins, re-arm deadline timers that fire just before the monotonic deadline, ignore stdout write errors after the client closed its end, and wait for the native host's final socket mode in the endpoint-permission test.
… into afif/ts-server
… path modules browser-control mcp runs the real server. The packed-tarball test now requires its 18 tools, the instructions, and status and tabs answers over stdio, both with the fallback session and with a metadata session. install, doctor and the smoke check reuse the server's modules instead of their own copies: stableHostPlan, publishTree and treeMatches from stable-copy.ts, the manifest name, isolated origin and socket limit from pool/provision.ts, BUNDLE from pool/start.ts, metadata from pool/registry.ts, guardianTrusted from private/clipboard-guard.ts, runProcess from pool/cua-cli.ts, and the SDK's StdioClientTransport. install and doctor have no default skills directory, because the old default was inside an agent client's configuration (C4). They link and check skills only in each --skills-dir. The package also ships the onepassword-session skill, which browser-control links to, the create-verification-skill skill, and docs/server/INSTALL.md. Test fixtures use synthetic sites, emails and group titles in place of organization and pool-account names. python-urls.json was regenerated from the reference with CAPTURE_ONLY=urls; it equals the previous capture with the names substituted. references.test.ts keeps these names, user home paths, Node managers and agent client configuration paths out of src/server, tests/server, docs/server and the shipped skills.
Keep the npm server packaging, remove the environment shim, and align doctor with the renamed host settings. Refresh the README and run disposable Chrome coverage in CI.
…ty completely - Keep the stdin and SIGTERM listeners until exit, so the parent's SIGTERM that follows EOF during cleanup no longer kills the process before cleanup ends. A subprocess test sends EOF, then SIGTERM 2 s later while finalizeTabs is still being answered, and requires exit 0 with the finalization read back. - Serialize stable-copy publication with <parent>/.publish.lock and check the target again under the lock, so a valid copy another process published is never deleted. A damaged copy is moved aside with one rename. Tests race six publisher processes and wait on another process's lock. - Keep integer and float literals apart at the native-host boundary (isPyInt), so protocolVersion 2.0, a response id 3.0, an error code -32000.0, a tab id 5.0, a pageProtocolVersion 2.0 and a submit lifetime 90000.0 are refused as Python refused them. - Run check-parity with --complete in pnpm run check. Accept "not ported" only for the approved removals, ignore skipped and todo titles, and require it.each for parametrized Python tests except the listed three. - State ~/.opzero-chrome/default.sock as the one path outside the state root, and record the approved deviations (D19/Q3, Q1, D6, D10) and follow-ups F1-F3 in DESIGN.md.
… root, and install the zip host as a stable copy Python's stdin reader has no size bound; the SDK-style 10 MiB cap closed the transport without starting the bounded shutdown. A stdin read error now closes the transport, and Server.onclose begins the same Shutdown as EOF and SIGTERM. The user route, the install wrapper and the bundled native host default to <state>/sockets/user.sock, so two state roots never share an endpoint and nothing is created under ~/.opzero-chrome (C4, D1). The release zip's install-native-host script publishes the host and its chunks under <state>/hosts/, writes a wrapper with single-quoted literals that execs process.execPath, and refuses to replace a manifest that names another host without --force. Registry pids and cua-driver CLI pids and window IDs refuse integral float literals, as type() is int did (D20).
claim_browser receipts carry controller_id and lease_id but no owner. The skill told agents to record the owner from the receipt, which is impossible. pool status lists every lease with its owner, so the operator matches the receipt's lease_id there and passes that owner to pool release.
…tallers The release zip's install-native-host.js rewrote scripts/extension-id.json in the directory it ran from. Run from the stable skill copy that 'browser-control install --skills-dir' publishes, that changed the content-addressed copy, and doctor then reported the skill as stale. The only readers are the two check scripts' fallback, which the build already fills with the store ID, so the installer no longer writes the file. An unpacked ID goes to the checks with --extension-id or BROWSER_CONTROL_EXTENSION_ID. native-host.md and INSTALL.md now say which installer owns the com.opzero.chrome manifest (the last one run; neither replaces the other without --force), which socket each host uses (the zip's ~/.opzero-chrome/default.sock, the package's <state>/sockets/user.sock), how the server's user route picks its socket, and how doctor reports the zip installer's manifest (manifest foreign, previous = hosts/skill wrapper).
… need store/ and site/ describe the 0.2.2 installer, which is deployed and under review, so they stay unchanged. RELEASE.md now lists what changed since then (stable copy under the state root, process.execPath, the --force conflict check, no writes into the unzipped folder) and what to refresh and re-run before the next release.
Both workflows run pnpm run check, which builds and tests the MCP server. The server needs node:sqlite, so package.json requires Node 24; check.yml already used it, while release.yml and chrome-web-store.yml still set up Node 22.
Under a loaded full suite, two tests failed in 3 of 6 check runs: - host.test.ts connected as soon as the socket file existed. bind() creates the file before listen(), so a preempted host refused the connection. The harness now also waits for the host to remove <socket>.lock, which it does once it is listening. - cua-mcp.test.ts gave the hung-call test a 1.5 s read deadline, but the path to the hung clipboard_read takes about 2 s under load (the full read in the test before it took 1.6-2.0 s), so the deadline expired before the hang. The budget is now 4 s and the elapsed bound 6.5 s. Neither product code nor the tests' assertions changed. Both files then passed 12 of 12 runs with four suites in parallel, and pnpm run check passed 3 of 3.
The merge of origin/main, the orphaned-lease recovery through pool status, the installer that no longer writes into its own directory, the two-installer documentation, Node 24 in the release workflows and the two test fixes.
The release zip's installer checked its host copy before staging, then moved any copy at the target aside. Two installers could both find it absent; one published, and the next moved that valid copy away. Chrome failed in the gap, a SIGKILL between the two renames left the wrapper on a missing directory, and a failed second rename deleted the displaced copy. Separately, both installers classified the Chrome manifest and replaced it later, so two installers could both find it absent and both report success, bypassing the --force refusal. src/shared/install-lock.ts is a lock both installers can use: the zip installer runs on Node 18, which has no node:sqlite. A lock is a directory holding one <pid>-<uuid> entry, taken by renaming a directory that already holds the entry into place. A waiter removes an entry only when kill(pid, 0) reports ESRCH, and gives up after 10 s naming the lock and its holder. - The zip installer publishes under <state>/hosts/.skill-publish.lock and checks the target again under it. A copy is moved aside only after it was read and differs, is put back if the new copy cannot be renamed in, and is deleted only once the new copy is in place. - Both installers take <manifest dir>/.com.opzero.chrome.json.lock, classify the manifest again under it, and only then replace it. The unlocked check stays first, so refusals, dry runs and current manifests write nothing. install reports a lock that stays held as manifest: locked. tests/acceptance/installer-races.test.ts runs the built installers as real processes. Against the previous installers, 6 and then 5 of its 10 tests failed in two runs: concurrent zip installs displaced the published copy, and both installers succeeded for one manifest. The test that kills installers mid-run failed in one of the two runs, depending on where the kills landed.
…octor install writes BROWSER_CONTROL_HOST_SOCKET into the user wrapper, and the docs tell users to give the server the same socket, but the snippets that install and config print carried only BROWSER_CONTROL_STATE_DIR. They now also set BROWSER_CONTROL_HOST_SOCKET when it differs from the state directory's <state>/sockets/user.sock (D1). When the wrapper differs from the expected one only in the socket it exports, doctor reported it as stale: "does not run this version's host with this Node.js". It now reports wrapper: socket-mismatch, with previous set to the wrapper's socket and a message that names the socket. Any other difference is still stale.
browser-pool.md said "the CLI also stops an idle controller's Chrome" without naming the command. It now names pool reap [controller] [--dry-run], lists it with the other operator commands, and says that without a controller it tries each one. Checked against dist/server/cli.js in a temporary state root: pool --help lists reap, pool reap isolated-1 --dry-run prints dry_run: true, and pool reap --dry-run prints one result per controller.
The shared installer lock as the one exception to section 4.4's rejected lock alternatives, the zip installer's serialized publication, the manifest lock both installers take, the socket in the printed configuration, doctor's socket-mismatch status, the pool reap reference, and the residual risks.
The shared installer lock trusted its path. A lock path that was a symlink was listed and cleaned through: the entries of dead processes in the directory it pointed at were deleted. Neither the lock nor its parent was checked for owner, mode or identity, so in a parent that others can write to without the sticky bit, another user could rename a held lock aside and let a second installer in while the first was still in its critical section. Before any cleanup or acquisition, the lock now checks: - The parent (followed if it is a symlink) is a directory owned by this user that group and others cannot write to, unless it has the sticky bit. - The lock path, by lstat, is missing or a real directory owned by this user that group and others cannot write to. A symlink or anything else is refused. A refusal is InstallLockUnsafe, with a fixed message that names the path at fault. browser-control install reports it as manifest: unsafe-lock with that path and code browser-controller-unsafe-install-lock. As in src/server/fs-private.ts, the lock directory is a path plus its (dev, ino). The identity is recorded when the directory is made, or when it is first checked, and verified again before each stale entry is removed, before the emptied directory is removed, and at release. A cleanup that finds the path replaced removes nothing, and the next attempt checks the new directory. A release that finds it replaced removes nothing and throws. Only regular files named <pid>-<uuid> are removed. Both installers now create a missing manifest directory with mode 0755. Under a group-writable umask the directory would otherwise be 0775, and the new check would refuse a directory the installer had just made. The checks are local to src/shared/install-lock.ts and use only Node 18 APIs, so the zip installer bundle gains no chunk and no node:sqlite. The built zip installer ran under Node 18.20.8 and refused a 0777 manifest directory and a symlinked lock, and it cleared a dead process's lock. Against the previous lock, 15 of the 18 new tests in installer-races.test.ts failed, and so did the new install.test.ts test. The previous lock deleted the file outside its directory through the symlink and accepted a 0777 parent. The three tests that take the lock in a 0700, 0755 or 01777 parent passed before and after. The stale-cleanup interleaving between two waiters is driven by an injected readdir, not by sleeps, and exactly one waiter takes the lock.
A 1 ms wait deadline can allow a second poll, which ran out of scripted responses and failed the test about once in four full runs. The test now scripts enough Loading pages; its assertions are unchanged.
The lock checked only its immediate parent, and it followed that parent if it was a symlink. It also removed its staging directory recursively through its path. Suppose another uid can write to a directory above the parent. It can rename the parent aside, rename one of the victim's private directories to the staging name, and replace the parent with a symlink. The recursive removal in the finally block then deleted the victim's directory. Trusted ancestors: the lock resolves its parent with realpath and lstats every directory from / down to it. Each must be a directory owned by the current uid or by root, and must not be group- or world-writable unless it has the sticky bit (OpenSSH's safe_path rule). If one fails, InstallLockUnsafe names the first directory at fault, and nothing is created. The acquisition then works only in the resolved path. The parent's dev and ino are verified again around each mutation and at release. A replaced parent throws InstallLockUnsafe, and nothing is removed. Bounded cleanup: the lock code no longer removes anything recursively. The identities of the staging directory and its one <pid>-<uuid> entry are recorded when they are made. Cleanup unlinks the entry and then rmdirs the directory, each only while it still matches. On a mismatch it removes nothing more, and rmdir leaves a directory that holds anything else. Release removes its entry and the lock directory the same way. The zip installer publishes in the real hosts directory that its publish lock checked. The files it wrote are removed one at a time, each checked by identity. A damaged copy is moved into the installer's own staging directory. It is removed recursively, and only once the new copy is in place and both directories have been verified. That is the one recursive removal left in src/shared and src/scripts. replaceFile removes its temporary file only when the rename fails. Tests inject readdir and lstat, and none of them sleeps. They cover the audit's substitution and a directory renamed to the staging name; in both, the substituted directory survives. They also cover a staging directory with another entry in it, which is left; a writable ancestor, which is refused unless it has the sticky bit; an ancestor owned by another uid, which is refused, or by root, which is accepted; and a symlink, which is resolved, after which exactly the resolved chain is checked. Against the previous lock, 15 of the 39 tests in the file fail. The built zip installer ran on Node 18.20.8.
Both installers took the manifest lock in the real directory it checked, but then classified and wrote the manifest through the path they were given. Suppose another uid owns a symlink in that path. After the installer found no manifest in A, that uid could point the symlink at B. The temporary manifest was then made in B and renamed over B's foreign manifest, without --force. Now both installers classify, write, rename and clean up <lock.directory.path>/com.opzero.chrome.json. Just before the rename, stillResolves checks that the locked directory keeps its dev and ino and that the given directory still resolves to it. If not, only the temporary file is removed, and the installer refuses. The zip installer exits 1, and browser-control install reports manifest: fail / moved. The zip installer recorded the state root as given. Suppose a directory above it was another uid's symlink into a trusted tree. Repointing that symlink after the install made Chrome run that uid's wrapper. A matching host copy also skipped the publish lock, the only place the ancestor rule ran. The installer now resolves the state root with checkDirectory on every run and requires it to be private. It makes hosts and hosts/skill in the resolved root. The wrapper, the host it execs and manifest.path are all under that real path. browser-control install already refused a symlink anywhere in the state root, through fs-private's walk. That walk checks owner and mode only on the last directory, so stateStep now applies the same ancestor rule and reports state: fail / unsafe-ancestor. Tests: 9 new. Against 25dc747, 8 of them fail. The retargeting tests fail because B's manifest is overwritten. The ELOOP test records the existing fs-private refusal. Three distribution tests now expect real paths under macOS /var. The built zip installer ran on Node 18.20.8.
…rver The checks covered only the resolved path, or only the socket's own directory. Suppose another uid owns a symlink, or a directory that holds one, somewhere in the path given. The installers accepted a manifest directory reached through a symlink in a 0777 directory, and npm's current-manifest fast path and dry run skipped even the resolved check. The host checked its socket's directory and then kept using the path it was given. Its startup lock wrote <socket>.lock.<pid> with writeFileSync, which follows a symlink and truncates. The server checked ownership and then connected through the same mutable path. src/shared/trusted-path.ts walks a path as given, one component at a time from /, as the kernel resolves it. Every directory, the last one included, must be owned by the uid or root and not group- or world-writable unless it is sticky. A symlink is followed only when its directory passed and the symlink is the uid's or root's; its target is walked the same way, through at most 16 symlinks. The result is the canonical path and the directory's dev and ino. macOS /var and /tmp pass. It uses only fs and path, so the zip installer and the host still run on Node 18. The install lock, the state root and hosts, and the manifest directory in both installers now use it, on every path through the code: npm's fast path, the dry run and each unlocked pre-check. Missing directories are made only inside directories that passed. The lock, classification, write and rename use the canonical directory. The host requires a private canonical socket directory and uses the canonical path for its lock, bind, recovery and cleanup. It creates the staged lock with O_CREAT | O_EXCL | O_NOFOLLOW, so a planted symlink refuses the startup. Connection.open checks and connects through the canonical path. The wrapper, the snippets and doctor export the canonical socket (D21 records the parity change). Tests: 24 new, 4 changed. Against b763a84 with them copied in, 12 fail: the old host overwrote the file behind a planted symlink and bound in a repointed tree, the client reached the other host, and both installers wrote through a symlink in a 0770 or 0777 directory. The built zip installer and host ran on Node 18.20.8: a fresh install, the host started through its wrapper, and host.ping answered over its socket.
The skill's clients were outside the trusted-path rule. client.ts connected with no check. transport.ts checked only the socket's own directory and then connected through the path it was given, so a symlink higher up could be repointed between the check and the connect. The handshake does not authenticate the host, and privateFill sends values after it. privateSocketEndpoint in src/shared/trusted-path.ts is now the one check for the server's Connection.open, client.ts and transport.ts. The socket's directory must pass trustedPath and be private, the endpoint must be the uid's socket, and all three connect only to its canonical path. TCP, the argv rules and the stopped-host message are unchanged. trustedPath's missing mode joined the rest of the path as text, so /tmp/gap/../attacker/hosts came back as an unchecked /tmp/attacker/hosts, where npm found a current manifest and stopped. A `..` after a missing directory now fails with ENOENT, as the kernel does. Neither installer reads through a missing result: the manifest is absent until the directory is made and locked. Both installers classified a manifest by its type and bytes, so another uid could plant a byte-identical one in a 01777 directory, and npm reported it unchanged even with --force. src/shared/manifest-file.ts trusts only the uid's regular file that group and others cannot write. Anything else is untrusted: refused without --force, and with --force replaced under the lock. Another uid's file in a sticky directory that is not the uid's is refused as cannot-replace. Doctor reports untrusted. Tests: 29 new, 3 changed. Against 7f8721f with them copied in, 20 fail. The old client and transport reached the other host through the repointed symlink. The walk returned the gap path, npm reported unchanged for it and for every untrusted manifest, and the zip installer read the attacker's manifest and replaced untrusted ones without --force. The built skill ran on Node 18.20.8 against a real host under a temporary HOME: client.js ping and a transport open and close worked over the default socket and <state>/sockets/user.sock, and the refusals held.
The state root, the artifact root, each --skills-dir, the manifest directory and every socket path now pass the trusted-path rule where they enter: mcp, pool, config, install, doctor, the zip installer, the host, client.js and transport.js. A root that fails is refused with a fixed message that names it and the directory at fault. - fs-private applies the rule to every ancestor on each walk, so the state root's ancestry is checked on every use, not only at install. - Skill links count as current only when the uid owns them. Another user's link is untrusted without --force, and --force replaces it only where rename allows. - Screenshots and recordings are made only in the canonical path of a private root (privateDirectory). - The pool probes and removes a stale socket only through privateSocket's canonical path, and unlinks it only while its identity is unchanged. - DESIGN.md adds a filesystem operation inventory: 107 call sites, each with its root and protection.
linkSkill cleaned up a failed rename through removeCreated, which refuses every symlink, so .<name>.<uuid>.tmp stayed in the skills directory. createdLink now records the link's own lstat identity when it is made. removeCreatedLink unlinks it only while it sits directly in the checked directory, the directory keeps its identity, and lstat still finds a symlink with that identity. unlink never follows it, and any other entry, including a symlink swapped in, is left. removeCreated still refuses every symlink. fsyncDirectory now opens with O_NOFOLLOW, so a symlink put in the verified directory's place is not followed. DESIGN.md describes the code exactly: childDirectory does not repeat the walk, doctor --smoke checks the real path of TMPDIR, the host's startup lock and reset's profile are not checked for mode, a skills directory need not be 0755, and reused executables are checked at the final file only.
The host binds its socket before the extension finishes the protocol handshake, so on a slower runner host.info could still report a pending extension and Connection.open refused with browser-control-protocol-mismatch. The test now retries the connection until the host reports ready.
expect.poll also retries thrown errors, so the connection filter was not enforced. An explicit deadline loop now retries only browser-control-unavailable and browser-control-protocol-mismatch and rethrows anything else at once.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports the fast-chrome MCP server from Python to TypeScript, and packages it as
@op1/browser-control(binbrowser-control), so that setup isnpx -y @op1/browser-control mcpand Node 24 is the only runtime requirement.src/server/**): the same 18 tools, schemas, limits, protocol 2 and bounded shutdown as the Python server. Every one of its 299 Python test functions is mapped intests/server/parity/: 290 ported, 9 not ported, all of them approved removals.check-parity --completeruns inpnpm run check.docs/server/DESIGN.md. Portable cua-driver and Node resolution; packaged assets with stable, versioned copies under one state root (BROWSER_CONTROL_STATE_DIR); configurable unshared sites; no Direct account-pool lease; a per-process session fallback for clients that send no session ID; the Browser Control names; and a fixed ID for isolated profiles (mpodnojmjjafgogldgieimgbmfhhknbe).mcp,install,doctor,configand the pool operator commands. Both native-host installers (npm, and the release zip) share one lock and refuse to replace each other's manifest without--force.browser-control,onepassword-sessionandcreate-verification-skill, generic and client-agnostic.Verification (independent of the implementers):
The reviewer steps in
site/andstore/still describe the released 0.2.2 helper, which is under Web Store review.docs/RELEASE.mdlists what to refresh before the next release.