diff --git a/.changeset/tidy-shrimps-listen.md b/.changeset/tidy-shrimps-listen.md new file mode 100644 index 0000000..cb2059d --- /dev/null +++ b/.changeset/tidy-shrimps-listen.md @@ -0,0 +1,6 @@ +--- +"@opencode-ai/browser-control": patch +--- + +Pin production WebSocket access to the assigned Chrome Web Store extension ID +while retaining an explicit source-development path for unpacked extensions. diff --git a/AGENTS.md b/AGENTS.md index 33cc108..06121a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,7 +242,7 @@ browser-control skill - Load `extension/dist` as the unpacked extension. - The relay listens on `127.0.0.1:19989` by default. -- Current shim version is `0.0.22` and extension protocol version is `1`. +- Current shim version is `0.0.23` and extension protocol version is `1`. - Store and npm versions may differ when protocol `1` remains compatible. - On socket open the shim sends `hello` and then re-announces every tab it still has `chrome.debugger` attached to (`debugger.attached` events), so a restarted diff --git a/PLAN.md b/PLAN.md index b343625..20548bd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -167,7 +167,7 @@ require a new extension capture protocol and permission model. link`. - Until the first Store review completes, the browser extension is loaded unpacked from the npm package's `extension/dist` directory or a source build. - Its current shim version is `0.0.22`. + Its current shim version is `0.0.23`. - Extension and npm releases are independently versioned. The extension hello reports an explicit protocol version, and compatibility rather than exact package-version equality determines whether the local driver may use it. diff --git a/docs/CHROME_WEB_STORE.md b/docs/CHROME_WEB_STORE.md index 4169c68..c8f0439 100644 --- a/docs/CHROME_WEB_STORE.md +++ b/docs/CHROME_WEB_STORE.md @@ -97,11 +97,9 @@ requires a separate explicit CLI request. Store assets live under `docs/chrome-web-store/`: - `icon-128.png` +- `browser-control-1280x800.jpg` - `small-promo-440x280.png` -A 1280x800 or 640x400 product screenshot is still required before submission; -capture it from the final Store-ID-pinned build rather than staging a mock. - Run: ```bash @@ -111,6 +109,6 @@ pnpm package:extension Upload `artifacts/browser-control-extension-.zip`. Record the printed SHA-256 digest with the release notes. -Before submission, add the Store-assigned extension ID to the relay's accepted -production extension origins while preserving an explicit source-development -path. +The production relay accepts Store extension ID +`gmjpoplfomnnjipeiojccjbpjlodkjhn`. Source-mode relays additionally accept +unpacked development extension origins. diff --git a/docs/chrome-web-store/browser-control-1280x800.jpg b/docs/chrome-web-store/browser-control-1280x800.jpg new file mode 100644 index 0000000..66b837b Binary files /dev/null and b/docs/chrome-web-store/browser-control-1280x800.jpg differ diff --git a/extension/manifest.json b/extension/manifest.json index 9cc4306..de30df5 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Browser Control", - "version": "0.0.22", + "version": "0.0.23", "description": "Connect browser tabs to the local Browser Control driver.", "minimum_chrome_version": "120", "permissions": ["activeTab", "alarms", "debugger", "offscreen", "tabCapture", "tabGroups"], diff --git a/src/relay-helpers.ts b/src/relay-helpers.ts index f467cc3..26dbc2f 100644 --- a/src/relay-helpers.ts +++ b/src/relay-helpers.ts @@ -9,6 +9,7 @@ import { RelayErrorCode } from "./relay-schema.ts" export const defaultHost = "127.0.0.1" export const defaultPort = 19989 +export const chromeWebStoreExtensionOrigin = "chrome-extension://gmjpoplfomnnjipeiojccjbpjlodkjhn" const maxCliBodyBytes = 1_000_000 @@ -54,16 +55,20 @@ export function validateBrowserFetchSite(request: http.IncomingMessage): string export function validateWebSocketOrigin(options: { readonly origin: string | undefined + readonly allowAnyChromeExtension?: boolean readonly requireChromeExtension?: boolean }): string | undefined { if (!options.origin) { return options.requireChromeExtension ? "Extension WebSocket origin is required" : undefined } - if (options.origin.startsWith("chrome-extension://")) { + if (options.origin === chromeWebStoreExtensionOrigin) { + return undefined + } + if (options.allowAnyChromeExtension && options.origin.startsWith("chrome-extension://")) { return undefined } if (options.requireChromeExtension) { - return "Extension WebSocket origin must be chrome-extension://" + return "Extension WebSocket origin is not allowed" } return "WebSocket origin is not allowed" } diff --git a/src/relay.ts b/src/relay.ts index 7aea172..245b8cf 100644 --- a/src/relay.ts +++ b/src/relay.ts @@ -58,6 +58,7 @@ import { RecordingRelay } from "./recording-relay.ts" import { appendManagedRelayProcessLog } from "./relay-log.ts" import { boundedToken, runtimeFailureKind, summarizeDiagnosticUrl, summarizeRuntimeEvaluate } from "./runtime-diagnostics.ts" import { resolveTargetInfoTarget, shouldExposeChildTarget, TargetRegistry, type RootTargetChange, type TargetOwnershipChange } from "./target-registry.ts" +import { browserControlVersion } from "./version.ts" export type { RelayServer } from "./relay-types.ts" @@ -147,6 +148,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { const releaseTargetGraceMs = Math.max(0, options.releaseTargetGraceMs ?? 10_000) const browserId = crypto.randomUUID() const endpointUrl = `http://${formatHostForUrl(host)}:${port}` + const allowAnyChromeExtension = browserControlVersion === "0.0.0-dev" const sessionCatalog = options.sessionCatalogPath === null ? undefined : new SessionCatalog(options.sessionCatalogPath ?? defaultSessionCatalogPath(port)) @@ -602,7 +604,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { const requestUrl = new URL(request.url ?? "/", endpointUrl) const origin = Array.isArray(request.headers.origin) ? request.headers.origin[0] : request.headers.origin if (requestUrl.pathname === "/extension") { - const originError = validateWebSocketOrigin({ origin, requireChromeExtension: true }) + const originError = validateWebSocketOrigin({ origin, requireChromeExtension: true, allowAnyChromeExtension }) if (originError) { sendUpgradeError({ socket, status: 403, message: originError }) return @@ -613,7 +615,7 @@ const makeRelay = Effect.fnUntraced(function* (options: { return } if (requestUrl.pathname.startsWith("/devtools/browser/")) { - const originError = validateWebSocketOrigin({ origin }) + const originError = validateWebSocketOrigin({ origin, allowAnyChromeExtension }) if (originError) { sendUpgradeError({ socket, status: 403, message: originError }) return diff --git a/test/relay-helpers.test.ts b/test/relay-helpers.test.ts index 1b5c474..3663239 100644 --- a/test/relay-helpers.test.ts +++ b/test/relay-helpers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" import { + chromeWebStoreExtensionOrigin, generateSessionId, getTargetInfo, isRestrictedTarget, @@ -48,15 +49,21 @@ describe("validateBrowserFetchSite", () => { }) describe("validateWebSocketOrigin", () => { - it("accepts extension origins and missing origins for non-extension clients", () => { - expect(validateWebSocketOrigin({ origin: "chrome-extension://abc" })).toBeUndefined() + it("accepts the Store extension and missing origins for non-extension clients", () => { + expect(validateWebSocketOrigin({ origin: chromeWebStoreExtensionOrigin })).toBeUndefined() expect(validateWebSocketOrigin({ origin: undefined })).toBeUndefined() }) + it("accepts unpacked extension origins only in source development", () => { + expect(validateWebSocketOrigin({ origin: "chrome-extension://unpacked", allowAnyChromeExtension: true })).toBeUndefined() + expect(validateWebSocketOrigin({ origin: "chrome-extension://unpacked", requireChromeExtension: true })).toBeDefined() + }) + it("rejects web origins for the extension endpoint", () => { expect(validateWebSocketOrigin({ origin: undefined, requireChromeExtension: true })).toBeDefined() expect(validateWebSocketOrigin({ origin: "https://example.com", requireChromeExtension: true })).toBeDefined() expect(validateWebSocketOrigin({ origin: "https://example.com" })).toBeDefined() + expect(validateWebSocketOrigin({ origin: "chrome-extension://different-extension" })).toBeDefined() }) })