From 85eb9fa6c131841849a73b1bf5037f0afff8b434 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:40:10 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20Linux=E3=81=AE=E9=96=8B=E7=99=BA?= =?UTF-8?q?=E8=80=85=E6=93=8D=E4=BD=9C=E3=82=92=E3=80=81Chrome=20Ops?= =?UTF-8?q?=E3=81=8C=E6=8C=81=E3=81=A4=E9=96=8B=E7=99=BA=E7=94=A8Chrome?= =?UTF-8?q?=E3=81=AEDevTools=E3=83=91=E3=82=A4=E3=83=97=E3=81=A7=E8=A1=8C?= =?UTF-8?q?=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- scripts/os/linux.mjs | 108 ++++++++++++++++++++++------ src/index.ts | 3 +- src/os/linux-chrome.ts | 142 +++++++++++++++++++++++++++++++++++++ src/os/linux-helper.ts | 37 ++++++++++ src/os/linux-paths.ts | 25 +++++++ src/os/linux.ts | 13 ++-- src/os/macos.ts | 1 + src/os/types.ts | 2 + src/os/windows.ts | 1 + test/adapters.test.mjs | 13 +++- test/linux-chrome.test.mjs | 52 ++++++++++++++ 12 files changed, 370 insertions(+), 29 deletions(-) create mode 100644 src/os/linux-chrome.ts create mode 100644 src/os/linux-helper.ts create mode 100644 src/os/linux-paths.ts create mode 100644 test/linux-chrome.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5802716..e7cd662 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/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 + - 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 test/linux-chrome.test.mjs - if: runner.os == 'macOS' run: swift build --package-path helper/macos -c release - run: npm audit --omit=dev diff --git a/scripts/os/linux.mjs b/scripts/os/linux.mjs index 0187db1..0437fbc 100644 --- a/scripts/os/linux.mjs +++ b/scripts/os/linux.mjs @@ -1,22 +1,28 @@ -// Linux: the Host runs as a systemd user service. There is no developer-operation helper yet. +// Linux: the Host runs as a systemd user service. A second user service runs Chrome Ops' own development Chrome, +// which performs the developer operations through its DevTools pipe (see src/os/linux-chrome.ts). 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 { hostStatus, root, waitFor } from "../lib/host-client.mjs"; import { check, spawn } from "../lib/process.mjs"; +import { chromeBinary, profileDir, serviceName as chromeUnitName, socketPath } from "../../dist/os/linux-paths.js"; const unitName = "chrome-ops-host.service"; -const unitPath = resolve(process.env.XDG_CONFIG_HOME || resolve(homedir(), ".config"), "systemd/user", unitName); +const unitDir = resolve(process.env.XDG_CONFIG_HOME || resolve(homedir(), ".config"), "systemd/user"); +const unitPath = resolve(unitDir, unitName); +const chromeUnitPath = resolve(unitDir, chromeUnitName); +const launcherPath = resolve(process.env.XDG_DATA_HOME || resolve(homedir(), ".local/share"), "applications/chrome-ops-chrome.desktop"); const host = resolve(root, "dist/host.js"); +const supervisor = resolve(root, "dist/os/linux-chrome.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 }); +function unitState(name = unitName) { + const result = systemctl(["show", name, "--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 }; @@ -49,11 +55,12 @@ function listeningPids(port) { const serving = pid => listeningPids(32145).includes(pid) && listeningPids(32146).includes(pid); +// 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("\"", "\\\"")}"`; + 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 @@ -70,24 +77,74 @@ 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`); +// The development Chrome is not started at login: setup starts it, and the helper or the launcher starts it on demand. +// Closing its last window ends the service until the next start. +function chromeUnitDefinition() { + const build = createHash("sha256").update(readFileSync(supervisor)).digest("hex"); + return `[Unit] +Description=Chrome Ops development Chrome + +[Service] +ExecStart=${quote(process.execPath)} ${quote(supervisor)} +WorkingDirectory=${literal(root)} +Environment=CHROME_OPS_CHROME_BUILD=${build} +Restart=on-failure +RestartSec=5 +UMask=0077 +`; +} + +const launcherDefinition = () => `[Desktop Entry] +Type=Application +Name=Chrome Ops Chrome +Comment=Development Chrome managed by Chrome Ops +Exec=systemctl --user start ${chromeUnitName} +Icon=google-chrome +Terminal=false +Categories=Development; +`; + +function assertOwned(path, marker) { + if (existsSync(path) && !readFileSync(path, "utf8").includes(marker)) { + throw new Error(`${path} was not written by Chrome Ops; no changes were made`); } } -function writeUnit(contents) { - mkdirSync(dirname(unitPath), { recursive: true, mode: 0o700 }); - const temporary = `${unitPath}.${process.pid}.tmp`; +function assertOwnedUnit() { + assertOwned(unitPath, "Description=Chrome Ops MCP Host"); + assertOwned(chromeUnitPath, "Description=Chrome Ops development Chrome"); + assertOwned(launcherPath, "Name=Chrome Ops Chrome"); +} + +function writeUnit(contents, path = unitPath) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const temporary = `${path}.${process.pid}.tmp`; try { writeFileSync(temporary, contents, { mode: 0o600 }); - renameSync(temporary, unitPath); + renameSync(temporary, path); } finally { rmSync(temporary, { force: true }); } } +const chromeReady = () => unitState(chromeUnitName).running && existsSync(socketPath()); + +// Start (or restart after a change) the development Chrome and wait for its Bridge to reach the Host. +async function installChrome() { + if (!chromeBinary()) throw new Error("Google Chrome or Chromium was not found; install it or set CHROME_OPS_CHROME_BINARY"); + const desired = chromeUnitDefinition(); + const previous = existsSync(chromeUnitPath) ? readFileSync(chromeUnitPath, "utf8") : null; + const changed = previous !== desired || !existsSync(launcherPath) || readFileSync(launcherPath, "utf8") !== launcherDefinition(); + if (!changed && chromeReady()) return false; + writeUnit(desired, chromeUnitPath); + writeUnit(launcherDefinition(), launcherPath); + systemctl(["daemon-reload"]); + systemctl([previous === desired ? "start" : "restart", chromeUnitName]); + await waitFor(chromeReady, "the Chrome Ops development Chrome to start (it needs a graphical session)", 30000); + await waitFor(async () => (await hostStatus().catch(() => null))?.connected === true, "the Bridge in the development Chrome to connect", 45000); + return true; +} + export const linux = { name: "linux", stateDir: resolve(process.env.XDG_STATE_HOME || resolve(homedir(), ".local/state"), "chrome-ops"), @@ -99,7 +156,7 @@ export const linux = { 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 }; + if (previous === desired && old.running && serving(old.pid)) return { changed: false, service: unitName, chromeChanged: await installChrome() }; 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); @@ -117,17 +174,19 @@ export const linux = { if (previous !== null && old.running) systemctl(["restart", unitName], { allowFailure: true }); throw error; } - return { changed: true, service: unitName }; + return { changed: true, service: unitName, chromeChanged: await installChrome() }; }, uninstallService() { assertOwnedUnit(); - const hadUnit = existsSync(unitPath); + const hadUnit = existsSync(unitPath) || existsSync(chromeUnitPath); const previous = unitState(); + if (unitState(chromeUnitName).loaded) systemctl(["stop", chromeUnitName], { allowFailure: true }); if (previous.loaded) systemctl(["disable", "--now", unitName], { allowFailure: true }); - rmSync(unitPath, { force: true }); + for (const path of [unitPath, chromeUnitPath, launcherPath]) rmSync(path, { force: true }); systemctl(["daemon-reload"], { allowFailure: true }); - return { removed: hadUnit || previous.loaded, service: unitName, unitRemoved: !existsSync(unitPath) }; + // The development Chrome profile holds the user's own browsing data, so it stays. + return { removed: hadUnit || previous.loaded, service: unitName, unitRemoved: !existsSync(unitPath) && !existsSync(chromeUnitPath), keptProfile: profileDir() }; }, serviceStatus() { @@ -136,6 +195,11 @@ export const linux = { 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" }), + developerOperations() { + const chrome = chromeBinary(); + const state = unitState(chromeUnitName); + return { supported: true, ok: chrome !== null && existsSync(chromeUnitPath), chrome, service: chromeUnitName, + running: state.running, socket: existsSync(socketPath()), profile: profileDir(), launcher: existsSync(launcherPath) ? launcherPath : null }; + }, nativeBridgeUpdate: null, }; diff --git a/src/index.ts b/src/index.ts index 3785b79..b853809 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,8 +20,9 @@ const bridgeSession=async()=>{ } return status.bridgeSession; }; -// Every OS helper acts on a management tab that the Bridge opens and focuses first. +// macOS and Windows helpers act on a management tab that the Bridge opens and focuses first. const preparedPage=async(session:string)=>{ + if(!currentOs().usesManagementTab) return undefined; 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"){ diff --git a/src/os/linux-chrome.ts b/src/os/linux-chrome.ts new file mode 100644 index 0000000..d69f6dd --- /dev/null +++ b/src/os/linux-chrome.ts @@ -0,0 +1,142 @@ +// Linux: run a dedicated development Chrome and serve the four developer operations through its DevTools pipe. +// The chrome-ops-chrome systemd user service starts this file; linux-helper.ts talks to it over a user-only Unix socket. +import { spawn } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs"; +import { createServer, type Socket } from "node:net"; +import { dirname, isAbsolute, join } from "node:path"; +import type { Readable, Writable } from "node:stream"; +import { chromeBinary, packageRoot, profileDir, socketPath } from "./linux-paths.js"; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const extensionId = /^[a-p]{32}$/; + +class DevTools { + private next = 1; + private buffer = ""; + private waiting = new Map(); + constructor(private input: Writable, output: Readable) { + output.setEncoding("utf8"); + output.on("data", (chunk: string) => { + this.buffer += chunk; + for (let end = this.buffer.indexOf("\0"); end >= 0; end = this.buffer.indexOf("\0")) { + const message = JSON.parse(this.buffer.slice(0, end)); + this.buffer = this.buffer.slice(end + 1); + const waiter = this.waiting.get(message.id); + if (!waiter) continue; + this.waiting.delete(message.id); + clearTimeout(waiter.timer); + message.error ? waiter.reject(new Error(message.error.message)) : waiter.resolve(message.result); + } + }); + } + send(method: string, params: object = {}, sessionId?: string): Promise { + const id = this.next++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { this.waiting.delete(id); reject(new Error(`Chrome did not answer ${method}`)); }, 15000); + this.waiting.set(id, { resolve, reject, timer }); + this.input.write(`${JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })}\0`); + }); + } +} + +// chrome.developerPrivate exists only on chrome://extensions, so each operation opens that page in a background tab. +async function onManagementPage(devtools: DevTools, use: (evaluate: (expression: string) => Promise) => Promise): Promise { + const { targetId } = await devtools.send("Target.createTarget", { url: "chrome://extensions/", background: true }); + try { + const { sessionId } = await devtools.send("Target.attachToTarget", { targetId, flatten: true }); + const evaluate = async (expression: string) => { + const reply = await devtools.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, sessionId); + if (reply.exceptionDetails) throw new Error(reply.exceptionDetails.exception?.description ?? reply.exceptionDetails.text); + return reply.result.value; + }; + for (let attempt = 0; !(await evaluate("location.href.startsWith('chrome://extensions') && !!globalThis.chrome?.developerPrivate")); attempt++) { + if (attempt >= 50) throw new Error("chrome://extensions did not load"); + await sleep(100); + } + return await use(evaluate); + } finally { + await devtools.send("Target.closeTarget", { targetId }).catch(() => undefined); + } +} + +// Only these four operations exist; values are an absolute directory or an exact extension id, never code. +async function run(devtools: DevTools, operation: string, value: string): Promise { + if (operation === "load") { + if (!isAbsolute(value) || !statSync(value, { throwIfNoEntry: false })?.isDirectory() || !existsSync(join(value, "manifest.json"))) { + throw new Error("Load path must be an absolute directory containing manifest.json"); + } + const { id } = await devtools.send("Extensions.loadUnpacked", { path: value }); + return { path: value, extensionId: id }; + } + if (!["reload", "errors", "remove"].includes(operation)) throw new Error(`Unknown operation ${operation}`); + if (!extensionId.test(value)) throw new Error("Expected an exact extension id"); + const id = JSON.stringify(value); + return onManagementPage(devtools, async evaluate => { + const info = await evaluate(`chrome.developerPrivate.getExtensionInfo(${id}).then(e => ({ + name: e.name, location: e.location, + runtimeErrors: e.runtimeErrors.map(x => ({ severity: x.severity, message: x.message, source: x.source })), + manifestErrors: e.manifestErrors.map(x => ({ message: x.message, manifestKey: x.manifestKey })) }))`); + if (operation === "errors") { + const errors = [...info.runtimeErrors, ...info.manifestErrors].map((error: { message: string }) => error.message); + return { extensionId: value, control: "errors", errors, hasErrorsView: true, runtimeErrors: info.runtimeErrors, manifestErrors: info.manifestErrors }; + } + if (info.location !== "UNPACKED") throw new Error(`Extension ${value} is not an unpacked development extension`); + if (operation === "remove") { + await devtools.send("Extensions.uninstall", { id: value }); + return { extensionId: value, name: info.name, removed: true }; + } + const failure = await evaluate(`chrome.developerPrivate.reload(${id}, { failQuietly: true, populateErrorForUnpacked: true }).then(error => error ?? null)`); + if (failure) throw new Error(`Reload failed: ${failure.error ?? JSON.stringify(failure)}`); + return { extensionId: value, control: "reload" }; + }); +} + +function serve(socket: Socket, handle: (operation: string, value: string) => Promise) { + let request = "", answered = false; + socket.setEncoding("utf8"); + socket.on("data", async chunk => { + request += chunk; + const end = request.indexOf("\n"); + if (end < 0 || answered) return; + answered = true; + let operation = "unknown"; + try { + const parsed = JSON.parse(request.slice(0, end)); + operation = String(parsed.operation); + const data = await handle(operation, String(parsed.value)); + socket.end(`${JSON.stringify({ ok: true, operation: `extension.dev.${operation}`, data })}\n`); + } catch (error) { + socket.end(`${JSON.stringify({ ok: false, operation: `extension.dev.${operation}`, error: (error as Error).message })}\n`); + } + }); + socket.on("error", () => undefined); +} + +const binary = chromeBinary(); +if (!binary) throw new Error("Google Chrome or Chromium was not found; set CHROME_OPS_CHROME_BINARY"); +mkdirSync(profileDir(), { recursive: true, mode: 0o700 }); +const extra = (process.env.CHROME_OPS_CHROME_ARGS ?? "").split(/\s+/).filter(Boolean); +const chrome = spawn(binary, [`--user-data-dir=${profileDir()}`, "--remote-debugging-pipe", "--enable-unsafe-extension-debugging", + "--no-first-run", "--no-default-browser-check", ...extra], { stdio: ["ignore", "inherit", "inherit", "pipe", "pipe"] }); +const devtools = new DevTools(chrome.stdio[3] as Writable, chrome.stdio[4] as Readable); +const path = socketPath(); +chrome.on("exit", code => { rmSync(path, { force: true }); process.exit(code === 0 ? 0 : 1); }); +for (const signal of ["SIGTERM", "SIGINT"] as const) process.on(signal, () => chrome.kill("SIGTERM")); + +await devtools.send("Browser.getVersion"); +// The Errors view only collects errors in developer mode. +await onManagementPage(devtools, evaluate => evaluate("chrome.developerPrivate.updateProfileConfiguration({ inDeveloperMode: true }).then(() => true)")); +// Loading the Bridge again on every start also brings it up to date with this checkout. +if (process.env.CHROME_OPS_LOAD_BRIDGE !== "0") await devtools.send("Extensions.loadUnpacked", { path: join(packageRoot, "extension") }); + +let queue: Promise = Promise.resolve(); +const handle = (operation: string, value: string) => { + const result = queue.then(() => run(devtools, operation, value)); + queue = result.catch(() => undefined); + return result; +}; +mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); +chmodSync(dirname(path), 0o700); +rmSync(path, { force: true }); +const server = createServer(socket => serve(socket, handle)); +server.listen(path, () => { chmodSync(path, 0o600); console.error(`Chrome Ops development Chrome ready: ${path}`); }); diff --git a/src/os/linux-helper.ts b/src/os/linux-helper.ts new file mode 100644 index 0000000..b8b2c3c --- /dev/null +++ b/src/os/linux-helper.ts @@ -0,0 +1,37 @@ +// Linux helper: hand one developer operation to the development Chrome supervisor and print its JSON result. +// It starts the chrome-ops-chrome service when the supervisor is not running yet. +import { spawnSync } from "node:child_process"; +import { createConnection } from "node:net"; +import { serviceName, socketPath } from "./linux-paths.js"; + +const [operation, value] = process.argv.slice(2); +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +function ask(): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath()); + let reply = ""; + socket.setEncoding("utf8"); + socket.on("connect", () => socket.write(`${JSON.stringify({ operation, value })}\n`)); + socket.on("data", chunk => { reply += chunk; }); + socket.on("end", () => resolve(reply.trim())); + socket.on("error", reject); + }); +} + +let reply: string|undefined; +for (let attempt = 0; attempt < 150 && reply === undefined; attempt++) { + try { + reply = await ask(); + } catch (error) { + if (!["ENOENT", "ECONNREFUSED"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; + if (attempt === 0 && process.env.CHROME_OPS_CHROME_SOCKET === undefined) spawnSync("systemctl", ["--user", "start", serviceName]); + await sleep(200); + } +} +if (reply === undefined) { + process.stdout.write(`${JSON.stringify({ ok: false, operation: `extension.dev.${operation}`, error: `The Chrome Ops development Chrome (${serviceName}) did not start` })}\n`); + process.exit(1); +} +process.stdout.write(`${reply}\n`); +process.exit(JSON.parse(reply).ok ? 0 : 1); diff --git a/src/os/linux-paths.ts b/src/os/linux-paths.ts new file mode 100644 index 0000000..505836d --- /dev/null +++ b/src/os/linux-paths.ts @@ -0,0 +1,25 @@ +// Linux: where the dedicated development Chrome keeps its profile and control socket. +// Shared by the supervisor (linux-chrome.ts), the helper (linux-helper.ts) and setup (scripts/os/linux.mjs). +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const packageRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); +export const serviceName = "chrome-ops-chrome.service"; + +export const profileDir = () => process.env.CHROME_OPS_CHROME_PROFILE ?? + join(process.env.XDG_DATA_HOME || join(homedir(), ".local/share"), "chrome-ops/chrome-profile"); + +export const socketPath = () => process.env.CHROME_OPS_CHROME_SOCKET ?? + join(process.env.XDG_RUNTIME_DIR || join(homedir(), ".local/state"), "chrome-ops/chrome.sock"); + +export function chromeBinary(): string|null { + if (process.env.CHROME_OPS_CHROME_BINARY) return process.env.CHROME_OPS_CHROME_BINARY; + for (const name of ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser"]) { + for (const directory of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) { + if (existsSync(join(directory, name))) return join(directory, name); + } + } + return null; +} diff --git a/src/os/linux.ts b/src/os/linux.ts index fd57736..602341f 100644 --- a/src/os/linux.ts +++ b/src/os/linux.ts @@ -1,9 +1,14 @@ -// Linux: Chrome's extension-management page is not reachable from AT-SPI yet, so there is no helper. +// Linux: Chrome Ops runs its own development Chrome and performs the operations through that Chrome's DevTools pipe. +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 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!); }, + unsupportedReason: null, + reportsLoadedId: true, + usesManagementTab: false, + helper: (operation, value) => ({ command: process.execPath, args: [resolve(here, "linux-helper.js"), operation, value] }), }; diff --git a/src/os/macos.ts b/src/os/macos.ts index 2d6727b..2986291 100644 --- a/src/os/macos.ts +++ b/src/os/macos.ts @@ -9,6 +9,7 @@ export const macos: OsAdapter = { name: "macos", unsupportedReason: null, reportsLoadedId: true, + usesManagementTab: 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 index 8558cff..984affb 100644 --- a/src/os/types.ts +++ b/src/os/types.ts @@ -9,5 +9,7 @@ export interface OsAdapter { unsupportedReason: string|null; /** True when the helper reads the new extension id from Chrome's page after Load unpacked. */ reportsLoadedId: boolean; + /** True when the helper drives a chrome://extensions tab that the Bridge must prepare first. */ + usesManagementTab: boolean; helper(operation: DeveloperOperation, value: string, pageToken?: string): HelperInvocation; } diff --git a/src/os/windows.ts b/src/os/windows.ts index d986a22..d46f5ba 100644 --- a/src/os/windows.ts +++ b/src/os/windows.ts @@ -10,6 +10,7 @@ export const windows: OsAdapter = { unsupportedReason: null, // The Windows helper cannot read the new id; the server takes the one new development extension instead. reportsLoadedId: false, + usesManagementTab: true, helper: (operation, value) => ({ command: "pwsh", args: ["-NoProfile","-ExecutionPolicy","Bypass","-File",resolve(here, "../../helper/windows/chrome-ops-helper.ps1"), diff --git a/test/adapters.test.mjs b/test/adapters.test.mjs index 1d4f46f..19afe64 100644 --- a/test/adapters.test.mjs +++ b/test/adapters.test.mjs @@ -13,7 +13,18 @@ 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); + assert.equal(linux.developerOperations().supported, true); +}); + +test("every MCP-server OS adapter fills the same helper contract", async () => { + const { linux: linuxServer } = await import("../dist/os/linux.js"); + const { macos: macosServer } = await import("../dist/os/macos.js"); + const { windows: windowsServer } = await import("../dist/os/windows.js"); + const keys = ["helper", "name", "reportsLoadedId", "unsupportedReason", "usesManagementTab"]; + for (const os of [linuxServer, macosServer, windowsServer]) assert.deepEqual(Object.keys(os).sort(), keys, os.name); + // Linux works on its own development Chrome, so the Bridge does not prepare a management tab for it. + assert.equal(linuxServer.usesManagementTab, false); + assert.deepEqual(linuxServer.helper("reload", "a".repeat(32)).args.slice(1), ["reload", "a".repeat(32)]); }); test("every harness adapter fills the same registration contract", () => { diff --git a/test/linux-chrome.test.mjs b/test/linux-chrome.test.mjs new file mode 100644 index 0000000..83112d2 --- /dev/null +++ b/test/linux-chrome.test.mjs @@ -0,0 +1,52 @@ +// Linux only: run the development Chrome supervisor headless and drive the four operations through the Linux helper. +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const { chromeBinary } = await import("../dist/os/linux-paths.js"); +const skip = process.platform !== "linux" ? "Linux only" : chromeBinary() ? false : "Chrome is not installed"; + +test("the Linux helper loads, reloads, reads errors from, and removes an unpacked extension", { skip, timeout: 120000 }, async t => { + const dir = mkdtempSync(join(tmpdir(), "chrome-ops-linux-")); + const env = { ...process.env, CHROME_OPS_CHROME_PROFILE: join(dir, "profile"), CHROME_OPS_CHROME_SOCKET: join(dir, "run/chrome.sock"), + CHROME_OPS_CHROME_ARGS: "--headless=new --no-sandbox", CHROME_OPS_LOAD_BRIDGE: "0" }; + const supervisor = spawn(process.execPath, ["dist/os/linux-chrome.js"], { env, stdio: ["ignore", "ignore", "pipe"] }); + t.after(async () => { + supervisor.kill("SIGTERM"); + await new Promise(resolve => supervisor.exitCode === null ? supervisor.once("exit", resolve) : resolve()); + rmSync(dir, { recursive: true, force: true }); + }); + for (let i = 0; i < 150 && !existsSync(env.CHROME_OPS_CHROME_SOCKET); i++) await new Promise(resolve => setTimeout(resolve, 200)); + assert.ok(existsSync(env.CHROME_OPS_CHROME_SOCKET), "supervisor socket"); + + const fixture = join(dir, "throwing"); + mkdirSync(fixture); + writeFileSync(join(fixture, "manifest.json"), JSON.stringify({ manifest_version: 3, name: "Chrome Ops Throwing Fixture", version: "0.0.1", background: { service_worker: "sw.js" } })); + writeFileSync(join(fixture, "sw.js"), "throw new Error('fixture boom');\n"); + const helper = (...args) => { + const result = spawnSync(process.execPath, ["dist/os/linux-helper.js", ...args], { env, encoding: "utf8" }); + return { status: result.status, reply: JSON.parse(result.stdout) }; + }; + + const loaded = helper("load", fixture); + assert.equal(loaded.status, 0, JSON.stringify(loaded.reply)); + const id = loaded.reply.data.extensionId; + assert.match(id, /^[a-p]{32}$/); + + let errors; + for (let i = 0; i < 30; i++) { + errors = helper("errors", id); + if (errors.reply.data?.errors?.some(message => message.includes("fixture boom"))) break; + await new Promise(resolve => setTimeout(resolve, 200)); + } + assert.ok(errors.reply.data.errors.some(message => message.includes("fixture boom")), JSON.stringify(errors.reply)); + + assert.deepEqual(helper("reload", id).reply.data, { extensionId: id, control: "reload" }); + assert.equal(helper("errors", "not-an-id").status, 1); + assert.equal(helper("load", "relative/path").status, 1); + assert.equal(helper("remove", id).reply.data.removed, true); + assert.equal(helper("errors", id).status, 1, "removed extension is gone"); +}); From 5dd69ef8c9f89e4b4456279748241b68ea6761d8 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:41:19 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E7=92=B0=E5=A2=83=E5=A4=89=E6=95=B0?= =?UTF-8?q?=E3=81=8C=E7=B5=9E=E3=82=89=E3=82=8C=E3=81=A6=E3=81=84=E3=81=A6?= =?UTF-8?q?=E3=82=82=E9=96=8B=E7=99=BA=E7=94=A8Chrome=E3=81=AE=E3=82=BD?= =?UTF-8?q?=E3=82=B1=E3=83=83=E3=83=88=E3=82=92=E8=A6=8B=E3=81=A4=E3=81=91?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/os/linux-paths.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/os/linux-paths.ts b/src/os/linux-paths.ts index 505836d..521d3da 100644 --- a/src/os/linux-paths.ts +++ b/src/os/linux-paths.ts @@ -11,8 +11,11 @@ export const serviceName = "chrome-ops-chrome.service"; export const profileDir = () => process.env.CHROME_OPS_CHROME_PROFILE ?? join(process.env.XDG_DATA_HOME || join(homedir(), ".local/share"), "chrome-ops/chrome-profile"); -export const socketPath = () => process.env.CHROME_OPS_CHROME_SOCKET ?? - join(process.env.XDG_RUNTIME_DIR || join(homedir(), ".local/state"), "chrome-ops/chrome.sock"); +// MCP clients may start the server with a trimmed environment, so the runtime directory is also found without XDG_RUNTIME_DIR. +const runtimeDir = () => process.env.XDG_RUNTIME_DIR || + [`/run/user/${process.getuid?.()}`].find(path => existsSync(path)) || join(homedir(), ".local/state"); + +export const socketPath = () => process.env.CHROME_OPS_CHROME_SOCKET ?? join(runtimeDir(), "chrome-ops/chrome.sock"); export function chromeBinary(): string|null { if (process.env.CHROME_OPS_CHROME_BINARY) return process.env.CHROME_OPS_CHROME_BINARY; From 3e2aa1b9ce1c6f968011007c9bb68e1d0438ca8e Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:44:01 +0000 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20Linux=E3=81=AE=E9=96=8B=E7=99=BA?= =?UTF-8?q?=E7=94=A8Chrome=E3=82=92=E6=96=87=E6=9B=B8=E3=81=B8=E5=8F=8D?= =?UTF-8?q?=E6=98=A0=E3=81=97=E3=80=81=E9=96=89=E3=81=98=E3=81=A6=E3=81=84?= =?UTF-8?q?=E3=81=A6=E3=82=82=E9=96=8B=E7=99=BA=E8=80=85=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E3=81=A7=E8=B5=B7=E5=8B=95=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 35 +++++++++++++++++++++-------------- docs/ARCHITECTURE.md | 18 +++++++++++++++--- helper/README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/index.ts | 13 +++++++++++-- src/os/linux.ts | 3 +++ src/os/macos.ts | 1 + src/os/types.ts | 2 ++ src/os/windows.ts | 1 + test/adapters.test.mjs | 5 ++++- 11 files changed, 62 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e6ed3db..0d3beec 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,15 @@ Network event observations are redacted before they cross the extension boundary | --- | --- | --- | | 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` | +| Linux (systemd) | systemd user service | Chrome Ops' own development Chrome (DevTools pipe) | Claude Code, Codex, Cursor, and Grok Build are registered the same way on every OS. +On Linux, Chrome Ops runs a separate **development Chrome** with its own profile instead of driving your everyday Chrome. Wayland does not let other programs press Chrome's buttons, so Chrome Ops starts that Chrome itself and uses Chrome's DevTools pipe for Load unpacked, reload, errors and remove. The Bridge lives in that Chrome, so every Chrome Ops tool works on its tabs. + ## Quick start -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. +Requirements: Chrome, 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 graphical systemd user session and Google Chrome or Chromium. ```sh git clone https://github.com/kitepon/chrome-ops-mcp.git @@ -34,7 +36,11 @@ npm run setup `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: +On macOS and Windows, 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. + +On Linux, `npm run setup` starts the development Chrome and loads the Bridge into it; there is nothing to load by hand. Do not load the Bridge into another Chrome on Linux. Close the development Chrome whenever you like; reopen it from **Chrome Ops Chrome** in the application menu, or let a developer operation start it. Its profile is `${XDG_DATA_HOME:-~/.local/share}/chrome-ops/chrome-profile`. + +Then register the harnesses you use: ```sh npm run register # every installed harness @@ -42,11 +48,11 @@ npm run register:claude # or one of: claude, codex, cursor, grok npm run doctor ``` -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. +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 (and on Linux the development Chrome service and launcher); it leaves the Bridge extension, the Linux development Chrome profile, and harness registrations in place. 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. -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). +Logs: macOS writes the Host log to `~/Library/Logs/ChromeOps/`; Linux uses `journalctl --user -u chrome-ops-host` and `journalctl --user -u chrome-ops-chrome`. 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). ## Code layout @@ -54,9 +60,9 @@ Shared code, OS adaptation, and harness adaptation live in separate files. | 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` | — | +| MCP server | `src/index.ts`, `src/bridge.ts`, `src/host.ts`, `src/helper.ts` | `src/os/{macos,windows,linux}.ts`, `src/os/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/` | — | +| Native helper | `helper/contract.ts` | `helper/macos/`, `helper/windows/`, `src/os/linux-helper.ts` | — | See `docs/ARCHITECTURE.md` for the contracts between them. @@ -86,16 +92,17 @@ Chrome 116+ keeps extension service workers alive when WebSocket traffic is acti - 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. +- v0.4 on Ubuntu 26.04 (GNOME Wayland): the development Chrome started by `npm run setup`, and load/reload/errors/remove through MCP and from Claude Code, Codex, Cursor, and Grok Build. CI runs the Linux helper against headless Chrome. ## 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. +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. On Linux it uses the DevTools pipe of the development Chrome it started itself. The helpers are not generic shell/UI automation APIs. -### v0.3 alpha limitations +### v0.4 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. 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. +- On macOS and Windows, Chrome Developer mode must already be enabled for unpacked-extension operations. The Linux development Chrome turns it on itself. +- Developer-management UI automation on macOS and Windows is currently validated against Japanese and English Chrome labels; other UI languages are not yet guaranteed. +- On macOS and Windows, the Chrome Ops extension itself must be loaded manually once during initial setup. Later `npm run setup` runs update an already connected unpacked Bridge. +- On Linux the developer operations and every other tool act on the development Chrome, not on your everyday Chrome. +- On macOS and Windows, 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 61b9305..935680d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,7 +29,7 @@ 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 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. +On macOS and Windows, 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 @@ -41,12 +41,14 @@ Shared code, OS adaptation, and harness adaptation are separated at the file lev - 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. + - `usesManagementTab` — whether the Bridge must prepare a chrome://extensions tab before the helper runs (macOS, Windows). - `helper(operation, value, token)` — the helper command line. + - `startBrowser` — starts the Chrome that carries the Bridge when Chrome Ops owns it (Linux); `null` elsewhere. ### 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: +- OS: `os/macos.mjs` (LaunchAgent, Swift build), `os/windows.mjs` (Scheduled Task, npm-shim resolution), `os/linux.mjs` (systemd user services for the Host and the development Chrome, and its launcher). 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`. @@ -56,4 +58,14 @@ Shared code, OS adaptation, and harness adaptation are separated at the file lev ### 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. +`helper/contract.ts` is shared. `helper/macos/` (Swift, Accessibility) and `helper/windows/` (PowerShell 7, UI Automation) implement it by driving the user's Chrome. + +### Linux development Chrome + +Wayland does not let another program press Chrome's buttons, and Chrome only exposes its pages to AT-SPI when started with `--force-renderer-accessibility`. So on Linux Chrome Ops does not drive the user's Chrome. Instead: + +- `src/os/linux-chrome.ts` runs as the `chrome-ops-chrome` systemd user service. It starts Chrome with a dedicated profile, `--remote-debugging-pipe` and `--enable-unsafe-extension-debugging`, turns on developer mode, and loads the Bridge from this checkout (loading again on every start keeps it current). No debugging port is opened. +- It listens on `$XDG_RUNTIME_DIR/chrome-ops/chrome.sock` (user-only). `src/os/linux-helper.ts` is the helper command: it sends one `{operation, value}` request there, starting the service first if needed, and prints the same result JSON as the other helpers. +- Load uses `Extensions.loadUnpacked`, remove uses `Extensions.uninstall` (unpacked only). Reload and errors open chrome://extensions in a background tab and call `chrome.developerPrivate.reload` / `getExtensionInfo`, the same source as the Errors view. Values are an absolute directory or an exact extension id; the expressions are fixed. +- The service is not started at login. `npm run setup`, a developer operation, or the **Chrome Ops Chrome** launcher starts it; closing the last window stops it. +- `src/os/linux-paths.ts` holds the profile, socket and Chrome locations shared by the service, the helper and setup. diff --git a/helper/README.md b/helper/README.md index 37d26fa..4da2426 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` builds the release helper used by the MCP server. Linux has no helper yet. The MCP server picks the helper through `src/os/`. +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. On Linux, `src/os/linux-helper.ts` hands the same four operations to the development Chrome that Chrome Ops runs (`src/os/linux-chrome.ts`), which performs them through its DevTools pipe. The MCP server picks the helper through `src/os/`. diff --git a/package-lock.json b/package-lock.json index 4f0ef84..108c601 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chrome-ops-mcp", - "version": "0.3.0-alpha.0", + "version": "0.4.0-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chrome-ops-mcp", - "version": "0.3.0-alpha.0", + "version": "0.4.0-alpha.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.1", diff --git a/package.json b/package.json index e489da3..8f26d80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chrome-ops-mcp", - "version": "0.3.0-alpha.0", + "version": "0.4.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", diff --git a/src/index.ts b/src/index.ts index b853809..a65a990 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,12 +8,21 @@ 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.3.0-alpha.0" }); +const server = new McpServer({ name: "chrome-ops-mcp", version: "0.4.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))); const bridgeSession=async()=>{ - const status=await bridge.call("host.status"); + let status=await bridge.call("host.status"); + // On Linux the Bridge lives in Chrome Ops' own development Chrome; start it when it is closed. + const start=currentOs().startBrowser; + if(start&&status&&typeof status==="object"&&"bridgeCount" in status&&status.bridgeCount===0){ + start(); + for(let i=0;i<150&&(status as {bridgeCount?:number}).bridgeCount===0;i++){ + await new Promise(resolve=>setTimeout(resolve,200)); + status=await bridge.call("host.status"); + } + } if(!status||typeof status!=="object"||!("connected" in status)||status.connected!==true|| !("bridgeSession" in status)||typeof status.bridgeSession!=="string"){ throw new Error("A unique Chrome Ops Bridge profile is required for developer operations"); diff --git a/src/os/linux.ts b/src/os/linux.ts index 602341f..2dd3977 100644 --- a/src/os/linux.ts +++ b/src/os/linux.ts @@ -1,6 +1,8 @@ // Linux: Chrome Ops runs its own development Chrome and performs the operations through that Chrome's DevTools pipe. +import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; +import { serviceName } from "./linux-paths.js"; import type { OsAdapter } from "./types.js"; const here = dirname(fileURLToPath(import.meta.url)); @@ -10,5 +12,6 @@ export const linux: OsAdapter = { unsupportedReason: null, reportsLoadedId: true, usesManagementTab: false, + startBrowser: () => { spawnSync("systemctl", ["--user", "start", serviceName]); }, helper: (operation, value) => ({ command: process.execPath, args: [resolve(here, "linux-helper.js"), operation, value] }), }; diff --git a/src/os/macos.ts b/src/os/macos.ts index 2986291..867d0da 100644 --- a/src/os/macos.ts +++ b/src/os/macos.ts @@ -10,6 +10,7 @@ export const macos: OsAdapter = { unsupportedReason: null, reportsLoadedId: true, usesManagementTab: true, + startBrowser: null, 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 index 984affb..3af39dc 100644 --- a/src/os/types.ts +++ b/src/os/types.ts @@ -12,4 +12,6 @@ export interface OsAdapter { /** True when the helper drives a chrome://extensions tab that the Bridge must prepare first. */ usesManagementTab: boolean; helper(operation: DeveloperOperation, value: string, pageToken?: string): HelperInvocation; + /** Starts the Chrome that carries the Bridge when Chrome Ops owns it (Linux); null when the user runs Chrome. */ + startBrowser: (() => void)|null; } diff --git a/src/os/windows.ts b/src/os/windows.ts index d46f5ba..411c8fc 100644 --- a/src/os/windows.ts +++ b/src/os/windows.ts @@ -11,6 +11,7 @@ export const windows: OsAdapter = { // The Windows helper cannot read the new id; the server takes the one new development extension instead. reportsLoadedId: false, usesManagementTab: true, + startBrowser: null, helper: (operation, value) => ({ command: "pwsh", args: ["-NoProfile","-ExecutionPolicy","Bypass","-File",resolve(here, "../../helper/windows/chrome-ops-helper.ps1"), diff --git a/test/adapters.test.mjs b/test/adapters.test.mjs index 19afe64..fe3157c 100644 --- a/test/adapters.test.mjs +++ b/test/adapters.test.mjs @@ -20,10 +20,13 @@ test("every MCP-server OS adapter fills the same helper contract", async () => { const { linux: linuxServer } = await import("../dist/os/linux.js"); const { macos: macosServer } = await import("../dist/os/macos.js"); const { windows: windowsServer } = await import("../dist/os/windows.js"); - const keys = ["helper", "name", "reportsLoadedId", "unsupportedReason", "usesManagementTab"]; + const keys = ["helper", "name", "reportsLoadedId", "startBrowser", "unsupportedReason", "usesManagementTab"]; for (const os of [linuxServer, macosServer, windowsServer]) assert.deepEqual(Object.keys(os).sort(), keys, os.name); // Linux works on its own development Chrome, so the Bridge does not prepare a management tab for it. assert.equal(linuxServer.usesManagementTab, false); + assert.equal(typeof linuxServer.startBrowser, "function"); + assert.equal(macosServer.startBrowser, null); + assert.equal(windowsServer.startBrowser, null); assert.deepEqual(linuxServer.helper("reload", "a".repeat(32)).args.slice(1), ["reload", "a".repeat(32)]); }); From 3027c54329f08d2616306d5f9ba95182dbd35d9c Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:44:51 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=E7=B5=9E=E3=82=89=E3=82=8C=E3=81=9F?= =?UTF-8?q?=E7=92=B0=E5=A2=83=E3=81=8B=E3=82=89=E3=82=82=E9=96=8B=E7=99=BA?= =?UTF-8?q?=E7=94=A8Chrome=E3=81=AE=E3=82=B5=E3=83=BC=E3=83=93=E3=82=B9?= =?UTF-8?q?=E3=82=92=E8=B5=B7=E5=8B=95=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/os/linux-helper.ts | 5 ++--- src/os/linux-paths.ts | 8 +++++++- src/os/linux.ts | 5 ++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/os/linux-helper.ts b/src/os/linux-helper.ts index b8b2c3c..23de2f6 100644 --- a/src/os/linux-helper.ts +++ b/src/os/linux-helper.ts @@ -1,8 +1,7 @@ // Linux helper: hand one developer operation to the development Chrome supervisor and print its JSON result. // It starts the chrome-ops-chrome service when the supervisor is not running yet. -import { spawnSync } from "node:child_process"; import { createConnection } from "node:net"; -import { serviceName, socketPath } from "./linux-paths.js"; +import { serviceName, socketPath, startService } from "./linux-paths.js"; const [operation, value] = process.argv.slice(2); const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); @@ -25,7 +24,7 @@ for (let attempt = 0; attempt < 150 && reply === undefined; attempt++) { reply = await ask(); } catch (error) { if (!["ENOENT", "ECONNREFUSED"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; - if (attempt === 0 && process.env.CHROME_OPS_CHROME_SOCKET === undefined) spawnSync("systemctl", ["--user", "start", serviceName]); + if (attempt === 0 && process.env.CHROME_OPS_CHROME_SOCKET === undefined) startService(); await sleep(200); } } diff --git a/src/os/linux-paths.ts b/src/os/linux-paths.ts index 521d3da..c93e155 100644 --- a/src/os/linux-paths.ts +++ b/src/os/linux-paths.ts @@ -1,5 +1,6 @@ // Linux: where the dedicated development Chrome keeps its profile and control socket. // Shared by the supervisor (linux-chrome.ts), the helper (linux-helper.ts) and setup (scripts/os/linux.mjs). +import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, join, resolve } from "node:path"; @@ -12,11 +13,16 @@ export const profileDir = () => process.env.CHROME_OPS_CHROME_PROFILE ?? join(process.env.XDG_DATA_HOME || join(homedir(), ".local/share"), "chrome-ops/chrome-profile"); // MCP clients may start the server with a trimmed environment, so the runtime directory is also found without XDG_RUNTIME_DIR. -const runtimeDir = () => process.env.XDG_RUNTIME_DIR || +export const runtimeDir = () => process.env.XDG_RUNTIME_DIR || [`/run/user/${process.getuid?.()}`].find(path => existsSync(path)) || join(homedir(), ".local/state"); export const socketPath = () => process.env.CHROME_OPS_CHROME_SOCKET ?? join(runtimeDir(), "chrome-ops/chrome.sock"); +// systemctl --user finds the user's service manager through XDG_RUNTIME_DIR, which a trimmed environment lacks. +export function startService(): void { + spawnSync("systemctl", ["--user", "start", serviceName], { env: { ...process.env, XDG_RUNTIME_DIR: runtimeDir() }, stdio: "ignore" }); +} + export function chromeBinary(): string|null { if (process.env.CHROME_OPS_CHROME_BINARY) return process.env.CHROME_OPS_CHROME_BINARY; for (const name of ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser"]) { diff --git a/src/os/linux.ts b/src/os/linux.ts index 2dd3977..883b38d 100644 --- a/src/os/linux.ts +++ b/src/os/linux.ts @@ -1,8 +1,7 @@ // Linux: Chrome Ops runs its own development Chrome and performs the operations through that Chrome's DevTools pipe. -import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; -import { serviceName } from "./linux-paths.js"; +import { startService } from "./linux-paths.js"; import type { OsAdapter } from "./types.js"; const here = dirname(fileURLToPath(import.meta.url)); @@ -12,6 +11,6 @@ export const linux: OsAdapter = { unsupportedReason: null, reportsLoadedId: true, usesManagementTab: false, - startBrowser: () => { spawnSync("systemctl", ["--user", "start", serviceName]); }, + startBrowser: startService, helper: (operation, value) => ({ command: process.execPath, args: [resolve(here, "linux-helper.js"), operation, value] }), }; From 53e86969b1d6129c93b7c40e06cd2847a7bec610 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:47:29 +0000 Subject: [PATCH 5/5] =?UTF-8?q?test:=20=E9=96=8B=E7=99=BA=E7=94=A8Chrome?= =?UTF-8?q?=E3=81=AE=E4=B8=80=E6=99=82=E3=83=97=E3=83=AD=E3=83=95=E3=82=A1?= =?UTF-8?q?=E3=82=A4=E3=83=AB=E3=82=92=E6=9B=B8=E3=81=8D=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E3=81=8C=E6=AD=A2=E3=81=BE=E3=81=A3=E3=81=A6=E3=81=8B=E3=82=89?= =?UTF-8?q?=E6=B6=88=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- test/linux-chrome.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/linux-chrome.test.mjs b/test/linux-chrome.test.mjs index 83112d2..b30de2b 100644 --- a/test/linux-chrome.test.mjs +++ b/test/linux-chrome.test.mjs @@ -17,7 +17,8 @@ test("the Linux helper loads, reloads, reads errors from, and removes an unpacke t.after(async () => { supervisor.kill("SIGTERM"); await new Promise(resolve => supervisor.exitCode === null ? supervisor.once("exit", resolve) : resolve()); - rmSync(dir, { recursive: true, force: true }); + // Chrome's helper processes can still be writing to the profile for a moment after the browser exits. + rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); }); for (let i = 0; i < 150 && !existsSync(env.CHROME_OPS_CHROME_SOCKET); i++) await new Promise(resolve => setTimeout(resolve, 200)); assert.ok(existsSync(env.CHROME_OPS_CHROME_SOCKET), "supervisor socket");