diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2357e8015..88184be1bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,7 @@ Docs: https://docs.openclaw.ai - Image generation/providers: stop inferring private-network access from configured OpenAI, MiniMax, and fal image base URLs, and cap shared HTTP error-body reads so hostile or misconfigured endpoints fail closed without relaxing SSRF policy or buffering unbounded error payloads. Thanks @vincentkoc. - Browser/host inspection: keep static Chrome inspection helpers out of the activated browser runtime so `openclaw doctor browser` and related checks do not eagerly load the bundled browser plugin. (#59471) Thanks @vincentkoc. - Browser/CDP: normalize trailing-dot localhost absolute-form hosts before loopback checks so remote CDP websocket URLs like `ws://localhost.:...` rewrite back to the configured remote host. (#59236) Thanks @mappel-nv. +- Browser/attach-only profiles: disconnect cached Playwright CDP sessions when stopping attach-only or remote CDP profiles, while still reporting never-started local managed profiles as not stopped. (#60097) Thanks @pedh. - Agents/output sanitization: strip namespaced `antml:thinking` blocks from user-visible text so Anthropic-style internal monologue tags do not leak into replies. (#59550) Thanks @obviyus. - Kimi Coding/tools: normalize Anthropic tool payloads into the OpenAI-compatible function shape Kimi Coding expects so tool calls stop losing required arguments. (#59440) Thanks @obviyus. - Image tool/paths: resolve relative local media paths against the agent `workspaceDir` instead of `process.cwd()` so inputs like `inbox/receipt.png` pass the local-path allowlist reliably. (#57222) Thanks Priyansh Gupta. diff --git a/extensions/browser/src/browser/server-context.stop-running-browser.test.ts b/extensions/browser/src/browser/server-context.stop-running-browser.test.ts new file mode 100644 index 00000000000..9cb114e4ac4 --- /dev/null +++ b/extensions/browser/src/browser/server-context.stop-running-browser.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createProfileAvailability } from "./server-context.availability.js"; +import type { BrowserServerState, ProfileRuntimeState } from "./server-context.types.js"; + +const pwAiMocks = vi.hoisted(() => ({ + closePlaywrightBrowserConnection: vi.fn(async () => {}), +})); + +vi.mock("./pw-ai.js", () => pwAiMocks); +vi.mock("./chrome.js", () => ({ + isChromeCdpReady: vi.fn(async () => true), + isChromeReachable: vi.fn(async () => true), + launchOpenClawChrome: vi.fn(async () => { + throw new Error("unexpected launch"); + }), + stopOpenClawChrome: vi.fn(async () => {}), +})); +vi.mock("./chrome-mcp.js", () => ({ + closeChromeMcpSession: vi.fn(async () => false), + ensureChromeMcpAvailable: vi.fn(async () => {}), + listChromeMcpTabs: vi.fn(async () => []), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function makeProfile( + overrides: Partial[0]["profile"]> = {}, +): Parameters[0]["profile"] { + return { + name: "openclaw", + cdpUrl: "http://127.0.0.1:18800", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + cdpPort: 18800, + color: "#f60", + driver: "openclaw", + attachOnly: false, + ...overrides, + }; +} + +function makeState( + profile: Parameters[0]["profile"], +): BrowserServerState { + return { + server: null, + port: 0, + resolved: { + enabled: true, + evaluateEnabled: false, + controlPort: 18791, + cdpProtocol: "http", + cdpHost: profile.cdpHost, + cdpIsLoopback: profile.cdpIsLoopback, + cdpPortRangeStart: 18800, + cdpPortRangeEnd: 18810, + remoteCdpTimeoutMs: 1500, + remoteCdpHandshakeTimeoutMs: 3000, + extraArgs: [], + color: profile.color, + headless: true, + noSandbox: false, + attachOnly: false, + ssrfPolicy: { dangerouslyAllowPrivateNetwork: true }, + defaultProfile: profile.name, + profiles: { + [profile.name]: profile, + }, + }, + profiles: new Map(), + }; +} + +function createStopHarness(profile: Parameters[0]["profile"]) { + const state = makeState(profile); + const runtimeState: ProfileRuntimeState = { + profile, + running: null, + lastTargetId: null, + reconcile: null, + }; + state.profiles.set(profile.name, runtimeState); + + const ops = createProfileAvailability({ + opts: { getState: () => state }, + profile, + state: () => state, + getProfileState: () => runtimeState, + setProfileRunning: (running) => { + runtimeState.running = running; + }, + }); + + return { ops }; +} + +describe("createProfileAvailability.stopRunningBrowser", () => { + it("disconnects attachOnly loopback profiles without an owned process", async () => { + const profile = makeProfile({ attachOnly: true }); + const { ops } = createStopHarness(profile); + + await expect(ops.stopRunningBrowser()).resolves.toEqual({ stopped: true }); + expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:18800", + }); + }); + + it("disconnects remote CDP profiles without an owned process", async () => { + const profile = makeProfile({ + cdpUrl: "http://10.0.0.5:9222", + cdpHost: "10.0.0.5", + cdpIsLoopback: false, + cdpPort: 9222, + }); + const { ops } = createStopHarness(profile); + + await expect(ops.stopRunningBrowser()).resolves.toEqual({ stopped: true }); + expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledWith({ + cdpUrl: "http://10.0.0.5:9222", + }); + }); + + it("keeps never-started local managed profiles as not stopped", async () => { + const profile = makeProfile(); + const { ops } = createStopHarness(profile); + + await expect(ops.stopRunningBrowser()).resolves.toEqual({ stopped: false }); + expect(pwAiMocks.closePlaywrightBrowserConnection).not.toHaveBeenCalled(); + }); +});