diff --git a/extensions/browser/browser-proxy-upload-cleanup.runtime.ts b/extensions/browser/browser-proxy-upload-cleanup.runtime.ts new file mode 100644 index 000000000000..a76f43de6335 --- /dev/null +++ b/extensions/browser/browser-proxy-upload-cleanup.runtime.ts @@ -0,0 +1,5 @@ +/** + * Browser proxy upload cleanup runtime. Availability watches load only this + * narrow artifact instead of the full browser registration runtime. + */ +export { ensureBrowserProxyUploadCleanup } from "./src/browser-proxy-upload.js"; diff --git a/extensions/browser/plugin-registration.ts b/extensions/browser/plugin-registration.ts index f993dd10afce..c91ad85a3f9a 100644 --- a/extensions/browser/plugin-registration.ts +++ b/extensions/browser/plugin-registration.ts @@ -45,6 +45,9 @@ const logger = createSubsystemLogger("browser"); const loadBrowserRegistrationRuntimeModule = createLazyRuntimeModule( () => import("./register.runtime.js"), ); +const loadBrowserProxyUploadCleanupRuntimeModule = createLazyRuntimeModule( + () => import("./browser-proxy-upload-cleanup.runtime.js"), +); function deriveChatTypeFromSessionKey( sessionKey: string | undefined, @@ -201,7 +204,7 @@ function createBrowserProxyNodeHostCommand(command: string): OpenClawPluginNodeH ...(command === BROWSER_PROXY_UPLOAD_COMMAND ? { watchAvailability: () => { - void loadBrowserRegistrationRuntimeModule() + void loadBrowserProxyUploadCleanupRuntimeModule() .then(({ ensureBrowserProxyUploadCleanup }) => ensureBrowserProxyUploadCleanup()) .catch((error: unknown) => { logger.warn(`browser proxy upload cleanup startup failed: ${String(error)}`); diff --git a/extensions/browser/plugin-registration.upload-cleanup.test.ts b/extensions/browser/plugin-registration.upload-cleanup.test.ts new file mode 100644 index 000000000000..a2f8dda9749f --- /dev/null +++ b/extensions/browser/plugin-registration.upload-cleanup.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { browserPluginNodeHostCommands } from "./plugin-registration.js"; + +const runtimeMocks = vi.hoisted(() => ({ + ensureBrowserProxyUploadCleanup: vi.fn(async () => undefined), + cleanupRuntimeEvaluated: vi.fn(), + registrationRuntimeEvaluated: vi.fn(), +})); + +vi.mock("./browser-proxy-upload-cleanup.runtime.js", () => { + runtimeMocks.cleanupRuntimeEvaluated(); + return { + ensureBrowserProxyUploadCleanup: runtimeMocks.ensureBrowserProxyUploadCleanup, + }; +}); + +vi.mock("./register.runtime.js", () => { + runtimeMocks.registrationRuntimeEvaluated(); + return {}; +}); + +describe("browser proxy upload cleanup registration", () => { + it("loads only the narrow cleanup runtime when availability watching starts", async () => { + const uploadCommand = browserPluginNodeHostCommands.find( + (command) => command.command === "browser.proxy.upload.v1", + ); + + uploadCommand?.watchAvailability?.(); + + await vi.waitFor(() => { + expect(runtimeMocks.ensureBrowserProxyUploadCleanup).toHaveBeenCalledOnce(); + }); + expect(runtimeMocks.cleanupRuntimeEvaluated).toHaveBeenCalledOnce(); + expect(runtimeMocks.registrationRuntimeEvaluated).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/browser/register.runtime.ts b/extensions/browser/register.runtime.ts index a65baac6a603..a55e9818d4de 100644 --- a/extensions/browser/register.runtime.ts +++ b/extensions/browser/register.runtime.ts @@ -3,7 +3,6 @@ * registration lazy-load these exports when browser runtime behavior is needed. */ export { createBrowserTool } from "./src/browser-tool.js"; -export { ensureBrowserProxyUploadCleanup } from "./src/browser-proxy-upload.js"; export { handleBrowserGatewayRequest } from "./src/gateway/browser-request.js"; export { runBrowserProxyCommand } from "./src/node-host/invoke-browser.js"; export { createBrowserPluginService } from "./src/plugin-service.js"; diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index 0d12c6476122..a2ef3cf132b3 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -6,7 +6,9 @@ */ import os from "node:os"; import path from "node:path"; +import { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-config"; import { mergeSsrFPolicies } from "openclaw/plugin-sdk/ssrf-policy"; +import { isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString, normalizeOptionalTrimmedStringList, @@ -21,7 +23,6 @@ import { import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { parseBooleanValue } from "../sdk-config.js"; import { resolveUserPath } from "../utils.js"; -import { parseBrowserHttpUrl, redactCdpUrl, isLoopbackHost } from "./cdp.helpers.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS, DEFAULT_BROWSER_ACTION_TIMEOUT_MS, diff --git a/scripts/check-built-plugin-control-plane-modules.mts b/scripts/check-built-plugin-control-plane-modules.mts index e3e9a4ac3dbb..80d5c0ad280b 100644 --- a/scripts/check-built-plugin-control-plane-modules.mts +++ b/scripts/check-built-plugin-control-plane-modules.mts @@ -19,7 +19,7 @@ type BuiltPluginControlPlaneModuleFailure = BuiltPluginControlPlaneModule & { error: string; }; -type BuiltDoctorContractClosureViolation = BuiltPluginControlPlaneModule & { +type BuiltPluginControlPlaneClosureViolation = BuiltPluginControlPlaneModule & { dependency: string; importerPath: string; }; @@ -31,6 +31,8 @@ type ProbeParams = { const ROOT = resolveRepoRoot(import.meta.url); const DIRECT_CONTRACT_FILES = ["contract-api.js", "doctor-contract-api.js"]; +const BROWSER_PLUGIN_REGISTRATION_FILE = "index.js"; +const BROWSER_PROXY_UPLOAD_CLEANUP_RUNTIME_FILE = "browser-proxy-upload-cleanup.runtime.js"; const LEGACY_SETUP_PROPERTIES = new Map([ ["legacyStateMigrations", "channel-legacy-state-migrations"], ["legacySessionSurface", "channel-legacy-session-surface"], @@ -46,7 +48,11 @@ const DEFAULT_TIMEOUT_MS = 120_000; // rejects them. `doctor-contract-closure-guard.test.ts` owns the same invariant over // sources; bundling can merge runtime code into the artifact behind its back, so the // built closure is checked here. -const FORBIDDEN_DOCTOR_CONTRACT_DEPENDENCIES = ["execa"]; +const FORBIDDEN_STATIC_CLOSURE_DEPENDENCIES = new Map([ + ["doctor-contract", ["execa"]], + ["browser-plugin-registration", ["openclaw/plugin-sdk/gateway-runtime", "playwright-core"]], + ["browser-proxy-upload-cleanup", ["openclaw/plugin-sdk/gateway-runtime", "playwright-core"]], +]); const REQUIRE_PROBE_SOURCE = String.raw` const { createRequire } = require("node:module"); const path = require("node:path"); @@ -122,6 +128,26 @@ export function listBuiltPluginControlPlaneModules(params: { rootDir?: string } }); } } + if (pluginId === "browser") { + const registrationPath = path.join(pluginDir, BROWSER_PLUGIN_REGISTRATION_FILE); + if (fs.existsSync(registrationPath)) { + const relativePath = path.relative(rootDir, registrationPath).split(path.sep).join("/"); + modules.set(relativePath, { + pluginId, + kind: "browser-plugin-registration", + relativePath, + }); + } + const modulePath = path.join(pluginDir, BROWSER_PROXY_UPLOAD_CLEANUP_RUNTIME_FILE); + if (fs.existsSync(modulePath)) { + const relativePath = path.relative(rootDir, modulePath).split(path.sep).join("/"); + modules.set(relativePath, { + pluginId, + kind: "browser-proxy-upload-cleanup", + relativePath, + }); + } + } const setupEntryPath = path.join(pluginDir, "setup-entry.js"); if (!fs.existsSync(setupEntryPath)) { continue; @@ -243,20 +269,31 @@ function collectBuiltModuleStaticDependencies(entryPath: string): Map candidate.kind === "doctor-contract")) { + const violations: BuiltPluginControlPlaneClosureViolation[] = []; + for (const module of modules) { + const forbiddenDependencies = FORBIDDEN_STATIC_CLOSURE_DEPENDENCIES.get(module.kind); + if (!forbiddenDependencies) { + continue; + } const dependencies = collectBuiltModuleStaticDependencies( path.join(rootDir, module.relativePath), ); - for (const dependency of FORBIDDEN_DOCTOR_CONTRACT_DEPENDENCIES) { - const importer = dependencies.get(dependency); - if (importer) { + for (const [dependency, importer] of dependencies) { + if ( + forbiddenDependencies.some((forbiddenDependency) => + matchesForbiddenDependency(dependency, forbiddenDependency), + ) + ) { violations.push({ ...module, dependency, @@ -279,18 +316,18 @@ export function verifyBuiltPluginControlPlaneModules(params: ProbeParams = {}) { ); throw new Error(`built plugin control-plane module load failures:\n${details.join("\n")}`); } - const closureViolations = collectBuiltDoctorContractClosureViolations(modules, params); + const closureViolations = collectBuiltPluginControlPlaneClosureViolations(modules, params); if (closureViolations.length > 0) { const details = closureViolations.map( (violation) => `- ${violation.pluginId} ${violation.relativePath} statically reaches ${violation.dependency} through ${violation.importerPath}`, ); throw new Error( - `built doctor contract closures reach forbidden runtime dependencies:\n${details.join("\n")}`, + `built plugin control-plane closures reach forbidden runtime dependencies:\n${details.join("\n")}`, ); } console.error( - `[plugin-control-plane-loads] verified ${modules.length} built modules with native require and checked doctor closures`, + `[plugin-control-plane-loads] verified ${modules.length} built modules with native require and checked guarded closures`, ); } diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index fdcf9ea45add..49e905b809e3 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -77,6 +77,14 @@ describe("bundled plugin build entries", () => { expect(artifacts).toContain("dist/extensions/codex/cli-metadata.js"); }); + it("emits the browser proxy upload cleanup runtime as a bundled build entry", () => { + const entries = listBundledPluginBuildEntries(); + + expect(entries["extensions/browser/browser-proxy-upload-cleanup.runtime"]).toBe( + "extensions/browser/browser-proxy-upload-cleanup.runtime.ts", + ); + }); + it("builds narrow QA runner public surfaces", () => { const entries = listBundledPluginBuildEntries(); diff --git a/test/scripts/check-built-plugin-control-plane-modules.test.ts b/test/scripts/check-built-plugin-control-plane-modules.test.ts index 538c9794bc7f..1bdd32cfd0a9 100644 --- a/test/scripts/check-built-plugin-control-plane-modules.test.ts +++ b/test/scripts/check-built-plugin-control-plane-modules.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - collectBuiltDoctorContractClosureViolations, + collectBuiltPluginControlPlaneClosureViolations, listBuiltPluginControlPlaneModules, probeBuiltPluginControlPlaneModules, verifyBuiltPluginControlPlaneModules, @@ -123,9 +123,12 @@ describe("built doctor contract closures", () => { write(rootDir, "dist/exec-chunk.js", 'import "execa";\nexport const rule = 1;\n'); expect( - collectBuiltDoctorContractClosureViolations(listBuiltPluginControlPlaneModules({ rootDir }), { - rootDir, - }), + collectBuiltPluginControlPlaneClosureViolations( + listBuiltPluginControlPlaneModules({ rootDir }), + { + rootDir, + }, + ), ).toEqual([ { pluginId: "demo", @@ -153,9 +156,124 @@ describe("built doctor contract closures", () => { ); expect( - collectBuiltDoctorContractClosureViolations(listBuiltPluginControlPlaneModules({ rootDir }), { - rootDir, - }), + collectBuiltPluginControlPlaneClosureViolations( + listBuiltPluginControlPlaneModules({ rootDir }), + { + rootDir, + }, + ), ).toEqual([]); }); }); + +describe("built browser startup closures", () => { + it("accepts narrow plugin registration and cleanup artifacts", () => { + const rootDir = makeRoot(); + write( + rootDir, + "dist/extensions/browser/index.js", + 'export { plugin } from "../../registration-chunk.js";\n', + ); + write( + rootDir, + "dist/extensions/browser/browser-proxy-upload-cleanup.runtime.js", + 'export { cleanup } from "../../cleanup-chunk.js";\n', + ); + write(rootDir, "dist/registration-chunk.js", 'import "node:path";\nexport const plugin = 1;\n'); + write(rootDir, "dist/cleanup-chunk.js", 'import "node:fs";\nexport const cleanup = 1;\n'); + + expect( + collectBuiltPluginControlPlaneClosureViolations( + listBuiltPluginControlPlaneModules({ rootDir }), + { rootDir }, + ), + ).toEqual([]); + }); + + it("roots the Playwright guard at the emitted Browser plugin startup entry", () => { + const rootDir = makeRoot(); + write( + rootDir, + "dist/extensions/browser/index.js", + 'export { plugin } from "../../registration-chunk.js";\n', + ); + write( + rootDir, + "dist/registration-chunk.js", + 'import "playwright-core/lib/server";\nexport const plugin = 1;\n', + ); + + expect( + collectBuiltPluginControlPlaneClosureViolations( + listBuiltPluginControlPlaneModules({ rootDir }), + { rootDir }, + ), + ).toEqual([ + { + pluginId: "browser", + kind: "browser-plugin-registration", + relativePath: "dist/extensions/browser/index.js", + dependency: "playwright-core/lib/server", + importerPath: "dist/registration-chunk.js", + }, + ]); + }); + + it("also guards the cleanup artifact's own emitted closure", () => { + const rootDir = makeRoot(); + write( + rootDir, + "dist/extensions/browser/browser-proxy-upload-cleanup.runtime.js", + 'export { cleanup } from "../../cleanup-chunk.js";\n', + ); + write( + rootDir, + "dist/cleanup-chunk.js", + 'import "playwright-core";\nexport const cleanup = 1;\n', + ); + + expect( + collectBuiltPluginControlPlaneClosureViolations( + listBuiltPluginControlPlaneModules({ rootDir }), + { rootDir }, + ), + ).toEqual([ + { + pluginId: "browser", + kind: "browser-proxy-upload-cleanup", + relativePath: "dist/extensions/browser/browser-proxy-upload-cleanup.runtime.js", + dependency: "playwright-core", + importerPath: "dist/cleanup-chunk.js", + }, + ]); + }); + + it("rejects the broad Gateway runtime from Browser startup closures", () => { + const rootDir = makeRoot(); + write( + rootDir, + "dist/extensions/browser/index.js", + 'export { plugin } from "../../registration-chunk.js";\n', + ); + write( + rootDir, + "dist/registration-chunk.js", + 'import "openclaw/plugin-sdk/gateway-runtime";\nexport const plugin = 1;\n', + ); + + expect( + collectBuiltPluginControlPlaneClosureViolations( + listBuiltPluginControlPlaneModules({ rootDir }), + { rootDir }, + ), + ).toEqual([ + { + pluginId: "browser", + kind: "browser-plugin-registration", + relativePath: "dist/extensions/browser/index.js", + dependency: "openclaw/plugin-sdk/gateway-runtime", + importerPath: "dist/registration-chunk.js", + }, + ]); + }); +});