diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1c0323..5802716 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: cache: npm - run: npm ci - run: npm test - - run: node --experimental-test-module-mocks --test test/redaction.test.mjs test/remove.test.mjs test/host-profiles.test.mjs test/host-origin.test.mjs test/prepare-page.test.mjs test/load.test.mjs + - run: node --experimental-test-module-mocks --test test/adapters.test.mjs test/redaction.test.mjs test/remove.test.mjs test/host-profiles.test.mjs test/host-origin.test.mjs test/prepare-page.test.mjs test/load.test.mjs - if: runner.os == 'macOS' run: swift build --package-path helper/macos -c release - run: npm audit --omit=dev diff --git a/README.md b/README.md index 2cf3423..e6ed3db 100644 --- a/README.md +++ b/README.md @@ -8,63 +8,57 @@ Initial capabilities: tab discovery, CDP attach/detach, Console/Log capture, Net This project targets **developers using AI coding agents**. The intended loop is edit -> reload -> inspect Console/Network -> fix -> repeat, including Chrome-extension development. -Client adapters are documented in `docs/CLIENTS.md`. Codex, Cursor, and the local Grok Build CLI use stdio MCP. Chrome Ops never needs to expose its localhost Host ports for these clients. +Supported harnesses are Claude Code, Codex, Cursor, and the local Grok Build CLI. Each launches the stdio MCP server; Chrome Ops never exposes its localhost Host ports to them. See `docs/CLIENTS.md`. Network event observations are redacted before they cross the extension boundary: Cookie, Set-Cookie, Authorization, proxy authorization, and cookie value fields are replaced with `[REDACTED]`. Response bodies requested explicitly with `network_response_body` are **not** content-scanned for arbitrary secrets; see `SECURITY.md`. -## Development +## Support -```powershell -npm install -npm run build -npm start -``` - -On Windows, the current developer baseline is PowerShell 7+. `npm run setup:windows` builds the project and registers **Chrome Ops Host** as a per-user logon scheduled task. The Host owns the persistent Chrome connection; short-lived stdio MCP processes connect to it on localhost. It does not modify an MCP client's configuration unless that client is explicitly supported/detected. +| | Host service | Unpacked-extension developer operations | +| --- | --- | --- | +| macOS | LaunchAgent | Swift helper (Accessibility) | +| Windows 11 | logon Scheduled Task | PowerShell 7 helper (UI Automation) | +| Linux (systemd) | systemd user service | not automated yet — use `chrome://extensions` | -On macOS, `npm run setup:macos` builds the Swift helper and installs a per-user LaunchAgent for the same Host. When an unpacked Bridge is already connected, setup also updates it from this installation and confirms that the new worker reconnects. The command is safe to repeat; `npm run uninstall:macos` removes the LaunchAgent and stops its Host. Neither command removes Chrome's bridge extension or client registrations. The Host logs to `~/Library/Logs/ChromeOps/`. +Claude Code, Codex, Cursor, and Grok Build are registered the same way on every OS. -## Quick start (macOS alpha) +## Quick start -Requirements: macOS with the Swift toolchain (`swift`), Chrome, Node.js 22+, and Chrome Developer mode. Give Accessibility permission to the app that runs Chrome Ops when macOS requests it. `npm run doctor` reports that authorization; the Swift helper does not use Screen Recording APIs. +Requirements: Chrome with Developer mode, Node.js 22+, and Git. macOS also needs the Swift toolchain (`swift`) and Accessibility permission for the app that runs Chrome Ops. Windows also needs PowerShell 7+ (`pwsh`). Linux needs a systemd user session. ```sh git clone https://github.com/kitepon/chrome-ops-mcp.git cd chrome-ops-mcp -npm ci -npm run setup:macos -npm run doctor +npm run setup ``` -Open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select this package's `extension/` directory. Keep Chrome open for developer-extension operations. Check that `npm run doctor` reports `host.connected: true` after the bridge connects. Register each installed MCP client you intend to use: +`npm run setup` installs dependencies, builds, and starts the persistent **Chrome Ops Host** as a per-user service for this OS. It is safe to repeat. When an unpacked Bridge is already connected, it also updates the Bridge from this checkout and waits for the new worker to reconnect. + +Open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select this repository's `extension/` directory. Load it in one Chrome profile only; if several Bridge profiles connect, Chrome Ops reports the ambiguity instead of guessing. Then register the harnesses you use: ```sh -npm run register:codex -npm run register:cursor -npm run register:grok +npm run register # every installed harness +npm run register:claude # or one of: claude, codex, cursor, grok +npm run doctor ``` -Each registration command verifies the Host and preserves a backup before changing an existing client configuration. Restart or reload the client if it does not discover the new MCP server immediately. `npm run doctor` reports the LaunchAgent, Host, native helper permission, and client registration state. Load the Bridge once in the intended Chrome profile. If multiple Bridge profiles connect at the same time, Chrome Ops reports the ambiguity and waits for one profile to remain connected. +Registration backs up the harness configuration before a change and refuses to replace a different `chrome-ops` entry. Restart or reload the harness if it does not discover the new server immediately. `npm run doctor` reports the Host service, the Bridge connection, developer-operation readiness, and the registration state of each harness. `npm run uninstall` stops and removes the Host service; it leaves the Bridge extension and harness registrations in place. -## Quick start (Windows alpha) +Registrations point at the absolute path of the current Node executable and of `dist/index.js` in this checkout. Moving the checkout or changing Node requires registering again. -Requirements: Windows 11, Chrome, Node.js 22+, PowerShell 7+, and Chrome Developer mode. The Windows helper is validated with Japanese and English Chrome UI labels. +Logs: macOS writes the Host log to `~/Library/Logs/ChromeOps/`; Linux uses `journalctl --user -u chrome-ops-host`. Backups of harness configuration go to `~/Library/Application Support/ChromeOps/backups` (macOS), `%LOCALAPPDATA%\ChromeOps\backups` (Windows), or `${XDG_STATE_HOME:-~/.local/state}/chrome-ops/backups` (Linux). -```powershell -npm install -npm run setup:windows -npm run doctor -``` +## Code layout -Open `chrome://extensions`, enable Developer mode, choose **Load unpacked**, and select `extension/` from this package/repository. Then register the MCP with one or more detected clients: +Shared code, OS adaptation, and harness adaptation live in separate files. -```powershell -npm run register:codex -npm run register:cursor -npm run register:grok -``` +| Area | Shared | OS adaptation | Harness adaptation | +| --- | --- | --- | --- | +| MCP server | `src/index.ts`, `src/bridge.ts`, `src/host.ts`, `src/helper.ts` | `src/os/{macos,windows,linux}.ts` | — | +| Setup | `scripts/chrome-ops.mjs`, `scripts/lib/` | `scripts/os/{macos,windows,linux}.mjs` | `scripts/harness/{claude,codex,cursor,grok}.mjs` | +| Native helper | `helper/contract.ts` | `helper/macos/`, `helper/windows/` | — | -Restart/reload the MCP client after registration if it does not pick up the new server immediately. +See `docs/ARCHITECTURE.md` for the contracts between them. ## Core MCP tools @@ -91,16 +85,17 @@ Chrome 116+ keeps extension service workers alive when WebSocket traffic is acti - Windows helper: Load unpacked, exact-ID reload, Errors extraction, remove + postcondition verification - macOS helper: Load unpacked, exact-ID reload, Errors extraction, remove + postcondition verification on a disposable fixture - macOS LaunchAgent: setup, repeat setup, restart, uninstall, and reinstall with stdio MCP reconnection +- v0.3 on macOS 27, Windows 11, and Ubuntu 26.04: `npm run setup`, `npm run register` for Claude Code, Codex, Cursor, and Grok Build, `npm run doctor`, and each harness's own MCP check. Load/reload/errors/remove passed on macOS and Windows with a disposable fixture. ## Known boundary Chrome's public `chrome.management` extension API can inspect installed extensions but does not expose arbitrary unpacked-directory loading or another extension's developer reload action. Chrome Ops uses deliberately narrow PowerShell 7 + Windows UI Automation and Swift + macOS Accessibility helpers for those developer-mode operations. The helpers are not generic shell/UI automation APIs. -### v0.2 alpha limitations +### v0.3 alpha limitations - Chrome Developer mode must already be enabled for unpacked-extension operations. - Developer-management UI automation is currently validated against Japanese and English Chrome labels; other UI languages are not yet guaranteed. -- The Chrome Ops extension itself must be loaded manually once during initial setup. Subsequent `setup:macos` runs update an already connected unpacked Bridge. -- Client registration adapters currently cover Codex, Cursor, and Grok Build. See `docs/CLIENTS.md`. -- On macOS, each unpacked-extension developer operation prepares its own management tab in the connected Bridge profile. Load verifies the new development extension through Chrome's management API before returning `verifiedLoaded: true`. Reload reports UI submission; check an observable version or behavior change. Remove verifies absence through Chrome's management API. The Windows helper contract remains unchanged. -- macOS was verified on one Mac and Chrome installation. A login/logout cycle, revoked Screen Recording permission, and a fresh Windows machine were not tested in this macOS pass. +- The Chrome Ops extension itself must be loaded manually once during initial setup. Later `npm run setup` runs update an already connected unpacked Bridge. +- Linux does not automate Load unpacked, reload, errors, or remove yet. Chrome's extension-management page is not exposed through AT-SPI in the tested GNOME Wayland session; everything else works on Linux. +- Each unpacked-extension developer operation prepares its own management tab in the connected Bridge profile. Load verifies the new development extension through Chrome's management API before returning `verifiedLoaded: true`. Reload reports UI submission; check an observable version or behavior change. Remove verifies absence through Chrome's management API. +- Each OS was verified on one machine and Chrome installation. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b2be98e..61b9305 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,4 +29,31 @@ Network secrets are redacted before crossing from the extension to the MCP proce Initial `Load unpacked` is a helper operation because Chrome's public extension APIs do not expose arbitrary unpacked-directory loading. -On macOS, the Bridge first prepares a fixed management tab in its own Chrome profile. The helper matches that exact tab before using developer controls. The installer has one internal, fixed Bridge-update action for an already connected unpacked Bridge; it is not an MCP tool and does not accept arbitrary URLs or input commands. +On every OS with a helper, the Bridge first prepares a fixed management tab in its own Chrome profile. The helper matches that exact tab before using developer controls. The installer has one internal, fixed Bridge-update action for an already connected unpacked Bridge; it is not an MCP tool and does not accept arbitrary URLs or input commands. + +## Code layout: shared, OS, harness + +Shared code, OS adaptation, and harness adaptation are separated at the file level. Shared files do not branch on `process.platform`; OS files do not know about harnesses; harness files do not know about OSes. + +### MCP server (`src/`) + +- Shared: `index.ts` (tools), `bridge.ts` (Host client), `host.ts` (persistent Host), `helper.ts` (runs the OS helper and parses its JSON), `protocol.ts`. +- OS: `os/macos.ts`, `os/windows.ts`, `os/linux.ts`, selected by `os/index.ts`. Contract in `os/types.ts`: + - `unsupportedReason` — `null` when the OS has a developer-operation helper. + - `reportsLoadedId` — whether the helper reads the new id after Load unpacked. Otherwise the server takes the one new development extension. + - `helper(operation, value, token)` — the helper command line. + +### Setup (`scripts/`) + +- Shared: `chrome-ops.mjs` (entry: `setup`, `uninstall`, `doctor`, `register`), `lib/host-client.mjs`, `lib/bridge-update.mjs`, `lib/registration.mjs`, `lib/process.mjs`. +- OS: `os/macos.mjs` (LaunchAgent, Swift build), `os/windows.mjs` (Scheduled Task, npm-shim resolution), `os/linux.mjs` (systemd user service). Each exports: + - `name`, `stateDir` (backups), `exec(program, args)` (runs a harness CLI), `prepare()`, + - `installService()` → `{ changed, service }` (idempotent, rolls back on failure), `uninstallService()`, `serviceStatus()`, + - `developerOperations()` (doctor), `nativeBridgeUpdate(id, activeTabHashes)` or `null`. +- Harness: `harness/claude.mjs`, `harness/codex.mjs`, `harness/cursor.mjs`, `harness/grok.mjs`. See `docs/CLIENTS.md`. + +`test/adapters.test.mjs` checks that every OS and harness adapter fills the same contract. + +### Native helpers (`helper/`) + +`helper/contract.ts` is shared. `helper/macos/` (Swift, Accessibility) and `helper/windows/` (PowerShell 7, UI Automation) implement it. Linux has no helper yet. diff --git a/docs/CLIENTS.md b/docs/CLIENTS.md index 7d686ec..caa1ae0 100644 --- a/docs/CLIENTS.md +++ b/docs/CLIENTS.md @@ -1,22 +1,45 @@ -# MCP client support +# Harness support -Chrome Ops keeps client-specific installation outside the core runtime. +Chrome Ops supports four harnesses: Claude Code, Codex, Cursor, and the local Grok Build CLI. Each launches the stdio MCP server (`node dist/index.js`); that short-lived process talks to the persistent Chrome Ops Host on `127.0.0.1:32146`. -## Local stdio clients +Topology: `harness -> Chrome Ops stdio MCP -> persistent Host -> Chrome Bridge extension -> Chrome`. -- Codex — local MCP configuration/CLI when detected. -- Cursor — global `~/.cursor/mcp.json`. -- Grok Build — local MCP configuration/CLI when detected. -- Claude clients — adapter to be implemented after current local configuration is detected/verified. +## Registration -These clients launch `dist/index.js`; that short-lived stdio process talks to the persistent Chrome Ops Host on `127.0.0.1:32146`. +```sh +npm run register # every installed harness +npm run register: # claude, codex, cursor, or grok +``` -On macOS, run `npm run setup:macos` first, then `npm run register:codex`, `npm run register:cursor`, or `npm run register:grok` for each installed client. Each registration command also checks Host setup. Existing client settings are backed up before a change, and a conflicting `chrome-ops` entry is reported instead of replaced. Run `npm run doctor` to inspect the exact registration state. Setup reloads an already connected unpacked Bridge when its worker source changes; it requires a uniquely connected Chrome profile and Accessibility authorization. Client configuration uses the current absolute Node executable and built MCP server path; relocation or a Node change requires deliberately updating that entry. +Registration runs `npm run setup`'s Host step first, so the Host is running before a harness can start the server. The flow is the same on every OS and for every harness: -## Grok Build +1. Read the existing `chrome-ops` entry through the harness adapter. +2. If it already launches this Node with this checkout's `dist/index.js`, do nothing. +3. If a different `chrome-ops` entry exists, stop without changing anything. +4. Otherwise back up the harness configuration file, add the entry, and read it back. -Grok Build supports local stdio MCP servers natively. Register Chrome Ops with Grok's own `grok mcp add` command. Grok Build also supports user/project TOML MCP configuration and compatibility imports from Cursor/Claude MCP files. +| Harness | Adapter | How it is registered | Configuration | +| --- | --- | --- | --- | +| Claude Code | `scripts/harness/claude.mjs` | `claude mcp add-json --scope user` | `~/.claude.json` | +| Codex | `scripts/harness/codex.mjs` | `codex mcp add` | `~/.codex/config.toml` | +| Cursor | `scripts/harness/cursor.mjs` | writes `mcpServers.chrome-ops` | `~/.cursor/mcp.json` (editor and `cursor-agent`) | +| Grok Build | `scripts/harness/grok.mjs` | `grok mcp add`, then `grok mcp doctor` | `~/.grok/config.toml` | -Topology: `Grok Build -> Chrome Ops stdio MCP -> persistent Host -> Chrome`. +On Windows the harness CLIs are often npm shims. The Windows OS adapter runs the program the shim points at, so arguments are never re-parsed by `cmd.exe` or PowerShell. -This support is for the local **Grok Build CLI**, not the grok.com Custom MCP connector product. +`npm run doctor` shows `detected`, `registered`, and any error per harness. + +## Checking a harness + +| Harness | Check | +| --- | --- | +| Claude Code | `claude mcp list` shows `chrome-ops ... ✔ Connected` | +| Codex | ask it to call `chrome_status` | +| Cursor | `cursor-agent mcp list-tools chrome-ops` lists the tools | +| Grok Build | `grok mcp doctor chrome-ops` reports it healthy | + +## Adding a harness + +Add `scripts/harness/.mjs` exporting `{ name, configFile, detect(os), read(os), add(os, server) }` and list it in `scripts/harness/index.mjs`. Run harness CLIs through `os.exec(program, args)` so each OS can resolve them. Do not put OS checks in a harness adapter. + +Grok support is for the local **Grok Build CLI**, not the grok.com Custom MCP connector product. diff --git a/extension/manifest.json b/extension/manifest.json index bc27549..b70a302 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,9 +1,9 @@ { "manifest_version": 3, "name": "Chrome Ops MCP Bridge", - "version": "0.2.0", + "version": "0.2.3", "description": "Expose Chrome management and DevTools operations to a local MCP server.", - "permissions": ["debugger", "management", "tabs", "contentSettings", "privacy"], + "permissions": ["debugger", "management", "tabs", "contentSettings", "privacy", "alarms"], "host_permissions": [""], "background": { "service_worker": "service-worker.js" }, "action": { "default_title": "Chrome Ops MCP" } diff --git a/extension/service-worker.js b/extension/service-worker.js index 53e76ea..3cf4d6f 100644 --- a/extension/service-worker.js +++ b/extension/service-worker.js @@ -49,6 +49,9 @@ function respond(socket, id, ok, result, error) { if (socket.readyState === WebS chrome.runtime.onStartup.addListener(connect); chrome.runtime.onInstalled.addListener(connect); chrome.action.onClicked.addListener(connect); +// While the Host is down the worker goes idle and its retry timer dies with it; an alarm wakes it to reconnect. +chrome.alarms.create("reconnect", { periodInMinutes: 0.5 }); +chrome.alarms.onAlarm.addListener(alarm => { if (alarm.name === "reconnect") connect(); }); connect(); chrome.debugger.onDetach.addListener(source => { if (source.tabId != null) attached.delete(source.tabId); }); @@ -97,7 +100,13 @@ async function dispatch(method, p) { const digest = await crypto.subtle.digest("SHA-256", bytes); return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, "0")).join(""); }; - return { preparePage:true, sourceHash:await hash("service-worker.js"), manifestHash:await hash("manifest.json") }; + // getURL reads the files on disk, which change before the running worker is reloaded; the in-memory manifest does not. + return { preparePage:true, reloadSelf:true, sourceHash:await hash("service-worker.js"), manifestHash:await hash("manifest.json"), runningVersion:chrome.runtime.getManifest().version }; + } + case "extensions.reloadSelf": { + // Answer first; the reload ends this worker and its Host connection. + setTimeout(() => chrome.runtime.reload(), 200); + return { reloading:true }; } case "extensions.preparePage": { if (chrome.extension.inIncognitoContext) throw new Error("Chrome Ops Bridge is running in an incognito profile"); diff --git a/helper/README.md b/helper/README.md index a6d1c16..37d26fa 100644 --- a/helper/README.md +++ b/helper/README.md @@ -4,4 +4,4 @@ Native helper boundary for Chrome developer-mode operations unavailable through The helper is intentionally **not** a shell, terminal, generic UI automation server, or arbitrary filesystem API. See `docs/ARCHITECTURE.md` for the allowlist. -Windows uses PowerShell 7 and UI Automation. macOS uses Swift and Accessibility APIs. Both implement the same four-operation contract. On macOS, `npm run setup:macos` builds the release helper used by the MCP server. +Windows uses PowerShell 7 and UI Automation. macOS uses Swift and Accessibility APIs. Both implement the same four-operation contract. On macOS, `npm run setup` builds the release helper used by the MCP server. Linux has no helper yet. The MCP server picks the helper through `src/os/`. diff --git a/helper/macos/Sources/ChromeOpsHelper/Accessibility.swift b/helper/macos/Sources/ChromeOpsHelper/Accessibility.swift index eb072fe..f5425bd 100644 --- a/helper/macos/Sources/ChromeOpsHelper/Accessibility.swift +++ b/helper/macos/Sources/ChromeOpsHelper/Accessibility.swift @@ -58,7 +58,11 @@ struct AXNode { } // Bound a single snapshot. Do not dump arbitrary browser tabs or silently truncate a tree. -func snapshot(_ root: AXUIElement, descendIntoWeb: Bool = true) throws -> [AXNode] { +// The open panel's file list can hold hundreds of rows. Picker controls never live inside it, and walking it +// on every poll made Load unpacked take over a minute. +let pickerFileListRoles: Set = ["AXOutline", "AXBrowser", "AXTable", "AXList"] + +func snapshot(_ root: AXUIElement, descendIntoWeb: Bool = true, skipping skipped: Set = []) throws -> [AXNode] { var nodes: [AXNode] = [] func visit(_ element: AXUIElement, parent: Int?, depth: Int) throws { guard nodes.count < 12000, depth < 60 else { throw HelperFailure("Accessibility tree exceeded the inspection budget") } @@ -69,6 +73,7 @@ func snapshot(_ root: AXUIElement, descendIntoWeb: Bool = true) throws -> [AXNod value: string(element, "AXValue"), detail: string(element, "AXDescription"), identifier: string(element, "AXIdentifier"))) if role == "AXWebArea" && !descendIntoWeb { return } + if skipped.contains(role) { return } for child in elements(element) { try visit(child, parent: index, depth: depth + 1) } } try visit(root, parent: nil, depth: 0) @@ -183,7 +188,7 @@ func pickerHasFocus(_ page: ExtensionsPage) throws -> Bool { // NSOpenPanel and its Go to Folder sheet become AXFocusedWindow on macOS. // Accept only sheets belonging to this exact, already-validated directory picker. guard let panel = try openPanel(page) else { return false } - return try snapshot(panel.element).contains { $0.role == "AXSheet" && CFEqual(focused, $0.element) } + return try snapshot(panel.element, skipping: pickerFileListRoles).contains { $0.role == "AXSheet" && CFEqual(focused, $0.element) } } func focusedApplicationPID() -> pid_t? { @@ -242,17 +247,36 @@ func pickerKey(_ page: ExtensionsPage, field: AXNode, goToFolder: Bool = false) } func openPanel(_ page: ExtensionsPage) throws -> AXNode? { - let matches = try snapshot(page.window).filter { $0.role == "AXSheet" && $0.identifier == "open-panel" } + let matches = try snapshot(page.window, skipping: pickerFileListRoles).filter { $0.role == "AXSheet" && $0.identifier == "open-panel" } guard matches.count <= 1 else { throw HelperFailure("Ambiguous extension directory picker") } return matches.first } func goToFolderSheet(_ panel: AXNode) throws -> AXNode? { - let matches = try snapshot(panel.element).filter { $0.role == "AXSheet" && $0.identifier == "GoToWindow" } + let matches = try snapshot(panel.element, skipping: pickerFileListRoles).filter { $0.role == "AXSheet" && $0.identifier == "GoToWindow" } guard matches.count <= 1 else { throw HelperFailure("Ambiguous Go to Folder sheet") } return matches.first } +// The Go to Folder path field. It sits in a GoToWindow sheet of the panel on some macOS builds; on others +// (seen on macOS 27.0) it is not reachable from the panel tree and is only the focused element of the picker process. +func goToFolderField(_ panel: AXNode) throws -> AXNode? { + if let sheet = try goToFolderSheet(panel) { + return try unique(snapshot(sheet.element, skipping: pickerFileListRoles), "Go to Folder path field") { + $0.role == "AXTextField" && $0.identifier == "PathTextField" + } + } + guard let raw = attribute(AXUIElementCreateSystemWide(), "AXFocusedUIElement"), + CFGetTypeID(raw) == AXUIElementGetTypeID() else { return nil } + let focused = unsafeDowncast(raw, to: AXUIElement.self) + var panelPID: pid_t = 0, focusedPID: pid_t = 0 + guard string(focused, "AXRole") == "AXTextField", string(focused, "AXIdentifier") == "PathTextField", + AXUIElementGetPid(panel.element, &panelPID) == .success, + AXUIElementGetPid(focused, &focusedPID) == .success, focusedPID == panelPID else { return nil } + return AXNode(element: focused, parent: nil, role: "AXTextField", subrole: string(focused, "AXSubrole"), + title: "", value: "", detail: "", identifier: "PathTextField") +} + func load(_ request: Request) throws -> [String: Any] { let page = try selectedPage(request.pageToken) try requireUnobstructed(page) @@ -265,7 +289,7 @@ func load(_ request: Request) throws -> [String: Any] { try focus(page) try press(button) let panel = try waitFor("extension directory picker") { try openPanel(page) } - let panelNodes = try snapshot(panel.element) + let panelNodes = try snapshot(panel.element, skipping: pickerFileListRoles) guard panelNodes.contains(where: { $0.labels.contains(where: { $0.contains("拡張機能のディレクトリ") || $0.localizedCaseInsensitiveContains("extension directory") }) }) else { throw HelperFailure("The open panel is not an extension directory picker") } @@ -275,18 +299,15 @@ func load(_ request: Request) throws -> [String: Any] { $0.role == "AXTextField" && $0.identifier == "Search" } try pickerKey(page, field: search, goToFolder: true) - let goSheet = try waitFor("Go to Folder sheet") { try goToFolderSheet(panel) } - let field = try unique(snapshot(goSheet.element), "Go to Folder path field") { - $0.role == "AXTextField" && $0.identifier == "PathTextField" - } + let field = try waitFor("Go to Folder path field") { try goToFolderField(panel) } let enteredPath = request.value + "/" guard AXUIElementSetAttributeValue(field.element, "AXValue" as CFString, enteredPath as CFString) == .success, string(field.element, "AXValue") == enteredPath else { throw HelperFailure("Could not set the validated extension directory") } try pickerKey(page, field: field) _ = try waitFor("Go to Folder completion") { () -> Bool? in - try goToFolderSheet(panel) == nil ? true : nil + try goToFolderField(panel) == nil ? true : nil } - let choose = try unique(snapshot(panel.element), "directory selection button") { + let choose = try unique(snapshot(panel.element, skipping: pickerFileListRoles), "directory selection button") { $0.role == "AXButton" && $0.identifier == "OKButton" } try press(choose) diff --git a/helper/windows/chrome-ops-helper.ps1 b/helper/windows/chrome-ops-helper.ps1 index 3bfa953..4381088 100644 --- a/helper/windows/chrome-ops-helper.ps1 +++ b/helper/windows/chrome-ops-helper.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory=$true)][ValidateSet('load','reload','errors','remove')][string]$Operation,[string]$ExtensionId,[string]$Path) +param([Parameter(Mandatory=$true)][ValidateSet('load','reload','errors','remove','bootstrap')][string]$Operation,[string]$ExtensionId,[string]$Path,[string]$ActiveUrlHashes) $ErrorActionPreference='Stop' if($PSVersionTable.PSVersion.Major -lt 7){throw 'Chrome Ops helper requires PowerShell 7+'} [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false) @@ -8,9 +8,51 @@ $JExt='拡張機能'; $JBack='戻る'; $JLoad='パッケージ化されていな $JFolder='フォルダー'; $JChoose='フォルダーの選択'; $JReload='再読み込み'; $JErrors='エラー'; $JRemove='削除' function Result($ok,$data=$null,$error=$null){[pscustomobject]@{ok=$ok;operation="extension.dev.$Operation";data=$data;error=$error}|ConvertTo-Json -Depth 8 -Compress} function Desc($r){$r.FindAll([System.Windows.Automation.TreeScope]::Descendants,[System.Windows.Automation.Condition]::TrueCondition)} -function ChromeWindow{$root=[System.Windows.Automation.AutomationElement]::RootElement;foreach($w in $root.FindAll([System.Windows.Automation.TreeScope]::Children,[System.Windows.Automation.Condition]::TrueCondition)){if($w.Current.ClassName -eq 'Chrome_WidgetWin_1' -and $w.Current.Name -match "^($JExt|Extensions)"){return $w}};throw 'chrome://extensions must be open in a Chrome window'} +function ChromeWindow{$root=[System.Windows.Automation.AutomationElement]::RootElement;for($try=0;$try -lt 25;$try++){foreach($w in $root.FindAll([System.Windows.Automation.TreeScope]::Children,[System.Windows.Automation.Condition]::TrueCondition)){if($w.Current.ClassName -eq 'Chrome_WidgetWin_1' -and $w.Current.Name -match "^($JExt|Extensions)"){return $w}};Start-Sleep -Milliseconds 200};throw 'chrome://extensions must be open in a Chrome window'} +# bootstrap: open chrome://extensions in the window that shows an active tab of the connected Bridge profile, +# then reload the Bridge by exact id. Only the fixed new-tab shortcut and the fixed URL are sent. +function Sha256([string]$v){[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($v))).ToLowerInvariant()} +function Omnibox($w){foreach($e in (Desc $w)){if($e.Current.ControlType -eq [System.Windows.Automation.ControlType]::Edit -and ($e.Current.Name -eq 'アドレス検索バー' -or $e.Current.Name -eq 'Address and search bar')){return $e}};return $null} +function UrlForms([string]$shown){$forms=@($shown);if($shown -notmatch '^[a-z][a-z0-9+.-]*:'){$forms+=@("https://$shown","http://$shown")};$more=@();foreach($f in $forms){$more+=$f;if($f -notmatch '/$'){$more+="$f/"}};return $more} +function ProfileWindow([string[]]$hashes){ + # A window is safe when every window showing that URL is accounted for by the Bridge profile's own active tabs. + $root=[System.Windows.Automation.AutomationElement]::RootElement;$byHash=@{} + foreach($w in $root.FindAll([System.Windows.Automation.TreeScope]::Children,[System.Windows.Automation.Condition]::TrueCondition)){ + if($w.Current.ClassName -ne 'Chrome_WidgetWin_1'){continue};$box=Omnibox $w;if(-not $box){continue} + $shown=$box.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Current.Value;if(-not $shown){continue} + foreach($h in @((UrlForms $shown)|ForEach-Object{Sha256 $_}|Where-Object{$hashes -contains $_}|Select-Object -Unique)){if(-not $byHash[$h]){$byHash[$h]=@()};$byHash[$h]+=$w} + } + foreach($h in ($byHash.Keys|Sort-Object)){if($byHash[$h].Count -eq @($hashes|Where-Object{$_ -eq $h}).Count){return $byHash[$h][0]}} + throw 'No Chrome window uniquely matches an active tab of the connected Bridge profile; no browser input was sent' +} +function ForegroundHandle([IntPtr]$handle){ + Add-Type -Namespace ChromeOps -Name Win32 -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h,int c); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);' -ErrorAction SilentlyContinue + if([ChromeOps.Win32]::IsIconic($handle)){[void][ChromeOps.Win32]::ShowWindow($handle,9)} + [void][ChromeOps.Win32]::SetForegroundWindow($handle);Start-Sleep -Milliseconds 200 +} +function Foreground($w){ForegroundHandle ([IntPtr]$w.Current.NativeWindowHandle)} +# Chrome's native confirmation dialog is its own window. Its buttons act only while that dialog is in the +# foreground, and activating the browser window instead dismisses it. +function Confirm($button){ + $walker=[System.Windows.Automation.TreeWalker]::ControlViewWalker;$owner=$button + while($owner -and -not $owner.Current.NativeWindowHandle){$owner=$walker.GetParent($owner)} + if($owner){ForegroundHandle ([IntPtr]$owner.Current.NativeWindowHandle)} + $button.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() +} +function OpenExtensionsPage([string[]]$hashes){ + Add-Type -AssemblyName System.Windows.Forms + $w=ProfileWindow $hashes + Foreground $w;(Omnibox $w).SetFocus();Start-Sleep -Milliseconds 300 + if([System.Windows.Automation.AutomationElement]::FocusedElement.Current.ProcessId -ne $w.Current.ProcessId){throw 'Cannot focus the verified Bridge-profile window; no keyboard input was sent'} + [System.Windows.Forms.SendKeys]::SendWait('^t');Start-Sleep -Milliseconds 500 + $box=Omnibox $w;$focused=[System.Windows.Automation.AutomationElement]::FocusedElement + if(-not $box -or -not [System.Windows.Automation.Automation]::Compare($box,$focused)){throw 'Chrome address bar did not take focus; no navigation key was sent'} + $box.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).SetValue('chrome://extensions/') + [System.Windows.Forms.SendKeys]::SendWait('{ENTER}') +} function InvokeNamed($r,[string]$name){foreach($e in (Desc $r)){if($e.Current.Name -eq $name){try{$e.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke();return $true}catch{}}};return $false} try{ + if($Operation -eq 'bootstrap'){if(-not $ActiveUrlHashes){throw 'ActiveUrlHashes is required'};$hashes=@($ActiveUrlHashes|ConvertFrom-Json);if(-not $hashes.Count -or ($hashes|Where-Object{$_ -notmatch '^[0-9a-f]{64}$'})){throw 'Expected active-tab SHA-256 hashes from one Bridge profile'};OpenExtensionsPage $hashes;$Operation='reload'} $w=ChromeWindow if($Operation -eq 'load'){ if(-not $Path){throw 'Path is required'};$resolved=(Resolve-Path -LiteralPath $Path).Path;if(-not(Test-Path -LiteralPath(Join-Path $resolved 'manifest.json') -PathType Leaf)){throw 'manifest.json not found'} @@ -23,10 +65,13 @@ try{ if(-not$ExtensionId){throw 'ExtensionId is required'};$idText="ID: $ExtensionId";$anchor=$null;foreach($e in(Desc $w)){if($e.Current.Name -eq $idText){$anchor=$e;break}} if(-not$anchor){if(InvokeNamed $w $JBack){Start-Sleep -Milliseconds 300}elseif(InvokeNamed $w 'Back'){Start-Sleep -Milliseconds 300};foreach($e in(Desc $w)){if($e.Current.Name -eq $idText){$anchor=$e;break}}} if(-not$anchor){throw "Extension id not found: $ExtensionId"};$group=[System.Windows.Automation.TreeWalker]::ControlViewWalker.GetParent($anchor);if(-not$group){throw 'Extensions group not found'};$siblings=@(Desc $group);$start=-1;for($i=0;$i -lt $siblings.Count;$i++){if($siblings[$i].Current.Name -eq $idText){$start=$i;break}};if($start -lt 0){throw 'Extension id sequence not found'};$extensionName=$null;for($i=$start-1;$i -ge 0;$i--){$candidate=$siblings[$i].Current.Name;if($candidate -match '^ID: [a-p]{32}$'){break};if($candidate -and $candidate -notmatch '^\d+\.\d+' -and $candidate -notmatch 'unpacked|package|詳細|削除|再読み込み|Errors|Remove|Reload'){$extensionName=$candidate;break}} - if($Operation -eq 'remove' -and $extensionName){foreach($x in(Desc $w)){if($x.Current.Name -like "*$extensionName*" -and $x.Current.Name -match '(削除|Remove)'){$dialog=$x;for($d=0;$d -lt 4 -and $dialog;$d++){$buttons=@((Desc $dialog)|Where-Object{$_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and ($_.Current.Name -eq $JRemove -or $_.Current.Name -eq 'Remove')});if($buttons.Count){$buttons[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke();Result $true @{extensionId=$ExtensionId;name=$extensionName;removed=$true};exit 0};$dialog=[System.Windows.Automation.TreeWalker]::ControlViewWalker.GetParent($dialog)}}}} + if($Operation -eq 'remove' -and $extensionName){foreach($x in(Desc $w)){if($x.Current.Name -like "*$extensionName*" -and $x.Current.Name -match '(削除|Remove)'){$dialog=$x;for($d=0;$d -lt 4 -and $dialog;$d++){$buttons=@((Desc $dialog)|Where-Object{$_.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and ($_.Current.Name -eq $JRemove -or $_.Current.Name -eq 'Remove')});if($buttons.Count){Confirm $buttons[0];Result $true @{extensionId=$ExtensionId;name=$extensionName;removed=$true};exit 0};$dialog=[System.Windows.Automation.TreeWalker]::ControlViewWalker.GetParent($dialog)}}}} $names=@{reload=@($JReload,'Reload');errors=@($JErrors,'Errors');remove=@($JRemove,'Remove')}[$Operation];for($i=$start+1;$i -lt $siblings.Count;$i++){ $e=$siblings[$i];if($e.Current.Name -match '^ID: [a-p]{32}$'){break};if($names -contains $e.Current.Name){try{$e.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke();$n=$e.Current.Name - if($Operation -eq 'errors'){Start-Sleep -Milliseconds 300;$items=@();foreach($e in(Desc $w)){if($e.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and $e.Current.Name -match '(WebSocket|Error|failed|ERR_|TypeError|ReferenceError|SyntaxError)'){$items+= $e.Current.Name}};Result $true @{extensionId=$ExtensionId;control=$Operation;errors=@($items|Select-Object -Unique)};exit 0} - if($Operation -eq 'remove'){Start-Sleep -Milliseconds 250;$confirm=$null;foreach($x in(Desc $w)){if($extensionName -and $x.Current.Name -like "*$extensionName*" -and $x.Current.Name -match '(削除|Remove)'){$confirm=$x;break}};if(-not$confirm){throw 'Remove confirmation for target extension not found'};for($d=0;$d -lt 4 -and $confirm;$d++){foreach($x in(Desc $confirm)){if($x.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and ($x.Current.Name -eq $JRemove -or $x.Current.Name -eq 'Remove')){$x.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke();Result $true @{extensionId=$ExtensionId;name=$extensionName;removed=$true};exit 0}};$confirm=[System.Windows.Automation.TreeWalker]::ControlViewWalker.GetParent($confirm)};throw 'Remove confirmation button not found'} + if($Operation -eq 'errors'){Start-Sleep -Milliseconds 300;$items=@();foreach($e in(Desc $w)){if($e.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and $e.Current.Name -match '(WebSocket|Error|failed|ERR_|TypeError|ReferenceError|SyntaxError)'){$items+= $e.Current.Name}};Result $true @{extensionId=$ExtensionId;control=$Operation;errors=@($items|Select-Object -Unique);hasErrorsView=$true};exit 0} + if($Operation -eq 'remove'){$confirm=$null;for($wait=0;$wait -lt 15 -and -not $confirm;$wait++){Start-Sleep -Milliseconds 200;foreach($x in(Desc $w)){if($extensionName -and $x.Current.Name -like "*$extensionName*" -and $x.Current.Name -match '(削除|Remove)'){$confirm=$x;break}}};if(-not$confirm){throw 'Remove confirmation for target extension not found'};for($d=0;$d -lt 4 -and $confirm;$d++){foreach($x in(Desc $confirm)){if($x.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button -and ($x.Current.Name -eq $JRemove -or $x.Current.Name -eq 'Remove')){Confirm $x;Result $true @{extensionId=$ExtensionId;name=$extensionName;removed=$true};exit 0}};$confirm=[System.Windows.Automation.TreeWalker]::ControlViewWalker.GetParent($confirm)};throw 'Remove confirmation button not found'} Result $true @{extensionId=$ExtensionId;control=$Operation};exit 0 - }catch{}}};throw "Control not found for $Operation" + }catch{}}} + # Chrome shows the Errors control only when the extension has errors, as on macOS. + if($Operation -eq 'errors'){Result $true @{extensionId=$ExtensionId;control=$Operation;errors=@();hasErrorsView=$false};exit 0} + throw "Control not found for $Operation" }catch{Result $false $null $_.Exception.Message;exit 1} diff --git a/package-lock.json b/package-lock.json index 53dd0c2..4f0ef84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chrome-ops-mcp", - "version": "0.2.0-alpha.0", + "version": "0.3.0-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chrome-ops-mcp", - "version": "0.2.0-alpha.0", + "version": "0.3.0-alpha.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.1", diff --git a/package.json b/package.json index c8ac4c6..e489da3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chrome-ops-mcp", - "version": "0.2.0-alpha.0", + "version": "0.3.0-alpha.0", "description": "MCP server for Chrome DevTools, extension management, and browser settings via a local Chrome extension.", "type": "module", "main": "dist/index.js", @@ -30,13 +30,20 @@ "build": "tsc", "start": "node dist/index.js", "host": "node dist/host.js", - "setup:windows": "pwsh -NoProfile -File scripts/setup-windows.ps1", - "setup:macos": "node scripts/macos-host.mjs setup", - "uninstall:macos": "node scripts/macos-host.mjs uninstall", - "doctor": "node scripts/platform.mjs doctor", - "register:codex": "node scripts/platform.mjs register-codex", - "register:cursor": "node scripts/platform.mjs register-cursor", - "register:grok": "node scripts/platform.mjs register-grok", + "setup": "npm ci && npm run build && node scripts/chrome-ops.mjs setup", + "setup:windows": "npm run setup", + "setup:macos": "npm run setup", + "setup:linux": "npm run setup", + "uninstall": "node scripts/chrome-ops.mjs uninstall", + "uninstall:macos": "npm run uninstall", + "uninstall:linux": "npm run uninstall", + "uninstall:windows": "npm run uninstall", + "doctor": "node scripts/chrome-ops.mjs doctor", + "register": "node scripts/chrome-ops.mjs register", + "register:claude": "node scripts/chrome-ops.mjs register claude", + "register:codex": "node scripts/chrome-ops.mjs register codex", + "register:cursor": "node scripts/chrome-ops.mjs register cursor", + "register:grok": "node scripts/chrome-ops.mjs register grok", "test": "npm run build", "test:smoke": "node test/smoke.mjs", "test:devtools": "node test/devtools-smoke.mjs" @@ -48,6 +55,7 @@ "codex", "cursor", "grok", + "claude", "developer-tools" ], "author": "kitepon.dev", diff --git a/scripts/chrome-ops.mjs b/scripts/chrome-ops.mjs new file mode 100644 index 0000000..04bbf9b --- /dev/null +++ b/scripts/chrome-ops.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Setup entry point: setup | uninstall | doctor | register [harness]. +// This file and scripts/lib/ are shared; scripts/os/ holds OS code and scripts/harness/ holds harness code. +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { updateBridge } from "./lib/bridge-update.mjs"; +import { hostStatus, root, waitFor } from "./lib/host-client.mjs"; +import { harnessNamed, inspect, register } from "./lib/registration.mjs"; +import { harnesses } from "./harness/index.mjs"; +import { currentOs } from "./os/index.mjs"; + +async function setup(os) { + os.prepare(); + if (!existsSync(resolve(root, "dist/host.js")) || !existsSync(resolve(root, "dist/index.js"))) { + throw new Error("Built TypeScript files are missing; run npm ci and npm run build"); + } + const before = await hostStatus().catch(() => null); + const service = await os.installService(); + await waitFor(async () => (await hostStatus().catch(() => null))?.host === true, "Chrome Ops Host to answer host.status", 10000); + // A Bridge that was connected before a Host restart comes back within its 30-second reconnect alarm. + if (service.changed && before?.connected) { + await waitFor(async () => (await hostStatus()).connected === true, "the previously connected Chrome Ops Bridge to reconnect", 45000); + } + return { ok: true, os: os.name, ...service, running: true, ...await updateBridge(os) }; +} + +async function registerHarnesses(os, name) { + const installed = await setup(os); + const targets = name ? [harnessNamed(name)] : Object.values(harnesses).filter(harness => harness.detect(os)); + if (targets.length === 0) throw new Error("No supported harness (Claude Code, Codex, Cursor, Grok) is installed for this user"); + return { ok: true, os: os.name, host: { changed: installed.changed, connected: installed.connected }, + registered: targets.map(harness => register(os, harness)) }; +} + +async function doctor(os) { + const service = os.serviceStatus(); + let host; + try { host = await hostStatus(); } catch (error) { host = { error: String(error.message) }; } + const developerOperations = os.developerOperations(); + return { + ok: service.ok && host.host === true && developerOperations.ok, + os: os.name, + node: { path: process.execPath, version: process.version }, + service, host, developerOperations, + harnesses: inspect(os), + }; +} + +try { + const os = currentOs(); + let result; + switch (process.argv[2]) { + case "setup": result = await setup(os); break; + case "uninstall": result = { ok: true, os: os.name, ...os.uninstallService() }; break; + case "doctor": result = await doctor(os); break; + case "register": result = await registerHarnesses(os, process.argv[3]); break; + default: throw new Error("Expected setup, uninstall, doctor, or register [claude|codex|cursor|grok]"); + } + process.stdout.write(`${JSON.stringify(result, null, process.argv[2] === "doctor" ? 2 : 0)}\n`); +} catch (error) { + process.stderr.write(`${JSON.stringify({ ok: false, error: String(error.message) })}\n`); + process.exitCode = 1; +} diff --git a/scripts/doctor.ps1 b/scripts/doctor.ps1 deleted file mode 100644 index db582d8..0000000 --- a/scripts/doctor.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -$ErrorActionPreference='SilentlyContinue' -$root=(Resolve-Path (Join-Path $PSScriptRoot '..')).Path -function Cmd($n){$x=Get-Command $n -ErrorAction SilentlyContinue;if($x){$x.Source}else{$null}} -$listeners=@(Get-NetTCPConnection -State Listen | Where-Object LocalPort -in 32145,32146 | Select-Object LocalPort,OwningProcess) -$cursor=Join-Path $HOME '.cursor\mcp.json';$codex=Join-Path $HOME '.codex\config.toml' -$result=[ordered]@{ - powershell=[ordered]@{ok=$PSVersionTable.PSVersion.Major -ge 7;version=$PSVersionTable.PSVersion.ToString();path=(Cmd 'pwsh')} - node=[ordered]@{ok=[bool](Cmd 'node');path=(Cmd 'node')} - host=[ordered]@{ok=(@($listeners|Where-Object LocalPort -eq 32146).Count -gt 0);ports=$listeners} - scheduledTask=[ordered]@{ok=[bool](Get-ScheduledTask -TaskName 'Chrome Ops Host');name='Chrome Ops Host'} - clients=[ordered]@{ - codex=[ordered]@{detected=[bool](Cmd 'codex');configExists=(Test-Path $codex);config=$codex} - cursor=[ordered]@{detected=([bool](Cmd 'cursor') -or [bool](Cmd 'agent') -or (Test-Path $cursor));configExists=(Test-Path $cursor);config=$cursor} - grok=[ordered]@{detected=[bool](Cmd 'grok');mode='local-stdio';config=(Join-Path $HOME '.grok\config.toml')} - } -} -$result|ConvertTo-Json -Depth 8 diff --git a/scripts/harness/claude.mjs b/scripts/harness/claude.mjs new file mode 100644 index 0000000..367078b --- /dev/null +++ b/scripts/harness/claude.mjs @@ -0,0 +1,17 @@ +// Claude Code keeps user-scoped MCP servers in ~/.claude.json and adds them with `claude mcp add-json`. +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; +import { check } from "../lib/process.mjs"; + +const config = resolve(homedir(), ".claude.json"); + +export const claude = { + name: "claude", + configFile: ".claude.json", + detect: os => !os.exec("claude", ["--version"]).error, + read: () => existsSync(config) ? JSON.parse(readFileSync(config, "utf8")).mcpServers?.["chrome-ops"] : undefined, + add(os, server) { + check(os.exec("claude", ["mcp", "add-json", "--scope", "user", "chrome-ops", JSON.stringify({ type: "stdio", ...server })]), "claude mcp add-json"); + }, +}; diff --git a/scripts/harness/codex.mjs b/scripts/harness/codex.mjs new file mode 100644 index 0000000..f117b93 --- /dev/null +++ b/scripts/harness/codex.mjs @@ -0,0 +1,20 @@ +// Codex manages ~/.codex/config.toml through `codex mcp`. +import { check } from "../lib/process.mjs"; + +const missing = stderr => stderr.includes("No MCP server named 'chrome-ops' found"); + +export const codex = { + name: "codex", + configFile: ".codex/config.toml", + detect: os => !os.exec("codex", ["--version"]).error, + read(os) { + const existing = os.exec("codex", ["mcp", "get", "chrome-ops", "--json"]); + if (existing.error) throw existing.error; + if (existing.status === 0) return JSON.parse(existing.stdout); + if (missing(existing.stderr)) return undefined; + throw new Error(`Cannot inspect Codex MCP registration: ${existing.stderr.trim()}`); + }, + add(os, server) { + check(os.exec("codex", ["mcp", "add", "chrome-ops", "--", server.command, ...server.args]), "codex mcp add"); + }, +}; diff --git a/scripts/harness/cursor.mjs b/scripts/harness/cursor.mjs new file mode 100644 index 0000000..34927e4 --- /dev/null +++ b/scripts/harness/cursor.mjs @@ -0,0 +1,30 @@ +// Cursor (editor and cursor-agent) reads global MCP servers from ~/.cursor/mcp.json. +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; + +const config = resolve(homedir(), ".cursor/mcp.json"); + +function load() { + const value = JSON.parse(existsSync(config) ? readFileSync(config, "utf8") : "{\"mcpServers\":{}}"); + if (value.mcpServers === undefined) value.mcpServers = {}; + if (!value.mcpServers || typeof value.mcpServers !== "object" || Array.isArray(value.mcpServers)) { + throw new Error("Cursor mcpServers is not an object; no configuration was changed"); + } + return value; +} + +export const cursor = { + name: "cursor", + configFile: ".cursor/mcp.json", + detect: os => existsSync(resolve(homedir(), ".cursor")) || !os.exec("cursor-agent", ["--version"]).error, + read: () => existsSync(config) ? load().mcpServers["chrome-ops"] : undefined, + add(_os, server) { + const value = load(); + value.mcpServers["chrome-ops"] = { type: "stdio", ...server }; + mkdirSync(dirname(config), { recursive: true, mode: 0o700 }); + const temporary = `${config}.${process.pid}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: existsSync(config) ? statSync(config).mode & 0o777 : 0o600 }); + renameSync(temporary, config); + }, +}; diff --git a/scripts/harness/grok.mjs b/scripts/harness/grok.mjs new file mode 100644 index 0000000..7337dcc --- /dev/null +++ b/scripts/harness/grok.mjs @@ -0,0 +1,19 @@ +// Grok Build manages ~/.grok/config.toml through `grok mcp` and can check a server with `grok mcp doctor`. +import { check } from "../lib/process.mjs"; + +export const grok = { + name: "grok", + configFile: ".grok/config.toml", + detect: os => !os.exec("grok", ["--version"]).error, + read(os) { + const inventory = JSON.parse(check(os.exec("grok", ["mcp", "list", "--json"]), "grok mcp list")); + if (!Array.isArray(inventory)) throw new Error("Grok MCP inventory is not an array"); + const matches = inventory.filter(item => item.name === "chrome-ops"); + if (matches.length > 1) throw new Error("Grok has duplicate chrome-ops entries"); + return matches[0]; + }, + add(os, server) { + check(os.exec("grok", ["mcp", "add", "chrome-ops", "--", server.command, ...server.args]), "grok mcp add"); + check(os.exec("grok", ["mcp", "doctor", "chrome-ops"]), "grok mcp doctor"); + }, +}; diff --git a/scripts/harness/index.mjs b/scripts/harness/index.mjs new file mode 100644 index 0000000..a1f9f9c --- /dev/null +++ b/scripts/harness/index.mjs @@ -0,0 +1,7 @@ +// Every supported harness. Each adapter knows only how its own configuration is read and written. +import { claude } from "./claude.mjs"; +import { codex } from "./codex.mjs"; +import { cursor } from "./cursor.mjs"; +import { grok } from "./grok.mjs"; + +export const harnesses = { claude, codex, cursor, grok }; diff --git a/scripts/lib/bridge-update.mjs b/scripts/lib/bridge-update.mjs new file mode 100644 index 0000000..23650b1 --- /dev/null +++ b/scripts/lib/bridge-update.mjs @@ -0,0 +1,30 @@ +// Bring an already connected unpacked Bridge up to date with this checkout. Shared by every OS. +import { createHash } from "node:crypto"; +import { bridgeIsCurrent, hostCall, hostStatus, reloadBridgeItself, waitFor } from "./host-client.mjs"; + +const sha256 = value => createHash("sha256").update(value).digest("hex"); + +export async function updateBridge(os) { + const status = await hostStatus(); + if (status.ambiguousProfiles) throw new Error("Multiple Chrome Ops Bridge profiles are connected; close the unintended Bridge profile before setup"); + if (!status.connected) return { connected: false, bridgeUpdated: false }; + if (await bridgeIsCurrent()) return { connected: true, bridgeUpdated: false }; + if (await reloadBridgeItself(status)) return { connected: true, bridgeUpdated: true }; + // Bridges older than 0.2.3 cannot reload themselves; only an OS helper can press their reload control. + if (!os.nativeBridgeUpdate) throw new Error("The connected Chrome Ops Bridge is older than 0.2.3; reload it once from chrome://extensions"); + const extensions = await hostCall("extensions.list"); + const matches = Array.isArray(extensions) ? extensions.filter(item => item?.name === "Chrome Ops MCP Bridge" && item.installType === "development" && item.enabled === true) : []; + if (matches.length !== 1) throw new Error("Expected exactly one enabled unpacked Chrome Ops MCP Bridge in the connected profile"); + const tabs = await hostCall("tabs.list"); + const hashes = (Array.isArray(tabs) ? tabs : []).filter(tab => tab?.active === true && typeof tab.url === "string" && tab.url.length > 0).map(tab => sha256(tab.url)); + if (hashes.length === 0) throw new Error("No active Bridge-profile tab can identify a Chrome window; no browser input was sent"); + const before = await hostStatus(); + if (!before.connected || before.bridgeSession !== status.bridgeSession) throw new Error("Bridge profile changed during setup; no browser input was sent"); + os.nativeBridgeUpdate(matches[0].id, hashes); + await waitFor(async () => { + const now = await hostStatus(); + if (now.ambiguousProfiles) throw new Error("Multiple Bridge profiles connected after update"); + return now.connected && now.bridgeSession !== status.bridgeSession && await bridgeIsCurrent(); + }, "updated Chrome Ops Bridge to reconnect with the expected source", 45000); + return { connected: true, bridgeUpdated: true }; +} diff --git a/scripts/lib/host-client.mjs b/scripts/lib/host-client.mjs new file mode 100644 index 0000000..eb18872 --- /dev/null +++ b/scripts/lib/host-client.mjs @@ -0,0 +1,71 @@ +// Talk to the persistent Chrome Ops Host from setup and doctor scripts. +import { createHash, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import WebSocket from "ws"; + +export const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +export const sleep = ms => new Promise(resolvePromise => setTimeout(resolvePromise, ms)); + +export function hostCall(method, params = {}) { + return new Promise((resolvePromise, reject) => { + const socket = new WebSocket("ws://127.0.0.1:32146"); + const id = randomUUID(); + const timer = setTimeout(() => { socket.terminate(); reject(new Error(`Host request timed out: ${method}`)); }, 17000); + socket.on("open", () => socket.send(JSON.stringify({ id, type: "request", method, params }))); + socket.on("message", raw => { + let response; + try { response = JSON.parse(raw.toString()); } catch (error) { clearTimeout(timer); socket.close(); reject(error); return; } + if (response.id !== id) return; + clearTimeout(timer); + socket.close(); + response.ok ? resolvePromise(response.result) : reject(new Error(response.error)); + }); + socket.on("error", error => { clearTimeout(timer); reject(error); }); + }); +} + +export const hostStatus = () => hostCall("host.status"); + +export async function waitFor(predicate, description, limitMs = 5000) { + const deadline = Date.now() + limitMs; + do { + if (await predicate()) return; + await sleep(120); + } while (Date.now() < deadline); + throw new Error(`Timed out waiting for ${description}`); +} + +// The Bridge reports hashes of the files on disk and the version of the manifest it is running. +// Replacing the files alone changes the hashes but not the running version. +export function bridgeExpectation() { + const sha256 = path => createHash("sha256").update(readFileSync(resolve(root, path))).digest("hex"); + const version = JSON.parse(readFileSync(resolve(root, "extension/manifest.json"), "utf8")).version; + return { sourceHash: sha256("extension/service-worker.js"), manifestHash: sha256("extension/manifest.json"), version }; +} + +export async function bridgeIsCurrent(expected = bridgeExpectation()) { + let capabilities; + try { capabilities = await hostCall("extensions.capabilities"); } + catch (error) { + if (error.message === "Unknown method: extensions.capabilities") return false; + throw error; + } + return capabilities?.preparePage === true && capabilities.sourceHash === expected.sourceHash && + capabilities.manifestHash === expected.manifestHash && capabilities.runningVersion === expected.version; +} + +// Bridges from 0.2.3 reload themselves from disk, so setup needs no browser UI input. +export async function reloadBridgeItself(status) { + let capabilities; + try { capabilities = await hostCall("extensions.capabilities"); } catch { return false; } + if (capabilities?.reloadSelf !== true) return false; + await hostCall("extensions.reloadSelf"); + await waitFor(async () => { + const now = await hostStatus(); + if (now.ambiguousProfiles) throw new Error("Multiple Bridge profiles connected after update"); + return now.connected && now.bridgeSession !== status.bridgeSession && await bridgeIsCurrent(); + }, "reloaded Chrome Ops Bridge to reconnect with the expected source", 45000); + return true; +} diff --git a/scripts/lib/process.mjs b/scripts/lib/process.mjs new file mode 100644 index 0000000..1ebbb1f --- /dev/null +++ b/scripts/lib/process.mjs @@ -0,0 +1,16 @@ +// Run programs for setup scripts. OS adapters decide how a harness CLI name becomes a process. +import { spawnSync } from "node:child_process"; +import { root } from "./host-client.mjs"; + +export function spawn(program, args, options = {}) { + return spawnSync(program, args, { cwd: root, encoding: "utf8", maxBuffer: 4 * 1024 * 1024, windowsHide: true, ...options }); +} + +export function check(result, label) { + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${label} failed (${result.status}): ${(result.stderr || result.stdout).trim()}`); + if (result.stderr) process.stderr.write(result.stderr); + return result.stdout; +} + +export const run = (program, args, options) => check(spawn(program, args, options), `${program} ${args[0] ?? ""}`.trim()); diff --git a/scripts/lib/registration.mjs b/scripts/lib/registration.mjs new file mode 100644 index 0000000..7848b4e --- /dev/null +++ b/scripts/lib/registration.mjs @@ -0,0 +1,61 @@ +// Register the stdio MCP server with a harness. The flow is shared; the harness adapter owns its configuration. +import { chmodSync, copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, resolve } from "node:path"; +import { harnesses } from "../harness/index.mjs"; +import { root } from "./host-client.mjs"; + +// Every harness launches this Node with the built server, so a registration is tied to this checkout. +export const server = () => ({ command: process.execPath, args: [resolve(root, "dist/index.js")] }); + +export function matching(entry) { + const found = entry?.transport ?? entry; + const expected = server(); + return (found?.type === undefined || found.type === "stdio") && + found?.command === expected.command && Array.isArray(found.args) && + found.args.length === 1 && found.args[0] === expected.args[0]; +} + +function backup(os, harness) { + const source = resolve(homedir(), harness.configFile); + if (!existsSync(source)) return null; + const directory = resolve(os.stateDir, "backups"); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const target = resolve(directory, `${harness.name}-${new Date().toISOString().replaceAll(":", "-")}-${process.pid}-${basename(source)}`); + copyFileSync(source, target); + chmodSync(target, 0o600); + return target; +} + +export function harnessNamed(name) { + const harness = harnesses[name]; + if (!harness) throw new Error(`Expected one of: ${Object.keys(harnesses).join(", ")}`); + return harness; +} + +export function register(os, harness) { + if (!existsSync(server().args[0])) throw new Error("Built MCP server is missing; run npm run setup"); + if (!harness.detect(os)) throw new Error(`${harness.name} is not installed for this user`); + const existing = harness.read(os); + if (existing) { + if (!matching(existing)) throw new Error(`${harness.name} has a different chrome-ops server; no configuration was changed`); + return { harness: harness.name, changed: false }; + } + const archive = backup(os, harness); + harness.add(os, server()); + if (!matching(harness.read(os))) throw new Error(`${harness.name} registration readback differs from the requested stdio command`); + return { harness: harness.name, changed: true, backup: archive }; +} + +export function inspect(os) { + return Object.fromEntries(Object.values(harnesses).map(harness => { + let detected = false; + let registered = false; + let error = null; + try { + detected = harness.detect(os); + if (detected) registered = matching(harness.read(os)); + } catch (failure) { error = String(failure.message); } + return [harness.name, { detected, registered, error }]; + })); +} diff --git a/scripts/macos-host.mjs b/scripts/macos-host.mjs deleted file mode 100644 index 413d9aa..0000000 --- a/scripts/macos-host.mjs +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env node -import { spawn, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { createHash, randomUUID } from "node:crypto"; -import WebSocket from "ws"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const label = "dev.kitepon.chrome-ops-host"; -const domain = `gui/${process.getuid()}`; -const service = `${domain}/${label}`; -const plist = resolve(homedir(), "Library/LaunchAgents", `${label}.plist`); -const host = resolve(root, "dist/host.js"); -const native = resolve(root, "helper/macos/.build/release/chrome-ops-helper"); -const logDir = resolve(homedir(), "Library/Logs/ChromeOps"); -const sleep = ms => new Promise(resolvePromise => setTimeout(resolvePromise, ms)); - -function command(program, args, { allowMissing = false, logOutput = false } = {}) { - const result = spawnSync(program, args, { encoding: "utf8", cwd: root, maxBuffer: 1024 * 1024 }); - if (result.error) throw result.error; - if (logOutput) { - if (result.stdout) process.stderr.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - } - if (result.status === 0 || (allowMissing && result.status === 1)) return result; - throw new Error(`${program} ${args[0]} failed (${result.status}): ${(result.stderr || result.stdout).trim()}`); -} - -function launchStatus() { - const result = spawnSync("launchctl", ["print", service], { encoding: "utf8" }); - if (result.error) throw result.error; - if (result.status !== 0) { - if (!result.stderr.includes(`Could not find service "${label}"`)) { - throw new Error(`launchctl print failed (${result.status}): ${result.stderr.trim()}`); - } - return { loaded: false, running: false, pid: null }; - } - const pid = Number(result.stdout.match(/\bpid = (\d+)\b/)?.[1] ?? 0) || null; - return { loaded: true, running: pid !== null, pid }; -} - -function listeningPids(port) { - const result = command("lsof", ["-nP", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"], { allowMissing: true }); - return result.stdout.trim().split(/\s+/).filter(Boolean).map(Number); -} - -function xml(value) { - return value.replaceAll("&", "&").replaceAll("<", "<") - .replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); -} - -function jobDefinition() { - const hostBuild = createHash("sha256").update(readFileSync(host)).digest("hex"); - return ` - - -Label${label} -ProgramArguments${xml(process.execPath)}${xml(host)} -EnvironmentVariablesCHROME_OPS_HOST_BUILD${hostBuild} -KeepAlive -Umask63 -StandardOutPath${xml(resolve(logDir, "host.stdout.log"))} -StandardErrorPath${xml(resolve(logDir, "host.stderr.log"))} - -`; -} - -function assertOwnedPlist() { - if (!existsSync(plist)) return; - const actual = command("plutil", ["-extract", "Label", "raw", "-o", "-", plist]).stdout.trim(); - if (actual !== label) throw new Error(`LaunchAgent file belongs to ${actual}; no changes were made`); -} - -function writeJob(contents) { - mkdirSync(dirname(plist), { recursive: true, mode: 0o700 }); - const temporary = `${plist}.${process.pid}.tmp`; - try { - writeFileSync(temporary, contents, { mode: 0o600 }); - command("plutil", ["-lint", temporary]); - renameSync(temporary, plist); - } finally { - rmSync(temporary, { force: true }); - } -} - -async function waitFor(predicate, description, limitMs = 5000) { - const deadline = Date.now() + limitMs; - do { - if (await predicate()) return; - await sleep(120); - } while (Date.now() < deadline); - throw new Error(`Timed out waiting for ${description}`); -} - -function legacyHostPid() { - const chrome = listeningPids(32145); - const clients = listeningPids(32146); - const owners = [...new Set([...chrome, ...clients])]; - if (owners.length === 0) return null; - if (owners.length !== 1 || chrome.length !== 1 || clients.length !== 1) { - throw new Error("Chrome Ops ports have different or ambiguous owners; no process was stopped"); - } - const pid = owners[0]; - const args = command("ps", ["-p", String(pid), "-o", "args="]).stdout.trim(); - const cwd = command("lsof", ["-nP", "-a", "-p", String(pid), "-d", "cwd", "-Fn"]) - .stdout.split("\n").find(line => line.startsWith("n"))?.slice(1); - if (cwd !== root || !/^(?:\S*\/)?node (?:dist\/host\.js|\S*\/dist\/host\.js)$/.test(args)) { - throw new Error(`Ports 32145/32146 belong to an unrecognized process (${pid}); no process was stopped`); - } - return pid; -} - -async function hostCall(method, params = {}) { - return new Promise((resolvePromise, reject) => { - const socket = new WebSocket("ws://127.0.0.1:32146"); - const id = randomUUID(); - const timer = setTimeout(() => { socket.terminate(); reject(new Error(`Host request timed out: ${method}`)); }, 17000); - socket.on("open", () => socket.send(JSON.stringify({ id, type: "request", method, params }))); - socket.on("message", raw => { - let response; - try { response = JSON.parse(raw.toString()); } catch (error) { clearTimeout(timer); socket.close(); reject(error); return; } - if (response.id !== id) return; - clearTimeout(timer); - socket.close(); - response.ok ? resolvePromise(response.result) : reject(new Error(response.error)); - }); - socket.on("error", error => { clearTimeout(timer); reject(error); }); - }); -} - -const hostStatus = () => hostCall("host.status"); - -async function updateBridge() { - const status = await hostStatus(); - if (status.ambiguousProfiles) throw new Error("Multiple Chrome Ops Bridge profiles are connected; close the unintended Bridge profile before setup"); - if (!status.connected) return { connected: false, bridgeUpdated: false }; - const sourceHash = createHash("sha256").update(readFileSync(resolve(root, "extension/service-worker.js"))).digest("hex"); - const manifestHash = createHash("sha256").update(readFileSync(resolve(root, "extension/manifest.json"))).digest("hex"); - let capabilities; - try { capabilities = await hostCall("extensions.capabilities"); } - catch (error) { - if (error.message !== "Unknown method: extensions.capabilities") throw error; - } - if (capabilities?.preparePage === true && capabilities.sourceHash === sourceHash && capabilities.manifestHash === manifestHash) { - return { connected: true, bridgeUpdated: false }; - } - const extensions = await hostCall("extensions.list"); - if (!Array.isArray(extensions)) throw new Error("Bridge extension inventory is invalid"); - const matches = extensions.filter(item => item?.name === "Chrome Ops MCP Bridge" && item.installType === "development" && item.enabled === true); - if (matches.length !== 1) throw new Error("Expected exactly one enabled unpacked Chrome Ops MCP Bridge in the connected profile"); - const tabs = await hostCall("tabs.list"); - if (!Array.isArray(tabs)) throw new Error("Bridge tab inventory is invalid"); - const hashes = tabs.filter(tab => tab?.active === true && typeof tab.url === "string" && tab.url.length > 0) - .map(tab => createHash("sha256").update(tab.url).digest("hex")); - if (hashes.length === 0) throw new Error("No active Bridge-profile tab can identify a Chrome window; no browser input was sent"); - const before = await hostStatus(); - if (!before.connected || before.bridgeSession !== status.bridgeSession) { - throw new Error("Bridge profile changed during setup; no browser input was sent"); - } - const result = command(native, ["bootstrap", matches[0].id, JSON.stringify(hashes)]); - let response; - try { response = JSON.parse(result.stdout); } catch { throw new Error("Native Bridge update returned invalid JSON"); } - if (response.ok !== true) throw new Error("Native Bridge update did not complete"); - await waitFor(async () => { - const current = await hostStatus(); - if (current.ambiguousProfiles) throw new Error("Multiple Bridge profiles connected after update"); - if (!current.connected || current.bridgeSession === status.bridgeSession) return false; - try { - const currentCapabilities = await hostCall("extensions.capabilities"); - return currentCapabilities?.preparePage === true && currentCapabilities.sourceHash === sourceHash && currentCapabilities.manifestHash === manifestHash; - } catch { return false; } - }, "updated Chrome Ops Bridge to reconnect with the expected source", 12000); - return { connected: true, bridgeUpdated: true }; -} - -async function setup() { - if (existsSync(resolve(root, "node_modules/typescript/bin/tsc"))) { - command(process.execPath, [resolve(root, "node_modules/typescript/bin/tsc")], { logOutput: true }); - } - if (!existsSync(host) || !existsSync(resolve(root, "dist/index.js"))) { - throw new Error("Built TypeScript files are missing; run npm ci and npm run build"); - } - command("swift", ["build", "--package-path", resolve(root, "helper/macos"), "-c", "release"], { logOutput: true }); - if (!existsSync(native)) throw new Error("Swift build did not create the release helper"); - assertOwnedPlist(); - mkdirSync(logDir, { recursive: true, mode: 0o700 }); - const desired = jobDefinition(); - const previous = existsSync(plist) ? readFileSync(plist, "utf8") : null; - const oldService = launchStatus(); - const oldConnection = oldService.running && listeningPids(32146).includes(oldService.pid) - ? await hostStatus() : null; - if (oldService.loaded && previous === null) { - throw new Error(`LaunchAgent ${label} is loaded from another location; no changes were made`); - } - if (oldService.loaded && previous === desired && oldService.running && - listeningPids(32145).includes(oldService.pid) && listeningPids(32146).includes(oldService.pid)) { - const status = await hostStatus(); - return { ok: true, changed: false, service: label, running: status.host, ...await updateBridge() }; - } - if (oldService.loaded) command("launchctl", ["bootout", service]); - let legacyStopped = false; - let installed; - try { - const oldPid = legacyHostPid(); - if (oldPid !== null) { - process.kill(oldPid, "SIGTERM"); - legacyStopped = true; - await waitFor(() => listeningPids(32145).length === 0 && listeningPids(32146).length === 0, - "previous development Host to release its ports"); - } - writeJob(desired); - command("launchctl", ["bootstrap", domain, plist]); - await waitFor(async () => { - const current = launchStatus(); - return current.running && listeningPids(32145).includes(current.pid) && - listeningPids(32146).includes(current.pid); - }, "LaunchAgent Host to listen on localhost", 10000); - const status = await hostStatus(); - if (status.host !== true) throw new Error("LaunchAgent Host did not answer host.status"); - if (oldConnection?.connected) { - await waitFor(async () => (await hostStatus()).connected === true, - "the previously connected Chrome Ops Bridge to reconnect", 10000); - } - installed = { ok: true, changed: true, service: label, running: true }; - } catch (error) { - if (launchStatus().loaded) command("launchctl", ["bootout", service]); - if (previous === null) rmSync(plist, { force: true }); - else writeJob(previous); - if (oldService.loaded) command("launchctl", ["bootstrap", domain, plist]); - else if (legacyStopped) spawn(process.execPath, [host], { cwd: root, detached: true, stdio: "ignore" }).unref(); - throw error; - } - return { ...installed, ...await updateBridge() }; -} - -function uninstall() { - assertOwnedPlist(); - const previous = launchStatus(); - const hadPlist = existsSync(plist); - if (previous.loaded) command("launchctl", ["bootout", service]); - if (existsSync(plist)) rmSync(plist); - return { ok: true, removed: previous.loaded || hadPlist, service: label, plistRemoved: !existsSync(plist) }; -} - -async function doctor() { - const job = launchStatus(); - const ports = { chrome: listeningPids(32145), clients: listeningPids(32146) }; - let hostResult; - try { hostResult = await hostStatus(); } catch (error) { hostResult = { error: String(error.message) }; } - let helperResult; - if (!existsSync(native)) helperResult = { built: false, error: "Run npm run setup:macos to build the Swift helper" }; - else { - const result = command(native, ["doctor", "--permissions-only"], { allowMissing: true }); - try { - const report = JSON.parse(result.stdout); - helperResult = { built: true, accessibility: report.data?.accessibility ?? false, - screenRecordingUsed: report.data?.screenRecordingUsed ?? false, - error: report.error ?? null }; - } catch { helperResult = { built: true, error: "Native helper doctor returned invalid JSON" }; } - } - const codex = spawnSync("codex", ["mcp", "get", "chrome-ops", "--json"], { encoding: "utf8" }); - let codexRegistered = false; - let codexError = null; - if (codex.status === 0) { - try { - const entry = JSON.parse(codex.stdout).transport; - codexRegistered = entry?.type === "stdio" && entry.command === process.execPath && - entry.args?.length === 1 && entry.args[0] === resolve(root, "dist/index.js"); - } catch { codexError = "Codex MCP registration returned invalid JSON"; } - } else if (codex.error) codexError = codex.error.message; - else if (!codex.stderr.includes("No MCP server named 'chrome-ops' found")) codexError = codex.stderr.trim(); - const cursorConfig = resolve(homedir(), ".cursor/mcp.json"); - let cursorRegistered = false; - let cursorError = null; - if (existsSync(cursorConfig)) { - try { - const entry = JSON.parse(readFileSync(cursorConfig, "utf8")).mcpServers?.["chrome-ops"]; - cursorRegistered = entry?.command === process.execPath && entry.args?.length === 1 && - entry.args[0] === resolve(root, "dist/index.js"); - } catch { cursorError = "Cursor MCP configuration is invalid JSON"; } - } - const grok = spawnSync("grok", ["mcp", "list", "--json"], { encoding: "utf8" }); - let grokRegistered = false; - let grokError = null; - if (grok.status === 0) { - try { - grokRegistered = JSON.parse(grok.stdout).some(item => item.name === "chrome-ops" && - item.command === process.execPath && item.args?.length === 1 && - item.args[0] === resolve(root, "dist/index.js")); - } catch { grokError = "Grok MCP inventory returned invalid JSON"; } - } else grokError = grok.error?.message ?? grok.stderr.trim(); - return { - ok: job.running && ports.chrome.includes(job.pid) && ports.clients.includes(job.pid) && - hostResult.host === true && helperResult.accessibility === true, - node: { path: process.execPath, version: process.version }, - launchAgent: { label, ...job, plistExists: existsSync(plist) }, - host: { ...hostResult, ports }, nativeHelper: helperResult, - clients: { - codex: { detected: codex.error === undefined, registered: codexRegistered, error: codexError }, - cursor: { detected: existsSync("/Applications/Cursor.app") || existsSync(resolve(homedir(), "Applications/Cursor.app")), registered: cursorRegistered, error: cursorError }, - grok: { detected: grok.error === undefined, registered: grokRegistered, error: grokError }, - }, - }; -} - -if (process.platform !== "darwin") throw new Error("macOS Host management requires macOS"); -try { - let result; - switch (process.argv[2]) { - case "setup": result = await setup(); break; - case "uninstall": result = uninstall(); break; - case "doctor": result = await doctor(); break; - default: throw new Error("Expected setup, uninstall, or doctor"); - } - process.stdout.write(`${JSON.stringify(result)}\n`); -} catch (error) { - process.stderr.write(`${JSON.stringify({ ok: false, error: String(error.message) })}\n`); - process.exitCode = 1; -} diff --git a/scripts/os/index.mjs b/scripts/os/index.mjs new file mode 100644 index 0000000..e3fd29e --- /dev/null +++ b/scripts/os/index.mjs @@ -0,0 +1,12 @@ +// Select the setup adapter for the running OS. +import { linux } from "./linux.mjs"; +import { macos } from "./macos.mjs"; +import { windows } from "./windows.mjs"; + +const adapters = { darwin: macos, win32: windows, linux }; + +export function currentOs() { + const adapter = adapters[process.platform]; + if (!adapter) throw new Error(`Unsupported platform: ${process.platform}`); + return adapter; +} diff --git a/scripts/os/linux.mjs b/scripts/os/linux.mjs new file mode 100644 index 0000000..0187db1 --- /dev/null +++ b/scripts/os/linux.mjs @@ -0,0 +1,141 @@ +// Linux: the Host runs as a systemd user service. There is no developer-operation helper yet. +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { root, waitFor } from "../lib/host-client.mjs"; +import { check, spawn } from "../lib/process.mjs"; + +const unitName = "chrome-ops-host.service"; +const unitPath = resolve(process.env.XDG_CONFIG_HOME || resolve(homedir(), ".config"), "systemd/user", unitName); +const host = resolve(root, "dist/host.js"); + +const systemctl = (args, { allowFailure = false } = {}) => { + const result = spawn("systemctl", ["--user", ...args]); + return allowFailure ? result : (check(result, `systemctl --user ${args.join(" ")}`), result); +}; + +function unitState() { + const result = systemctl(["show", unitName, "--property=LoadState,ActiveState,MainPID"], { allowFailure: true }); + const fields = Object.fromEntries((result.stdout ?? "").trim().split("\n").filter(Boolean).map(line => line.split("=", 2))); + const pid = Number(fields.MainPID) || null; + return { loaded: fields.LoadState === "loaded", running: fields.ActiveState === "active" && pid !== null, pid }; +} + +// Owners of the listening Host ports, read from /proc so no extra tools are needed. +function listeningPids(port) { + const inodes = new Set(); + for (const table of ["/proc/net/tcp", "/proc/net/tcp6"]) { + if (!existsSync(table)) continue; + for (const line of readFileSync(table, "utf8").trim().split("\n").slice(1)) { + const fields = line.trim().split(/\s+/); + if (fields[3] === "0A" && parseInt(fields[1].split(":").at(-1), 16) === port) inodes.add(fields[9]); + } + } + if (inodes.size === 0) return []; + const pids = new Set(); + for (const pid of readdirSync("/proc").filter(name => /^\d+$/.test(name))) { + let fds; + try { fds = readdirSync(`/proc/${pid}/fd`); } catch { continue; } + for (const fd of fds) { + let link; + try { link = readlinkSync(`/proc/${pid}/fd/${fd}`); } catch { continue; } + const inode = link.match(/^socket:\[(\d+)\]$/)?.[1]; + if (inode && inodes.has(inode)) { pids.add(Number(pid)); break; } + } + } + return [...pids]; +} + +const serving = pid => listeningPids(32145).includes(pid) && listeningPids(32146).includes(pid); + +function unitDefinition() { + const hostBuild = createHash("sha256").update(readFileSync(host)).digest("hex"); + // systemd expands % specifiers everywhere; ExecStart also takes quoted words, WorkingDirectory a bare path. + const literal = value => value.replaceAll("%", "%%"); + const quote = value => `"${literal(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`; + return `[Unit] +Description=Chrome Ops MCP Host + +[Service] +ExecStart=${quote(process.execPath)} ${quote(host)} +WorkingDirectory=${literal(root)} +Environment=CHROME_OPS_HOST_BUILD=${hostBuild} +Restart=always +RestartSec=2 +UMask=0077 + +[Install] +WantedBy=default.target +`; +} + +function assertOwnedUnit() { + if (!existsSync(unitPath)) return; + if (!readFileSync(unitPath, "utf8").includes("Description=Chrome Ops MCP Host")) { + throw new Error(`${unitPath} is not a Chrome Ops unit; no changes were made`); + } +} + +function writeUnit(contents) { + mkdirSync(dirname(unitPath), { recursive: true, mode: 0o700 }); + const temporary = `${unitPath}.${process.pid}.tmp`; + try { + writeFileSync(temporary, contents, { mode: 0o600 }); + renameSync(temporary, unitPath); + } finally { + rmSync(temporary, { force: true }); + } +} + +export const linux = { + name: "linux", + stateDir: resolve(process.env.XDG_STATE_HOME || resolve(homedir(), ".local/state"), "chrome-ops"), + exec: (program, args) => spawn(program, args), + prepare() {}, + + async installService() { + assertOwnedUnit(); + const desired = unitDefinition(); + const previous = existsSync(unitPath) ? readFileSync(unitPath, "utf8") : null; + const old = unitState(); + if (previous === desired && old.running && serving(old.pid)) return { changed: false, service: unitName }; + const others = [...new Set([...listeningPids(32145), ...listeningPids(32146)])].filter(pid => pid !== old.pid); + if (others.length) throw new Error(`Ports 32145/32146 belong to another process (${others.join(", ")}); no process was stopped`); + writeUnit(desired); + try { + systemctl(["daemon-reload"]); + systemctl(["enable", unitName]); + systemctl(["restart", unitName]); + await waitFor(() => { const now = unitState(); return now.running && serving(now.pid); }, "systemd Host to listen on localhost", 10000); + } catch (error) { + if (previous === null) { + systemctl(["disable", "--now", unitName], { allowFailure: true }); + rmSync(unitPath, { force: true }); + } else writeUnit(previous); + systemctl(["daemon-reload"], { allowFailure: true }); + if (previous !== null && old.running) systemctl(["restart", unitName], { allowFailure: true }); + throw error; + } + return { changed: true, service: unitName }; + }, + + uninstallService() { + assertOwnedUnit(); + const hadUnit = existsSync(unitPath); + const previous = unitState(); + if (previous.loaded) systemctl(["disable", "--now", unitName], { allowFailure: true }); + rmSync(unitPath, { force: true }); + systemctl(["daemon-reload"], { allowFailure: true }); + return { removed: hadUnit || previous.loaded, service: unitName, unitRemoved: !existsSync(unitPath) }; + }, + + serviceStatus() { + const state = unitState(); + const ports = { chrome: listeningPids(32145), clients: listeningPids(32146) }; + return { ok: state.running && serving(state.pid), kind: "systemd", unit: unitName, ...state, unitExists: existsSync(unitPath), ports }; + }, + + developerOperations: () => ({ supported: false, ok: true, reason: "Load unpacked, reload, errors and remove are not automated on Linux yet" }), + nativeBridgeUpdate: null, +}; diff --git a/scripts/os/macos.mjs b/scripts/os/macos.mjs new file mode 100644 index 0000000..8d924c4 --- /dev/null +++ b/scripts/os/macos.mjs @@ -0,0 +1,173 @@ +// macOS: the Host runs as a LaunchAgent and the Swift helper drives chrome://extensions. +import { spawn as spawnDetached } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { root, waitFor } from "../lib/host-client.mjs"; +import { check, run, spawn } from "../lib/process.mjs"; + +const label = "dev.kitepon.chrome-ops-host"; +const domain = () => `gui/${process.getuid()}`; +const service = () => `${domain()}/${label}`; +const plist = resolve(homedir(), "Library/LaunchAgents", `${label}.plist`); +const host = resolve(root, "dist/host.js"); +const native = resolve(root, "helper/macos/.build/release/chrome-ops-helper"); +const logDir = resolve(homedir(), "Library/Logs/ChromeOps"); + +function launchStatus() { + const result = spawn("launchctl", ["print", service()]); + if (result.error) throw result.error; + if (result.status !== 0) { + if (!result.stderr.includes(`Could not find service "${label}"`)) { + throw new Error(`launchctl print failed (${result.status}): ${result.stderr.trim()}`); + } + return { loaded: false, running: false, pid: null }; + } + const pid = Number(result.stdout.match(/\bpid = (\d+)\b/)?.[1] ?? 0) || null; + return { loaded: true, running: pid !== null, pid }; +} + +function listeningPids(port) { + const result = spawn("lsof", ["-nP", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"]); + if (result.error) throw result.error; + if (result.status !== 0 && result.status !== 1) throw new Error(`lsof failed (${result.status}): ${result.stderr.trim()}`); + return result.stdout.trim().split(/\s+/).filter(Boolean).map(Number); +} + +const serving = pid => listeningPids(32145).includes(pid) && listeningPids(32146).includes(pid); + +function xml(value) { + return value.replaceAll("&", "&").replaceAll("<", "<") + .replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +} + +function jobDefinition() { + const hostBuild = createHash("sha256").update(readFileSync(host)).digest("hex"); + return ` + + +Label${label} +ProgramArguments${xml(process.execPath)}${xml(host)} +EnvironmentVariablesCHROME_OPS_HOST_BUILD${hostBuild} +KeepAlive +Umask63 +StandardOutPath${xml(resolve(logDir, "host.stdout.log"))} +StandardErrorPath${xml(resolve(logDir, "host.stderr.log"))} + +`; +} + +function assertOwnedPlist() { + if (!existsSync(plist)) return; + const actual = run("plutil", ["-extract", "Label", "raw", "-o", "-", plist]).trim(); + if (actual !== label) throw new Error(`LaunchAgent file belongs to ${actual}; no changes were made`); +} + +function writeJob(contents) { + mkdirSync(dirname(plist), { recursive: true, mode: 0o700 }); + const temporary = `${plist}.${process.pid}.tmp`; + try { + writeFileSync(temporary, contents, { mode: 0o600 }); + run("plutil", ["-lint", temporary]); + renameSync(temporary, plist); + } finally { + rmSync(temporary, { force: true }); + } +} + +// A Host started by hand from this checkout before the LaunchAgent existed. +function legacyHostPid() { + const chrome = listeningPids(32145); + const clients = listeningPids(32146); + const owners = [...new Set([...chrome, ...clients])]; + if (owners.length === 0) return null; + if (owners.length !== 1 || chrome.length !== 1 || clients.length !== 1) { + throw new Error("Chrome Ops ports have different or ambiguous owners; no process was stopped"); + } + const pid = owners[0]; + const args = run("ps", ["-p", String(pid), "-o", "args="]).trim(); + const cwd = run("lsof", ["-nP", "-a", "-p", String(pid), "-d", "cwd", "-Fn"]) + .split("\n").find(line => line.startsWith("n"))?.slice(1); + if (cwd !== root || !/^(?:\S*\/)?node (?:dist\/host\.js|\S*\/dist\/host\.js)$/.test(args)) { + throw new Error(`Ports 32145/32146 belong to an unrecognized process (${pid}); no process was stopped`); + } + return pid; +} + +export const macos = { + name: "macos", + stateDir: resolve(homedir(), "Library/Application Support/ChromeOps"), + exec: (program, args) => spawn(program, args), + + prepare() { + const build = spawn("swift", ["build", "--package-path", resolve(root, "helper/macos"), "-c", "release"]); + if (build.stdout) process.stderr.write(build.stdout); + check(build, "swift build"); + if (!existsSync(native)) throw new Error("Swift build did not create the release helper"); + }, + + async installService() { + assertOwnedPlist(); + mkdirSync(logDir, { recursive: true, mode: 0o700 }); + const desired = jobDefinition(); + const previous = existsSync(plist) ? readFileSync(plist, "utf8") : null; + const old = launchStatus(); + if (old.loaded && previous === null) throw new Error(`LaunchAgent ${label} is loaded from another location; no changes were made`); + if (old.loaded && previous === desired && old.running && serving(old.pid)) return { changed: false, service: label }; + if (old.loaded) run("launchctl", ["bootout", service()]); + let legacyStopped = false; + try { + const legacy = legacyHostPid(); + if (legacy !== null) { + process.kill(legacy, "SIGTERM"); + legacyStopped = true; + await waitFor(() => listeningPids(32145).length === 0 && listeningPids(32146).length === 0, "previous development Host to release its ports"); + } + writeJob(desired); + run("launchctl", ["bootstrap", domain(), plist]); + await waitFor(() => { const now = launchStatus(); return now.running && serving(now.pid); }, "LaunchAgent Host to listen on localhost", 10000); + } catch (error) { + if (launchStatus().loaded) run("launchctl", ["bootout", service()]); + if (previous === null) rmSync(plist, { force: true }); + else writeJob(previous); + if (old.loaded) run("launchctl", ["bootstrap", domain(), plist]); + else if (legacyStopped) spawnDetached(process.execPath, [host], { cwd: root, detached: true, stdio: "ignore" }).unref(); + throw error; + } + return { changed: true, service: label }; + }, + + uninstallService() { + assertOwnedPlist(); + const previous = launchStatus(); + const hadPlist = existsSync(plist); + if (previous.loaded) run("launchctl", ["bootout", service()]); + if (existsSync(plist)) rmSync(plist); + return { removed: previous.loaded || hadPlist, service: label, plistRemoved: !existsSync(plist) }; + }, + + serviceStatus() { + const job = launchStatus(); + const ports = { chrome: listeningPids(32145), clients: listeningPids(32146) }; + return { ok: job.running && serving(job.pid), kind: "launchd", label, ...job, plistExists: existsSync(plist), ports }; + }, + + developerOperations() { + if (!existsSync(native)) return { supported: true, ok: false, built: false, error: "Run npm run setup to build the Swift helper" }; + const result = spawn(native, ["doctor", "--permissions-only"]); + try { + const report = JSON.parse(result.stdout); + const accessibility = report.data?.accessibility ?? false; + return { supported: true, ok: accessibility === true, built: true, accessibility, + screenRecordingUsed: report.data?.screenRecordingUsed ?? false, error: report.error ?? null }; + } catch { return { supported: true, ok: false, built: true, error: "Native helper doctor returned invalid JSON" }; } + }, + + nativeBridgeUpdate(extensionId, hashes) { + let response; + try { response = JSON.parse(run(native, ["bootstrap", extensionId, JSON.stringify(hashes)])); } + catch (error) { throw new Error(`Native Bridge update failed: ${error.message}`); } + if (response.ok !== true) throw new Error("Native Bridge update did not complete"); + }, +}; diff --git a/scripts/os/windows.mjs b/scripts/os/windows.mjs new file mode 100644 index 0000000..e8c1314 --- /dev/null +++ b/scripts/os/windows.mjs @@ -0,0 +1,135 @@ +// Windows: the Host runs as a logon Scheduled Task and the PowerShell 7 helper drives chrome://extensions. +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { root, waitFor } from "../lib/host-client.mjs"; +import { check, spawn } from "../lib/process.mjs"; + +const taskName = "Chrome Ops Host"; +const taskDescription = "Persistent local host for Chrome Ops MCP"; +const host = resolve(root, "dist/host.js"); +const helper = resolve(root, "helper/windows/chrome-ops-helper.ps1"); + +// Values travel as environment variables so PowerShell never has to parse them as code. +function powershell(script, env = {}) { + const prologue = "$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);"; + const output = check(spawn("pwsh", ["-NoProfile", "-NonInteractive", "-Command", prologue + script], + { env: { ...process.env, ...env } }), "pwsh"); + return output.trim() ? JSON.parse(output) : null; +} + +function listeningPids(port) { + const rows = powershell("@(Get-NetTCPConnection -State Listen -LocalPort $env:CO_PORT -ErrorAction SilentlyContinue|ForEach-Object{$_.OwningProcess})|ConvertTo-Json -Compress -AsArray", + { CO_PORT: String(port) }); + return [...new Set(rows ?? [])]; +} + +function readTask() { + return powershell("$t=Get-ScheduledTask -TaskName $env:CO_TASK -ErrorAction SilentlyContinue;if(-not $t){'null'}else{[pscustomobject]@{state=[string]$t.State;description=$t.Description;execute=$t.Actions[0].Execute;arguments=$t.Actions[0].Arguments;workingDirectory=$t.Actions[0].WorkingDirectory}|ConvertTo-Json -Compress}", + { CO_TASK: taskName }); +} + +// The build hash rides on the command line so a changed Host build changes the task definition. +function desiredTask() { + const hostBuild = createHash("sha256").update(readFileSync(host)).digest("hex"); + return { execute: process.execPath, arguments: `"${host}" --build=${hostBuild}`, workingDirectory: root }; +} + +function registerTask(task) { + powershell(`$a=New-ScheduledTaskAction -Execute $env:CO_EXE -Argument $env:CO_ARGS -WorkingDirectory $env:CO_DIR +$tr=New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME +$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) +Register-ScheduledTask -TaskName $env:CO_TASK -Action $a -Trigger $tr -Settings $s -Description $env:CO_DESC -Force|Out-Null`, + { CO_EXE: task.execute, CO_ARGS: task.arguments, CO_DIR: task.workingDirectory, CO_TASK: taskName, CO_DESC: taskDescription }); +} + +const taskCommand = verb => powershell(`${verb}-ScheduledTask -TaskName $env:CO_TASK -ErrorAction SilentlyContinue`, { CO_TASK: taskName }); +const portsFree = () => listeningPids(32145).length === 0 && listeningPids(32146).length === 0; +const portsServed = () => listeningPids(32145).length === 1 && listeningPids(32146).length === 1; + +// Harness CLIs are often npm shims (.cmd/.ps1). Run what the shim runs so no shell re-parses the arguments. +function resolveCommand(name) { + const found = spawn("where.exe", [name]); + if (found.status !== 0) return null; + const paths = found.stdout.split(/\r?\n/).filter(Boolean); + const exe = paths.find(path => /\.exe$/i.test(path)); + if (exe) return { command: exe, prefix: [] }; + const cmd = paths.find(path => /\.cmd$/i.test(path)); + // npm shims name the real program relative to themselves, after an optional bundled node.exe. + const targets = cmd ? [...readFileSync(cmd, "utf8").matchAll(/"%dp0%\\([^"%]+)"/g)].map(match => resolve(dirname(cmd), match[1])) : []; + const script = targets.find(path => /\.[cm]?js$/i.test(path) && existsSync(path)); + if (script) return { command: process.execPath, prefix: [script] }; + const program = targets.find(path => /\.exe$/i.test(path) && existsSync(path)); + if (program) return { command: program, prefix: [] }; + const ps1 = paths.find(path => /\.ps1$/i.test(path)) ?? (cmd && existsSync(cmd.replace(/\.cmd$/i, ".ps1")) ? cmd.replace(/\.cmd$/i, ".ps1") : null); + if (ps1) return { command: "pwsh", prefix: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1] }; + return null; +} + +export const windows = { + name: "windows", + stateDir: resolve(process.env.LOCALAPPDATA ?? resolve(process.env.USERPROFILE ?? ".", "AppData/Local"), "ChromeOps"), + + exec(program, args) { + const resolved = resolveCommand(program); + if (!resolved) return { error: Object.assign(new Error(`${program} is not on PATH`), { code: "ENOENT" }), status: null, stdout: "", stderr: "" }; + return spawn(resolved.command, [...resolved.prefix, ...args]); + }, + + prepare() { + const version = spawn("pwsh", ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.Major"]); + if (version.error || Number(version.stdout.trim()) < 7) throw new Error("PowerShell 7+ (pwsh) is required"); + }, + + async installService() { + const desired = desiredTask(); + const previous = readTask(); + if (previous && previous.description !== taskDescription) throw new Error(`Scheduled task ${taskName} is not a Chrome Ops task; no changes were made`); + const same = previous && previous.execute === desired.execute && previous.arguments === desired.arguments && + previous.workingDirectory === desired.workingDirectory; + if (same && previous.state === "Running" && portsServed()) return { changed: false, service: taskName }; + registerTask(desired); + try { + taskCommand("Stop"); + await waitFor(portsFree, "Chrome Ops ports 32145/32146 to be released after stopping the Host task", 15000); + taskCommand("Start"); + await waitFor(portsServed, "Scheduled Task Host to listen on localhost", 15000); + } catch (error) { + if (previous) { registerTask(previous); taskCommand("Start"); } + else powershell("Unregister-ScheduledTask -TaskName $env:CO_TASK -Confirm:$false -ErrorAction SilentlyContinue", { CO_TASK: taskName }); + throw error; + } + return { changed: true, service: taskName }; + }, + + uninstallService() { + const previous = readTask(); + if (previous && previous.description !== taskDescription) throw new Error(`Scheduled task ${taskName} is not a Chrome Ops task; no changes were made`); + if (previous) { + taskCommand("Stop"); + powershell("Unregister-ScheduledTask -TaskName $env:CO_TASK -Confirm:$false", { CO_TASK: taskName }); + } + return { removed: previous !== null, service: taskName }; + }, + + serviceStatus() { + const task = readTask(); + const ports = { chrome: listeningPids(32145), clients: listeningPids(32146) }; + return { ok: task?.state === "Running" && ports.chrome.length === 1 && ports.clients.length === 1, + kind: "scheduled-task", name: taskName, exists: task !== null, state: task?.state ?? null, ports }; + }, + + developerOperations() { + const version = spawn("pwsh", ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"]); + const ok = !version.error && Number(version.stdout.trim().split(".")[0]) >= 7; + return { supported: true, ok, powershell: ok ? version.stdout.trim() : null, helper }; + }, + + nativeBridgeUpdate(extensionId, hashes) { + const result = spawn("pwsh", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", helper, "-Operation", "bootstrap", + "-ExtensionId", extensionId, "-ActiveUrlHashes", JSON.stringify(hashes)]); + let response; + try { response = JSON.parse(result.stdout.trim()); } catch { throw new Error(`Native Bridge update returned invalid output: ${result.stderr || result.stdout}`); } + if (response.ok !== true) throw new Error(`Native Bridge update did not complete: ${response.error}`); + }, +}; diff --git a/scripts/platform.mjs b/scripts/platform.mjs deleted file mode 100644 index 0f75519..0000000 --- a/scripts/platform.mjs +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scripts = dirname(fileURLToPath(import.meta.url)); -const action = process.argv[2]; -const names = { doctor: "doctor", "register-codex": "register-codex", - "register-cursor": "register-cursor", "register-grok": "register-grok" }; -if (!(action in names)) throw new Error("Expected doctor or a supported register action"); -let program; -let args; -if (process.platform === "win32") { - program = "pwsh"; - args = ["-NoProfile", "-File", resolve(scripts, `${names[action]}.ps1`)]; -} else if (process.platform === "darwin") { - program = process.execPath; - args = action === "doctor" - ? [resolve(scripts, "macos-host.mjs"), "doctor"] - : [resolve(scripts, "register-macos.mjs"), action.slice("register-".length)]; -} else { - throw new Error(`Unsupported platform: ${process.platform}`); -} -const result = spawnSync(program, args, { stdio: "inherit" }); -if (result.error) throw result.error; -process.exitCode = result.status ?? 1; diff --git a/scripts/register-codex.ps1 b/scripts/register-codex.ps1 deleted file mode 100644 index 19210c6..0000000 --- a/scripts/register-codex.ps1 +++ /dev/null @@ -1,5 +0,0 @@ -$ErrorActionPreference='Stop';if($PSVersionTable.PSVersion.Major-lt 7){throw 'PowerShell 7+ required'} -$root=(Resolve-Path(Join-Path $PSScriptRoot '..')).Path;$path=Join-Path $HOME '.codex\config.toml';if(-not(Test-Path $path)){throw "Codex config not found: $path"};$text=Get-Content $path -Raw -if($text -match '(?m)^\[mcp_servers\.chrome-ops\]'){[pscustomobject]@{ok=$true;client='codex';config=$path;changed=$false}|ConvertTo-Json -Compress;exit 0} -Copy-Item $path "$path.chrome-ops.bak" -Force;$node=(Get-Command node).Source.Replace("'","''");$index=(Join-Path $root 'dist\index.js').Replace("'","''");Add-Content -Encoding utf8NoBOM $path "`n[mcp_servers.chrome-ops]`ncommand = '$node'`nargs = ['$index']`n" -[pscustomobject]@{ok=$true;client='codex';config=$path;backup="$path.chrome-ops.bak";changed=$true}|ConvertTo-Json -Compress diff --git a/scripts/register-cursor.ps1 b/scripts/register-cursor.ps1 deleted file mode 100644 index 91cb233..0000000 --- a/scripts/register-cursor.ps1 +++ /dev/null @@ -1,6 +0,0 @@ -$ErrorActionPreference='Stop';if($PSVersionTable.PSVersion.Major-lt 7){throw 'PowerShell 7+ required'} -$root=(Resolve-Path(Join-Path $PSScriptRoot '..')).Path;$path=Join-Path $HOME '.cursor\mcp.json';New-Item -ItemType Directory -Force (Split-Path $path)|Out-Null -$obj=if(Test-Path $path){Get-Content $path -Raw|ConvertFrom-Json}else{[pscustomobject]@{mcpServers=[pscustomobject]@{}}};if(-not$obj.mcpServers){$obj|Add-Member mcpServers ([pscustomobject]@{})} -if(Test-Path $path){Copy-Item $path "$path.chrome-ops.bak" -Force} -$entry=[pscustomobject]@{command=(Get-Command node).Source;args=@((Join-Path $root 'dist\index.js'))};$obj.mcpServers|Add-Member -NotePropertyName 'chrome-ops' -NotePropertyValue $entry -Force -$obj|ConvertTo-Json -Depth 20|Set-Content -Encoding utf8NoBOM $path;[pscustomobject]@{ok=$true;client='cursor';config=$path;backup="$path.chrome-ops.bak"}|ConvertTo-Json -Compress diff --git a/scripts/register-grok.ps1 b/scripts/register-grok.ps1 deleted file mode 100644 index 205566d..0000000 --- a/scripts/register-grok.ps1 +++ /dev/null @@ -1,13 +0,0 @@ -$ErrorActionPreference='Stop' -if($PSVersionTable.PSVersion.Major -lt 7){throw 'PowerShell 7+ required'} -$grok=Get-Command grok -ErrorAction Stop -$root=(Resolve-Path(Join-Path $PSScriptRoot '..')).Path -$node=(Get-Command node -ErrorAction Stop).Source -$index=Join-Path $root 'dist\index.js' -$existing=& $grok.Source mcp list --json | ConvertFrom-Json -if($existing|Where-Object{$_.name -eq 'chrome-ops'}){[pscustomobject]@{ok=$true;client='grok-build';changed=$false;server='chrome-ops'}|ConvertTo-Json -Compress;exit 0} -& $grok.Source mcp add chrome-ops -- $node $index -if($LASTEXITCODE -ne 0){throw "grok mcp add failed: $LASTEXITCODE"} -& $grok.Source mcp doctor chrome-ops -if($LASTEXITCODE -ne 0){throw "grok mcp doctor failed: $LASTEXITCODE"} -[pscustomobject]@{ok=$true;client='grok-build';changed=$true;server='chrome-ops';grok=$grok.Source}|ConvertTo-Json -Compress diff --git a/scripts/register-macos.mjs b/scripts/register-macos.mjs deleted file mode 100644 index 69734cc..0000000 --- a/scripts/register-macos.mjs +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const home = homedir(); -const client = process.argv[2]; -const index = resolve(root, "dist/index.js"); -const command = process.execPath; - -function run(program, args) { - const result = spawnSync(program, args, { cwd: root, encoding: "utf8", maxBuffer: 1024 * 1024 }); - if (result.error) throw result.error; - if (result.status !== 0) throw new Error(`${program} ${args[0]} failed (${result.status}): ${(result.stderr || result.stdout).trim()}`); - if (result.stderr) process.stderr.write(result.stderr); - return result.stdout; -} - -function backup(relative) { - const source = resolve(home, relative); - if (!existsSync(source)) return null; - const directory = resolve(home, "Library/Application Support/ChromeOps/backups"); - mkdirSync(directory, { recursive: true, mode: 0o700 }); - const filename = `${client}-${new Date().toISOString().replaceAll(":", "-")}-${process.pid}.tar.gz`; - const target = resolve(directory, filename); - run("tar", ["-czf", target, "-C", home, relative]); - chmodSync(target, 0o600); - return target; -} - -function matching(entry) { - const server = entry?.transport ?? entry; - return (server?.type === undefined || server.type === "stdio") && - server?.command === command && Array.isArray(server.args) && - server.args.length === 1 && server.args[0] === index; -} - -function codexRegistration() { - const existing = spawnSync("codex", ["mcp", "get", "chrome-ops", "--json"], { encoding: "utf8" }); - if (existing.error) throw existing.error; - if (existing.status === 0) { - const entry = JSON.parse(existing.stdout); - if (!matching(entry)) throw new Error("Codex has a different chrome-ops server; no configuration was changed"); - return { ok: true, client: "codex", changed: false }; - } - if (!existing.stderr.includes("No MCP server named 'chrome-ops' found")) { - throw new Error(`Cannot inspect Codex MCP registration: ${existing.stderr.trim()}`); - } - const archive = backup(".codex/config.toml"); - run("codex", ["mcp", "add", "chrome-ops", "--", command, index]); - const entry = JSON.parse(run("codex", ["mcp", "get", "chrome-ops", "--json"])); - if (!matching(entry)) throw new Error("Codex registration readback differs from the requested stdio command"); - return { ok: true, client: "codex", changed: true, backup: archive }; -} - -function cursorRegistration() { - if (!existsSync("/Applications/Cursor.app") && !existsSync(resolve(home, "Applications/Cursor.app"))) { - throw new Error("Cursor application is not installed on this Mac"); - } - const path = resolve(home, ".cursor/mcp.json"); - const source = existsSync(path) ? readFileSync(path, "utf8") : "{\n \"mcpServers\": {}\n}\n"; - const config = JSON.parse(source); - if (config.mcpServers === undefined) config.mcpServers = {}; - if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers)) { - throw new Error("Cursor mcpServers is not an object; no configuration was changed"); - } - const existing = config.mcpServers["chrome-ops"]; - if (existing) { - if (!matching(existing)) throw new Error("Cursor has a different chrome-ops server; no configuration was changed"); - return { ok: true, client: "cursor", changed: false }; - } - const archive = backup(".cursor/mcp.json"); - config.mcpServers["chrome-ops"] = { type: "stdio", command, args: [index] }; - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - const temporary = `${path}.${process.pid}.tmp`; - writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: existsSync(path) ? statSync(path).mode & 0o777 : 0o600 }); - renameSync(temporary, path); - const readback = JSON.parse(readFileSync(path, "utf8")).mcpServers?.["chrome-ops"]; - if (!matching(readback)) throw new Error("Cursor registration readback differs from the requested stdio command"); - return { ok: true, client: "cursor", changed: true, backup: archive }; -} - -function grokRegistration() { - const existing = JSON.parse(run("grok", ["mcp", "list", "--json"])); - if (!Array.isArray(existing)) throw new Error("Grok MCP inventory is not an array"); - const matches = existing.filter(item => item.name === "chrome-ops"); - if (matches.length > 1) throw new Error("Grok has duplicate chrome-ops entries"); - if (matches.length === 1) { - if (!matching(matches[0])) throw new Error("Grok has a different chrome-ops server; no configuration was changed"); - return { ok: true, client: "grok", changed: false }; - } - const archive = backup(".grok/config.toml"); - run("grok", ["mcp", "add", "chrome-ops", "--", command, index]); - const after = JSON.parse(run("grok", ["mcp", "list", "--json"])); - if (after.filter(item => item.name === "chrome-ops" && matching(item)).length !== 1) { - throw new Error("Grok registration readback differs from the requested stdio command"); - } - run("grok", ["mcp", "doctor", "chrome-ops"]); - return { ok: true, client: "grok", changed: true, backup: archive }; -} - -if (process.platform !== "darwin") throw new Error("macOS client registration requires macOS"); -try { - if (!existsSync(index)) throw new Error("Built MCP server is missing; run npm run setup:macos"); - // 登録の正規入口でHostの準備と実効結果も確認する。 - run(process.execPath, [resolve(root, "scripts/macos-host.mjs"), "setup"]); - const result = client === "codex" ? codexRegistration() - : client === "cursor" ? cursorRegistration() - : client === "grok" ? grokRegistration() - : (() => { throw new Error("Expected codex, cursor, or grok"); })(); - process.stdout.write(`${JSON.stringify(result)}\n`); -} catch (error) { - process.stderr.write(`${JSON.stringify({ ok: false, client, error: String(error.message) })}\n`); - process.exitCode = 1; -} diff --git a/scripts/setup-windows.ps1 b/scripts/setup-windows.ps1 deleted file mode 100644 index 9894f20..0000000 --- a/scripts/setup-windows.ps1 +++ /dev/null @@ -1,18 +0,0 @@ -param([switch]$Uninstall) -$ErrorActionPreference='Stop' -if($PSVersionTable.PSVersion.Major -lt 7){throw 'PowerShell 7+ (pwsh) is required'} -$root=(Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$taskName='Chrome Ops Host' -if($Uninstall){ - Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue - [pscustomobject]@{ok=$true;removedTask=$taskName}|ConvertTo-Json -Compress;exit 0 -} -Push-Location $root -try{npm install --silent;npm run build --silent}finally{Pop-Location} -$node=(Get-Command node).Source -$hostJs=Join-Path $root 'dist\host.js' -$action=New-ScheduledTaskAction -Execute $node -Argument ('"'+$hostJs+'"') -WorkingDirectory $root -$trigger=New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME -$settings=New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Description 'Persistent local host for Chrome Ops MCP' -Force | Out-Null -[pscustomobject]@{ok=$true;task=$taskName;root=$root;node=$node;host=$hostJs;extension=(Join-Path $root 'extension')}|ConvertTo-Json -Compress diff --git a/src/helper.ts b/src/helper.ts index c3c27cb..fe7930a 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -1,20 +1,12 @@ import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; +import { currentOs } from "./os/index.js"; +import type { DeveloperOperation } from "./os/types.js"; -const here = dirname(fileURLToPath(import.meta.url)); -const platform = process.platform; - -export function helper(operation: "load"|"reload"|"errors"|"remove", value: string, pageToken?: string): Promise { - if (platform !== "darwin" && platform !== "win32") { - throw new Error(`Unsupported helper platform: ${platform}`); - } - const command = platform === "darwin" - ? resolve(here, "../helper/macos/.build/release/chrome-ops-helper") - : "pwsh"; - const args = platform === "darwin" - ? [operation, value, ...(pageToken ? [pageToken] : [])] - : ["-NoProfile","-ExecutionPolicy","Bypass","-File",resolve(here, "../helper/windows/chrome-ops-helper.ps1"),"-Operation",operation, ...(operation === "load" ? ["-Path", value] : ["-ExtensionId", value])]; +// Run the OS helper once and return its JSON result. The helper itself is chosen by the OS adapter. +export function helper(operation: DeveloperOperation, value: string, pageToken?: string): Promise { + const os = currentOs(); + if (os.unsupportedReason) throw new Error(os.unsupportedReason); + const { command, args } = os.helper(operation, value, pageToken); return new Promise((resolvePromise,reject)=>{ const child=spawn(command,args,{windowsHide:true}); let out="",err=""; diff --git a/src/index.ts b/src/index.ts index 55e8c16..3785b79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,10 @@ import { realpath, stat } from "node:fs/promises"; import { join } from "node:path"; import { ChromeBridgeClient } from "./bridge.js"; import { helper } from "./helper.js"; +import { currentOs } from "./os/index.js"; const bridge = new ChromeBridgeClient(); -const server = new McpServer({ name: "chrome-ops-mcp", version: "0.2.0-alpha.0" }); +const server = new McpServer({ name: "chrome-ops-mcp", version: "0.3.0-alpha.0" }); const text = (v: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(v, null, 2) }] }); const proxy = (name: string, description: string, schema: Record, method = name) => server.tool(name, description, schema, async args => text(await bridge.call(method, args))); @@ -19,7 +20,8 @@ const bridgeSession=async()=>{ } return status.bridgeSession; }; -const preparedMacPage=async(session:string)=>{ +// Every OS helper acts on a management tab that the Bridge opens and focuses first. +const preparedPage=async(session:string)=>{ const prepared=await bridge.call("extensions.preparePage"); if(!prepared||typeof prepared!=="object"||!("token" in prepared)|| typeof prepared.token!=="string"||!("tabId" in prepared)||typeof prepared.tabId!=="number"){ @@ -28,9 +30,9 @@ const preparedMacPage=async(session:string)=>{ if(await bridgeSession()!==session) throw new Error("Chrome Ops Bridge profile changed before the developer operation"); return prepared.token; }; -const macDeveloperOperation=async(operation:"reload"|"errors"|"remove",id:string)=>{ +const developerOperation=async(operation:"reload"|"errors"|"remove",id:string)=>{ const session=await bridgeSession(); - const token=await preparedMacPage(session); + const token=await preparedPage(session); const action=await helper(operation,id,token); if(await bridgeSession()!==session) throw new Error("Chrome Ops Bridge profile changed during the developer operation"); return {action,session}; @@ -55,7 +57,6 @@ proxy("extension_get", "Get metadata for one installed extension, including inst proxy("extension_set_enabled", "Enable or disable an installed extension. Chrome may require a user gesture/confirmation.", { id: z.string(), enabled: z.boolean() }, "extensions.setEnabled"); proxy("extension_uninstall", "Request uninstall of another extension. Chrome always presents confirmation for another extension.", { id: z.string() }, "extensions.uninstall"); server.tool("extension_dev_load", "Load an unpacked Chrome extension directory through the restricted native helper. The directory must contain manifest.json.", { path:z.string().min(1) }, async ({path})=>{ - if(process.platform!=="darwin") return text(await helper("load",path)); const directory=await realpath(path); if(!(await stat(directory)).isDirectory()||!(await stat(join(directory,"manifest.json"))).isFile()){ throw new Error("Load path must be a directory containing manifest.json"); @@ -67,16 +68,20 @@ server.tool("extension_dev_load", "Load an unpacked Chrome extension directory t }; const originalSession=await bridgeSession(); const before=new Set((await inventory()).map(entry=>entry?.id).filter((id):id is string=>typeof id==="string")); - const token=await preparedMacPage(originalSession); + const token=await preparedPage(originalSession); const action=await helper("load",directory,token); - if(!action||typeof action!=="object"||!("data" in action)||!action.data|| - typeof action.data!=="object"||!("extensionId" in action.data)||typeof action.data.extensionId!=="string"){ - throw new Error("Native helper did not identify the newly loaded extension"); + if(!action||typeof action!=="object"||!("data" in action)||!action.data||typeof action.data!=="object"){ + throw new Error("Native helper did not report the Load unpacked result"); } - const id=action.data.extensionId; - if(before.has(id)) throw new Error(`Chrome reported an existing extension ${id} as a new load`); + // Some helpers read the new id from the page; otherwise it is the one new development extension. + const reported="extensionId" in action.data&&typeof action.data.extensionId==="string"?action.data.extensionId:null; + if(currentOs().reportsLoadedId&&!reported) throw new Error("Native helper did not identify the newly loaded extension"); + if(reported&&before.has(reported)) throw new Error(`Chrome reported an existing extension ${reported} as a new load`); for(let i=0;i<20;i++){ - const found=(await inventory()).find(entry=>entry?.id===id); + const added=(await inventory()).filter(entry=>typeof entry?.id==="string"&&!before.has(entry.id)); + if(!reported&&added.length>1) throw new Error("More than one extension appeared during Load unpacked"); + const found=reported?added.find(entry=>entry.id===reported):added[0]; + const id=found?.id; if(found){ if(await bridgeSession()!==originalSession) throw new Error("Chrome Ops Bridge profile changed during Load unpacked"); if(found.installType!=="development") throw new Error(`Loaded extension ${id} is not an unpacked development extension`); @@ -84,19 +89,19 @@ server.tool("extension_dev_load", "Load an unpacked Chrome extension directory t } await new Promise(resolve=>setTimeout(resolve,100)); } - throw new Error(`Chrome management did not report the loaded extension ${id}`); + throw new Error(reported?`Chrome management did not report the loaded extension ${reported}`:"Chrome management did not report a newly loaded extension"); }); -server.tool("extension_dev_reload", "Reload an installed unpacked extension by exact extension id through Chrome's extension-management UI.", { id:z.string().regex(/^[a-p]{32}$/) }, async ({id})=>text(process.platform==="darwin"?(await macDeveloperOperation("reload",id)).action:await helper("reload",id))); -server.tool("extension_dev_errors", "Open the Errors view for an unpacked extension by exact extension id.", { id:z.string().regex(/^[a-p]{32}$/) }, async ({id})=>text(process.platform==="darwin"?(await macDeveloperOperation("errors",id)).action:await helper("errors",id))); +server.tool("extension_dev_reload", "Reload an installed unpacked extension by exact extension id through Chrome's extension-management UI.", { id:z.string().regex(/^[a-p]{32}$/) }, async ({id})=>text((await developerOperation("reload",id)).action)); +server.tool("extension_dev_errors", "Open the Errors view for an unpacked extension by exact extension id.", { id:z.string().regex(/^[a-p]{32}$/) }, async ({id})=>text((await developerOperation("errors",id)).action)); server.tool("extension_dev_remove", "Remove an unpacked extension by exact extension id and verify it disappeared from chrome.management.", { id:z.string().regex(/^[a-p]{32}$/) }, async ({id})=>{ - const operation=process.platform==="darwin"?await macDeveloperOperation("remove",id):{action:await helper("remove",id),session:null}; + const operation=await developerOperation("remove",id); const action=operation.action; let removed=false; for(let i=0;i<20;i++){ await new Promise(r=>setTimeout(r,100)); - if(operation.session!==null&&await bridgeSession()!==operation.session) throw new Error("Chrome Ops Bridge profile changed before remove verification"); + if(await bridgeSession()!==operation.session) throw new Error("Chrome Ops Bridge profile changed before remove verification"); const inventory=await bridge.call("extensions.list"); if(!Array.isArray(inventory)) throw new Error("Chrome extension inventory is unavailable after remove confirmation"); - if(operation.session!==null&&await bridgeSession()!==operation.session) throw new Error("Chrome Ops Bridge profile changed during remove verification"); + if(await bridgeSession()!==operation.session) throw new Error("Chrome Ops Bridge profile changed during remove verification"); if(!inventory.some(entry=>entry && typeof entry==="object" && entry.id===id)){removed=true;break} } if(!removed) throw new Error(`Chrome still reports extension ${id} after remove confirmation`); diff --git a/src/os/index.ts b/src/os/index.ts new file mode 100644 index 0000000..90ef665 --- /dev/null +++ b/src/os/index.ts @@ -0,0 +1,13 @@ +// Select the adapter for the running OS. +import { linux } from "./linux.js"; +import { macos } from "./macos.js"; +import type { OsAdapter } from "./types.js"; +import { windows } from "./windows.js"; + +const adapters: Partial> = { darwin: macos, win32: windows, linux }; + +export function currentOs(): OsAdapter { + const adapter = adapters[process.platform]; + if (!adapter) throw new Error(`Unsupported platform: ${process.platform}`); + return adapter; +} diff --git a/src/os/linux.ts b/src/os/linux.ts new file mode 100644 index 0000000..fd57736 --- /dev/null +++ b/src/os/linux.ts @@ -0,0 +1,9 @@ +// Linux: Chrome's extension-management page is not reachable from AT-SPI yet, so there is no helper. +import type { OsAdapter } from "./types.js"; + +export const linux: OsAdapter = { + name: "linux", + unsupportedReason: "Unpacked-extension developer operations are not automated on Linux yet; use chrome://extensions for Load unpacked, reload, errors and remove", + reportsLoadedId: false, + helper: () => { throw new Error(linux.unsupportedReason!); }, +}; diff --git a/src/os/macos.ts b/src/os/macos.ts new file mode 100644 index 0000000..2d6727b --- /dev/null +++ b/src/os/macos.ts @@ -0,0 +1,16 @@ +// macOS: the Swift helper drives chrome://extensions through the Accessibility API. +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import type { OsAdapter } from "./types.js"; + +const here = dirname(fileURLToPath(import.meta.url)); + +export const macos: OsAdapter = { + name: "macos", + unsupportedReason: null, + reportsLoadedId: true, + helper: (operation, value, pageToken) => ({ + command: resolve(here, "../../helper/macos/.build/release/chrome-ops-helper"), + args: [operation, value, ...(pageToken ? [pageToken] : [])], + }), +}; diff --git a/src/os/types.ts b/src/os/types.ts new file mode 100644 index 0000000..8558cff --- /dev/null +++ b/src/os/types.ts @@ -0,0 +1,13 @@ +// Contract that each OS adapter fills for the MCP server. Everything else in src/ is shared by all OSes. +export type DeveloperOperation = "load"|"reload"|"errors"|"remove"; + +export interface HelperInvocation { command: string; args: string[] } + +export interface OsAdapter { + name: "macos"|"windows"|"linux"; + /** Why unpacked-extension developer operations are unavailable here, or null when the helper exists. */ + unsupportedReason: string|null; + /** True when the helper reads the new extension id from Chrome's page after Load unpacked. */ + reportsLoadedId: boolean; + helper(operation: DeveloperOperation, value: string, pageToken?: string): HelperInvocation; +} diff --git a/src/os/windows.ts b/src/os/windows.ts new file mode 100644 index 0000000..d986a22 --- /dev/null +++ b/src/os/windows.ts @@ -0,0 +1,18 @@ +// Windows: the PowerShell 7 helper drives chrome://extensions through UI Automation. +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import type { OsAdapter } from "./types.js"; + +const here = dirname(fileURLToPath(import.meta.url)); + +export const windows: OsAdapter = { + name: "windows", + unsupportedReason: null, + // The Windows helper cannot read the new id; the server takes the one new development extension instead. + reportsLoadedId: false, + helper: (operation, value) => ({ + command: "pwsh", + args: ["-NoProfile","-ExecutionPolicy","Bypass","-File",resolve(here, "../../helper/windows/chrome-ops-helper.ps1"), + "-Operation",operation, ...(operation === "load" ? ["-Path", value] : ["-ExtensionId", value])], + }), +}; diff --git a/test/adapters.test.mjs b/test/adapters.test.mjs new file mode 100644 index 0000000..1d4f46f --- /dev/null +++ b/test/adapters.test.mjs @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const { linux } = await import("../scripts/os/linux.mjs"); +const { macos } = await import("../scripts/os/macos.mjs"); +const { windows } = await import("../scripts/os/windows.mjs"); +const { harnesses } = await import("../scripts/harness/index.mjs"); + +test("every OS adapter fills the same setup contract", () => { + const keys = ["name", "stateDir", "exec", "prepare", "installService", "uninstallService", "serviceStatus", "developerOperations", "nativeBridgeUpdate"]; + for (const os of [linux, macos, windows]) assert.deepEqual(Object.keys(os).sort(), [...keys].sort(), os.name); + assert.equal(linux.nativeBridgeUpdate, null); + assert.equal(linux.developerOperations().supported, false); +}); + +test("every harness adapter fills the same registration contract", () => { + assert.deepEqual(Object.keys(harnesses).sort(), ["claude", "codex", "cursor", "grok"]); + for (const harness of Object.values(harnesses)) { + assert.deepEqual(Object.keys(harness).sort(), ["add", "configFile", "detect", "name", "read"], harness.name); + } +}); + +test("registration writes a stdio entry that matches this checkout and keeps other servers", async t => { + const home = mkdtempSync(join(tmpdir(), "chrome-ops-home-")); + t.after(() => rmSync(home, { recursive: true, force: true })); + const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + process.env.HOME = home; process.env.USERPROFILE = home; + t.after(() => Object.assign(process.env, saved)); + const { cursor } = await import(`../scripts/harness/cursor.mjs?home=${Date.now()}`); + const { matching, server } = await import("../scripts/lib/registration.mjs"); + const configPath = join(home, ".cursor/mcp.json"); + const { mkdirSync, writeFileSync } = await import("node:fs"); + mkdirSync(join(home, ".cursor")); + writeFileSync(configPath, JSON.stringify({ mcpServers: { other: { command: "x" } } })); + cursor.add(null, server()); + const written = JSON.parse(readFileSync(configPath, "utf8")); + assert.deepEqual(written.mcpServers.other, { command: "x" }); + assert.equal(matching(written.mcpServers["chrome-ops"]), true); + assert.equal(matching({ ...server(), command: "/elsewhere/node" }), false); + assert.equal(matching({ transport: { type: "stdio", ...server() } }), true); +}); diff --git a/test/prepare-page.test.mjs b/test/prepare-page.test.mjs index 436afef..dcfd65d 100644 --- a/test/prepare-page.test.mjs +++ b/test/prepare-page.test.mjs @@ -20,6 +20,7 @@ function bridge({ windows, last, incognito = false }) { const chrome = { runtime: { onStartup: runtimeListener, onInstalled: runtimeListener }, action: { onClicked: listener }, + alarms: { create() {}, onAlarm: listener }, debugger: { onDetach: listener, onEvent: listener }, extension: { inIncognitoContext: incognito }, windows: { diff --git a/test/redaction.test.mjs b/test/redaction.test.mjs index d341df0..2594813 100644 --- a/test/redaction.test.mjs +++ b/test/redaction.test.mjs @@ -7,7 +7,7 @@ const noop = () => {}; const event = {addListener:noop}; const context = vm.createContext({ WebSocket:class {}, clearTimeout:noop, clearInterval:noop, - chrome:{runtime:{onStartup:event,onInstalled:event},action:{onClicked:event},debugger:{onDetach:event,onEvent:event}}, + chrome:{runtime:{onStartup:event,onInstalled:event},action:{onClicked:event},alarms:{create:noop,onAlarm:event},debugger:{onDetach:event,onEvent:event}}, }); vm.runInContext(readFileSync(new URL("../extension/service-worker.js",import.meta.url),"utf8"),context); const sanitize = input => JSON.parse(JSON.stringify(context.sanitize(input)));