diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fd5a48c5..0d5f5bcc7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -300,32 +300,35 @@ jobs: - name: Install frontend dependencies run: pnpm install --frozen-lockfile - # Build the `codeg-mcp` companion binary for the matrix target and - # stage it as a Tauri sidecar at - # `src-tauri/binaries/codeg-mcp-{.exe}`. `tauri-action` (below) - # then bundles it via the `bundle.externalBin` entry in + # Build the `codeg-mcp` companion and the fixed, offline Browser runtime + # package for the matrix target. Stage 2 Browser support is Windows-only; + # non-Windows targets receive an explicit fail-closed stub so Tauri's + # externalBin validation remains honest. `tauri-action` then bundles both + # files via the `bundle.externalBin` entry in # `tauri.conf.json` — Tauri installs it next to the main executable # (Contents/MacOS on macOS, install root on Linux/Windows) where the - # runtime `locate_codeg_mcp_binary()` finds it via `current_exe` - # sibling lookup. The Linux arm64 cross env vars set above also + # runtime locators find them via `current_exe` sibling lookup. The Linux + # arm64 cross env vars set above also # apply to this cargo invocation. - - name: Stage codeg-mcp sidecar for Tauri bundle + - name: Stage Tauri sidecars shell: bash run: pnpm tauri:prepare-sidecars --target ${{ matrix.target }} - - name: Verify codeg-mcp sidecar landed + - name: Verify Tauri sidecars landed shell: bash run: | ext="" case "${{ matrix.target }}" in *windows*) ext=".exe" ;; esac - file="src-tauri/binaries/codeg-mcp-${{ matrix.target }}${ext}" - if [ ! -f "$file" ]; then - echo "FATAL: sidecar $file missing after prepare-sidecars" - exit 1 - fi - ls -la "$file" + for name in codeg-mcp codeg-browser-sidecar; do + file="src-tauri/binaries/${name}-${{ matrix.target }}${ext}" + if [ ! -s "$file" ]; then + echo "FATAL: non-empty sidecar $file missing after prepare-sidecars" + exit 1 + fi + ls -la "$file" + done - name: Import Apple Developer ID certificate if: contains(matrix.target, 'apple-darwin') diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..9a623ce07 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,47 @@ +# Third-party notices + +Codeg is licensed under Apache-2.0. The stage 2 Browser sidecar additionally +bundles the following MIT-licensed projects at fixed versions: + +- `@modelcontextprotocol/sdk` 1.30.0 — Model Context Protocol TypeScript SDK, + https://github.com/modelcontextprotocol/typescript-sdk +- `ipaddr.js` 2.5.0 — IPv4 and IPv6 address parsing, + https://github.com/whitequark/ipaddr.js +- `ws` 8.21.3 — WebSocket implementation for Node.js, + https://github.com/websockets/ws +- `zod` 4.4.3 — TypeScript-first schema validation, + https://github.com/colinhacks/zod +- Node.js 22.23.2 runtime embedded by the fixed `@yao-pkg/pkg` build tool — + https://github.com/nodejs/node/blob/v22.23.2/LICENSE + +Build-only MIT dependencies used to create the packaged sidecar are: + +- `esbuild` 0.28.2 — https://github.com/evanw/esbuild +- `@yao-pkg/pkg` 6.22.0 — https://github.com/yao-pkg/pkg +- `@yao-pkg/pkg-fetch` 3.6.5 — https://github.com/yao-pkg/pkg-fetch + +The package itself is Apache-2.0 under the repository `LICENSE`. The following +MIT terms apply to the MIT components listed above (their individual copyright +notices remain attributed to their respective authors and contributors): + +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +Node.js includes additional third-party component notices in its fixed-version +license file linked above. `pnpm-lock.yaml` records the complete JavaScript +dependency graph used for the packaged sidecar. diff --git a/eslint.config.mjs b/eslint.config.mjs index 8fc5f7586..70f886112 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,7 @@ const eslintConfig = defineConfig([ "next-env.d.ts", "src-tauri/target/**", "src-tauri/experts/**", + "packages/*/dist/**", "public/vs/**", // Gitignored scratch space for planning/review docs and one-off probe // scripts. Prettier already skips it — its `--ignore-path` defaults to diff --git a/package.json b/package.json index eb44424bd..3f954d6ee 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "test:watch": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", + "browser:build": "pnpm --filter @codeg/browser-mcp build", + "browser:test": "pnpm --filter @codeg/browser-mcp test", + "browser:smoke:windows": "node packages/codeg-plus-browser-mcp/scripts/smoke-windows.mjs", "server:build": "cd src-tauri && cargo build --release --bin codeg-server --no-default-features", "server:dev": "cd src-tauri && cargo run --bin codeg-server --no-default-features", "tauri:prepare-sidecars": "node src-tauri/scripts/prepare-sidecars.mjs", diff --git a/packages/codeg-plus-browser-mcp/PROVENANCE.md b/packages/codeg-plus-browser-mcp/PROVENANCE.md new file mode 100644 index 000000000..0543abd24 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/PROVENANCE.md @@ -0,0 +1,62 @@ +# Browser sidecar source and packaging record + +## Source boundary + +All files under `src/` and `scripts/` in this package are original Codeg +implementation authored for stage 2. No Cindy or OpenClaw source file is copied +into this package. + +Cindy was used only as an architectural and behavioral reference for these +facts: + +- keep the Browser runtime independent from the desktop renderer; +- expose progressive MCP discovery through `list_tools` and `call_tool`; +- isolate the managed browser profile from the user's default Chrome profile; +- enforce a 200,000-byte bound on textual tool output; +- own and clean up the complete browser process tree. + +The reviewed Cindy reference is Apache-2.0. Its vendored OpenClaw runtime is +MIT and pinned there at commit +`b972feb3f791ed38dafc27c1961dc87f2e30b210`, but none of that generated runtime +is imported here. In particular, Codeg does not carry Cindy's generated +`npx chrome-devtools-mcp@latest` fallback. + +## Fixed production dependencies + +| Package | Version | License | Purpose | +| --------------------------- | ------: | ------- | -------------------------------------------- | +| `@modelcontextprotocol/sdk` | 1.30.0 | MIT | MCP server and Streamable HTTP transport | +| `ipaddr.js` | 2.5.0 | MIT | IP range classification for SSRF enforcement | +| `ws` | 8.21.3 | MIT | Chrome DevTools Protocol WebSocket transport | +| `zod` | 4.4.3 | MIT | Strict public tool schemas | + +## Fixed build-only dependencies + +| Package | Version | License | Purpose | +| -------------------- | ------: | ---------- | ------------------------------------------------------------ | +| `esbuild` | 0.28.2 | MIT | Produce one deterministic CommonJS sidecar bundle | +| `@yao-pkg/pkg` | 6.22.0 | MIT | Embed the bundle and Node 22 runtime in a Windows executable | +| `@yao-pkg/pkg-fetch` | 3.6.5 | MIT | Fetch checksum-verified base runtimes for `pkg` | +| `typescript` | 5.8.x | Apache-2.0 | Static type checking | + +`pnpm-lock.yaml` is the authoritative transitive dependency lock. Release and +local Tauri builds run `src-tauri/scripts/prepare-sidecars.mjs`, which first +builds the bundle and then produces +`codeg-browser-sidecar-.exe`. The installed client never runs npm, +pnpm, npx, or another online installer. + +`@yao-pkg/pkg-fetch` 3.6.5 resolves the `node22` targets to Node.js 22.23.2. +Its checked-in expected SHA-256 values for the Windows targets used by Codeg +Plus are: + +- `node-v22.23.2-win-x64`: + `555d3dceaaf1c5628ac8fa23d1ebde46a424d2ad6782cc2412be8eed4a7a6b69` +- `node-v22.23.2-win-arm64`: + `d625fdb98c359a0b234566ff66d50e802c9f48abad732b46d54014e41d9389db` + +The packaged executable therefore has a fixed base-runtime version and an +upstream checksum gate in addition to the workspace lockfile. + +Non-Windows Tauri targets receive a non-functional packaged stub so existing +macOS/Linux builds remain valid. Stage 2 runtime support and acceptance are +Windows-only. diff --git a/packages/codeg-plus-browser-mcp/package.json b/packages/codeg-plus-browser-mcp/package.json new file mode 100644 index 000000000..3660c35fb --- /dev/null +++ b/packages/codeg-plus-browser-mcp/package.json @@ -0,0 +1,31 @@ +{ + "name": "@codeg/browser-mcp", + "version": "0.1.0", + "private": true, + "description": "Codeg managed Browser MCP sidecar", + "license": "Apache-2.0", + "type": "module", + "bin": { + "codeg-browser-sidecar": "dist/cli.cjs" + }, + "scripts": { + "build": "tsc --noEmit && node scripts/build.mjs", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.30.0", + "ipaddr.js": "2.5.0", + "ws": "8.21.3", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "26.2.0", + "@types/ws": "8.18.1", + "@yao-pkg/pkg": "6.22.0", + "esbuild": "0.28.2", + "typescript": "~5.8.3", + "vitest": "2.1.8" + } +} diff --git a/packages/codeg-plus-browser-mcp/scripts/build.mjs b/packages/codeg-plus-browser-mcp/scripts/build.mjs new file mode 100644 index 000000000..657778617 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/scripts/build.mjs @@ -0,0 +1,20 @@ +import { mkdir } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import { build } from "esbuild" + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const output = resolve(packageDir, "dist", "cli.cjs") + +await mkdir(dirname(output), { recursive: true }) +await build({ + entryPoints: [resolve(packageDir, "src", "cli.ts")], + outfile: output, + bundle: true, + platform: "node", + target: "node22", + format: "cjs", + sourcemap: false, + legalComments: "none", +}) diff --git a/packages/codeg-plus-browser-mcp/scripts/smoke-windows.mjs b/packages/codeg-plus-browser-mcp/scripts/smoke-windows.mjs new file mode 100644 index 000000000..638a0bba2 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/scripts/smoke-windows.mjs @@ -0,0 +1,574 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { existsSync } from "node:fs" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { createInterface } from "node:readline" +import { spawn } from "node:child_process" +import { promisify } from "node:util" + +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" + +const execFileAsync = promisify(execFile) +const DEFAULT_SIDECAR = resolve( + "src-tauri/binaries/codeg-browser-sidecar-x86_64-pc-windows-msvc.exe" +) +const FORM_URL = "https://www.selenium.dev/selenium/web/web-form.html" +const DOWNLOAD_URL = + "https://httpbingo.org/response-headers?Content-Disposition=attachment%3B%20filename%3Dcodeg-browser-smoke.txt&Content-Type=text%2Fplain" +const TOKEN = "codeg-browser-smoke-token-" + "x".repeat(32) +const BACKEND = ( + process.argv[3] ?? + process.env.CODEG_BROWSER_SMOKE_BACKEND ?? + "external" +).toLowerCase() + +if (process.platform !== "win32") { + throw new Error("windows_only") +} +assert.ok( + BACKEND === "external" || BACKEND === "embedded", + `Invalid Browser backend: ${BACKEND}` +) + +const sidecarPath = resolve(process.argv[2] ?? DEFAULT_SIDECAR) +assert.ok(existsSync(sidecarPath), `Browser sidecar not found: ${sidecarPath}`) + +const tempRoot = await mkdtemp(join(tmpdir(), "codeg-browser-smoke-")) +const profileDir = join(tempRoot, "profile") +const downloadDir = join(tempRoot, "downloads") +const outputLines = [] +const errorLines = [] +let child = null +let client = null +let isolationClient = null +let surfaceClient = null +let restoredSurfaceClient = null +const surfaceStreams = [] +let runError = null +const checks = [] + +function record(name, detail = "ok") { + checks.push({ name, detail }) + process.stdout.write(`${JSON.stringify({ check: name, detail })}\n`) +} + +function withTimeout(promise, timeoutMs, label) { + let timer + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label}_timeout`)), timeoutMs) + }), + ]).finally(() => clearTimeout(timer)) +} + +function waitForJsonLine(lines, predicate, timeoutMs, label) { + return withTimeout( + new Promise((resolveLine) => { + const poll = setInterval(() => { + for (const line of lines) { + try { + const parsed = JSON.parse(line) + if (predicate(parsed)) { + clearInterval(poll) + resolveLine(parsed) + return + } + } catch { + // The sidecar contract is JSONL; non-JSON stays visible in evidence. + } + } + }, 50) + }), + timeoutMs, + label + ) +} + +async function admin(baseUrl, action) { + const response = await fetch(`${baseUrl}/admin/${action}`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}` }, + }) + const body = await response.json() + if (!response.ok) throw new Error(`${action}:${JSON.stringify(body)}`) + return body +} + +async function surfacePost(baseUrl, path, body) { + const response = await fetch(`${baseUrl}/admin/${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + const text = await response.text() + const value = text ? JSON.parse(text) : null + if (!response.ok) { + throw new Error(`${path}:${response.status}:${text}`) + } + return value +} + +async function openSurfaceStream(baseUrl, sessionId) { + const controller = new AbortController() + const response = await fetch( + `${baseUrl}/admin/surface/stream?sessionId=${encodeURIComponent(sessionId)}`, + { + headers: { Authorization: `Bearer ${TOKEN}` }, + signal: controller.signal, + } + ) + assert.equal( + response.status, + 200, + `surface stream failed: ${response.status}` + ) + assert.ok(response.body, "surface stream body missing") + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffered = "" + let closed = false + + return { + async next(predicate, label, timeoutMs = 20_000) { + return await withTimeout( + (async () => { + while (true) { + const lineEnd = buffered.indexOf("\n") + if (lineEnd >= 0) { + const line = buffered.slice(0, lineEnd).trim() + buffered = buffered.slice(lineEnd + 1) + if (!line) continue + const event = JSON.parse(line) + if (predicate(event)) return event + continue + } + const chunk = await reader.read() + if (chunk.done) throw new Error(`${label}_stream_closed`) + buffered += decoder.decode(chunk.value, { stream: true }) + } + })(), + timeoutMs, + label + ) + }, + async close() { + if (closed) return + closed = true + controller.abort() + await reader.cancel().catch(() => undefined) + }, + } +} + +function createClient(baseUrl, sessionId) { + const next = new Client({ name: "codeg-browser-smoke", version: "1" }) + const transport = new StreamableHTTPClientTransport( + new URL(`${baseUrl}/mcp`), + { + requestInit: { + headers: { + Authorization: `Bearer ${TOKEN}`, + "X-Codeg-Browser-Session": sessionId, + }, + }, + } + ) + return { client: next, transport } +} + +async function call(activeClient, name, args = {}, expectError = false) { + const result = await activeClient.callTool({ name, arguments: args }) + const text = result.content.find((item) => item.type === "text")?.text + assert.equal(typeof text, "string", `${name} must return a text envelope`) + const envelope = JSON.parse(text) + if (expectError) { + assert.equal(result.isError, true, `${name} should fail`) + assert.equal(envelope.ok, false, `${name} should return ok=false`) + } else { + assert.notEqual(result.isError, true, `${name} failed: ${text}`) + assert.equal(envelope.ok, true, `${name} should return ok=true`) + } + return { result, envelope } +} + +async function waitForRuntime(activeClient, expected, timeoutMs = 15_000) { + return withTimeout( + new Promise((resolveState) => { + const poll = setInterval(async () => { + try { + const { envelope } = await call(activeClient, "runtime.status") + if (envelope.data?.state === expected) { + clearInterval(poll) + resolveState(envelope.data) + } + } catch { + // Runtime transitions can briefly race the CDP disconnect. + } + }, 200) + }), + timeoutMs, + `runtime_${expected}` + ) +} + +async function terminateOwnedProcessTree(pid) { + assert.ok(Number.isInteger(pid) && pid > 0, "process id must be positive") + await execFileAsync("taskkill.exe", ["/PID", String(pid), "/T", "/F"]) +} + +async function removeTempTree(path) { + let lastError + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + await rm(path, { recursive: true, force: true }) + return + } catch (error) { + lastError = error + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)) + } + } + throw lastError +} + +try { + child = spawn( + sidecarPath, + [ + "--profile-dir", + profileDir, + "--download-dir", + downloadDir, + "--auto-start", + "false", + "--backend", + BACKEND, + ], + { + env: { ...process.env, CODEG_BROWSER_TOKEN: TOKEN }, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + } + ) + createInterface({ input: child.stdout }).on("line", (line) => { + outputLines.push(line) + }) + createInterface({ input: child.stderr }).on("line", (line) => { + errorLines.push(line) + }) + + const ready = await waitForJsonLine( + outputLines, + (line) => line.event === "ready" && Number.isInteger(line.port), + 20_000, + "sidecar_ready" + ) + const baseUrl = `http://127.0.0.1:${ready.port}` + record( + "sidecar_ready", + `pid=${ready.pid};version=${ready.version};backend=${BACKEND}` + ) + + const unauthorized = await fetch(`${baseUrl}/health`) + assert.equal(unauthorized.status, 401) + record("bearer_required") + + await admin(baseUrl, "start") + await admin(baseUrl, "start") + record("runtime_start_idempotent") + + const primary = createClient(baseUrl, "smoke-primary") + client = primary.client + await client.connect(primary.transport) + const catalog = await client.listTools() + assert.equal(catalog.tools.length, 20) + assert.ok(catalog.tools.some((tool) => tool.name === "page.screenshot")) + assert.ok(!catalog.tools.some((tool) => tool.name.includes("evaluate"))) + record("frozen_tool_catalog", `${catalog.tools.length} tools`) + + const surfaceSessionId = "smoke-surface" + const surfaceConnection = createClient(baseUrl, surfaceSessionId) + surfaceClient = surfaceConnection.client + await surfaceClient.connect(surfaceConnection.transport) + const surfaceOpened = await call(surfaceClient, "tabs.open") + const surfaceTargetId = surfaceOpened.envelope.tab?.id + assert.equal(typeof surfaceTargetId, "string") + await call(surfaceClient, "page.navigate", { + url: FORM_URL, + timeoutMs: 20_000, + }) + await call(surfaceClient, "action.wait", { + text: "Web form", + timeoutMs: 20_000, + }) + + const ensured = await surfacePost(baseUrl, "surface/ensure", { + sessionId: surfaceSessionId, + }) + const ensuredAgain = await surfacePost(baseUrl, "surface/ensure", { + sessionId: surfaceSessionId, + }) + assert.equal(ensured.activeTargetId, surfaceTargetId) + assert.deepEqual(ensuredAgain, ensured) + assert.deepEqual( + ensured.tabs.map((tab) => tab.id), + [surfaceTargetId] + ) + record("surface_unique_target") + + const surfaceStream = await openSurfaceStream(baseUrl, surfaceSessionId) + surfaceStreams.push(surfaceStream) + const streamedSnapshot = await surfaceStream.next( + (event) => event.type === "snapshot", + "surface_snapshot" + ) + assert.equal(streamedSnapshot.snapshot.activeTargetId, surfaceTargetId) + assert.deepEqual(streamedSnapshot.snapshot.tabs, ensured.tabs) + const streamedFrame = await surfaceStream.next( + (event) => event.type === "frame", + "surface_frame" + ) + assert.equal(streamedFrame.frame.targetId, surfaceTargetId) + assert.equal(streamedFrame.frame.mimeType, "image/jpeg") + assert.ok(streamedFrame.frame.data.length > 1_000) + assert.ok(streamedFrame.frame.deviceWidth > 0) + assert.ok(streamedFrame.frame.deviceHeight > 0) + record("surface_same_target_frame") + + const duplicateStream = await fetch( + `${baseUrl}/admin/surface/stream?sessionId=${surfaceSessionId}`, + { headers: { Authorization: `Bearer ${TOKEN}` } } + ) + assert.equal(duplicateStream.status, 409) + await duplicateStream.body?.cancel() + record("surface_single_subscriber") + + const surfaceSemantic = await call(surfaceClient, "page.snapshot", { + interactive: true, + limit: 500, + }) + const surfaceTextBox = surfaceSemantic.envelope.data?.nodes?.find( + (node) => + typeof node.ref === "string" && + /textbox/i.test(node.role ?? "") && + /text input/i.test(node.name ?? "") + ) + assert.ok(surfaceTextBox?.ref, "surface semantic text input ref not found") + await call(surfaceClient, "action.click", { ref: surfaceTextBox.ref }) + await surfacePost(baseUrl, "surface/action", { + sessionId: surfaceSessionId, + action: "input", + input: { kind: "text", text: "Codeg Surface Input" }, + }) + const afterSurfaceInput = await call(surfaceClient, "page.snapshot", { + interactive: true, + limit: 500, + }) + const updatedTextBox = afterSurfaceInput.envelope.data?.nodes?.find( + (node) => node.ref === surfaceTextBox.ref + ) + assert.match(updatedTextBox?.value ?? "", /Codeg Surface Input/) + record("surface_input_same_target") + + const twoTargets = await surfacePost(baseUrl, "surface/action", { + sessionId: surfaceSessionId, + action: "open", + }) + assert.equal(twoTargets.tabs.length, 2) + const agentTargetIds = ( + await call(surfaceClient, "tabs.list") + ).envelope.data.tabs.map((tab) => tab.id) + assert.deepEqual( + [...agentTargetIds].sort(), + twoTargets.tabs.map((tab) => tab.id).sort() + ) + record("surface_full_target_snapshot") + + await surfaceStream.close() + await new Promise((resolveDelay) => setTimeout(resolveDelay, 200)) + const afterHide = await surfacePost(baseUrl, "surface/ensure", { + sessionId: surfaceSessionId, + }) + assert.equal(afterHide.tabs.length, 2) + const reopenedStream = await openSurfaceStream(baseUrl, surfaceSessionId) + surfaceStreams.push(reopenedStream) + const reopenedSnapshot = await reopenedStream.next( + (event) => event.type === "snapshot", + "surface_reopen" + ) + assert.equal(reopenedSnapshot.snapshot.tabs.length, 2) + await reopenedStream.close() + record("surface_hide_reopen_reuses_targets") + + await surfacePost(baseUrl, "release-session", { + sessionId: surfaceSessionId, + }) + await surfaceClient.close().catch(() => undefined) + surfaceClient = null + const restoredConnection = createClient(baseUrl, surfaceSessionId) + restoredSurfaceClient = restoredConnection.client + await restoredSurfaceClient.connect(restoredConnection.transport) + const restoredTab = await call(restoredSurfaceClient, "tabs.open") + const restored = await surfacePost(baseUrl, "surface/ensure", { + sessionId: surfaceSessionId, + }) + assert.equal(restored.tabs.length, 1) + assert.equal(restored.activeTargetId, restoredTab.envelope.tab?.id) + record("surface_explicit_close_then_agent_restore") + + const opened = await call(client, "tabs.open") + const tabId = opened.envelope.tab?.id + assert.equal(typeof tabId, "string") + await call(client, "page.navigate", { url: FORM_URL, timeoutMs: 20_000 }) + await call(client, "action.wait", { + text: "Web form", + timeoutMs: 20_000, + }) + const snapshot = await call(client, "page.snapshot", { + interactive: true, + limit: 500, + }) + assert.match(snapshot.envelope.tab?.url ?? "", /selenium\.dev/) + assert.ok( + snapshot.envelope.tab?.title || snapshot.envelope.data?.title, + `page title missing: ${JSON.stringify(snapshot.envelope)}` + ) + const nodes = snapshot.envelope.data?.nodes + assert.ok(Array.isArray(nodes) && nodes.length > 0) + const textBox = nodes.find( + (node) => + typeof node.ref === "string" && + /textbox/i.test(node.role ?? "") && + /text input/i.test(node.name ?? "") + ) + const submit = nodes.find( + (node) => + typeof node.ref === "string" && + /button/i.test(node.role ?? "") && + /submit/i.test(node.name ?? "") + ) + assert.ok(textBox?.ref, "semantic text input ref not found") + assert.ok(submit?.ref, "semantic submit button ref not found") + record("semantic_snapshot", `${nodes.length} nodes`) + + await call(client, "action.fill", { + ref: textBox.ref, + text: "Codeg Browser MCP", + }) + await call(client, "action.type", { ref: textBox.ref, text: " smoke" }) + await call(client, "action.press", { key: "Tab" }) + await call(client, "action.scroll", { deltaY: 240 }) + await call(client, "action.wait", { milliseconds: 100 }) + await call(client, "action.click", { ref: submit.ref }) + await call(client, "action.wait", { + urlIncludes: "submitted-form.html", + timeoutMs: 20_000, + }) + record("semantic_actions") + + const screenshot = await call(client, "page.screenshot", { + format: "png", + fullPage: false, + }) + const screenshotImage = screenshot.result.content.find( + (item) => item.type === "image" + ) + assert.ok(screenshotImage?.data) + assert.equal(screenshotImage.mimeType, "image/png") + assert.ok(Buffer.byteLength(screenshotImage.data, "base64") <= 5_000_000) + record("screenshot_media", `${screenshotImage.data.length} base64 chars`) + + const second = createClient(baseUrl, "smoke-isolated") + isolationClient = second.client + await isolationClient.connect(second.transport) + const isolatedTabs = await call(isolationClient, "tabs.list") + assert.deepEqual(isolatedTabs.envelope.data?.tabs, []) + record("session_isolation") + + const listed = await call(client, "tabs.list") + assert.ok(listed.envelope.data.tabs.some((tab) => tab.id === tabId)) + await call(client, "tabs.focus", { tabId }) + await call(client, "tabs.close", { tabId }) + record("tab_lifecycle") + + const ssrf = await call( + client, + "tabs.open", + { url: "http://127.0.0.1:1/private" }, + true + ) + assert.equal(ssrf.envelope.error?.code, "SSRF_BLOCKED") + record("ssrf_blocked") + + const running = await call(client, "runtime.status") + const browserPid = running.envelope.data?.browserPid + await terminateOwnedProcessTree(browserPid) + await waitForRuntime(client, "error") + await call(client, "runtime.recover") + await waitForRuntime(client, "ready", 25_000) + record("browser_crash_recovery") + + await call(client, "tabs.open") + const downloadNavigation = await client.callTool({ + name: "page.navigate", + arguments: { url: DOWNLOAD_URL, timeoutMs: 5_000 }, + }) + const downloadNavigationText = downloadNavigation.content.find( + (item) => item.type === "text" + )?.text + assert.equal(typeof downloadNavigationText, "string") + const downloaded = await call(client, "download.wait", { timeoutMs: 20_000 }) + const download = downloaded.envelope.data?.download + assert.equal(download?.state, "completed") + assert.ok(download?.path) + assert.ok(resolve(download.path).startsWith(`${resolve(downloadDir)}\\`)) + assert.ok(existsSync(download.path)) + record("controlled_download", download.filename) + + await call(client, "runtime.stop") + await waitForRuntime(client, "stopped") + record("runtime_stop") + + const combinedLogs = [...outputLines, ...errorLines].join("\n") + assert.ok(!combinedLogs.includes(TOKEN)) + assert.ok(!combinedLogs.includes(FORM_URL)) + assert.ok(!/cookie/i.test(combinedLogs)) + record("logs_redacted") +} catch (error) { + runError = error +} finally { + for (const stream of surfaceStreams) { + await stream.close().catch(() => undefined) + } + await restoredSurfaceClient?.close().catch(() => undefined) + await surfaceClient?.close().catch(() => undefined) + await isolationClient?.close().catch(() => undefined) + await client?.close().catch(() => undefined) + if (child && child.exitCode === null) { + child.kill("SIGTERM") + await withTimeout( + new Promise((resolveExit) => child.once("exit", resolveExit)), + 8_000, + "sidecar_exit" + ).catch(async () => { + await terminateOwnedProcessTree(child.pid).catch(() => undefined) + }) + } + const resolvedTemp = resolve(tempRoot) + const resolvedSystemTemp = resolve(tmpdir()) + if (resolvedTemp.startsWith(`${resolvedSystemTemp}\\codeg-browser-smoke-`)) { + await removeTempTree(resolvedTemp).catch((error) => { + errorLines.push(`cleanup_failed:${String(error)}`) + }) + } +} + +if (runError) throw runError + +process.stdout.write(`${JSON.stringify({ ok: true, checks })}\n`) diff --git a/packages/codeg-plus-browser-mcp/src/__tests__/browser-runtime.test.ts b/packages/codeg-plus-browser-mcp/src/__tests__/browser-runtime.test.ts new file mode 100644 index 000000000..8c0c3a3f0 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/__tests__/browser-runtime.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from "vitest" + +import { BrowserRuntime } from "../browser-runtime.js" +import type { + BrowserBackend, + BrowserDoctorResult, + BrowserDownload, + BrowserRuntimeState, + BrowserRuntimeStatus, + BrowserSurfaceFrame, + BrowserTab, + PageController, + PageSnapshot, +} from "../runtime-types.js" + +class FakePage implements PageController { + loading = false + historyIndex = 0 + history = ["about:blank"] + screencastStarts = 0 + screencastStops = 0 + readonly viewportRequests: Array<{ width: number; height: number }> = [] + private frameListener: ((frame: BrowserSurfaceFrame) => void) | null = null + + constructor(readonly tab: BrowserTab) {} + + async info(): Promise { + return this.tab + } + + async navigate(url: string): Promise { + this.tab.url = url + this.tab.title = "Navigated" + this.history = this.history.slice(0, this.historyIndex + 1) + this.history.push(url) + this.historyIndex = this.history.length - 1 + return this.tab + } + + async snapshot(): Promise { + return { + nodes: [{ ref: "e1", role: "button", name: "Continue" }], + documentUrl: this.tab.url, + title: this.tab.title, + } + } + + async screenshot() { + return { data: "AA==", mimeType: "image/png" as const } + } + + async click(): Promise {} + async type(): Promise {} + async press(): Promise {} + async scroll(): Promise {} + async wait(): Promise {} + async surfaceState() { + return { + tab: this.tab, + loading: this.loading, + canGoBack: this.historyIndex > 0, + canGoForward: this.historyIndex + 1 < this.history.length, + } + } + async goHistory(delta: -1 | 1): Promise { + this.historyIndex += delta + this.tab.url = this.history[this.historyIndex]! + } + async reload(): Promise {} + async stopLoading(): Promise { + this.loading = false + } + async dispatchSurfaceInput(): Promise {} + async setSurfaceViewport(width: number, height: number): Promise { + this.viewportRequests.push({ width, height }) + } + async startScreencast(listener: (frame: BrowserSurfaceFrame) => void) { + this.screencastStarts += 1 + this.frameListener = listener + return async () => { + this.screencastStops += 1 + this.frameListener = null + } + } + emitFrame() { + this.frameListener?.({ + targetId: this.tab.id, + data: "AA==", + mimeType: "image/jpeg", + deviceWidth: 800, + deviceHeight: 600, + pageScaleFactor: 1, + }) + } + async close(): Promise {} +} + +class FakeBackend implements BrowserBackend { + state: BrowserRuntimeState = "ready" + readonly pages = new Map() + readonly owners = new Map() + readonly openTargetUrls: Array = [] + private nextId = 1 + + status(): BrowserRuntimeStatus { + return { + state: this.state, + browserPid: 123, + browserName: "Fake Chrome", + browserVersion: "1", + sessionCount: new Set(this.owners.values()).size, + recoveryAttempt: 0, + lastErrorCode: null, + } + } + + async doctor(): Promise { + return { ok: true, checks: [] } + } + + async start(): Promise { + this.state = "ready" + } + + async stop(): Promise { + this.state = "stopped" + this.pages.clear() + this.owners.clear() + } + + async recover(): Promise { + this.state = "ready" + } + + async listTargets(): Promise { + return [...this.pages.values()].map((page) => page.tab) + } + + async openTarget(url?: string): Promise { + this.openTargetUrls.push(url) + const id = `t${this.nextId++}` + const page = new FakePage({ + id, + url: url ?? "about:blank", + title: "New Tab", + }) + this.pages.set(id, page) + return page + } + + async page(targetId: string): Promise { + const page = this.pages.get(targetId) + if (!page) throw new Error("missing page") + return page + } + + async focusTarget(): Promise {} + + async closeTarget(targetId: string): Promise { + this.pages.delete(targetId) + this.owners.delete(targetId) + } + + async downloads(): Promise { + return [] + } + + async waitForDownload(): Promise { + return { + guid: "d1", + state: "completed", + filename: "file.txt", + receivedBytes: 1, + totalBytes: 1, + } + } + + assignTarget(targetId: string, sessionId: string): void { + this.owners.set(targetId, sessionId) + } + + async releaseSession(sessionId: string): Promise { + for (const [targetId, owner] of this.owners) { + if (owner === sessionId) await this.closeTarget(targetId) + } + } +} + +describe("BrowserRuntime", () => { + it("keeps tabs isolated by Agent session", async () => { + const backend = new FakeBackend() + const runtime = new BrowserRuntime(backend) + const first = await runtime.callTool("session-a", "tabs.open", { + url: "https://example.com/", + }) + const second = await runtime.callTool("session-b", "tabs.open", { + url: "https://example.org/", + }) + const firstId = first.envelope.tab?.id + expect(firstId).toBeTruthy() + expect(second.envelope.tab?.id).not.toBe(firstId) + + const firstList = await runtime.callTool("session-a", "tabs.list", {}) + expect(firstList.envelope.data).toMatchObject({ + tabs: [{ id: firstId }], + }) + const crossSession = await runtime.callTool("session-b", "tabs.focus", { + tabId: firstId, + }) + expect(crossSession.envelope).toMatchObject({ + ok: false, + error: { code: "TAB_NOT_FOUND" }, + }) + }) + + it("attaches interception on a blank target before navigating an opened URL", async () => { + const backend = new FakeBackend() + const runtime = new BrowserRuntime(backend) + const result = await runtime.callTool("session-a", "tabs.open", { + url: "https://example.com/", + }) + + expect(backend.openTargetUrls).toEqual([undefined]) + expect(result.envelope.tab?.url).toBe("https://example.com/") + expect(backend.owners.get(result.envelope.tab?.id ?? "")).toBe("session-a") + }) + + it("releases only the selected session's owned targets", async () => { + const backend = new FakeBackend() + const runtime = new BrowserRuntime(backend) + await runtime.callTool("session-a", "tabs.open", {}) + await runtime.callTool("session-b", "tabs.open", {}) + + await runtime.releaseSession("session-a") + expect([...backend.owners.values()]).toEqual(["session-b"]) + }) + + it("fails closed while the managed runtime is stopped", async () => { + const backend = new FakeBackend() + backend.state = "stopped" + const runtime = new BrowserRuntime(backend) + const result = await runtime.callTool("session-a", "page.snapshot", {}) + expect(result.envelope).toMatchObject({ + ok: false, + error: { code: "RUNTIME_NOT_READY", recovery: "recover_runtime" }, + }) + }) + + it("does not expose capabilities outside the frozen tool catalog", async () => { + const runtime = new BrowserRuntime(new FakeBackend()) + const result = await runtime.callTool("session-a", "page.evaluate", { + expression: "document.cookie", + }) + expect(result.envelope).toMatchObject({ + ok: false, + error: { code: "UNKNOWN_TOOL" }, + }) + }) + + it("evicts stale sessions and their tabs", async () => { + let now = 1_000 + const backend = new FakeBackend() + const runtime = new BrowserRuntime(backend, { + sessionTtlMs: 100, + now: () => now, + }) + await runtime.callTool("session-a", "tabs.open", {}) + now += 101 + await runtime.callTool("session-b", "runtime.status", {}) + expect([...backend.owners.values()]).toEqual([]) + }) + + it("ensures and reuses one surface target for an Agent session", async () => { + const backend = new FakeBackend() + const runtime = new BrowserRuntime(backend) + + const first = await runtime.ensureSurface("session-a") + const second = await runtime.ensureSurface("session-a") + + expect(first.activeTargetId).toBe("t1") + expect(second.activeTargetId).toBe("t1") + expect(second.tabs).toHaveLength(1) + expect(backend.openTargetUrls).toEqual([undefined]) + }) + + it("streams only the active target and publishes complete snapshots", async () => { + const backend = new FakeBackend() + const runtime = new BrowserRuntime(backend) + const events: Array<{ type: string; [key: string]: unknown }> = [] + const detach = await runtime.subscribeSurface("session-a", (event) => { + events.push(event) + }) + const first = backend.pages.get("t1")! + first.emitFrame() + + await runtime.surfaceAction("session-a", { action: "open" }) + const second = backend.pages.get("t2")! + second.emitFrame() + + expect(first.screencastStops).toBe(1) + expect(second.screencastStarts).toBe(1) + expect(events.filter((event) => event.type === "frame")).toHaveLength(2) + expect(events[events.length - 2]).toMatchObject({ + type: "snapshot", + snapshot: { activeTargetId: "t2", tabs: [{ id: "t1" }, { id: "t2" }] }, + }) + + await detach() + expect(second.screencastStops).toBe(1) + }) + + it("resizes the real page viewport and reuses it for newly active targets", async () => { + const backend = new FakeBackend() + const runtime = new BrowserRuntime(backend) + + await runtime.surfaceAction("session-a", { + action: "resize", + width: 320, + height: 620, + }) + expect(backend.pages.get("t1")?.viewportRequests).toEqual([ + { width: 320, height: 620 }, + ]) + + const detach = await runtime.subscribeSurface("session-a", () => undefined) + await runtime.surfaceAction("session-a", { action: "open" }) + expect(backend.pages.get("t2")?.viewportRequests).toEqual([ + { width: 320, height: 620 }, + ]) + await detach() + }) + + it("rejects a second screencast subscriber for the same session", async () => { + const runtime = new BrowserRuntime(new FakeBackend()) + const detach = await runtime.subscribeSurface("session-a", () => undefined) + + await expect( + runtime.subscribeSurface("session-a", () => undefined) + ).rejects.toMatchObject({ code: "SURFACE_ALREADY_ATTACHED" }) + + await detach() + }) + + it("explicit release detaches the surface and closes all owned targets", async () => { + const backend = new FakeBackend() + const runtime = new BrowserRuntime(backend) + await runtime.subscribeSurface("session-a", () => undefined) + await runtime.surfaceAction("session-a", { action: "open" }) + + await runtime.releaseSession("session-a") + + expect([...backend.owners.values()]).toEqual([]) + expect(backend.pages.size).toBe(0) + }) +}) diff --git a/packages/codeg-plus-browser-mcp/src/__tests__/cdp.test.ts b/packages/codeg-plus-browser-mcp/src/__tests__/cdp.test.ts new file mode 100644 index 000000000..add637f7b --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/__tests__/cdp.test.ts @@ -0,0 +1,84 @@ +import { EventEmitter } from "node:events" + +import { describe, expect, it, vi } from "vitest" + +import { CdpConnection, type CdpSocket } from "../cdp.js" + +class FakeSocket extends EventEmitter implements CdpSocket { + readonly sent: string[] = [] + readyState = 1 + + send(data: string): void { + this.sent.push(data) + } + + close(): void { + this.readyState = 3 + this.emit("close") + } + + receive(value: unknown): void { + this.emit("message", Buffer.from(JSON.stringify(value))) + } +} + +describe("CdpConnection", () => { + it("matches responses to requests", async () => { + const socket = new FakeSocket() + const cdp = new CdpConnection(socket) + const pending = cdp.call("Page.getNavigationHistory", {}) + const request = JSON.parse(socket.sent[0] ?? "{}") as { + id: number + method: string + } + expect(request.method).toBe("Page.getNavigationHistory") + + socket.receive({ id: request.id, result: { currentIndex: 1 } }) + await expect(pending).resolves.toEqual({ currentIndex: 1 }) + }) + + it("dispatches protocol events", () => { + const socket = new FakeSocket() + const cdp = new CdpConnection(socket) + const listener = vi.fn() + cdp.on("Page.loadEventFired", listener) + + socket.receive({ method: "Page.loadEventFired", params: { timestamp: 42 } }) + expect(listener).toHaveBeenCalledWith({ timestamp: 42 }) + }) + + it("times out a request without leaking the pending call", async () => { + vi.useFakeTimers() + const socket = new FakeSocket() + const cdp = new CdpConnection(socket) + const pending = cdp.call("Runtime.evaluate", {}, { timeoutMs: 50 }) + const assertion = expect(pending).rejects.toMatchObject({ code: "TIMEOUT" }) + + await vi.advanceTimersByTimeAsync(51) + await assertion + expect(cdp.pendingCount).toBe(0) + vi.useRealTimers() + }) + + it("rejects all pending requests when the socket closes", async () => { + const socket = new FakeSocket() + const cdp = new CdpConnection(socket) + const first = cdp.call("Page.enable") + const second = cdp.call("DOM.enable") + + socket.close() + await expect(first).rejects.toMatchObject({ code: "BROWSER_CRASHED" }) + await expect(second).rejects.toMatchObject({ code: "BROWSER_CRASHED" }) + expect(cdp.pendingCount).toBe(0) + }) + + it("aborts an in-flight request", async () => { + const socket = new FakeSocket() + const cdp = new CdpConnection(socket) + const controller = new AbortController() + const pending = cdp.call("Page.navigate", {}, { signal: controller.signal }) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: "ABORTED" }) + expect(cdp.pendingCount).toBe(0) + }) +}) diff --git a/packages/codeg-plus-browser-mcp/src/__tests__/contracts.test.ts b/packages/codeg-plus-browser-mcp/src/__tests__/contracts.test.ts new file mode 100644 index 000000000..52e1b054b --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/__tests__/contracts.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest" + +import { + PUBLIC_TOOLS, + browserToolCatalog, + parseToolArguments, +} from "../contracts.js" + +describe("Browser MCP public contract", () => { + it("exposes only the approved stage-2 capabilities", () => { + expect(PUBLIC_TOOLS.map((tool) => tool.name)).toEqual([ + "runtime.status", + "runtime.doctor", + "runtime.start", + "runtime.stop", + "runtime.recover", + "tabs.list", + "tabs.open", + "tabs.focus", + "tabs.close", + "page.navigate", + "page.snapshot", + "page.screenshot", + "action.click", + "action.type", + "action.fill", + "action.press", + "action.scroll", + "action.wait", + "download.list", + "download.wait", + ]) + + const names = PUBLIC_TOOLS.map((tool) => tool.name).join(" ") + expect(names).not.toMatch(/evaluate|upload|saveResource|recipe|siteguide/i) + }) + + it("keeps progressive discovery compact", () => { + const serialized = JSON.stringify(browserToolCatalog()) + expect(Buffer.byteLength(serialized, "utf8")).toBeLessThan(12_000) + }) + + it("rejects unknown fields instead of silently ignoring them", () => { + const parsed = parseToolArguments("page.navigate", { + url: "https://example.com", + unexpected: true, + }) + expect(parsed.ok).toBe(false) + if (!parsed.ok && parsed.errorCode === "INVALID_ARGS") { + expect(parsed.errorCode).toBe("INVALID_ARGS") + expect(parsed.schema).toBeTruthy() + } + }) + + it("reports unknown tool names deterministically", () => { + const parsed = parseToolArguments("page.evaluate", {}) + expect(parsed).toEqual({ + ok: false, + errorCode: "UNKNOWN_TOOL", + available: PUBLIC_TOOLS.map((tool) => tool.name), + }) + }) +}) diff --git a/packages/codeg-plus-browser-mcp/src/__tests__/mcp-server.test.ts b/packages/codeg-plus-browser-mcp/src/__tests__/mcp-server.test.ts new file mode 100644 index 000000000..e1915f4a2 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/__tests__/mcp-server.test.ts @@ -0,0 +1,312 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { afterEach, describe, expect, it } from "vitest" + +import { BrowserRuntime } from "../browser-runtime.js" +import { BrowserSidecarServer } from "../mcp-server.js" +import type { + BrowserBackend, + BrowserDoctorResult, + BrowserDownload, + BrowserRuntimeState, + BrowserRuntimeStatus, + PageSnapshot, + BrowserTab, + PageController, +} from "../runtime-types.js" + +class SurfacePage implements PageController { + readonly viewportRequests: Array<{ width: number; height: number }> = [] + + constructor(readonly tab: BrowserTab) {} + async info() { + return this.tab + } + async navigate(url: string) { + this.tab.url = url + return this.tab + } + async snapshot(): Promise { + return { nodes: [], documentUrl: this.tab.url, title: this.tab.title } + } + async screenshot() { + return { data: "AA==", mimeType: "image/png" as const } + } + async click() {} + async type() {} + async press() {} + async scroll() {} + async wait() {} + async surfaceState() { + return { + tab: this.tab, + loading: false, + canGoBack: false, + canGoForward: false, + } + } + async goHistory() {} + async reload() {} + async stopLoading() {} + async dispatchSurfaceInput() {} + async setSurfaceViewport(width: number, height: number) { + this.viewportRequests.push({ width, height }) + } + async startScreencast() { + return async () => undefined + } + async close() {} +} + +class StatusOnlyBackend implements BrowserBackend { + state: BrowserRuntimeState = "stopped" + readonly pages = new Map() + readonly owners = new Map() + private nextId = 1 + + status(): BrowserRuntimeStatus { + return { + state: this.state, + browserPid: null, + browserName: null, + browserVersion: null, + sessionCount: 0, + recoveryAttempt: 0, + lastErrorCode: null, + } + } + + async doctor(): Promise { + return { ok: false, checks: [] } + } + + async start(): Promise { + this.state = "ready" + } + + async stop(): Promise { + this.state = "stopped" + this.pages.clear() + this.owners.clear() + } + + async recover(): Promise { + this.state = "ready" + } + + async listTargets(): Promise { + return [...this.pages.values()].map((page) => page.tab) + } + + async openTarget(): Promise { + const id = `t${this.nextId++}` + const page = new SurfacePage({ id, url: "about:blank", title: "New Tab" }) + this.pages.set(id, page) + return page + } + + async page(targetId: string): Promise { + const page = this.pages.get(targetId) + if (!page) throw new Error("missing page") + return page + } + + async focusTarget(): Promise {} + async closeTarget(targetId: string): Promise { + this.pages.delete(targetId) + this.owners.delete(targetId) + } + async downloads(): Promise { + return [] + } + async waitForDownload(): Promise { + throw new Error("not used") + } + assignTarget(targetId: string, sessionId: string): void { + this.owners.set(targetId, sessionId) + } + async releaseSession(sessionId: string): Promise { + for (const [targetId, owner] of [...this.owners]) { + if (owner === sessionId) await this.closeTarget(targetId) + } + } +} + +const servers: BrowserSidecarServer[] = [] +const clients: Client[] = [] + +afterEach(async () => { + for (const client of clients.splice(0)) + await client.close().catch(() => undefined) + for (const server of servers.splice(0)) + await server.stop().catch(() => undefined) +}) + +async function createTestServer() { + const token = "t".repeat(32) + const server = new BrowserSidecarServer({ + runtime: new BrowserRuntime(new StatusOnlyBackend()), + token, + }) + const port = await server.start() + servers.push(server) + return { server, token, url: `http://127.0.0.1:${port}` } +} + +async function createSurfaceServer() { + const token = "s".repeat(32) + const backend = new StatusOnlyBackend() + backend.state = "ready" + const server = new BrowserSidecarServer({ + runtime: new BrowserRuntime(backend), + token, + }) + const port = await server.start() + servers.push(server) + return { backend, token, url: `http://127.0.0.1:${port}` } +} + +describe("BrowserSidecarServer", () => { + it("rejects requests without the private bearer token", async () => { + const { url } = await createTestServer() + const response = await fetch(`${url}/health`) + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) + }) + + it("requires an Agent session header for MCP", async () => { + const { token, url } = await createTestServer() + const response = await fetch(`${url}/mcp`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: "invalid_agent_session", + }) + }) + + it("serves exactly the frozen tool catalog over authenticated MCP", async () => { + const { token, url } = await createTestServer() + const client = new Client({ name: "test-client", version: "1" }) + clients.push(client) + const transport = new StreamableHTTPClientTransport(new URL(`${url}/mcp`), { + requestInit: { + headers: { + Authorization: `Bearer ${token}`, + "X-Codeg-Browser-Session": "session-a", + }, + }, + }) + await client.connect(transport) + const tools = await client.listTools() + expect(tools.tools).toHaveLength(20) + expect(tools.tools.map((tool) => tool.name)).toContain("page.screenshot") + expect(tools.tools.map((tool) => tool.name)).not.toContain("page.evaluate") + + const result = await client.callTool({ + name: "runtime.status", + arguments: {}, + }) + expect(result.isError).toBe(false) + expect(result.content).toEqual([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining('"state":"stopped"'), + }), + ]) + }) + + it("serves a complete per-session surface snapshot and releases all targets", async () => { + const { backend, token, url } = await createSurfaceServer() + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + } + const first = await fetch(`${url}/admin/surface/ensure`, { + method: "POST", + headers, + body: JSON.stringify({ sessionId: "session-a" }), + }) + expect(first.status).toBe(200) + await expect(first.json()).resolves.toMatchObject({ + sessionId: "session-a", + activeTargetId: "t1", + tabs: [{ id: "t1" }], + }) + + const second = await fetch(`${url}/admin/surface/ensure`, { + method: "POST", + headers, + body: JSON.stringify({ sessionId: "session-a" }), + }) + await expect(second.json()).resolves.toMatchObject({ + activeTargetId: "t1", + tabs: [{ id: "t1" }], + }) + expect(backend.pages.size).toBe(1) + + const resized = await fetch(`${url}/admin/surface/action`, { + method: "POST", + headers, + body: JSON.stringify({ + sessionId: "session-a", + action: "resize", + width: 320, + height: 620, + }), + }) + expect(resized.status).toBe(200) + expect(backend.pages.get("t1")?.viewportRequests).toEqual([ + { width: 320, height: 620 }, + ]) + + const invalidResize = await fetch(`${url}/admin/surface/action`, { + method: "POST", + headers, + body: JSON.stringify({ + sessionId: "session-a", + action: "resize", + width: 0, + height: 620, + }), + }) + expect(invalidResize.status).toBe(400) + + const released = await fetch(`${url}/admin/release-session`, { + method: "POST", + headers, + body: JSON.stringify({ sessionId: "session-a" }), + }) + expect(released.status).toBe(200) + expect(backend.pages.size).toBe(0) + }) + + it("allows only one bounded screencast stream per Agent session", async () => { + const { token, url } = await createSurfaceServer() + const controller = new AbortController() + const first = await fetch( + `${url}/admin/surface/stream?sessionId=session-a`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + } + ) + expect(first.status).toBe(200) + expect(first.headers.get("content-type")).toContain("application/x-ndjson") + + const second = await fetch( + `${url}/admin/surface/stream?sessionId=session-a`, + { headers: { Authorization: `Bearer ${token}` } } + ) + expect(second.status).toBe(409) + await expect(second.json()).resolves.toEqual({ + error: "surface_already_attached", + }) + controller.abort() + }) +}) diff --git a/packages/codeg-plus-browser-mcp/src/__tests__/security.test.ts b/packages/codeg-plus-browser-mcp/src/__tests__/security.test.ts new file mode 100644 index 000000000..29374ee4a --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/__tests__/security.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, mkdir, realpath } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { describe, expect, it } from "vitest" + +import { + assertControlledPath, + classifyIpAddress, + sanitizeRedirectHeaders, + validateNavigationUrl, +} from "../security.js" + +describe("navigation security", () => { + it.each(["file:///etc/passwd", "data:text/plain,hello", "chrome://settings"])( + "rejects privileged scheme %s", + async (url) => { + await expect(validateNavigationUrl(url)).rejects.toMatchObject({ + code: "URL_SCHEME_BLOCKED", + }) + } + ) + + it.each([ + "127.0.0.1", + "10.0.0.1", + "169.254.169.254", + "0.0.0.0", + "224.0.0.1", + "::1", + "fc00::1", + "fe80::1", + "::ffff:127.0.0.1", + ])("classifies %s as blocked", (address) => { + expect(classifyIpAddress(address)).not.toBe("public") + }) + + it("allows a public address", () => { + expect(classifyIpAddress("8.8.8.8")).toBe("public") + expect(classifyIpAddress("2606:4700:4700::1111")).toBe("public") + }) + + it("checks every DNS answer and blocks a rebound private answer", async () => { + let attempt = 0 + const lookup = async () => { + attempt += 1 + return attempt === 1 + ? [{ address: "93.184.216.34", family: 4 as const }] + : [{ address: "127.0.0.1", family: 4 as const }] + } + + await expect( + validateNavigationUrl("https://example.test", { lookup }) + ).resolves.toMatchObject({ hostname: "example.test" }) + await expect( + validateNavigationUrl("https://example.test", { lookup }) + ).rejects.toMatchObject({ code: "SSRF_BLOCKED" }) + }) + + it("strips sensitive headers only across origins", () => { + const headers = [ + { name: "Authorization", value: "Bearer secret" }, + { name: "Cookie", value: "session=secret" }, + { name: "Accept", value: "text/html" }, + ] + expect( + sanitizeRedirectHeaders( + "https://a.example/path", + "https://b.example/path", + headers + ) + ).toEqual([{ name: "Accept", value: "text/html" }]) + expect( + sanitizeRedirectHeaders( + "https://a.example/one", + "https://a.example/two", + headers + ) + ).toEqual(headers) + }) +}) + +describe("controlled filesystem paths", () => { + it("accepts a real path inside the controlled root", async () => { + const root = await mkdtemp(join(tmpdir(), "codeg-browser-root-")) + const nested = join(root, "downloads") + await mkdir(nested) + await expect(assertControlledPath(root, nested)).resolves.toBe( + await realpath(nested) + ) + }) + + it("rejects lexical path traversal", async () => { + const root = await mkdtemp(join(tmpdir(), "codeg-browser-root-")) + const outside = await mkdtemp(join(tmpdir(), "codeg-browser-outside-")) + await expect(assertControlledPath(root, outside)).rejects.toMatchObject({ + code: "PATH_ESCAPE", + }) + }) +}) diff --git a/packages/codeg-plus-browser-mcp/src/browser-runtime.ts b/packages/codeg-plus-browser-mcp/src/browser-runtime.ts new file mode 100644 index 000000000..4da79ed19 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/browser-runtime.ts @@ -0,0 +1,587 @@ +import { + DEFAULT_TOOL_TIMEOUT_MS, + MAX_TEXT_RESULT_BYTES, + parseToolArguments, +} from "./contracts.js" +import { BrowserError, asBrowserError } from "./errors.js" +import type { + BrowserBackend, + BrowserRuntimeStatus, + BrowserSurfaceAction, + BrowserSurfaceEvent, + BrowserSurfaceSnapshot, + BrowserTab, + BrowserToolOutput, + PageController, + ToolResultEnvelope, +} from "./runtime-types.js" + +interface BrowserSession { + readonly targets: Set + activeTargetId: string | null + surfaceViewport: { width: number; height: number } | null + lastSeenAt: number +} + +interface SurfaceSubscription { + emit: (event: BrowserSurfaceEvent) => void + targetId: string | null + stopScreencast: (() => Promise) | null +} + +export interface BrowserRuntimeOptions { + sessionTtlMs?: number + now?: () => number +} + +const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1_000 + +export class BrowserRuntime { + private readonly sessions = new Map() + private readonly surfaceSubscriptions = new Map() + private readonly sessionTtlMs: number + private readonly now: () => number + + constructor( + private readonly backend: BrowserBackend, + options: BrowserRuntimeOptions = {} + ) { + this.sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS + this.now = options.now ?? Date.now + } + + status(): BrowserRuntimeStatus { + return { + ...this.backend.status(), + sessionCount: this.sessions.size, + } + } + + async callTool( + sessionId: string, + name: string, + rawArguments: unknown + ): Promise { + const parsed = parseToolArguments(name, rawArguments) + if (!parsed.ok) { + const message = + parsed.errorCode === "UNKNOWN_TOOL" + ? "The requested browser tool is not available" + : "Browser tool arguments are invalid" + return this.failure( + sessionId, + name, + new BrowserError(parsed.errorCode, message) + ) + } + + try { + await this.cleanupStaleSessions() + const output = await this.dispatch(sessionId, name, parsed.value) + if (output.envelope.ok && this.surfaceSubscriptions.has(sessionId)) { + try { + await this.refreshSurface(sessionId) + } catch (error) { + await this.releaseSession(sessionId) + throw error + } + } + ensureBoundedEnvelope(output.envelope) + return output + } catch (error) { + return this.failure(sessionId, name, asBrowserError(error)) + } + } + + async releaseSession(sessionId: string): Promise { + await this.detachSurface(sessionId) + this.sessions.delete(sessionId) + await this.backend.releaseSession(sessionId) + } + + async shutdown(): Promise { + for (const sessionId of [...this.surfaceSubscriptions.keys()]) { + await this.detachSurface(sessionId) + } + this.sessions.clear() + await this.backend.stop() + } + + async ensureSurface(sessionId: string): Promise { + await this.cleanupStaleSessions() + this.requireReady() + const session = this.session(sessionId) + if (!session.activeTargetId) await this.activePage(sessionId, session) + return await this.surfaceSnapshot(sessionId, session) + } + + async surfaceAction( + sessionId: string, + command: BrowserSurfaceAction + ): Promise { + await this.cleanupStaleSessions() + this.requireReady() + const session = this.session(sessionId) + + if (command.action === "open") { + const page = await this.backend.openTarget() + let tab = await page.info() + this.ownTarget(sessionId, session, tab.id) + if (command.url) { + tab = await page.navigate(command.url, DEFAULT_TOOL_TIMEOUT_MS) + } + } else if (command.action === "focus") { + this.assertOwned(session, command.targetId) + await this.backend.focusTarget(command.targetId) + session.activeTargetId = command.targetId + } else if (command.action === "close") { + this.assertOwned(session, command.targetId) + await this.backend.closeTarget(command.targetId) + session.targets.delete(command.targetId) + if (session.activeTargetId === command.targetId) { + session.activeTargetId = session.targets.values().next().value ?? null + } + } else { + const page = await this.activePage(sessionId, session) + if (command.action === "navigate") { + await page.navigate(command.url, DEFAULT_TOOL_TIMEOUT_MS) + } else if (command.action === "back") { + await page.goHistory(-1, DEFAULT_TOOL_TIMEOUT_MS) + } else if (command.action === "forward") { + await page.goHistory(1, DEFAULT_TOOL_TIMEOUT_MS) + } else if (command.action === "reload") { + await page.reload(DEFAULT_TOOL_TIMEOUT_MS) + } else if (command.action === "stop") { + await page.stopLoading(DEFAULT_TOOL_TIMEOUT_MS) + } else if (command.action === "resize") { + session.surfaceViewport = { + width: command.width, + height: command.height, + } + await this.applySurfaceViewport(session, page) + } else if (command.action === "input") { + await page.dispatchSurfaceInput(command.input, DEFAULT_TOOL_TIMEOUT_MS) + } + } + + await this.refreshSurface(sessionId) + return await this.surfaceSnapshot(sessionId, session) + } + + async subscribeSurface( + sessionId: string, + emit: (event: BrowserSurfaceEvent) => void + ): Promise<() => Promise> { + if (this.surfaceSubscriptions.has(sessionId)) { + throw new BrowserError( + "SURFACE_ALREADY_ATTACHED", + "The Browser surface is already attached for this Agent session" + ) + } + const subscription: SurfaceSubscription = { + emit, + targetId: null, + stopScreencast: null, + } + this.surfaceSubscriptions.set(sessionId, subscription) + try { + await this.ensureSurface(sessionId) + await this.refreshSurface(sessionId) + } catch (error) { + this.surfaceSubscriptions.delete(sessionId) + await subscription.stopScreencast?.().catch(() => undefined) + throw error + } + let detached = false + return async () => { + if (detached) return + detached = true + if (this.surfaceSubscriptions.get(sessionId) === subscription) { + await this.detachSurface(sessionId) + } + } + } + + private async dispatch( + sessionId: string, + name: string, + args: Record + ): Promise { + if (name === "runtime.status") { + return this.success(sessionId, name, { + ...this.status(), + }) + } + if (name === "runtime.doctor") { + return this.success(sessionId, name, await this.backend.doctor()) + } + if (name === "runtime.start") { + await this.backend.start() + return this.success(sessionId, name, this.backend.status()) + } + if (name === "runtime.stop") { + for (const sessionId of [...this.surfaceSubscriptions.keys()]) { + await this.detachSurface(sessionId) + } + this.sessions.clear() + await this.backend.stop() + return this.success(sessionId, name, this.backend.status()) + } + if (name === "runtime.recover") { + for (const sessionId of [...this.surfaceSubscriptions.keys()]) { + await this.detachSurface(sessionId) + } + this.sessions.clear() + await this.backend.recover() + return this.success(sessionId, name, this.backend.status()) + } + + this.requireReady() + const session = this.session(sessionId) + if (name === "tabs.list") { + const tabs = await this.syncSessionTargets(session) + return this.success(sessionId, name, { + tabs, + activeTabId: session.activeTargetId, + }) + } + if (name === "tabs.open") { + // Always create about:blank first, attach the page controller (which + // enables request interception), and establish session ownership before + // navigating. Creating the target directly at an external URL would let + // its first wave of subresource requests race the SSRF interceptor. + const page = await this.backend.openTarget() + let tab = await page.info() + this.ownTarget(sessionId, session, tab.id) + const requestedUrl = optionalString(args.url) + if (requestedUrl) { + tab = await page.navigate(requestedUrl, DEFAULT_TOOL_TIMEOUT_MS) + } + return this.success(sessionId, name, { opened: true }, tab) + } + if (name === "tabs.focus") { + const targetId = requiredString(args.tabId) + this.assertOwned(session, targetId) + await this.backend.focusTarget(targetId) + session.activeTargetId = targetId + const tab = await (await this.backend.page(targetId)).info() + return this.success(sessionId, name, { focused: true }, tab) + } + if (name === "tabs.close") { + const targetId = requiredString(args.tabId) + this.assertOwned(session, targetId) + await this.backend.closeTarget(targetId) + session.targets.delete(targetId) + if (session.activeTargetId === targetId) { + session.activeTargetId = session.targets.values().next().value ?? null + } + return this.success(sessionId, name, { + closedTabId: targetId, + activeTabId: session.activeTargetId, + }) + } + + if (name === "download.list") { + return this.success(sessionId, name, { + downloads: await this.backend.downloads(sessionId), + }) + } + if (name === "download.wait") { + const download = await this.backend.waitForDownload( + sessionId, + optionalString(args.guid), + optionalNumber(args.timeoutMs) ?? DEFAULT_TOOL_TIMEOUT_MS + ) + return this.success(sessionId, name, { download }) + } + + const page = await this.activePage(sessionId, session) + const timeoutMs = optionalNumber(args.timeoutMs) ?? DEFAULT_TOOL_TIMEOUT_MS + if (name === "page.navigate") { + const tab = await page.navigate(requiredString(args.url), timeoutMs) + return this.success(sessionId, name, { navigated: true }, tab) + } + if (name === "page.snapshot") { + const snapshot = await page.snapshot({ + interactive: args.interactive === true, + limit: optionalNumber(args.limit) ?? 500, + timeoutMs, + }) + const tab = await page.info() + return this.success(sessionId, name, snapshot, tab) + } + if (name === "page.screenshot") { + const format = args.format === "jpeg" ? "jpeg" : "png" + const image = await page.screenshot({ + format, + fullPage: args.fullPage === true, + quality: optionalNumber(args.quality), + timeoutMs, + }) + const tab = await page.info() + return { + ...this.success(sessionId, name, { captured: true }, tab), + image, + } + } + if (name === "action.click") { + await page.click(requiredString(args.ref), timeoutMs) + return this.actionSuccess(sessionId, name, page) + } + if (name === "action.type" || name === "action.fill") { + await page.type( + requiredString(args.ref), + requiredString(args.text), + name === "action.fill", + timeoutMs + ) + return this.actionSuccess(sessionId, name, page) + } + if (name === "action.press") { + await page.press(requiredString(args.key), timeoutMs) + return this.actionSuccess(sessionId, name, page) + } + if (name === "action.scroll") { + await page.scroll( + optionalString(args.ref), + optionalNumber(args.deltaX) ?? 0, + optionalNumber(args.deltaY) ?? 600, + timeoutMs + ) + return this.actionSuccess(sessionId, name, page) + } + if (name === "action.wait") { + await page.wait({ + milliseconds: optionalNumber(args.milliseconds), + text: optionalString(args.text), + urlIncludes: optionalString(args.urlIncludes), + timeoutMs, + }) + return this.actionSuccess(sessionId, name, page) + } + throw new BrowserError( + "UNKNOWN_TOOL", + "The requested browser tool is not available" + ) + } + + private async actionSuccess( + sessionId: string, + action: string, + page: PageController + ): Promise { + return this.success( + sessionId, + action, + { completed: true }, + await page.info() + ) + } + + private session(sessionId: string): BrowserSession { + let session = this.sessions.get(sessionId) + if (!session) { + session = { + targets: new Set(), + activeTargetId: null, + surfaceViewport: null, + lastSeenAt: this.now(), + } + this.sessions.set(sessionId, session) + } + session.lastSeenAt = this.now() + return session + } + + private async activePage( + sessionId: string, + session: BrowserSession + ): Promise { + if (!session.activeTargetId) { + const page = await this.backend.openTarget() + const tab = await page.info() + this.ownTarget(sessionId, session, tab.id) + await this.applySurfaceViewport(session, page) + return page + } + this.assertOwned(session, session.activeTargetId) + return await this.backend.page(session.activeTargetId) + } + + private async surfaceSnapshot( + sessionId: string, + session: BrowserSession + ): Promise { + const tabs = await this.syncSessionTargets(session) + const activeTargetId = session.activeTargetId + const active = activeTargetId + ? await (await this.backend.page(activeTargetId)).surfaceState() + : null + return { sessionId, tabs, activeTargetId, active } + } + + private async syncSessionTargets( + session: BrowserSession + ): Promise { + const all = await this.backend.listTargets() + const tabs = all.filter((tab) => session.targets.has(tab.id)) + const live = new Set(tabs.map((tab) => tab.id)) + for (const targetId of [...session.targets]) { + if (!live.has(targetId)) session.targets.delete(targetId) + } + if ( + session.activeTargetId !== null && + !session.targets.has(session.activeTargetId) + ) { + session.activeTargetId = tabs[tabs.length - 1]?.id ?? null + } + return tabs + } + + private async refreshSurface(sessionId: string): Promise { + const subscription = this.surfaceSubscriptions.get(sessionId) + const session = this.sessions.get(sessionId) + if (!subscription || !session) return + const snapshot = await this.surfaceSnapshot(sessionId, session) + const targetId = snapshot.activeTargetId + if (subscription.targetId !== targetId) { + await subscription.stopScreencast?.().catch(() => undefined) + subscription.stopScreencast = null + subscription.targetId = targetId + if (targetId) { + const page = await this.backend.page(targetId) + await this.applySurfaceViewport(session, page) + subscription.stopScreencast = await page.startScreencast((frame) => { + if (this.surfaceSubscriptions.get(sessionId) === subscription) { + subscription.emit({ type: "frame", frame }) + } + }) + } + } + subscription.emit({ type: "snapshot", snapshot }) + } + + private async detachSurface(sessionId: string): Promise { + const subscription = this.surfaceSubscriptions.get(sessionId) + if (!subscription) return + this.surfaceSubscriptions.delete(sessionId) + await subscription.stopScreencast?.().catch(() => undefined) + } + + private ownTarget( + sessionId: string, + session: BrowserSession, + targetId: string + ): void { + session.targets.add(targetId) + session.activeTargetId = targetId + this.backend.assignTarget(targetId, sessionId) + } + + private async applySurfaceViewport( + session: BrowserSession, + page: PageController + ): Promise { + if (!session.surfaceViewport) return + await page.setSurfaceViewport( + session.surfaceViewport.width, + session.surfaceViewport.height, + DEFAULT_TOOL_TIMEOUT_MS + ) + } + + private assertOwned(session: BrowserSession, targetId: string): void { + if (!session.targets.has(targetId)) { + throw new BrowserError( + "TAB_NOT_FOUND", + "The tab does not exist in the current Agent session" + ) + } + } + + private requireReady(): void { + if (this.backend.state !== "ready") { + throw new BrowserError( + "RUNTIME_NOT_READY", + "Browser runtime is not ready", + { + retryable: true, + recovery: "recover_runtime", + } + ) + } + } + + private async cleanupStaleSessions(): Promise { + const cutoff = this.now() - this.sessionTtlMs + const stale = [...this.sessions.entries()] + .filter(([, session]) => session.lastSeenAt < cutoff) + .map(([sessionId]) => sessionId) + for (const sessionId of stale) await this.releaseSession(sessionId) + } + + private success( + sessionId: string, + action: string, + data: unknown, + tab?: BrowserTab + ): BrowserToolOutput { + const envelope: ToolResultEnvelope = { + ok: true, + action, + sessionId, + data, + } + if (tab) envelope.tab = tab + return { envelope } + } + + private failure( + sessionId: string, + action: string, + error: BrowserError + ): BrowserToolOutput { + return { + envelope: { + ok: false, + action, + sessionId, + error: { + code: error.code, + message: error.message, + retryable: error.retryable, + ...(error.recovery ? { recovery: error.recovery } : {}), + }, + }, + } + } +} + +function requiredString(value: unknown): string { + if (typeof value !== "string") { + throw new BrowserError( + "INVALID_ARGS", + "A required string argument is missing" + ) + } + return value +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +function optionalNumber(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined +} + +function ensureBoundedEnvelope(envelope: ToolResultEnvelope): void { + if ( + Buffer.byteLength(JSON.stringify(envelope), "utf8") > MAX_TEXT_RESULT_BYTES + ) { + throw new BrowserError( + "RESULT_TOO_LARGE", + "Browser text result exceeds the 200 kilobyte limit" + ) + } +} diff --git a/packages/codeg-plus-browser-mcp/src/cdp.ts b/packages/codeg-plus-browser-mcp/src/cdp.ts new file mode 100644 index 000000000..d8e6ea510 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/cdp.ts @@ -0,0 +1,312 @@ +import { EventEmitter } from "node:events" + +import WebSocket from "ws" + +import { BrowserError } from "./errors.js" + +export interface CdpSocket { + readonly readyState: number + send(data: string): void + close(): void + on(event: string, listener: (...args: unknown[]) => void): this + off(event: string, listener: (...args: unknown[]) => void): this +} + +interface PendingCall { + readonly reject: (error: BrowserError) => void + readonly resolve: (value: unknown) => void + readonly timer: ReturnType + readonly signal?: AbortSignal + readonly abortListener?: () => void +} + +interface CdpResponse { + readonly id?: number + readonly method?: string + readonly params?: unknown + readonly result?: unknown + readonly error?: { + readonly code?: number + readonly message?: string + } +} + +export interface CdpCallOptions { + readonly signal?: AbortSignal + readonly timeoutMs?: number +} + +const DEFAULT_CALL_TIMEOUT_MS = 15_000 +const SOCKET_OPEN = 1 + +export class CdpConnection { + private readonly events = new EventEmitter() + private readonly pending = new Map() + private nextId = 1 + private closed = false + + private readonly messageListener = (...args: unknown[]) => { + this.handleMessage(args[0]) + } + + private readonly closeListener = () => { + this.closeWithError( + new BrowserError("BROWSER_CRASHED", "Chrome DevTools connection closed", { + retryable: true, + recovery: "recover_runtime", + }) + ) + } + + private readonly errorListener = (...args: unknown[]) => { + const cause = args[0] + this.closeWithError( + new BrowserError("BROWSER_CRASHED", "Chrome DevTools connection failed", { + cause, + retryable: true, + recovery: "recover_runtime", + }) + ) + } + + constructor(private readonly socket: CdpSocket) { + socket.on("message", this.messageListener) + socket.on("close", this.closeListener) + socket.on("error", this.errorListener) + } + + get pendingCount(): number { + return this.pending.size + } + + on(method: string, listener: (params: T) => void): () => void { + const wrapped = listener as (value: unknown) => void + this.events.on(method, wrapped) + return () => this.events.off(method, wrapped) + } + + async call( + method: string, + params: Record = {}, + options: CdpCallOptions = {} + ): Promise { + if (this.closed || this.socket.readyState !== SOCKET_OPEN) { + throw new BrowserError( + "BROWSER_CRASHED", + "Chrome DevTools connection is not open", + { + retryable: true, + recovery: "recover_runtime", + } + ) + } + if (options.signal?.aborted) { + throw new BrowserError("ABORTED", "Chrome DevTools request was aborted", { + retryable: true, + recovery: "retry", + }) + } + + const id = this.nextId++ + const timeoutMs = options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS + + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.rejectPending( + id, + new BrowserError( + "TIMEOUT", + `Chrome DevTools request timed out: ${method}`, + { + retryable: true, + recovery: "retry", + } + ) + ) + }, timeoutMs) + + const abortListener = options.signal + ? () => { + this.rejectPending( + id, + new BrowserError( + "ABORTED", + "Chrome DevTools request was aborted", + { + retryable: true, + recovery: "retry", + } + ) + ) + } + : undefined + + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + timer, + signal: options.signal, + abortListener, + }) + options.signal?.addEventListener("abort", abortListener!, { once: true }) + + try { + this.socket.send(JSON.stringify({ id, method, params })) + } catch (error) { + this.rejectPending( + id, + new BrowserError( + "BROWSER_CRASHED", + "Could not send Chrome DevTools request", + { + cause: error, + retryable: true, + recovery: "recover_runtime", + } + ) + ) + } + }) + } + + close(): void { + if (this.closed) return + this.closeWithError( + new BrowserError("ABORTED", "Chrome DevTools connection was closed", { + retryable: true, + recovery: "retry", + }) + ) + this.socket.close() + } + + private handleMessage(raw: unknown): void { + let message: CdpResponse + try { + const text = + typeof raw === "string" + ? raw + : Buffer.isBuffer(raw) + ? raw.toString("utf8") + : raw instanceof ArrayBuffer + ? Buffer.from(raw).toString("utf8") + : String(raw) + message = JSON.parse(text) as CdpResponse + } catch { + return + } + + if (typeof message.id === "number") { + const pending = this.takePending(message.id) + if (!pending) return + if (message.error) { + pending.reject( + new BrowserError( + "INTERNAL_ERROR", + `Chrome DevTools error ${message.error.code ?? "unknown"}: ${message.error.message ?? "Unknown protocol error"}` + ) + ) + } else { + pending.resolve(message.result) + } + return + } + + if (message.method) { + this.events.emit(message.method, message.params) + } + } + + private takePending(id: number): PendingCall | undefined { + const pending = this.pending.get(id) + if (!pending) return undefined + this.pending.delete(id) + clearTimeout(pending.timer) + if (pending.signal && pending.abortListener) { + pending.signal.removeEventListener("abort", pending.abortListener) + } + return pending + } + + private rejectPending(id: number, error: BrowserError): void { + this.takePending(id)?.reject(error) + } + + private closeWithError(error: BrowserError): void { + if (this.closed) return + this.closed = true + this.socket.off("message", this.messageListener) + this.socket.off("close", this.closeListener) + this.socket.off("error", this.errorListener) + for (const id of [...this.pending.keys()]) { + this.rejectPending(id, error) + } + this.events.removeAllListeners() + } +} + +export async function connectCdp( + webSocketUrl: string, + options: CdpCallOptions = {} +): Promise { + const socket = new WebSocket(webSocketUrl, { + handshakeTimeout: options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, + maxPayload: 8 * 1024 * 1024, + }) + + await new Promise((resolve, reject) => { + const timeoutMs = options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS + const timer = setTimeout(() => { + cleanup() + socket.terminate() + reject( + new BrowserError("TIMEOUT", "Timed out connecting to Chrome DevTools", { + retryable: true, + recovery: "retry", + }) + ) + }, timeoutMs) + + const cleanup = () => { + clearTimeout(timer) + socket.off("open", handleOpen) + socket.off("error", handleError) + options.signal?.removeEventListener("abort", handleAbort) + } + const handleOpen = () => { + cleanup() + resolve() + } + const handleError = (error: Error) => { + cleanup() + reject( + new BrowserError( + "BROWSER_CRASHED", + "Could not connect to Chrome DevTools", + { + cause: error, + retryable: true, + recovery: "recover_runtime", + } + ) + ) + } + const handleAbort = () => { + cleanup() + socket.terminate() + reject( + new BrowserError("ABORTED", "Chrome DevTools connection was aborted", { + retryable: true, + recovery: "retry", + }) + ) + } + + socket.once("open", handleOpen) + socket.once("error", handleError) + options.signal?.addEventListener("abort", handleAbort, { once: true }) + if (options.signal?.aborted) handleAbort() + }) + + return new CdpConnection(socket) +} diff --git a/packages/codeg-plus-browser-mcp/src/chrome-backend.ts b/packages/codeg-plus-browser-mcp/src/chrome-backend.ts new file mode 100644 index 000000000..c732e7fef --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/chrome-backend.ts @@ -0,0 +1,1127 @@ +import { mkdir, realpath } from "node:fs/promises" +import { basename, join, resolve } from "node:path" + +import { CdpConnection, connectCdp } from "./cdp.js" +import { DEFAULT_TOOL_TIMEOUT_MS, MAX_SCREENSHOT_BYTES } from "./contracts.js" +import { ManagedChromeProcess, discoverBrowserPath } from "./chrome-process.js" +import { BrowserError, asBrowserError } from "./errors.js" +import { + assertControlledPath, + sanitizeRedirectHeaders, + validateNavigationUrl, + type HeaderEntry, +} from "./security.js" +import type { + BrowserBackend, + BrowserDoctorResult, + BrowserDownload, + BrowserRuntimeState, + BrowserRuntimeStatus, + BrowserSurfaceFrame, + BrowserSurfaceInput, + BrowserTab, + PageController, + PageSnapshot, + SemanticNode, +} from "./runtime-types.js" + +interface ChromeBackendOptions { + profileDir: string + downloadDir: string + browserPath?: string + headless?: boolean +} + +interface TargetInfo { + targetId: string + type: string + title: string + url: string +} + +interface AxValue { + value?: unknown +} + +interface AxNode { + backendDOMNodeId?: number + ignored?: boolean + role?: AxValue + name?: AxValue + value?: AxValue + description?: AxValue + properties?: Array<{ name: string; value?: AxValue }> +} + +interface DownloadRecord extends BrowserDownload { + sessionId: string | null + suggestedFilename: string +} + +const INTERACTIVE_ROLES = new Set([ + "button", + "checkbox", + "combobox", + "gridcell", + "link", + "listbox", + "menuitem", + "option", + "radio", + "searchbox", + "slider", + "spinbutton", + "switch", + "tab", + "textbox", + "treeitem", +]) + +const RECOVERY_BACKOFF_MS = [1_000, 2_000, 4_000] as const + +export class ChromeBackend implements BrowserBackend { + private stateValue: BrowserRuntimeState = "stopped" + private browserCdp: CdpConnection | null = null + private browserVersion: string | null = null + private lastErrorCode: string | null = null + private recoveryAttempt = 0 + private readonly pages = new Map() + private readonly targetOwners = new Map() + private readonly frameOwners = new Map() + private readonly downloadRecords = new Map() + private readonly process: ManagedChromeProcess + + constructor(private readonly options: ChromeBackendOptions) { + this.process = new ManagedChromeProcess(options) + this.process.onCrash(() => { + this.browserCdp = null + this.browserVersion = null + this.stateValue = "error" + this.lastErrorCode = "BROWSER_CRASHED" + for (const page of this.pages.values()) + page.close().catch(() => undefined) + this.pages.clear() + }) + } + + get state(): BrowserRuntimeState { + return this.stateValue + } + + status(): BrowserRuntimeStatus { + const info = this.process.info + return { + state: this.stateValue, + browserPid: info?.pid ?? null, + browserName: info?.name ?? null, + browserVersion: this.browserVersion, + sessionCount: new Set(this.targetOwners.values()).size, + recoveryAttempt: this.recoveryAttempt, + lastErrorCode: this.lastErrorCode, + } + } + + async doctor(): Promise { + const checks: BrowserDoctorResult["checks"] = [] + try { + const executable = await discoverBrowserPath(this.options.browserPath) + checks.push({ name: "browser", ok: true, detail: basename(executable) }) + } catch { + checks.push({ name: "browser", ok: false, detail: "not_found" }) + } + for (const [name, directory] of [ + ["profile", this.options.profileDir], + ["downloads", this.options.downloadDir], + ] as const) { + try { + await mkdir(directory, { recursive: true }) + await realpath(directory) + checks.push({ name, ok: true, detail: "writable" }) + } catch { + checks.push({ name, ok: false, detail: "unavailable" }) + } + } + checks.push({ + name: "process", + ok: this.process.info !== null, + detail: this.process.info ? "running" : "stopped", + }) + checks.push({ + name: "cdp", + ok: this.browserCdp !== null && this.stateValue === "ready", + detail: this.browserCdp ? "connected" : "disconnected", + }) + return { ok: checks.every((check) => check.ok), checks } + } + + async start(): Promise { + if (this.stateValue === "ready") return + if (this.stateValue === "starting") { + throw new BrowserError( + "RUNTIME_NOT_READY", + "Browser runtime is still starting", + { + retryable: true, + recovery: "retry", + } + ) + } + this.stateValue = "starting" + this.lastErrorCode = null + try { + const info = await this.process.start() + const version = await fetchChromeJson<{ webSocketDebuggerUrl?: string }>( + info.port, + "/json/version" + ) + if (!version.webSocketDebuggerUrl) { + throw new BrowserError( + "RUNTIME_START_FAILED", + "Chrome did not publish its browser DevTools endpoint" + ) + } + const cdp = await connectCdp(version.webSocketDebuggerUrl) + this.browserCdp = cdp + const details = await cdp.call<{ product?: string }>("Browser.getVersion") + this.browserVersion = details.product ?? null + await cdp.call("Target.setDiscoverTargets", { discover: true }) + await cdp.call("Browser.setDownloadBehavior", { + behavior: "allow", + downloadPath: resolve(this.options.downloadDir), + eventsEnabled: true, + }) + cdp.on<{ + guid: string + frameId?: string + suggestedFilename?: string + }>("Browser.downloadWillBegin", (event) => this.onDownloadBegin(event)) + cdp.on<{ + guid: string + state: "inProgress" | "completed" | "canceled" + receivedBytes?: number + totalBytes?: number + }>("Browser.downloadProgress", (event) => this.onDownloadProgress(event)) + this.stateValue = "ready" + this.recoveryAttempt = 0 + } catch (error) { + this.lastErrorCode = asBrowserError(error).code + this.stateValue = "error" + await this.process.stop() + throw error + } + } + + async stop(): Promise { + for (const page of this.pages.values()) + await page.close().catch(() => undefined) + this.pages.clear() + this.targetOwners.clear() + this.frameOwners.clear() + this.downloadRecords.clear() + this.browserCdp?.close() + this.browserCdp = null + this.browserVersion = null + await this.process.stop() + this.stateValue = "stopped" + this.lastErrorCode = null + this.recoveryAttempt = 0 + } + + async recover(): Promise { + let lastError: unknown + this.stateValue = "recovering" + for (let index = 0; index < RECOVERY_BACKOFF_MS.length; index += 1) { + this.recoveryAttempt = index + 1 + await this.process.stop() + this.browserCdp = null + await sleep(RECOVERY_BACKOFF_MS[index]!) + try { + this.stateValue = "stopped" + await this.start() + return + } catch (error) { + lastError = error + this.stateValue = "recovering" + } + } + this.stateValue = "error" + this.lastErrorCode = asBrowserError(lastError).code + throw new BrowserError( + "RUNTIME_START_FAILED", + "Browser recovery exhausted three attempts", + { cause: lastError, recovery: "check_settings" } + ) + } + + async listTargets(): Promise { + const cdp = this.requireReady() + const result = await cdp.call<{ targetInfos: TargetInfo[] }>( + "Target.getTargets" + ) + return result.targetInfos + .filter((target) => target.type === "page") + .map((target) => ({ + id: target.targetId, + url: target.url, + title: target.title, + })) + } + + async openTarget(url?: string): Promise { + const cdp = this.requireReady() + const targetUrl = url + ? (await validateNavigationUrl(url)).url + : "about:blank" + const result = await cdp.call<{ targetId: string }>("Target.createTarget", { + url: targetUrl, + newWindow: false, + background: false, + }) + return await this.page(result.targetId) + } + + async page(targetId: string): Promise { + this.requireReady() + const cached = this.pages.get(targetId) + if (cached) return cached + const port = this.process.info?.port + if (!port) throw runtimeNotReady() + const page = new ChromePageController({ + targetId, + port, + getTargetInfo: async () => await this.targetInfo(targetId), + getOwner: () => this.targetOwners.get(targetId), + onFrame: (frameId, sessionId) => this.frameOwners.set(frameId, sessionId), + }) + this.pages.set(targetId, page) + return page + } + + async focusTarget(targetId: string): Promise { + await this.requireReady().call("Target.activateTarget", { targetId }) + } + + async closeTarget(targetId: string): Promise { + const page = this.pages.get(targetId) + await page?.close().catch(() => undefined) + this.pages.delete(targetId) + this.targetOwners.delete(targetId) + const result = await this.requireReady().call<{ success: boolean }>( + "Target.closeTarget", + { targetId } + ) + if (!result.success) { + throw new BrowserError( + "TAB_NOT_FOUND", + "The browser tab could not be closed" + ) + } + } + + async downloads(sessionId: string): Promise { + return [...this.downloadRecords.values()] + .filter((download) => download.sessionId === sessionId) + .slice(-100) + .map(withoutSessionId) + } + + async waitForDownload( + sessionId: string, + guid: string | undefined, + timeoutMs: number + ): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() <= deadline) { + const candidates = [...this.downloadRecords.values()].filter( + (download) => download.sessionId === sessionId + ) + const download = guid + ? candidates.find((candidate) => candidate.guid === guid) + : candidates[candidates.length - 1] + if (download && download.state !== "inProgress") { + return withoutSessionId(download) + } + await sleep(100) + } + throw new BrowserError("TIMEOUT", "Timed out waiting for the download", { + retryable: true, + recovery: "retry", + }) + } + + assignTarget(targetId: string, sessionId: string): void { + this.targetOwners.set(targetId, sessionId) + } + + async releaseSession(sessionId: string): Promise { + const targets = [...this.targetOwners.entries()] + .filter(([, owner]) => owner === sessionId) + .map(([targetId]) => targetId) + for (const targetId of targets) { + await this.closeTarget(targetId).catch(() => undefined) + } + for (const [frameId, owner] of this.frameOwners) { + if (owner === sessionId) this.frameOwners.delete(frameId) + } + for (const [guid, download] of this.downloadRecords) { + if (download.sessionId === sessionId) this.downloadRecords.delete(guid) + } + } + + private requireReady(): CdpConnection { + if (this.stateValue !== "ready" || !this.browserCdp) throw runtimeNotReady() + return this.browserCdp + } + + private async targetInfo(targetId: string): Promise { + const result = await this.requireReady().call<{ targetInfo: TargetInfo }>( + "Target.getTargetInfo", + { targetId } + ) + return { + id: result.targetInfo.targetId, + url: result.targetInfo.url, + title: result.targetInfo.title, + } + } + + private onDownloadBegin(event: { + guid: string + frameId?: string + suggestedFilename?: string + }): void { + const suggestedFilename = basename(event.suggestedFilename ?? "download") + this.downloadRecords.set(event.guid, { + guid: event.guid, + state: "inProgress", + filename: suggestedFilename, + suggestedFilename, + receivedBytes: 0, + totalBytes: 0, + sessionId: event.frameId + ? (this.frameOwners.get(event.frameId) ?? null) + : null, + }) + } + + private onDownloadProgress(event: { + guid: string + state: "inProgress" | "completed" | "canceled" + receivedBytes?: number + totalBytes?: number + }): void { + const record = this.downloadRecords.get(event.guid) + if (!record) return + record.state = event.state + record.receivedBytes = event.receivedBytes ?? record.receivedBytes + record.totalBytes = event.totalBytes ?? record.totalBytes + if (event.state === "canceled") record.errorCode = "DOWNLOAD_FAILED" + if (event.state === "completed") { + const candidate = join(this.options.downloadDir, record.suggestedFilename) + void assertControlledPath(this.options.downloadDir, candidate) + .then((path) => { + record.path = path + }) + .catch(() => { + record.state = "canceled" + record.errorCode = "PATH_ESCAPE" + }) + } + } +} + +interface ChromePageOptions { + targetId: string + port: number + getTargetInfo(): Promise + getOwner(): string | undefined + onFrame(frameId: string, sessionId: string): void +} + +class ChromePageController implements PageController { + private cdp: CdpConnection | null = null + private initialized = false + private readonly refs = new Map() + private readonly requestUrls = new Map() + private loading = false + private screencastActive = false + + constructor(private readonly options: ChromePageOptions) {} + + async info(): Promise { + return await this.options.getTargetInfo() + } + + async navigate(url: string, timeoutMs: number): Promise { + const safeUrl = (await validateNavigationUrl(url)).url + const cdp = await this.connection() + const result = await cdp.call<{ errorText?: string }>( + "Page.navigate", + { url: safeUrl }, + { timeoutMs } + ) + if (result.errorText) { + throw new BrowserError( + "INTERNAL_ERROR", + "The browser rejected the navigation", + { + retryable: true, + recovery: "retry", + } + ) + } + await this.waitForReadyState(timeoutMs) + return await this.info() + } + + async snapshot(options: { + interactive: boolean + limit: number + timeoutMs: number + }): Promise { + const cdp = await this.connection() + const result = await cdp.call<{ nodes: AxNode[] }>( + "Accessibility.getFullAXTree", + {}, + { timeoutMs: options.timeoutMs } + ) + this.refs.clear() + const nodes: SemanticNode[] = [] + for (const node of result.nodes) { + if (node.ignored || !node.backendDOMNodeId) continue + const role = stringValue(node.role) + if (!role || (options.interactive && !INTERACTIVE_ROLES.has(role))) + continue + const name = stringValue(node.name) + const value = stringValue(node.value) + if (!options.interactive && !name && !value && role === "generic") + continue + const ref = `e${nodes.length + 1}` + this.refs.set(ref, node.backendDOMNodeId) + const semantic: SemanticNode = { + ref, + role: bounded(role, 128), + name: bounded(name, 4_096), + } + if (value) semantic.value = bounded(value, 4_096) + const description = stringValue(node.description) + if (description) semantic.description = bounded(description, 4_096) + const disabled = propertyBoolean(node, "disabled") + if (disabled !== undefined) semantic.disabled = disabled + const focused = propertyBoolean(node, "focused") + if (focused !== undefined) semantic.focused = focused + nodes.push(semantic) + if (nodes.length >= options.limit) break + } + const info = await this.info() + return { nodes, documentUrl: info.url, title: info.title } + } + + async screenshot(options: { + format: "png" | "jpeg" + fullPage: boolean + quality?: number + timeoutMs: number + }): Promise<{ data: string; mimeType: "image/png" | "image/jpeg" }> { + const cdp = await this.connection() + let clip: Record | undefined + if (options.fullPage) { + const metrics = await cdp.call<{ + contentSize: { width: number; height: number } + }>("Page.getLayoutMetrics", {}, { timeoutMs: options.timeoutMs }) + clip = { + x: 0, + y: 0, + width: Math.max(1, Math.ceil(metrics.contentSize.width)), + height: Math.max(1, Math.ceil(metrics.contentSize.height)), + scale: 1, + } + } + const result = await cdp.call<{ data: string }>( + "Page.captureScreenshot", + { + format: options.format, + ...(options.format === "jpeg" && options.quality + ? { quality: options.quality } + : {}), + captureBeyondViewport: options.fullPage, + fromSurface: true, + ...(clip ? { clip } : {}), + }, + { timeoutMs: options.timeoutMs } + ) + if (Buffer.byteLength(result.data, "base64") > MAX_SCREENSHOT_BYTES) { + throw new BrowserError( + "RESULT_TOO_LARGE", + "The screenshot exceeds the five megabyte result limit" + ) + } + return { + data: result.data, + mimeType: options.format === "jpeg" ? "image/jpeg" : "image/png", + } + } + + async click(ref: string, timeoutMs: number): Promise { + const rect = await this.callOnRef<{ x: number; y: number }>( + ref, + `function () { + this.scrollIntoView({ block: "center", inline: "center" }); + const rect = this.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + }`, + [], + timeoutMs + ) + const cdp = await this.connection() + await cdp.call( + "Input.dispatchMouseEvent", + { + type: "mousePressed", + x: rect.x, + y: rect.y, + button: "left", + clickCount: 1, + }, + { timeoutMs } + ) + await cdp.call( + "Input.dispatchMouseEvent", + { + type: "mouseReleased", + x: rect.x, + y: rect.y, + button: "left", + clickCount: 1, + }, + { timeoutMs } + ) + } + + async type( + ref: string, + text: string, + clear: boolean, + timeoutMs: number + ): Promise { + await this.callOnRef( + ref, + `function (clear) { + this.scrollIntoView({ block: "center", inline: "center" }); + this.focus(); + if (clear) { + const prototype = this instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + if (setter) setter.call(this, ""); else this.value = ""; + this.dispatchEvent(new Event("input", { bubbles: true })); + } + return true; + }`, + [{ value: clear }], + timeoutMs + ) + await ( + await this.connection() + ).call("Input.insertText", { text }, { timeoutMs }) + } + + async press(key: string, timeoutMs: number): Promise { + const details = keyDetails(key) + const cdp = await this.connection() + await cdp.call( + "Input.dispatchKeyEvent", + { type: "keyDown", ...details }, + { timeoutMs } + ) + await cdp.call( + "Input.dispatchKeyEvent", + { type: "keyUp", ...details, text: undefined }, + { timeoutMs } + ) + } + + async scroll( + ref: string | undefined, + deltaX: number, + deltaY: number, + timeoutMs: number + ): Promise { + if (ref) { + await this.callOnRef( + ref, + "function (x, y) { this.scrollBy(x, y); return true; }", + [{ value: deltaX }, { value: deltaY }], + timeoutMs + ) + return + } + await ( + await this.connection() + ).call( + "Runtime.evaluate", + { + expression: `window.scrollBy(${JSON.stringify(deltaX)}, ${JSON.stringify(deltaY)})`, + returnByValue: true, + }, + { timeoutMs } + ) + } + + async wait(options: { + milliseconds?: number + text?: string + urlIncludes?: string + timeoutMs: number + }): Promise { + if (options.milliseconds !== undefined) { + await sleep(Math.min(options.milliseconds, options.timeoutMs)) + if (!options.text && !options.urlIncludes) return + } + const deadline = Date.now() + options.timeoutMs + while (Date.now() <= deadline) { + const info = await this.info() + const urlMatches = + options.urlIncludes === undefined || + info.url.includes(options.urlIncludes) + let textMatches = options.text === undefined + if (options.text !== undefined) { + const snapshot = await this.snapshot({ + interactive: false, + limit: 1_000, + timeoutMs: Math.max(100, deadline - Date.now()), + }) + textMatches = snapshot.nodes.some( + (node) => + node.name.includes(options.text!) || + node.value?.includes(options.text!) + ) + } + if (urlMatches && textMatches) return + await sleep(100) + } + throw new BrowserError("TIMEOUT", "Browser wait condition timed out", { + retryable: true, + recovery: "retry", + }) + } + + async surfaceState() { + const cdp = await this.connection() + const history = await cdp.call<{ + currentIndex: number + entries: Array<{ id: number }> + }>("Page.getNavigationHistory") + return { + tab: await this.info(), + loading: this.loading, + canGoBack: history.currentIndex > 0, + canGoForward: history.currentIndex + 1 < history.entries.length, + } + } + + async goHistory(delta: -1 | 1, timeoutMs: number): Promise { + const cdp = await this.connection() + const history = await cdp.call<{ + currentIndex: number + entries: Array<{ id: number }> + }>("Page.getNavigationHistory", {}, { timeoutMs }) + const entry = history.entries[history.currentIndex + delta] + if (!entry) return + await cdp.call( + "Page.navigateToHistoryEntry", + { entryId: entry.id }, + { timeoutMs } + ) + await this.waitForReadyState(timeoutMs) + } + + async reload(timeoutMs: number): Promise { + await ( + await this.connection() + ).call("Page.reload", { ignoreCache: false }, { timeoutMs }) + await this.waitForReadyState(timeoutMs) + } + + async stopLoading(timeoutMs: number): Promise { + await (await this.connection()).call("Page.stopLoading", {}, { timeoutMs }) + this.loading = false + } + + async dispatchSurfaceInput( + input: BrowserSurfaceInput, + timeoutMs: number + ): Promise { + const cdp = await this.connection() + if (input.kind === "text") { + await cdp.call("Input.insertText", { text: input.text }, { timeoutMs }) + return + } + if (input.kind === "key") { + await cdp.call( + "Input.dispatchKeyEvent", + { + type: input.event === "down" ? "keyDown" : "keyUp", + key: bounded(input.key, 128), + code: input.code ? bounded(input.code, 128) : undefined, + text: + input.event === "down" && input.text + ? bounded(input.text, 4_096) + : undefined, + modifiers: input.modifiers ?? 0, + }, + { timeoutMs } + ) + return + } + const typeByEvent = { + pressed: "mousePressed", + released: "mouseReleased", + moved: "mouseMoved", + wheel: "mouseWheel", + } as const + await cdp.call( + "Input.dispatchMouseEvent", + { + type: typeByEvent[input.event], + x: finiteCoordinate(input.x), + y: finiteCoordinate(input.y), + button: input.button ?? (input.event === "wheel" ? "none" : "left"), + clickCount: + input.event === "pressed" || input.event === "released" ? 1 : 0, + deltaX: input.event === "wheel" ? (input.deltaX ?? 0) : undefined, + deltaY: input.event === "wheel" ? (input.deltaY ?? 0) : undefined, + modifiers: input.modifiers ?? 0, + }, + { timeoutMs } + ) + } + + async setSurfaceViewport( + width: number, + height: number, + timeoutMs: number + ): Promise { + await ( + await this.connection() + ).call( + "Emulation.setDeviceMetricsOverride", + { + width, + height, + deviceScaleFactor: 1, + mobile: false, + screenWidth: width, + screenHeight: height, + }, + { timeoutMs } + ) + } + + async startScreencast( + listener: (frame: BrowserSurfaceFrame) => void + ): Promise<() => Promise> { + if (this.screencastActive) { + throw new BrowserError( + "SURFACE_ALREADY_ATTACHED", + "This browser target already has a screencast subscriber" + ) + } + const cdp = await this.connection() + this.screencastActive = true + const unsubscribe = cdp.on<{ + data: string + sessionId: number + metadata?: { + deviceWidth?: number + deviceHeight?: number + pageScaleFactor?: number + } + }>("Page.screencastFrame", (event) => { + void cdp + .call("Page.screencastFrameAck", { sessionId: event.sessionId }) + .then(() => { + if (!this.screencastActive) return + listener({ + targetId: this.options.targetId, + data: event.data, + mimeType: "image/jpeg", + deviceWidth: Math.max(1, event.metadata?.deviceWidth ?? 1), + deviceHeight: Math.max(1, event.metadata?.deviceHeight ?? 1), + pageScaleFactor: event.metadata?.pageScaleFactor ?? 1, + }) + }) + .catch(() => undefined) + }) + try { + await cdp.call("Page.startScreencast", { + format: "jpeg", + quality: 70, + maxWidth: 1440, + maxHeight: 900, + everyNthFrame: 1, + }) + } catch (error) { + unsubscribe() + this.screencastActive = false + throw error + } + let stopped = false + return async () => { + if (stopped) return + stopped = true + unsubscribe() + if (!this.screencastActive) return + this.screencastActive = false + await cdp.call("Page.stopScreencast").catch(() => undefined) + } + } + + async close(): Promise { + this.screencastActive = false + this.cdp?.close() + this.cdp = null + this.initialized = false + this.refs.clear() + this.requestUrls.clear() + } + + private async connection(): Promise { + if (!this.cdp) { + this.cdp = await connectCdp( + `ws://127.0.0.1:${this.options.port}/devtools/page/${encodeURIComponent(this.options.targetId)}` + ) + } + if (!this.initialized) { + await Promise.all([ + this.cdp.call("Page.enable"), + this.cdp.call("DOM.enable"), + this.cdp.call("Runtime.enable"), + this.cdp.call("Accessibility.enable"), + ]) + this.cdp.on<{ + requestId: string + networkId?: string + request: { url: string; headers?: Record } + }>("Fetch.requestPaused", (event) => { + void this.handlePausedRequest(event) + }) + this.cdp.on<{ frame?: { id?: string } }>( + "Page.frameNavigated", + (event) => { + const owner = this.options.getOwner() + if (owner && event.frame?.id) + this.options.onFrame(event.frame.id, owner) + } + ) + this.cdp.on("Page.frameStartedLoading", () => { + this.loading = true + }) + this.cdp.on("Page.frameStoppedLoading", () => { + this.loading = false + }) + await this.cdp.call("Fetch.enable", { + patterns: [ + { urlPattern: "http://*/*", requestStage: "Request" }, + { urlPattern: "https://*/*", requestStage: "Request" }, + ], + handleAuthRequests: false, + }) + const frameTree = await this.cdp.call<{ + frameTree?: { frame?: { id?: string } } + }>("Page.getFrameTree") + const owner = this.options.getOwner() + if (owner && frameTree.frameTree?.frame?.id) { + this.options.onFrame(frameTree.frameTree.frame.id, owner) + } + this.initialized = true + } + return this.cdp + } + + private async handlePausedRequest(event: { + requestId: string + networkId?: string + request: { url: string; headers?: Record } + }): Promise { + const cdp = this.cdp + if (!cdp) return + try { + const safe = await validateNavigationUrl(event.request.url) + const previous = event.networkId + ? this.requestUrls.get(event.networkId) + : undefined + const originalHeaders: HeaderEntry[] = Object.entries( + event.request.headers ?? {} + ).map(([name, value]) => ({ name, value })) + const headers = previous + ? sanitizeRedirectHeaders(previous, safe.url, originalHeaders) + : originalHeaders + if (event.networkId) this.requestUrls.set(event.networkId, safe.url) + await cdp.call("Fetch.continueRequest", { + requestId: event.requestId, + headers, + }) + } catch { + await cdp + .call("Fetch.failRequest", { + requestId: event.requestId, + errorReason: "Aborted", + }) + .catch(() => undefined) + } + } + + private async callOnRef( + ref: string, + functionDeclaration: string, + args: Array<{ value: unknown }>, + timeoutMs: number + ): Promise { + const backendNodeId = this.refs.get(ref) + if (!backendNodeId) { + throw new BrowserError( + "REF_NOT_FOUND", + "The semantic ref is stale; take a new snapshot and retry", + { retryable: true, recovery: "retry" } + ) + } + const cdp = await this.connection() + const resolved = await cdp.call<{ + object?: { objectId?: string } + }>("DOM.resolveNode", { backendNodeId }, { timeoutMs }) + const objectId = resolved.object?.objectId + if (!objectId) + throw new BrowserError("REF_NOT_FOUND", "The semantic ref is gone") + try { + const result = await cdp.call<{ + result?: { value?: T } + exceptionDetails?: unknown + }>( + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration, + arguments: args, + returnByValue: true, + awaitPromise: false, + }, + { timeoutMs } + ) + if (result.exceptionDetails || result.result?.value === undefined) { + throw new BrowserError("INTERNAL_ERROR", "The browser action failed") + } + return result.result.value + } finally { + await cdp + .call("Runtime.releaseObject", { objectId }) + .catch(() => undefined) + } + } + + private async waitForReadyState(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + const cdp = await this.connection() + while (Date.now() <= deadline) { + const result = await cdp.call<{ + result?: { value?: string } + }>( + "Runtime.evaluate", + { expression: "document.readyState", returnByValue: true }, + { timeoutMs: Math.max(100, deadline - Date.now()) } + ) + if (result.result?.value === "complete") return + await sleep(100) + } + throw new BrowserError("TIMEOUT", "Page navigation timed out", { + retryable: true, + recovery: "retry", + }) + } +} + +function finiteCoordinate(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.min(100_000, value)) : 0 +} + +function stringValue(value: AxValue | undefined): string { + return typeof value?.value === "string" ? value.value : "" +} + +function propertyBoolean(node: AxNode, name: string): boolean | undefined { + const value = node.properties?.find((property) => property.name === name) + ?.value?.value + return typeof value === "boolean" ? value : undefined +} + +function bounded(value: string, maximum: number): string { + return value.length <= maximum ? value : value.slice(0, maximum) +} + +function keyDetails(key: string): Record { + const named: Record = { + Enter: { code: "Enter", keyCode: 13 }, + Tab: { code: "Tab", keyCode: 9 }, + Escape: { code: "Escape", keyCode: 27 }, + Backspace: { code: "Backspace", keyCode: 8 }, + Delete: { code: "Delete", keyCode: 46 }, + ArrowUp: { code: "ArrowUp", keyCode: 38 }, + ArrowDown: { code: "ArrowDown", keyCode: 40 }, + ArrowLeft: { code: "ArrowLeft", keyCode: 37 }, + ArrowRight: { code: "ArrowRight", keyCode: 39 }, + Home: { code: "Home", keyCode: 36 }, + End: { code: "End", keyCode: 35 }, + PageUp: { code: "PageUp", keyCode: 33 }, + PageDown: { code: "PageDown", keyCode: 34 }, + } + const match = named[key] + if (match) { + return { + key, + code: match.code, + windowsVirtualKeyCode: match.keyCode, + nativeVirtualKeyCode: match.keyCode, + } + } + const character = [...key][0] ?? "" + return { + key: character, + code: character.length === 1 ? `Key${character.toUpperCase()}` : character, + text: character, + windowsVirtualKeyCode: character.toUpperCase().charCodeAt(0), + } +} + +function withoutSessionId(download: DownloadRecord): BrowserDownload { + const { sessionId, suggestedFilename, ...result } = download + void sessionId + void suggestedFilename + return { ...result } +} + +function runtimeNotReady(): BrowserError { + return new BrowserError("RUNTIME_NOT_READY", "Browser runtime is not ready", { + retryable: true, + recovery: "recover_runtime", + }) +} + +async function fetchChromeJson(port: number, path: string): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), DEFAULT_TOOL_TIMEOUT_MS) + try { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + return (await response.json()) as T + } finally { + clearTimeout(timer) + } +} + +function sleep(milliseconds: number): Promise { + return new Promise((resolveDone) => setTimeout(resolveDone, milliseconds)) +} diff --git a/packages/codeg-plus-browser-mcp/src/chrome-process.ts b/packages/codeg-plus-browser-mcp/src/chrome-process.ts new file mode 100644 index 000000000..e06c10ce3 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/chrome-process.ts @@ -0,0 +1,247 @@ +import { spawn, type ChildProcess } from "node:child_process" +import { constants as fsConstants } from "node:fs" +import { access, mkdir, readFile, rm, stat } from "node:fs/promises" +import { basename, join, resolve } from "node:path" + +import { BrowserError } from "./errors.js" + +export interface ChromeProcessOptions { + profileDir: string + downloadDir: string + browserPath?: string + startupTimeoutMs?: number + headless?: boolean +} + +export interface ChromeProcessInfo { + executable: string + name: string + pid: number + port: number +} + +const DEFAULT_STARTUP_TIMEOUT_MS = 20_000 + +async function isExecutableFile(path: string): Promise { + try { + const details = await stat(path) + if (!details.isFile()) return false + await access(path, fsConstants.X_OK) + return true + } catch { + return false + } +} + +export async function discoverBrowserPath( + configuredPath?: string +): Promise { + if (configuredPath) { + const path = resolve(configuredPath) + if (await isExecutableFile(path)) return path + throw new BrowserError( + "BROWSER_NOT_FOUND", + "The configured browser executable does not exist or cannot be executed", + { recovery: "check_settings" } + ) + } + + const candidates: string[] = [] + const programFiles = process.env.PROGRAMFILES + const programFilesX86 = process.env["PROGRAMFILES(X86)"] + const localAppData = process.env.LOCALAPPDATA + for (const root of [programFiles, programFilesX86, localAppData]) { + if (!root) continue + candidates.push( + join(root, "Google", "Chrome", "Application", "chrome.exe"), + join(root, "Microsoft", "Edge", "Application", "msedge.exe"), + join(root, "Chromium", "Application", "chrome.exe") + ) + } + if (process.platform === "darwin") { + candidates.push( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Chromium.app/Contents/MacOS/Chromium" + ) + } + if (process.platform === "linux") { + candidates.push( + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/microsoft-edge", + "/usr/bin/chromium", + "/usr/bin/chromium-browser" + ) + } + + for (const candidate of candidates) { + if (await isExecutableFile(candidate)) return candidate + } + throw new BrowserError( + "BROWSER_NOT_FOUND", + "No supported Chrome, Edge, or Chromium installation was found", + { recovery: "check_settings" } + ) +} + +function browserName(executable: string): string { + const name = basename(executable).toLowerCase() + if (name.includes("edge")) return "Microsoft Edge" + if (name.includes("chromium")) return "Chromium" + return "Google Chrome" +} + +export class ManagedChromeProcess { + private child: ChildProcess | null = null + private infoValue: ChromeProcessInfo | null = null + private stopping = false + private crashListener: ((code: number | null) => void) | undefined + + constructor(private readonly options: ChromeProcessOptions) {} + + get info(): ChromeProcessInfo | null { + return this.infoValue + } + + onCrash(listener: (code: number | null) => void): void { + this.crashListener = listener + } + + async start(): Promise { + if (this.infoValue) return this.infoValue + const executable = await discoverBrowserPath(this.options.browserPath) + const profileDir = resolve(this.options.profileDir) + const downloadDir = resolve(this.options.downloadDir) + await Promise.all([ + mkdir(profileDir, { recursive: true }), + mkdir(downloadDir, { recursive: true }), + ]) + + const portFile = join(profileDir, "DevToolsActivePort") + await rm(portFile, { force: true }) + const chromeArgs = [ + ...(this.options.headless + ? ["--headless=new", "--window-size=1440,900"] + : []), + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=0", + `--user-data-dir=${profileDir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + "--disable-sync", + "--metrics-recording-only", + "about:blank", + ] + const child = spawn(executable, chromeArgs, { + detached: false, + windowsHide: true, + stdio: "ignore", + }) + if (!child.pid) { + throw new BrowserError( + "RUNTIME_START_FAILED", + "The browser process did not return a process identifier", + { recovery: "check_settings" } + ) + } + this.child = child + this.stopping = false + child.once("exit", (code) => { + const unexpected = !this.stopping + this.child = null + this.infoValue = null + if (unexpected) this.crashListener?.(code) + }) + + try { + const port = await this.waitForPort( + portFile, + this.options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS, + child + ) + this.infoValue = { + executable, + name: browserName(executable), + pid: child.pid, + port, + } + return this.infoValue + } catch (error) { + await this.stop() + if (error instanceof BrowserError) throw error + throw new BrowserError( + "RUNTIME_START_FAILED", + "The browser did not expose its DevTools endpoint", + { cause: error, recovery: "check_settings" } + ) + } + } + + async stop(): Promise { + const child = this.child + this.stopping = true + this.child = null + this.infoValue = null + if (!child?.pid || child.exitCode !== null) return + await killProcessTree(child.pid) + } + + private async waitForPort( + portFile: string, + timeoutMs: number, + child: ChildProcess + ): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new BrowserError( + "RUNTIME_START_FAILED", + "The browser exited before DevTools became ready", + { recovery: "check_settings" } + ) + } + try { + const [line] = (await readFile(portFile, "utf8")).split(/\r?\n/) + const port = Number(line) + if (Number.isInteger(port) && port > 0 && port <= 65_535) return port + } catch { + // Chrome writes this file only after DevTools is listening. + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new BrowserError( + "TIMEOUT", + "Timed out waiting for the browser DevTools endpoint", + { recovery: "check_settings" } + ) + } +} + +async function killProcessTree(pid: number): Promise { + if (!Number.isInteger(pid) || pid <= 0) return + if (process.platform === "win32") { + await new Promise((resolveDone) => { + const killer = spawn("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore", + }) + killer.once("error", () => resolveDone()) + killer.once("exit", () => resolveDone()) + }) + return + } + try { + process.kill(pid, "SIGTERM") + } catch { + return + } + await new Promise((resolveDone) => setTimeout(resolveDone, 750)) + try { + process.kill(pid, "SIGKILL") + } catch { + // The process already exited. + } +} diff --git a/packages/codeg-plus-browser-mcp/src/cli.ts b/packages/codeg-plus-browser-mcp/src/cli.ts new file mode 100644 index 000000000..0ba8ddf9a --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/cli.ts @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +import { resolve } from "node:path" + +import { BrowserRuntime } from "./browser-runtime.js" +import { ChromeBackend } from "./chrome-backend.js" +import { BrowserSidecarServer } from "./mcp-server.js" + +interface CliOptions { + profileDir: string + downloadDir: string + browserPath?: string + parentPid?: number + autoStart: boolean + backend: "embedded" | "external" +} + +const VERSION = "0.1.0" + +async function main(): Promise { + if (process.argv.includes("--version")) { + process.stdout.write(`${VERSION}\n`) + return + } + + const options = parseArguments(process.argv.slice(2)) + const token = process.env.CODEG_BROWSER_TOKEN + if (!token || Buffer.byteLength(token, "utf8") < 32) { + throw new Error("missing_or_short_control_token") + } + + const backend = new ChromeBackend({ + profileDir: options.profileDir, + downloadDir: options.downloadDir, + browserPath: options.browserPath, + headless: options.backend === "embedded", + }) + const runtime = new BrowserRuntime(backend) + const sidecar = new BrowserSidecarServer({ + runtime, + token, + onControlEvent: writeControlEvent, + }) + + const port = await sidecar.start() + writeControlEvent({ + event: "ready", + version: VERSION, + pid: process.pid, + port, + }) + + let parentTimer: ReturnType | undefined + if (options.parentPid) { + parentTimer = setInterval(() => { + if (!processExists(options.parentPid!)) void shutdown("parent_exited") + }, 2_000) + parentTimer.unref() + } + + let shuttingDown = false + async function shutdown(reason: string): Promise { + if (shuttingDown) return + shuttingDown = true + if (parentTimer) clearInterval(parentTimer) + await sidecar.stop().catch(() => undefined) + writeControlEvent({ event: "stopped", reason }) + process.exit(0) + } + + process.once("SIGINT", () => void shutdown("signal")) + process.once("SIGTERM", () => void shutdown("signal")) + + if (options.autoStart) { + const result = await runtime.callTool("admin", "runtime.start", {}) + writeControlEvent({ + event: "browser-status", + status: runtime.status(), + ok: result.envelope.ok, + }) + } +} + +function parseArguments(args: string[]): CliOptions { + const values = new Map() + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]! + if (!argument.startsWith("--")) throw new Error("invalid_argument") + const equalsAt = argument.indexOf("=") + if (equalsAt >= 0) { + values.set(argument.slice(2, equalsAt), argument.slice(equalsAt + 1)) + continue + } + const next = args[index + 1] + if (!next || next.startsWith("--")) { + values.set(argument.slice(2), "true") + continue + } + values.set(argument.slice(2), next) + index += 1 + } + + const profileDir = values.get("profile-dir") + const downloadDir = values.get("download-dir") + if (!profileDir || !downloadDir) { + throw new Error("profile_and_download_directories_are_required") + } + const parentPidRaw = values.get("parent-pid") + const parentPid = parentPidRaw ? Number(parentPidRaw) : undefined + if ( + parentPid !== undefined && + (!Number.isInteger(parentPid) || parentPid <= 0) + ) { + throw new Error("invalid_parent_pid") + } + const backend = values.get("backend") ?? "external" + if (backend !== "embedded" && backend !== "external") { + throw new Error("invalid_backend") + } + return { + profileDir: resolve(profileDir), + downloadDir: resolve(downloadDir), + browserPath: values.get("browser-path"), + parentPid, + autoStart: values.get("auto-start") === "true", + backend, + } +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +function writeControlEvent(event: Record): void { + process.stdout.write(`${JSON.stringify(event)}\n`) +} + +void main().catch((error: unknown) => { + const code = error instanceof Error ? error.message : "unknown_fatal_error" + process.stderr.write(`${JSON.stringify({ event: "fatal", code })}\n`) + process.exitCode = 1 +}) diff --git a/packages/codeg-plus-browser-mcp/src/contracts.ts b/packages/codeg-plus-browser-mcp/src/contracts.ts new file mode 100644 index 000000000..725030595 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/contracts.ts @@ -0,0 +1,254 @@ +import { z } from "zod" + +export const MAX_TEXT_RESULT_BYTES = 200_000 +export const MAX_SCREENSHOT_BYTES = 5_000_000 +export const DEFAULT_TOOL_TIMEOUT_MS = 15_000 +export const MAX_TOOL_TIMEOUT_MS = 60_000 + +const empty = z.strictObject({}) +const timeoutMs = z.number().int().min(100).max(MAX_TOOL_TIMEOUT_MS).optional() +const tabId = z.string().trim().min(1).max(256) +const ref = z.string().regex(/^e[1-9]\d{0,5}$/) +const url = z.string().trim().min(1).max(8_192) + +export type BrowserToolCategory = + | "runtime" + | "tabs" + | "page" + | "action" + | "download" + +export interface BrowserToolDefinition { + name: string + category: BrowserToolCategory + description: string + schema: z.ZodType> +} + +export const PUBLIC_TOOLS: readonly BrowserToolDefinition[] = [ + { + name: "runtime.status", + category: "runtime", + description: "Read sidecar, Chrome, profile, session, and recovery state.", + schema: empty, + }, + { + name: "runtime.doctor", + category: "runtime", + description: + "Check browser discovery, directories, CDP, and process health.", + schema: empty, + }, + { + name: "runtime.start", + category: "runtime", + description: "Start the managed browser when it is stopped.", + schema: empty, + }, + { + name: "runtime.stop", + category: "runtime", + description: "Stop the managed browser and its owned process tree.", + schema: empty, + }, + { + name: "runtime.recover", + category: "runtime", + description: "Restart an unhealthy managed browser with bounded retry.", + schema: empty, + }, + { + name: "tabs.list", + category: "tabs", + description: "List tabs owned by the current Agent session.", + schema: empty, + }, + { + name: "tabs.open", + category: "tabs", + description: "Open a tab owned by the current Agent session.", + schema: z.strictObject({ url: url.optional() }), + }, + { + name: "tabs.focus", + category: "tabs", + description: "Focus a tab owned by the current Agent session.", + schema: z.strictObject({ tabId }), + }, + { + name: "tabs.close", + category: "tabs", + description: "Close a tab owned by the current Agent session.", + schema: z.strictObject({ tabId }), + }, + { + name: "page.navigate", + category: "page", + description: "Navigate the active tab to an allowed HTTP(S) URL.", + schema: z.strictObject({ url, timeoutMs }), + }, + { + name: "page.snapshot", + category: "page", + description: + "Read a bounded semantic accessibility snapshot with stable refs.", + schema: z.strictObject({ + interactive: z.boolean().optional(), + limit: z.number().int().min(1).max(1_000).optional(), + timeoutMs, + }), + }, + { + name: "page.screenshot", + category: "page", + description: "Capture the active tab as an MCP image result.", + schema: z.strictObject({ + format: z.enum(["png", "jpeg"]).optional(), + fullPage: z.boolean().optional(), + quality: z.number().int().min(1).max(100).optional(), + timeoutMs, + }), + }, + { + name: "action.click", + category: "action", + description: "Click a semantic ref from the latest snapshot.", + schema: z.strictObject({ ref, timeoutMs }), + }, + { + name: "action.type", + category: "action", + description: "Type text at a semantic ref without clearing it first.", + schema: z.strictObject({ + ref, + text: z.string().max(100_000), + timeoutMs, + }), + }, + { + name: "action.fill", + category: "action", + description: "Clear a semantic field and insert text.", + schema: z.strictObject({ + ref, + text: z.string().max(100_000), + timeoutMs, + }), + }, + { + name: "action.press", + category: "action", + description: "Press a keyboard key in the active tab.", + schema: z.strictObject({ + key: z.string().trim().min(1).max(64), + timeoutMs, + }), + }, + { + name: "action.scroll", + category: "action", + description: "Scroll the active tab or a semantic ref by pixel deltas.", + schema: z.strictObject({ + ref: ref.optional(), + deltaX: z.number().finite().min(-100_000).max(100_000).optional(), + deltaY: z.number().finite().min(-100_000).max(100_000).optional(), + timeoutMs, + }), + }, + { + name: "action.wait", + category: "action", + description: "Wait for time, visible semantic text, or a URL fragment.", + schema: z + .strictObject({ + milliseconds: z + .number() + .int() + .min(0) + .max(MAX_TOOL_TIMEOUT_MS) + .optional(), + text: z.string().min(1).max(4_096).optional(), + urlIncludes: z.string().min(1).max(4_096).optional(), + timeoutMs, + }) + .refine( + (value) => + value.milliseconds !== undefined || + value.text !== undefined || + value.urlIncludes !== undefined, + "Provide milliseconds, text, or urlIncludes" + ), + }, + { + name: "download.list", + category: "download", + description: + "List bounded download status and controlled completion paths.", + schema: empty, + }, + { + name: "download.wait", + category: "download", + description: "Wait for a selected or latest download to settle.", + schema: z.strictObject({ + guid: z.string().trim().min(1).max(256).optional(), + timeoutMs, + }), + }, +] as const + +const TOOL_BY_NAME = new Map(PUBLIC_TOOLS.map((tool) => [tool.name, tool])) + +export function browserToolCatalog(category?: BrowserToolCategory) { + const tools = category + ? PUBLIC_TOOLS.filter((tool) => tool.category === category) + : PUBLIC_TOOLS + return tools.map((tool) => ({ + name: tool.name, + category: tool.category, + description: tool.description, + })) +} + +export type ParsedToolArguments = + | { ok: true; value: Record } + | { + ok: false + errorCode: "UNKNOWN_TOOL" + available: string[] + } + | { + ok: false + errorCode: "INVALID_ARGS" + issues: z.core.$ZodIssue[] + schema: unknown + } + +export function parseToolArguments( + name: string, + raw: unknown +): ParsedToolArguments { + const definition = TOOL_BY_NAME.get(name) + if (!definition) { + return { + ok: false, + errorCode: "UNKNOWN_TOOL", + available: PUBLIC_TOOLS.map((tool) => tool.name), + } + } + const result = definition.schema.safeParse(raw ?? {}) + if (!result.success) { + return { + ok: false, + errorCode: "INVALID_ARGS", + issues: result.error.issues, + schema: z.toJSONSchema(definition.schema), + } + } + return { ok: true, value: result.data } +} + +export function toolSchema(name: string): unknown | null { + const definition = TOOL_BY_NAME.get(name) + return definition ? z.toJSONSchema(definition.schema) : null +} diff --git a/packages/codeg-plus-browser-mcp/src/errors.ts b/packages/codeg-plus-browser-mcp/src/errors.ts new file mode 100644 index 000000000..b39faecbf --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/errors.ts @@ -0,0 +1,61 @@ +export type BrowserErrorCode = + | "INVALID_ARGS" + | "UNKNOWN_TOOL" + | "RUNTIME_NOT_READY" + | "RUNTIME_START_FAILED" + | "BROWSER_NOT_FOUND" + | "BROWSER_CRASHED" + | "SESSION_STALE" + | "SURFACE_ALREADY_ATTACHED" + | "TAB_NOT_FOUND" + | "REF_NOT_FOUND" + | "URL_INVALID" + | "URL_SCHEME_BLOCKED" + | "URL_CREDENTIALS_BLOCKED" + | "DNS_FAILED" + | "SSRF_BLOCKED" + | "PATH_ESCAPE" + | "DOWNLOAD_FAILED" + | "TIMEOUT" + | "ABORTED" + | "RESULT_TOO_LARGE" + | "INTERNAL_ERROR" + +export class BrowserError extends Error { + readonly code: BrowserErrorCode + readonly retryable: boolean + readonly recovery?: "retry" | "recover_runtime" | "check_settings" + readonly cause?: unknown + + constructor( + code: BrowserErrorCode, + message: string, + options: { + cause?: unknown + retryable?: boolean + recovery?: "retry" | "recover_runtime" | "check_settings" + } = {} + ) { + super(message) + this.name = "BrowserError" + this.code = code + this.retryable = options.retryable ?? false + this.recovery = options.recovery + this.cause = options.cause + } +} + +export function asBrowserError(error: unknown): BrowserError { + if (error instanceof BrowserError) return error + if (error instanceof Error && error.name === "AbortError") { + return new BrowserError("ABORTED", "Browser operation was aborted", { + cause: error, + retryable: true, + recovery: "retry", + }) + } + return new BrowserError("INTERNAL_ERROR", "Browser operation failed", { + cause: error, + recovery: "recover_runtime", + }) +} diff --git a/packages/codeg-plus-browser-mcp/src/mcp-server.ts b/packages/codeg-plus-browser-mcp/src/mcp-server.ts new file mode 100644 index 000000000..f5ec10824 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/mcp-server.ts @@ -0,0 +1,574 @@ +import { randomUUID, timingSafeEqual } from "node:crypto" +import { + createServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from "node:http" + +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type Tool, +} from "@modelcontextprotocol/sdk/types.js" + +import { BrowserRuntime } from "./browser-runtime.js" +import { PUBLIC_TOOLS, toolSchema } from "./contracts.js" +import { BrowserError } from "./errors.js" +import type { + BrowserSurfaceAction, + BrowserSurfaceEvent, +} from "./runtime-types.js" + +export interface BrowserSidecarServerOptions { + runtime: BrowserRuntime + token: string + host?: "127.0.0.1" + port?: number + onControlEvent?: (event: Record) => void +} + +interface McpSession { + server: Server + transport: WebStandardStreamableHTTPServerTransport +} + +interface AuditEntry { + at: string + code: string +} + +const MAX_REQUEST_BYTES = 1_000_000 +const MAX_AUDIT_ENTRIES = 100 +const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/ + +export class BrowserSidecarServer { + private httpServer: HttpServer | null = null + private portValue: number | null = null + private readonly mcpSessions = new Map() + private readonly auditEntries: AuditEntry[] = [] + private readonly surfaceStreams = new Set() + + constructor(private readonly options: BrowserSidecarServerOptions) {} + + get port(): number | null { + return this.portValue + } + + async start(): Promise { + if (this.portValue !== null) return this.portValue + const host = this.options.host ?? "127.0.0.1" + const server = createServer((request, response) => { + void this.handle(request, response).catch(() => { + this.audit("request_internal_error") + if (!response.headersSent) { + this.json(response, 500, { error: "internal_error" }) + } else { + response.end() + } + }) + }) + this.httpServer = server + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(this.options.port ?? 0, host, () => { + server.off("error", reject) + resolve() + }) + }) + const address = server.address() + if (!address || typeof address === "string") { + await this.stop() + throw new Error("Loopback server did not publish a TCP port") + } + this.portValue = address.port + this.audit("sidecar_ready") + return address.port + } + + async stop(): Promise { + for (const [sessionId] of this.mcpSessions) { + await this.releaseMcpSession(sessionId) + } + await this.options.runtime.shutdown() + for (const response of this.surfaceStreams) response.end() + this.surfaceStreams.clear() + const server = this.httpServer + this.httpServer = null + this.portValue = null + if (server) { + await new Promise((resolve) => server.close(() => resolve())) + } + } + + private async handle( + request: IncomingMessage, + response: ServerResponse + ): Promise { + response.setHeader("Cache-Control", "no-store") + response.setHeader("X-Content-Type-Options", "nosniff") + if (!this.isLoopbackRequest(request)) { + this.audit("non_loopback_rejected") + this.json(response, 403, { error: "forbidden" }) + return + } + if (!this.isAuthorized(request)) { + this.audit("unauthorized") + response.setHeader("WWW-Authenticate", "Bearer") + this.json(response, 401, { error: "unauthorized" }) + return + } + + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1") + const path = requestUrl.pathname + if (path === "/health" && request.method === "GET") { + this.json(response, 200, { + ok: true, + sidecar: "ready", + runtime: this.options.runtime.status(), + }) + return + } + if (path === "/admin/diagnostics" && request.method === "GET") { + this.json(response, 200, { + status: this.options.runtime.status(), + audit: [...this.auditEntries], + }) + return + } + if (path === "/mcp") { + await this.handleMcp(request, response) + return + } + if (path === "/admin/surface/stream" && request.method === "GET") { + await this.handleSurfaceStream(requestUrl, request, response) + return + } + if (path.startsWith("/admin/") && request.method === "POST") { + await this.handleAdmin(path, request, response) + return + } + this.json(response, 404, { error: "not_found" }) + } + + private async handleMcp( + request: IncomingMessage, + response: ServerResponse + ): Promise { + if (request.method !== "POST") { + response.setHeader("Allow", "POST") + this.json(response, 405, { error: "method_not_allowed" }) + return + } + const sessionId = this.readSessionId(request) + if (!sessionId) { + this.audit("mcp_session_rejected") + this.json(response, 400, { error: "invalid_agent_session" }) + return + } + const body = await readJsonBody(request) + const session = await this.getMcpSession(sessionId) + const headers = new Headers() + for (const [name, value] of Object.entries(request.headers)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item) + } else if (value !== undefined) { + headers.set(name, value) + } + } + const webRequest = new Request( + `http://127.0.0.1:${this.portValue ?? 0}${request.url ?? "/mcp"}`, + { + method: "POST", + headers, + body: JSON.stringify(body), + } + ) + const webResponse = await session.transport.handleRequest(webRequest, { + parsedBody: body, + }) + await writeWebResponse(response, webResponse) + } + + private async handleAdmin( + path: string, + request: IncomingMessage, + response: ServerResponse + ): Promise { + if (path === "/admin/surface/ensure") { + const sessionId = await this.readAdminSessionId(request, response) + if (!sessionId) return + const snapshot = await this.options.runtime.ensureSurface(sessionId) + this.json(response, 200, snapshot) + return + } + if (path === "/admin/surface/action") { + const body = await readJsonBody(request) + const sessionId = readObjectString(body, "sessionId") + const command = readSurfaceAction(body) + if (!sessionId || !SESSION_ID_PATTERN.test(sessionId) || !command) { + this.json(response, 400, { error: "invalid_surface_action" }) + return + } + const snapshot = await this.options.runtime.surfaceAction( + sessionId, + command + ) + this.json(response, 200, snapshot) + return + } + if (path === "/admin/release-session") { + const body = await readJsonBody(request) + const sessionId = readObjectString(body, "sessionId") + if (!sessionId || !SESSION_ID_PATTERN.test(sessionId)) { + this.json(response, 400, { error: "invalid_agent_session" }) + return + } + await this.releaseMcpSession(sessionId) + this.audit("session_released") + this.json(response, 200, { ok: true }) + return + } + + const actionByPath: Record = { + "/admin/start": "runtime.start", + "/admin/stop": "runtime.stop", + "/admin/recover": "runtime.recover", + "/admin/doctor": "runtime.doctor", + "/admin/status": "runtime.status", + } + const action = actionByPath[path] + if (!action) { + this.json(response, 404, { error: "not_found" }) + return + } + await drainBody(request) + const result = await this.options.runtime.callTool("admin", action, {}) + this.audit(`admin_${action.slice("runtime.".length)}`) + this.options.onControlEvent?.({ + event: "browser-status", + status: this.options.runtime.status(), + }) + this.json(response, result.envelope.ok ? 200 : 409, result.envelope) + } + + private async readAdminSessionId( + request: IncomingMessage, + response: ServerResponse + ): Promise { + const body = await readJsonBody(request) + const sessionId = readObjectString(body, "sessionId") + if (!sessionId || !SESSION_ID_PATTERN.test(sessionId)) { + this.json(response, 400, { error: "invalid_agent_session" }) + return null + } + return sessionId + } + + private async handleSurfaceStream( + requestUrl: URL, + request: IncomingMessage, + response: ServerResponse + ): Promise { + const sessionId = requestUrl.searchParams.get("sessionId") + if (!sessionId || !SESSION_ID_PATTERN.test(sessionId)) { + this.json(response, 400, { error: "invalid_agent_session" }) + return + } + response.statusCode = 200 + response.setHeader("Content-Type", "application/x-ndjson; charset=utf-8") + response.setHeader("Connection", "keep-alive") + const writer = new BoundedSurfaceWriter(response) + let detach: (() => Promise) | null = null + try { + detach = await this.options.runtime.subscribeSurface(sessionId, (event) => + writer.send(event) + ) + } catch (error) { + if ( + error instanceof BrowserError && + error.code === "SURFACE_ALREADY_ATTACHED" + ) { + this.json(response, 409, { error: "surface_already_attached" }) + return + } + throw error + } + this.surfaceStreams.add(response) + response.flushHeaders() + let closed = false + const close = () => { + if (closed) return + closed = true + this.surfaceStreams.delete(response) + writer.close() + void detach?.() + } + request.once("aborted", close) + response.once("close", close) + } + + private async getMcpSession(sessionId: string): Promise { + const existing = this.mcpSessions.get(sessionId) + if (existing) return existing + + const server = new Server( + { name: "codeg-browser", version: "0.1.0" }, + { capabilities: { tools: {} } } + ) + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: PUBLIC_TOOLS.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: toolSchema(tool.name) as Tool["inputSchema"], + })), + })) + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (!request.params.name.startsWith("runtime.")) { + this.options.onControlEvent?.({ + event: "browser-session-activity", + sessionId, + }) + } + const output = await this.options.runtime.callTool( + sessionId, + request.params.name, + request.params.arguments + ) + const content: Array< + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string } + > = [ + { + type: "text", + text: JSON.stringify(output.envelope), + }, + ] + if (output.image) { + content.push({ + type: "image", + data: output.image.data, + mimeType: output.image.mimeType, + }) + } + this.audit(output.envelope.ok ? "tool_succeeded" : "tool_failed") + return { content, isError: !output.envelope.ok } + }) + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + enableJsonResponse: true, + }) + await server.connect(transport) + const created = { server, transport } + this.mcpSessions.set(sessionId, created) + return created + } + + private async releaseMcpSession(sessionId: string): Promise { + const session = this.mcpSessions.get(sessionId) + this.mcpSessions.delete(sessionId) + await this.options.runtime.releaseSession(sessionId) + await session?.transport.close().catch(() => undefined) + await session?.server.close().catch(() => undefined) + } + + private readSessionId(request: IncomingMessage): string | null { + const value = request.headers["x-codeg-browser-session"] + const sessionId = Array.isArray(value) ? value[0] : value + return sessionId && SESSION_ID_PATTERN.test(sessionId) ? sessionId : null + } + + private isAuthorized(request: IncomingMessage): boolean { + const header = request.headers.authorization + if (!header?.startsWith("Bearer ")) return false + const supplied = Buffer.from(header.slice("Bearer ".length), "utf8") + const expected = Buffer.from(this.options.token, "utf8") + return ( + supplied.length === expected.length && timingSafeEqual(supplied, expected) + ) + } + + private isLoopbackRequest(request: IncomingMessage): boolean { + const address = request.socket.remoteAddress?.replace(/^::ffff:/, "") + if (address !== "127.0.0.1" && address !== "::1") return false + const host = request.headers.host?.toLowerCase() + if (!host || this.portValue === null) return false + return ( + host === `127.0.0.1:${this.portValue}` || + host === `[::1]:${this.portValue}` + ) + } + + private json(response: ServerResponse, status: number, body: unknown): void { + const data = JSON.stringify(body) + response.statusCode = status + response.setHeader("Content-Type", "application/json; charset=utf-8") + response.setHeader("Content-Length", Buffer.byteLength(data)) + response.end(data) + } + + private audit(code: string): void { + this.auditEntries.push({ at: new Date().toISOString(), code }) + if (this.auditEntries.length > MAX_AUDIT_ENTRIES) this.auditEntries.shift() + } +} + +class BoundedSurfaceWriter { + private blocked = false + private closedValue = false + private readonly pending: BrowserSurfaceEvent[] = [] + private latestFrame: BrowserSurfaceEvent | null = null + + constructor(private readonly response: ServerResponse) { + response.on("drain", () => this.flush()) + } + + send(event: BrowserSurfaceEvent): void { + if (this.closedValue) return + if (this.blocked) { + if (event.type === "frame") this.latestFrame = event + else { + this.pending.push(event) + if (this.pending.length > 8) this.pending.shift() + } + return + } + this.write(event) + } + + close(): void { + this.closedValue = true + this.pending.length = 0 + this.latestFrame = null + } + + private write(event: BrowserSurfaceEvent): void { + if (!this.response.write(`${JSON.stringify(event)}\n`)) this.blocked = true + } + + private flush(): void { + if (this.closedValue) return + this.blocked = false + while (!this.blocked && this.pending.length > 0) { + this.write(this.pending.shift()!) + } + if (!this.blocked && this.latestFrame) { + const frame = this.latestFrame + this.latestFrame = null + this.write(frame) + } + } +} + +async function readJsonBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = [] + let size = 0 + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + size += buffer.length + if (size > MAX_REQUEST_BYTES) { + throw new Error("request_too_large") + } + chunks.push(buffer) + } + if (chunks.length === 0) return {} + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown +} + +async function drainBody(request: IncomingMessage): Promise { + let size = 0 + for await (const chunk of request) { + size += Buffer.byteLength(chunk) + if (size > MAX_REQUEST_BYTES) throw new Error("request_too_large") + } +} + +function readObjectString(value: unknown, key: string): string | null { + if (!value || typeof value !== "object") return null + const result = (value as Record)[key] + return typeof result === "string" ? result : null +} + +function readSurfaceAction(value: unknown): BrowserSurfaceAction | null { + if (!value || typeof value !== "object") return null + const body = value as Record + const action = body.action + if (action === "open") { + return typeof body.url === "string" ? { action, url: body.url } : { action } + } + if (action === "focus" || action === "close") { + return typeof body.targetId === "string" + ? { action, targetId: body.targetId } + : null + } + if (action === "navigate") { + return typeof body.url === "string" ? { action, url: body.url } : null + } + if (action === "resize") { + const width = body.width + const height = body.height + return Number.isInteger(width) && + Number.isInteger(height) && + (width as number) >= 1 && + (width as number) <= 4_096 && + (height as number) >= 1 && + (height as number) <= 4_096 + ? { action, width: width as number, height: height as number } + : null + } + if ( + action === "back" || + action === "forward" || + action === "reload" || + action === "stop" + ) { + return { action } + } + if (action === "input" && isSurfaceInput(body.input)) { + return { action, input: body.input } + } + return null +} + +function isSurfaceInput( + value: unknown +): value is Extract["input"] { + if (!value || typeof value !== "object") return false + const input = value as Record + if (input.kind === "text") return typeof input.text === "string" + if (input.kind === "key") { + return ( + (input.event === "down" || input.event === "up") && + typeof input.key === "string" + ) + } + if (input.kind === "mouse") { + return ( + (input.event === "pressed" || + input.event === "released" || + input.event === "moved" || + input.event === "wheel") && + typeof input.x === "number" && + typeof input.y === "number" + ) + } + return false +} + +async function writeWebResponse( + response: ServerResponse, + webResponse: Response +): Promise { + response.statusCode = webResponse.status + webResponse.headers.forEach((value, name) => response.setHeader(name, value)) + if (!webResponse.body) { + response.end() + return + } + const body = Buffer.from(await webResponse.arrayBuffer()) + response.setHeader("Content-Length", body.length) + response.end(body) +} diff --git a/packages/codeg-plus-browser-mcp/src/runtime-types.ts b/packages/codeg-plus-browser-mcp/src/runtime-types.ts new file mode 100644 index 000000000..bad61d582 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/runtime-types.ts @@ -0,0 +1,220 @@ +export type BrowserRuntimeState = + | "stopped" + | "starting" + | "ready" + | "recovering" + | "error" + +export interface BrowserRuntimeStatus { + state: BrowserRuntimeState + browserPid: number | null + browserName: string | null + browserVersion: string | null + sessionCount: number + recoveryAttempt: number + lastErrorCode: string | null +} + +export interface BrowserDoctorResult { + ok: boolean + checks: Array<{ + name: "browser" | "profile" | "downloads" | "cdp" | "process" + ok: boolean + detail: string + }> +} + +export interface BrowserTab { + id: string + url: string + title: string +} + +export interface BrowserSurfacePageState { + tab: BrowserTab + loading: boolean + canGoBack: boolean + canGoForward: boolean +} + +export interface BrowserSurfaceSnapshot { + sessionId: string + tabs: BrowserTab[] + activeTargetId: string | null + active: BrowserSurfacePageState | null +} + +export interface BrowserSurfaceFrame { + targetId: string + data: string + mimeType: "image/jpeg" + deviceWidth: number + deviceHeight: number + pageScaleFactor: number +} + +export type BrowserSurfaceEvent = + | { type: "snapshot"; snapshot: BrowserSurfaceSnapshot } + | { type: "frame"; frame: BrowserSurfaceFrame } + | { + type: "error" + code: string + message: string + retryable: boolean + } + +export type BrowserSurfaceInput = + | { + kind: "mouse" + event: "pressed" | "released" | "moved" | "wheel" + x: number + y: number + button?: "none" | "left" | "middle" | "right" + deltaX?: number + deltaY?: number + modifiers?: number + } + | { + kind: "key" + event: "down" | "up" + key: string + code?: string + text?: string + modifiers?: number + } + | { kind: "text"; text: string } + +export type BrowserSurfaceAction = + | { action: "open"; url?: string } + | { action: "focus"; targetId: string } + | { action: "close"; targetId: string } + | { action: "resize"; width: number; height: number } + | { action: "navigate"; url: string } + | { action: "back" } + | { action: "forward" } + | { action: "reload" } + | { action: "stop" } + | { action: "input"; input: BrowserSurfaceInput } + +export interface SemanticNode { + ref: string + role: string + name: string + value?: string + description?: string + disabled?: boolean + focused?: boolean +} + +export interface BrowserDownload { + guid: string + state: "inProgress" | "completed" | "canceled" + filename: string + receivedBytes: number + totalBytes: number + path?: string + errorCode?: string +} + +export interface ToolResultEnvelope { + ok: boolean + action: string + sessionId: string + tab?: BrowserTab + data?: unknown + error?: { + code: string + message: string + retryable: boolean + recovery?: string + } +} + +export interface BrowserToolOutput { + envelope: ToolResultEnvelope + image?: { + data: string + mimeType: "image/png" | "image/jpeg" + } +} + +export interface PageSnapshot { + nodes: SemanticNode[] + documentUrl: string + title: string +} + +export interface PageController { + info(): Promise + navigate(url: string, timeoutMs: number): Promise + snapshot(options: { + interactive: boolean + limit: number + timeoutMs: number + }): Promise + screenshot(options: { + format: "png" | "jpeg" + fullPage: boolean + quality?: number + timeoutMs: number + }): Promise<{ data: string; mimeType: "image/png" | "image/jpeg" }> + click(ref: string, timeoutMs: number): Promise + type( + ref: string, + text: string, + clear: boolean, + timeoutMs: number + ): Promise + press(key: string, timeoutMs: number): Promise + scroll( + ref: string | undefined, + deltaX: number, + deltaY: number, + timeoutMs: number + ): Promise + wait(options: { + milliseconds?: number + text?: string + urlIncludes?: string + timeoutMs: number + }): Promise + surfaceState(): Promise + goHistory(delta: -1 | 1, timeoutMs: number): Promise + reload(timeoutMs: number): Promise + stopLoading(timeoutMs: number): Promise + dispatchSurfaceInput( + input: BrowserSurfaceInput, + timeoutMs: number + ): Promise + setSurfaceViewport( + width: number, + height: number, + timeoutMs: number + ): Promise + startScreencast( + listener: (frame: BrowserSurfaceFrame) => void + ): Promise<() => Promise> + close(): Promise +} + +export interface BrowserBackend { + readonly state: BrowserRuntimeState + status(): BrowserRuntimeStatus + doctor(): Promise + start(): Promise + stop(): Promise + recover(): Promise + listTargets(): Promise + openTarget(url?: string): Promise + page(targetId: string): Promise + focusTarget(targetId: string): Promise + closeTarget(targetId: string): Promise + downloads(sessionId: string): Promise + waitForDownload( + sessionId: string, + guid: string | undefined, + timeoutMs: number + ): Promise + assignTarget(targetId: string, sessionId: string): void + releaseSession(sessionId: string): Promise +} diff --git a/packages/codeg-plus-browser-mcp/src/security.ts b/packages/codeg-plus-browser-mcp/src/security.ts new file mode 100644 index 000000000..ed677260d --- /dev/null +++ b/packages/codeg-plus-browser-mcp/src/security.ts @@ -0,0 +1,188 @@ +import { lookup as nodeLookup } from "node:dns/promises" +import { realpath } from "node:fs/promises" +import { isIP } from "node:net" +import { isAbsolute, relative, resolve, sep } from "node:path" + +import ipaddr from "ipaddr.js" + +import { BrowserError } from "./errors.js" + +export interface LookupAddress { + address: string + family: 4 | 6 +} + +export type DnsLookup = (hostname: string) => Promise + +export interface NavigationValidationOptions { + lookup?: DnsLookup + /** Exact hosts permitted only by deterministic tests; production passes none. */ + allowHosts?: ReadonlySet +} + +export type IpClassification = "public" | "private" | "special" + +export function classifyIpAddress(address: string): IpClassification { + let parsed: ipaddr.IPv4 | ipaddr.IPv6 + try { + parsed = ipaddr.parse(address.replace(/^\[|\]$/g, "")) + } catch { + return "special" + } + + if (parsed.kind() === "ipv6") { + const ipv6 = parsed as ipaddr.IPv6 + if (ipv6.isIPv4MappedAddress()) { + parsed = ipv6.toIPv4Address() + } + } + const range = parsed.range() + if (range === "unicast") return "public" + if ( + range === "private" || + range === "loopback" || + range === "linkLocal" || + range === "uniqueLocal" || + range === "carrierGradeNat" + ) { + return "private" + } + return "special" +} + +function defaultLookup(hostname: string): Promise { + return nodeLookup(hostname, { all: true, verbatim: true }) as Promise< + LookupAddress[] + > +} + +function hostnameIsLocallyScoped(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.$/, "") + return ( + normalized === "localhost" || + normalized.endsWith(".localhost") || + normalized.endsWith(".local") || + normalized.endsWith(".internal") || + normalized.endsWith(".home.arpa") + ) +} + +export async function validateNavigationUrl( + input: string, + options: NavigationValidationOptions = {} +): Promise<{ url: string; hostname: string; addresses: string[] }> { + let parsed: URL + try { + parsed = new URL(input) + } catch (error) { + throw new BrowserError("URL_INVALID", "URL is invalid", { cause: error }) + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new BrowserError( + "URL_SCHEME_BLOCKED", + "Only http:// and https:// URLs are allowed" + ) + } + if (parsed.username || parsed.password) { + throw new BrowserError( + "URL_CREDENTIALS_BLOCKED", + "Credentials embedded in URLs are not allowed" + ) + } + + const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase() + if (!hostname) { + throw new BrowserError("URL_INVALID", "URL hostname is missing") + } + if (options.allowHosts?.has(hostname)) { + return { url: parsed.toString(), hostname, addresses: [hostname] } + } + if (hostnameIsLocallyScoped(hostname)) { + throw new BrowserError("SSRF_BLOCKED", "Local hostnames are blocked") + } + + let addresses: LookupAddress[] + if (isIP(hostname)) { + addresses = [{ address: hostname, family: isIP(hostname) as 4 | 6 }] + } else { + try { + addresses = await (options.lookup ?? defaultLookup)(hostname) + } catch (error) { + throw new BrowserError( + "DNS_FAILED", + "URL hostname could not be resolved", + { + cause: error, + retryable: true, + recovery: "retry", + } + ) + } + } + if (addresses.length === 0) { + throw new BrowserError("DNS_FAILED", "URL hostname returned no addresses") + } + for (const answer of addresses) { + if (classifyIpAddress(answer.address) !== "public") { + throw new BrowserError( + "SSRF_BLOCKED", + "URL resolves to a private or special-use address" + ) + } + } + return { + url: parsed.toString(), + hostname, + addresses: addresses.map((answer) => answer.address), + } +} + +export interface HeaderEntry { + name: string + value: string +} + +const SENSITIVE_REDIRECT_HEADERS = new Set([ + "authorization", + "proxy-authorization", + "cookie", + "x-api-key", +]) + +export function sanitizeRedirectHeaders( + previousUrl: string, + nextUrl: string, + headers: readonly HeaderEntry[] +): HeaderEntry[] { + let sameOrigin = false + try { + sameOrigin = new URL(previousUrl).origin === new URL(nextUrl).origin + } catch { + sameOrigin = false + } + if (sameOrigin) return [...headers] + return headers.filter( + (header) => !SENSITIVE_REDIRECT_HEADERS.has(header.name.toLowerCase()) + ) +} + +export async function assertControlledPath( + rootPath: string, + candidatePath: string +): Promise { + const [rootReal, candidateReal] = await Promise.all([ + realpath(resolve(rootPath)), + realpath(resolve(candidatePath)), + ]) + const rel = relative(rootReal, candidateReal) + if ( + rel === "" || + (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)) + ) { + return candidateReal + } + throw new BrowserError( + "PATH_ESCAPE", + "Path escapes the controlled Browser directory" + ) +} diff --git a/packages/codeg-plus-browser-mcp/tsconfig.json b/packages/codeg-plus-browser-mcp/tsconfig.json new file mode 100644 index 000000000..99ff84908 --- /dev/null +++ b/packages/codeg-plus-browser-mcp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "scripts/**/*.mjs"] +} diff --git a/packages/codeg-plus-browser-mcp/vitest.config.ts b/packages/codeg-plus-browser-mcp/vitest.config.ts new file mode 100644 index 000000000..1c426ccaf --- /dev/null +++ b/packages/codeg-plus-browser-mcp/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/__tests__/**/*.test.ts"], + setupFiles: [], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82731e62d..cff740641 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,13 +37,13 @@ importers: version: 1.2.3(@types/react@19.2.13)(react@19.2.4) '@streamdown/cjk': specifier: ^1.0.2 - version: 1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5) + version: 1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(react@19.2.4)(unified@11.0.5) '@streamdown/code': specifier: ^1.0.2 version: 1.0.2(react@19.2.4) '@streamdown/math': specifier: ^1.0.2 - version: 1.0.2(react@19.2.4) + version: 1.0.2(react@19.2.4)(supports-color@7.2.0) '@streamdown/mermaid': specifier: ^1.0.2 version: 1.0.2(react@19.2.4) @@ -130,7 +130,7 @@ importers: version: 0.563.0(react@19.2.4) mdast-util-from-markdown: specifier: ^2.0.2 - version: 2.0.3 + version: 2.0.3(supports-color@7.2.0) monaco-editor: specifier: ^0.55.1 version: 0.55.1 @@ -139,10 +139,10 @@ importers: version: 12.34.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: ^16 - version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.1.6(@babel/core@7.29.0(supports-color@7.2.0))(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-intl: specifier: ^4.8.3 - version: 4.8.3(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.8.3) + version: 4.8.3(next@16.1.6(@babel/core@7.29.0(supports-color@7.2.0))(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.8.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -172,7 +172,7 @@ importers: version: 19.2.4(react@19.2.4) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.13)(react@19.2.4) + version: 10.1.0(@types/react@19.2.13)(react@19.2.4)(supports-color@7.2.0) react-resizable-panels: specifier: ^2.1.9 version: 2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -181,10 +181,10 @@ importers: version: 4.0.0 remark-gfm: specifier: ^4.0.1 - version: 4.0.1 + version: 4.0.1(supports-color@7.2.0) shadcn: specifier: ^3.8.4 - version: 3.8.4(@types/node@25.2.2)(babel-plugin-macros@3.1.0)(typescript@5.8.3) + version: 3.8.4(@types/node@25.2.2)(babel-plugin-macros@3.1.0)(supports-color@7.2.0)(typescript@5.8.3) shiki: specifier: ^3.22.0 version: 3.22.0 @@ -196,7 +196,7 @@ importers: version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) streamdown: specifier: ^2.2.0 - version: 2.2.0(react@19.2.4) + version: 2.2.0(react@19.2.4)(supports-color@7.2.0) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -239,37 +239,37 @@ importers: version: 19.2.4(@types/react@19.2.13) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2)) + version: 4.7.0(supports-color@7.2.0)(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2)) '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9) + version: 2.1.9(supports-color@7.2.0)(vitest@2.1.9) '@vitest/ui': specifier: ^2.1.8 version: 2.1.9(vitest@2.1.9) eslint: specifier: ^9.39.2 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) eslint-config-next: specifier: ^16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + version: 16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + version: 10.1.8(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)) eslint-plugin-prettier: specifier: ^5.5.5 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)))(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(prettier@3.8.1) jsdom: specifier: ^25.0.1 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) prettier: specifier: ^3.8.1 version: 3.8.1 remark-math: specifier: ^6.0.0 - version: 6.0.0 + version: 6.0.0(supports-color@7.2.0) remark-parse: specifier: ^11.0.0 - version: 11.0.0 + version: 11.0.0(supports-color@7.2.0) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -284,7 +284,41 @@ importers: version: 5.1.0 vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@25.2.2)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3)) + version: 2.1.9(@types/node@25.2.2)(@vitest/ui@2.1.9)(jsdom@25.0.1(supports-color@7.2.0))(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3))(supports-color@7.2.0) + + packages/codeg-plus-browser-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: 1.30.0 + version: 1.30.0(supports-color@7.2.0)(zod@4.4.3) + ipaddr.js: + specifier: 2.5.0 + version: 2.5.0 + ws: + specifier: 8.21.3 + version: 8.21.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: 26.2.0 + version: 26.2.0 + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 + '@yao-pkg/pkg': + specifier: 6.22.0 + version: 6.22.0(supports-color@7.2.0) + esbuild: + specifier: 0.28.2 + version: 0.28.2 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vitest: + specifier: 2.1.8 + version: 2.1.8(@types/node@26.2.0)(@vitest/ui@2.1.9(vitest@2.1.9))(jsdom@25.0.1(supports-color@7.2.0))(lightningcss@1.30.2)(msw@2.12.9(@types/node@26.2.0)(typescript@5.8.3))(supports-color@7.2.0) packages: @@ -582,138 +616,294 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1028,6 +1218,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@istanbuljs/schema@0.1.6': resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} @@ -1051,8 +1245,8 @@ packages: '@mermaid-js/parser@1.0.1': resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==} - '@modelcontextprotocol/sdk@1.26.0': - resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -2137,6 +2331,10 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + '@roberts_lando/vfs@0.3.3': + resolution: {integrity: sha512-YjkxVSLw5WMZQoARaryRAjcxA+GbBzWMJdwYZX5oLUt9cC/gew9as4Dn7tcLzPp7BPoR221VpTZ+78TRPawnjg==} + engines: {node: '>= 22'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -2952,6 +3150,9 @@ packages: '@types/node@25.2.2': resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -2981,6 +3182,9 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.54.0': resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3169,9 +3373,23 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@2.1.8': + resolution: {integrity: sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/mocker@2.1.8': + resolution: {integrity: sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@2.1.9': resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: @@ -3183,15 +3401,27 @@ packages: vite: optional: true + '@vitest/pretty-format@2.1.8': + resolution: {integrity: sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==} + '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + '@vitest/runner@2.1.8': + resolution: {integrity: sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==} + '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + '@vitest/snapshot@2.1.8': + resolution: {integrity: sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==} + '@vitest/snapshot@2.1.9': resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/spy@2.1.8': + resolution: {integrity: sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==} + '@vitest/spy@2.1.9': resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} @@ -3200,6 +3430,9 @@ packages: peerDependencies: vitest: 2.1.9 + '@vitest/utils@2.1.8': + resolution: {integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==} + '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} @@ -3216,6 +3449,15 @@ packages: '@xterm/xterm@6.0.0': resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + '@yao-pkg/pkg-fetch@3.6.5': + resolution: {integrity: sha512-Sd1Hff7imsF2rcZ2GLQuIzVm2fc3Za+nE4KSWPTwIYegN/r90UFg3bq0MLSugbWQaVht72zzxV0MiKE0wvT1zA==} + hasBin: true + + '@yao-pkg/pkg@6.22.0': + resolution: {integrity: sha512-u+ZgwLsvEFB+Q1rA+IGymgxnm+anl1qGJWsTH6hDHyy2cCiOvMteDNK7NgqFYxN/zdithtORIlCsYSyhAXAgQA==} + engines: {node: '>=22.0.0'} + hasBin: true + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3368,6 +3610,14 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -3382,6 +3632,46 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.0: + resolution: {integrity: sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.5.2: + resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.38: resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} engines: {node: '>=6.0.0'} @@ -3391,6 +3681,12 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3419,6 +3715,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -3492,6 +3791,13 @@ packages: chevrotain@11.1.2: resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3510,6 +3816,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3557,6 +3866,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -3586,6 +3899,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -3839,6 +4155,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dedent@1.7.1: resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: @@ -3851,6 +4171,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -3931,6 +4255,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3960,6 +4287,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.19.0: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} @@ -4015,6 +4345,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -4184,6 +4519,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -4200,6 +4538,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4227,6 +4569,9 @@ packages: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -4336,6 +4681,13 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + fs-extra@11.3.3: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} @@ -4412,6 +4764,9 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -4584,6 +4939,9 @@ packages: icu-minify@4.8.3: resolution: {integrity: sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4607,6 +4965,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -4624,6 +4985,10 @@ packages: intl-messageformat@11.1.2: resolution: {integrity: sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==} + into-stream@9.1.0: + resolution: {integrity: sha512-DRsRnQrbzdFjaQ1oe4C6/EIUymIOEix1qROEJTF9dbMq+M4Zrm6VaLp6SD/B9IsiEjPZuBSnWWFN+udajugdWA==} + engines: {node: '>=20'} + ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -4632,6 +4997,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -4817,6 +5186,9 @@ packages: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -5325,6 +5697,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -5351,6 +5727,13 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -5394,6 +5777,9 @@ packages: typescript: optional: true + multistream@4.1.0: + resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5403,6 +5789,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -5455,6 +5844,10 @@ packages: sass: optional: true + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -5467,6 +5860,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -5701,10 +6097,21 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -5726,6 +6133,13 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + promise-stream-reader@1.0.1: resolution: {integrity: sha512-Tnxit5trUjBAqqZCGWwjyxhmgMN4hGrtpW3Oc/tRI4bpm/O2+ej72BB08l6JBnGQgVDGCLvHFGjGgQS6vzhwXg==} engines: {node: '>8.0.0'} @@ -5783,6 +6197,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -5820,6 +6237,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-day-picker@9.14.0: resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} engines: {node: '>=18'} @@ -5887,6 +6308,13 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -5983,6 +6411,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -6046,6 +6478,12 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -6141,6 +6579,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -6193,11 +6637,17 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-meter@1.0.4: + resolution: {integrity: sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==} + streamdown@2.2.0: resolution: {integrity: sha512-Y51o1I/sjpAy4Yn7j7R4TbUl9gcUZ7BTrHS+68IhrUBoYpNQZ28z06vww1MBFu4mSwvgF8xQIxIH2b9S9IHDyQ==} peerDependencies: react: ^18.0.0 || ^19.0.0 + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -6236,6 +6686,12 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -6267,6 +6723,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -6325,10 +6785,33 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-fs@3.1.3: + resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -6428,6 +6911,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -6484,6 +6970,13 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -6526,6 +7019,9 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + unzipper@0.12.5: + resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -6614,6 +7110,11 @@ packages: vue: optional: true + vite-node@2.1.8: + resolution: {integrity: sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -6650,15 +7151,15 @@ packages: terser: optional: true - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + vitest@2.1.8: + resolution: {integrity: sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 + '@vitest/browser': 2.1.8 + '@vitest/ui': 2.1.8 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -6675,15 +7176,40 @@ packages: jsdom: optional: true - vscode-jsonrpc@8.2.0: - resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} - engines: {node: '>=14.0.0'} - - vscode-languageserver-protocol@3.17.5: - resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} - - vscode-languageserver-textdocument@1.0.12: - resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} vscode-languageserver-types@3.17.5: resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} @@ -6776,8 +7302,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -6809,14 +7335,26 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@1.10.3: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + yargs@17.7.3: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} @@ -6847,6 +7385,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} @@ -6925,20 +7466,20 @@ snapshots: '@babel/compat-data@7.29.0': {} - '@babel/core@7.29.0': + '@babel/core@7.29.0(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.28.6 '@babel/parser': 7.29.0 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -6965,41 +7506,41 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0) '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0) + '@babel/traverse': 7.29.0(supports-color@7.2.0) semver: 6.3.1 transitivePeerDependencies: - supports-color '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.28.5': + '@babel/helper-member-expression-to-functions@7.28.5(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.28.6(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -7009,18 +7550,18 @@ snapshots: '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0) '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + '@babel/helper-skip-transparent-expression-wrappers@7.27.1(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -7042,53 +7583,53 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0)) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -7102,7 +7643,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.0(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 @@ -7110,7 +7651,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -7230,83 +7771,161 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.1(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -7319,10 +7938,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.3(supports-color@7.2.0)': dependencies: ajv: 6.12.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -7527,6 +8146,14 @@ snapshots: optionalDependencies: '@types/node': 25.2.2 + '@inquirer/confirm@5.1.21(@types/node@26.2.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.2.0) + '@inquirer/type': 3.0.10(@types/node@26.2.0) + optionalDependencies: + '@types/node': 26.2.0 + optional: true + '@inquirer/core@10.3.2(@types/node@25.2.2)': dependencies: '@inquirer/ansi': 1.0.2 @@ -7540,12 +8167,31 @@ snapshots: optionalDependencies: '@types/node': 25.2.2 + '@inquirer/core@10.3.2(@types/node@26.2.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.2.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.2.0 + optional: true + '@inquirer/figures@1.0.15': {} '@inquirer/type@3.0.10(@types/node@25.2.2)': optionalDependencies: '@types/node': 25.2.2 + '@inquirer/type@3.0.10(@types/node@26.2.0)': + optionalDependencies: + '@types/node': 26.2.0 + optional: true + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.1': @@ -7561,6 +8207,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@istanbuljs/schema@0.1.6': {} '@jridgewell/gen-mapping@0.3.13': @@ -7586,7 +8236,7 @@ snapshots: dependencies: langium: 4.2.1 - '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.9) ajv: 8.17.1 @@ -7596,8 +8246,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) + express: 5.2.1(supports-color@7.2.0) + express-rate-limit: 8.2.1(express@5.2.1(supports-color@7.2.0)) hono: 4.11.9 jose: 6.1.3 json-schema-typed: 8.0.2 @@ -7608,6 +8258,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.9) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1(supports-color@7.2.0) + express-rate-limit: 8.2.1(express@5.2.1(supports-color@7.2.0)) + hono: 4.11.9 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.1(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@monaco-editor/loader@1.7.0': dependencies: state-local: 1.0.7 @@ -8652,6 +9324,8 @@ snapshots: '@radix-ui/rect@1.1.2': {} + '@roberts_lando/vfs@0.3.3': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.60.4': @@ -8772,11 +9446,11 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@streamdown/cjk@1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5)': + '@streamdown/cjk@1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(react@19.2.4)(unified@11.0.5)': dependencies: react: 19.2.4 - remark-cjk-friendly: 1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) - remark-cjk-friendly-gfm-strikethrough: 1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) + remark-cjk-friendly: 1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) + remark-cjk-friendly-gfm-strikethrough: 1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) unist-util-visit: 5.1.0 transitivePeerDependencies: - '@types/mdast' @@ -8789,12 +9463,12 @@ snapshots: react: 19.2.4 shiki: 3.22.0 - '@streamdown/math@1.0.2(react@19.2.4)': + '@streamdown/math@1.0.2(react@19.2.4)(supports-color@7.2.0)': dependencies: katex: 0.16.45 react: 19.2.4 rehype-katex: 7.0.1 - remark-math: 6.0.0 + remark-math: 6.0.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -9404,6 +10078,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + '@types/parse-json@4.0.2': optional: true @@ -9428,15 +10106,19 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.2.2 + + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.8.3) @@ -9444,23 +10126,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.54.0(supports-color@7.2.0)(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.54.0 - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.54.0(typescript@5.8.3)': + '@typescript-eslint/project-service@8.54.0(supports-color@7.2.0)(typescript@5.8.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.8.3) '@typescript-eslint/types': 8.54.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -9474,13 +10156,13 @@ snapshots: dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/typescript-estree': 8.54.0(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) ts-api-utils: 2.4.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -9488,13 +10170,13 @@ snapshots: '@typescript-eslint/types@8.54.0': {} - '@typescript-eslint/typescript-estree@8.54.0(typescript@5.8.3)': + '@typescript-eslint/typescript-estree@8.54.0(supports-color@7.2.0)(typescript@5.8.3)': dependencies: - '@typescript-eslint/project-service': 8.54.0(typescript@5.8.3) + '@typescript-eslint/project-service': 8.54.0(supports-color@7.2.0)(typescript@5.8.3) '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.8.3) '@typescript-eslint/types': 8.54.0 '@typescript-eslint/visitor-keys': 8.54.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 9.0.5 semver: 7.7.4 tinyglobby: 0.2.15 @@ -9503,13 +10185,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.8.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/typescript-estree': 8.54.0(supports-color@7.2.0)(typescript@5.8.3) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -9587,11 +10269,11 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2))': + '@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0(supports-color@7.2.0)) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0(supports-color@7.2.0)) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 @@ -9599,24 +10281,31 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@2.1.9(vitest@2.1.9)': + '@vitest/coverage-v8@2.1.9(supports-color@7.2.0)(vitest@2.1.9)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 + istanbul-lib-source-maps: 5.0.6(supports-color@7.2.0) istanbul-reports: 3.2.0 magic-string: 0.30.21 magicast: 0.3.5 std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@25.2.2)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3)) + vitest: 2.1.9(@types/node@25.2.2)(@vitest/ui@2.1.9)(jsdom@25.0.1(supports-color@7.2.0))(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3))(supports-color@7.2.0) transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.8': + dependencies: + '@vitest/spy': 2.1.8 + '@vitest/utils': 2.1.8 + chai: 5.3.3 + tinyrainbow: 1.2.0 + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -9624,6 +10313,15 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 + '@vitest/mocker@2.1.8(msw@2.12.9(@types/node@26.2.0)(typescript@5.8.3))(vite@5.4.21(@types/node@26.2.0)(lightningcss@1.30.2))': + dependencies: + '@vitest/spy': 2.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.9(@types/node@26.2.0)(typescript@5.8.3) + vite: 5.4.21(@types/node@26.2.0)(lightningcss@1.30.2) + '@vitest/mocker@2.1.9(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3))(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2))': dependencies: '@vitest/spy': 2.1.9 @@ -9633,21 +10331,40 @@ snapshots: msw: 2.12.9(@types/node@25.2.2)(typescript@5.8.3) vite: 5.4.21(@types/node@25.2.2)(lightningcss@1.30.2) + '@vitest/pretty-format@2.1.8': + dependencies: + tinyrainbow: 1.2.0 + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 + '@vitest/runner@2.1.8': + dependencies: + '@vitest/utils': 2.1.8 + pathe: 1.1.2 + '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 pathe: 1.1.2 + '@vitest/snapshot@2.1.8': + dependencies: + '@vitest/pretty-format': 2.1.8 + magic-string: 0.30.21 + pathe: 1.1.2 + '@vitest/snapshot@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 magic-string: 0.30.21 pathe: 1.1.2 + '@vitest/spy@2.1.8': + dependencies: + tinyspy: 3.0.2 + '@vitest/spy@2.1.9': dependencies: tinyspy: 3.0.2 @@ -9661,7 +10378,13 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@25.2.2)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3)) + vitest: 2.1.9(@types/node@25.2.2)(@vitest/ui@2.1.9)(jsdom@25.0.1(supports-color@7.2.0))(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3))(supports-color@7.2.0) + + '@vitest/utils@2.1.8': + dependencies: + '@vitest/pretty-format': 2.1.8 + loupe: 3.2.1 + tinyrainbow: 1.2.0 '@vitest/utils@2.1.9': dependencies: @@ -9680,6 +10403,46 @@ snapshots: '@xterm/xterm@6.0.0': {} + '@yao-pkg/pkg-fetch@3.6.5': + dependencies: + picocolors: 1.1.1 + progress: 2.0.3 + semver: 7.7.4 + tar-fs: 3.1.3 + undici: 7.29.0 + yargs: 16.2.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + '@yao-pkg/pkg@6.22.0(supports-color@7.2.0)': + dependencies: + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) + '@babel/types': 7.29.0 + '@roberts_lando/vfs': 0.3.3 + '@yao-pkg/pkg-fetch': 3.6.5 + esbuild: 0.28.2 + into-stream: 9.1.0 + multistream: 4.1.0 + picocolors: 1.1.1 + picomatch: 4.0.3 + postject: 1.0.0-alpha.6 + prebuild-install: 7.1.3 + resolve: 1.22.12 + resolve.exports: 2.0.3 + stream-meter: 1.0.4 + tar: 7.5.22 + tinyglobby: 0.2.15 + unzipper: 0.12.5 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -9844,6 +10607,8 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.8.1: {} + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.29.2 @@ -9857,15 +10622,54 @@ snapshots: balanced-match@4.0.4: {} + bare-events@2.9.1: {} + + bare-fs@4.8.0: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.5.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.5.2: + dependencies: + bare-path: 3.1.1 + + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.38: {} baseline-browser-mapping@2.9.19: {} - body-parser@2.2.2: + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird@3.7.2: {} + + body-parser@2.2.2(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -9908,6 +10712,11 @@ snapshots: node-releases: 2.0.48 update-browserslist-db: 1.2.3(browserslist@4.28.4) + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -9980,6 +10789,10 @@ snapshots: '@chevrotain/utils': 11.1.2 lodash-es: 4.17.23 + chownr@1.1.4: {} + + chownr@3.0.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -9994,6 +10807,12 @@ snapshots: client-only@0.0.1: {} + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -10036,6 +10855,8 @@ snapshots: commander@8.3.0: {} + commander@9.5.0: {} + concat-map@0.0.1: {} confbox@0.1.8: {} @@ -10052,6 +10873,8 @@ snapshots: cookie@1.1.1: {} + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -10317,13 +11140,17 @@ snapshots: dayjs@1.11.20: {} - debug@3.2.7: + debug@3.2.7(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decimal.js@10.6.0: {} @@ -10331,12 +11158,18 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + dedent@1.7.1(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -10406,6 +11239,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + eastasianwidth@0.2.0: {} eciesjs@0.4.17: @@ -10429,6 +11266,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 @@ -10466,7 +11307,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -10537,7 +11378,7 @@ snapshots: es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 es-to-primitive@1.3.0: dependencies: @@ -10571,6 +11412,35 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-carriage@1.3.1: {} @@ -10581,18 +11451,18 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3): + eslint-config-next@16.1.6(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3): dependencies: '@next/eslint-plugin-next': 16.1.6 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) + eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)) + eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) globals: 16.4.0 - typescript-eslint: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + typescript-eslint: 8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -10601,56 +11471,56 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) - eslint-import-resolver-node@0.3.9: + eslint-import-resolver-node@0.3.9(supports-color@7.2.0): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@7.2.0) is-core-module: 2.16.1 resolve: 1.22.11 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) get-tsconfig: 4.13.6 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@7.2.0) optionalDependencies: - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) + eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7 + debug: 3.2.7(supports-color@7.2.0) doctrine: 2.1.0 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) + eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -10662,13 +11532,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -10678,7 +11548,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -10687,27 +11557,27 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)))(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(prettier@3.8.1): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)) - eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/parser': 7.29.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) hermes-parser: 0.25.1 - zod: 3.25.76 - zod-validation-error: 4.0.2(zod@3.25.76) + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -10715,7 +11585,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.2 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -10738,14 +11608,14 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(jiti@2.6.1): + eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.1(supports-color@7.2.0) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 + '@eslint/eslintrc': 3.3.3(supports-color@7.2.0) '@eslint/js': 9.39.2 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -10755,7 +11625,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -10807,6 +11677,12 @@ snapshots: etag@1.8.1: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -10840,27 +11716,29 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + expect-type@1.3.0: {} - express-rate-limit@8.2.1(express@5.2.1): + express-rate-limit@8.2.1(express@5.2.1(supports-color@7.2.0)): dependencies: - express: 5.2.1 + express: 5.2.1(supports-color@7.2.0) ip-address: 10.0.1 - express@5.2.1: + express@5.2.1(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.2.2(supports-color@7.2.0) content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@7.2.0) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -10871,9 +11749,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.14.1 range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) statuses: 2.0.2 type-is: 2.0.1 vary: 1.1.2 @@ -10888,6 +11766,8 @@ snapshots: fast-equals@5.4.0: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10937,9 +11817,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -11006,6 +11886,14 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + + fs-extra@11.3.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fs-extra@11.3.3: dependencies: graceful-fs: 4.2.11 @@ -11023,7 +11911,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.4 is-callable: 1.2.7 functions-have-names@1.2.3: {} @@ -11050,7 +11938,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -11081,6 +11969,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -11140,7 +12030,6 @@ snapshots: hasown@2.0.4: dependencies: function-bind: 1.1.2 - optional: true hast-util-from-dom@5.0.1: dependencies: @@ -11219,7 +12108,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6: + hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 @@ -11228,9 +12117,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -11298,17 +12187,17 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -11328,6 +12217,8 @@ snapshots: dependencies: '@formatjs/icu-messageformat-parser': 3.5.1 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.6: {} @@ -11343,12 +12234,14 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + inline-style-parser@0.2.7: {} internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.4 side-channel: 1.1.0 internmap@1.0.1: {} @@ -11362,10 +12255,14 @@ snapshots: '@formatjs/icu-messageformat-parser': 3.5.1 tslib: 2.8.1 + into-stream@9.1.0: {} + ip-address@10.0.1: {} ipaddr.js@1.9.1: {} + ipaddr.js@2.5.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -11411,7 +12308,6 @@ snapshots: is-core-module@2.16.2: dependencies: hasown: 2.0.4 - optional: true is-data-view@1.0.2: dependencies: @@ -11484,7 +12380,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 is-regexp@3.1.0: {} @@ -11532,6 +12428,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -11546,10 +12444,10 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: + istanbul-lib-source-maps@5.0.6(supports-color@7.2.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -11584,15 +12482,15 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@25.0.1: + jsdom@25.0.1(supports-color@7.2.0): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 decimal.js: 10.6.0 form-data: 4.0.5 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.23 parse5: 7.3.0 @@ -11605,7 +12503,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.20.1 + ws: 8.21.3 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -11809,14 +12707,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@7.2.0) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -11834,79 +12732,79 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0: + mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0: + mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0: + mdast-util-gfm-table@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0: + mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0: + mdast-util-gfm@3.1.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-math@3.0.0: + mdast-util-math@3.0.0(supports-color@7.2.0): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 longest-streak: 3.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 unist-util-remove-position: 5.0.0 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: + mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0: + mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -11914,7 +12812,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -11923,13 +12821,13 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1: + mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -12023,11 +12921,11 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-cjk-friendly-gfm-strikethrough@1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2): + micromark-extension-cjk-friendly-gfm-strikethrough@1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): dependencies: devlop: 1.1.0 get-east-asian-width: 1.4.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@7.2.0) micromark-extension-cjk-friendly-util: 2.1.1(micromark-util-types@2.0.2) micromark-util-character: 2.1.1 micromark-util-chunked: 2.0.1 @@ -12044,10 +12942,10 @@ snapshots: optionalDependencies: micromark-util-types: 2.0.2 - micromark-extension-cjk-friendly@1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2): + micromark-extension-cjk-friendly@1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): dependencies: devlop: 1.1.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@7.2.0) micromark-extension-cjk-friendly-util: 2.1.1(micromark-util-types@2.0.2) micromark-util-chunked: 2.0.1 micromark-util-resolve-all: 2.0.1 @@ -12215,10 +13113,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@7.2.0): dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -12258,6 +13156,8 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@3.1.0: {} + min-indent@1.0.1: {} minimatch@10.1.2: @@ -12280,6 +13180,12 @@ snapshots: minipass@7.1.3: {} + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp-classic@0.5.3: {} + mlly@1.8.0: dependencies: acorn: 8.16.0 @@ -12336,10 +13242,43 @@ snapshots: transitivePeerDependencies: - '@types/node' + msw@2.12.9(@types/node@26.2.0)(typescript@5.8.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@26.2.0) + '@mswjs/interceptors': 0.41.2 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@types/node' + optional: true + + multistream@4.1.0: + dependencies: + once: 1.4.0 + readable-stream: 3.6.2 + mute-stream@2.0.0: {} nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -12348,14 +13287,14 @@ snapshots: next-intl-swc-plugin-extractor@4.8.3: {} - next-intl@4.8.3(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.8.3): + next-intl@4.8.3(next@16.1.6(@babel/core@7.29.0(supports-color@7.2.0))(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.8.3): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@parcel/watcher': 2.5.6 '@swc/core': 1.15.18 icu-minify: 4.8.3 negotiator: 1.0.0 - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.6(@babel/core@7.29.0(supports-color@7.2.0))(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-intl-swc-plugin-extractor: 4.8.3 po-parser: 2.1.1 react: 19.2.4 @@ -12370,7 +13309,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.1.6(@babel/core@7.29.0(supports-color@7.2.0))(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 @@ -12379,7 +13318,7 @@ snapshots: postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0(supports-color@7.2.0))(babel-plugin-macros@3.1.0)(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.1.6 '@next/swc-darwin-x64': 16.1.6 @@ -12395,6 +13334,10 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-abi@3.94.0: + dependencies: + semver: 7.7.4 + node-addon-api@7.1.1: {} node-domexception@1.0.0: {} @@ -12405,6 +13348,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-int64@0.4.0: {} + node-releases@2.0.27: {} node-releases@2.0.48: {} @@ -12652,8 +13597,27 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -12672,6 +13636,10 @@ snapshots: dependencies: parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} + + progress@2.0.3: {} + promise-stream-reader@1.0.1: {} prompts@2.4.2: @@ -12766,6 +13734,11 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} qrcode.react@4.2.0(react@19.2.4): @@ -12850,6 +13823,13 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-day-picker@9.14.0(react@19.2.4): dependencies: '@date-fns/tz': 1.5.0 @@ -12867,17 +13847,17 @@ snapshots: react-is@17.0.2: {} - react-markdown@10.1.0(@types/react@19.2.13)(react@19.2.4): + react-markdown@10.1.0(@types/react@19.2.13)(react@19.2.4)(supports-color@7.2.0): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/react': 19.2.13 devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6 + hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 react: 19.2.4 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@7.2.0) remark-rehype: 11.1.2 unified: 11.0.5 unist-util-visit: 5.1.0 @@ -12921,6 +13901,22 @@ snapshots: react@19.2.4: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -12995,9 +13991,9 @@ snapshots: mdast-util-newline-to-break: 2.0.0 unified: 11.0.5 - remark-cjk-friendly-gfm-strikethrough@1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): + remark-cjk-friendly-gfm-strikethrough@1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): dependencies: - micromark-extension-cjk-friendly-gfm-strikethrough: 1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2) + micromark-extension-cjk-friendly-gfm-strikethrough: 1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) unified: 11.0.5 optionalDependencies: '@types/mdast': 4.0.4 @@ -13005,9 +14001,9 @@ snapshots: - micromark - micromark-util-types - remark-cjk-friendly@1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): + remark-cjk-friendly@1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): dependencies: - micromark-extension-cjk-friendly: 1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2) + micromark-extension-cjk-friendly: 1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) unified: 11.0.5 optionalDependencies: '@types/mdast': 4.0.4 @@ -13015,30 +14011,30 @@ snapshots: - micromark - micromark-util-types - remark-gfm@4.0.1: + remark-gfm@4.0.1(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 + mdast-util-gfm: 3.1.0(supports-color@7.2.0) micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@7.2.0) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-math@6.0.0: + remark-math@6.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-math: 3.0.0 + mdast-util-math: 3.0.0(supports-color@7.2.0) micromark-extension-math: 3.1.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -13070,6 +14066,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -13082,7 +14080,6 @@ snapshots: is-core-module: 2.16.2 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - optional: true resolve@2.0.0-next.5: dependencies: @@ -13141,9 +14138,9 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - router@2.2.0: + router@2.2.0(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -13171,6 +14168,10 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -13194,9 +14195,9 @@ snapshots: semver@7.7.4: {} - send@1.2.1: + send@1.2.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -13210,12 +14211,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.1: + serve-static@2.2.1(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -13243,15 +14244,15 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@3.8.4(@types/node@25.2.2)(babel-plugin-macros@3.1.0)(typescript@5.8.3): + shadcn@3.8.4(@types/node@25.2.2)(babel-plugin-macros@3.1.0)(supports-color@7.2.0)(typescript@5.8.3): dependencies: '@antfu/ni': 25.0.0 - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/parser': 7.29.0 - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@dotenvx/dotenvx': 1.52.0 - '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.1 commander: 14.0.3 @@ -13263,7 +14264,7 @@ snapshots: fast-glob: 3.3.3 fs-extra: 11.3.3 fuzzysort: 3.1.0 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@7.2.0) kleur: 4.1.5 msw: 2.12.9(@types/node@25.2.2)(typescript@5.8.3) node-fetch: 3.3.2 @@ -13370,6 +14371,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -13408,18 +14417,22 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - streamdown@2.2.0(react@19.2.4): + stream-meter@1.0.4: + dependencies: + readable-stream: 2.3.8 + + streamdown@2.2.0(react@19.2.4)(supports-color@7.2.0): dependencies: clsx: 2.1.1 - hast-util-to-jsx-runtime: 2.3.6 + hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) html-url-attributes: 3.0.1 marked: 17.0.1 react: 19.2.4 rehype-harden: 1.1.7 rehype-raw: 7.0.0 rehype-sanitize: 6.0.0 - remark-gfm: 4.0.1 - remark-parse: 11.0.0 + remark-gfm: 4.0.1(supports-color@7.2.0) + remark-parse: 11.0.0(supports-color@7.2.0) remark-rehype: 11.1.2 remend: 1.2.0 tailwind-merge: 3.4.0 @@ -13429,6 +14442,15 @@ snapshots: transitivePeerDependencies: - supports-color + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-event-emitter@0.5.1: {} string-width@4.2.3: @@ -13499,6 +14521,14 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -13528,6 +14558,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} style-to-js@1.1.21: @@ -13538,12 +14570,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.29.0(supports-color@7.2.0))(babel-plugin-macros@3.1.0)(react@19.2.4): dependencies: client-only: 0.0.1 react: 19.2.4 optionalDependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) babel-plugin-macros: 3.1.0 stylis@4.3.6: {} @@ -13570,12 +14602,71 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-fs@3.1.3: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.8.0 + bare-path: 3.1.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.8.0 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.6 glob: 10.4.5 minimatch: 10.2.5 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + tiny-inflate@1.0.3: {} tiny-invariant@1.3.3: {} @@ -13659,6 +14750,10 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -13708,13 +14803,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3): + typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.54.0(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + eslint: 9.39.2(jiti@2.6.1)(supports-color@7.2.0) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -13734,6 +14829,10 @@ snapshots: undici-types@7.16.0: {} + undici-types@8.3.0: {} + + undici@7.29.0: {} + unicorn-magic@0.3.0: {} unified@11.0.5: @@ -13809,6 +14908,14 @@ snapshots: until-async@3.0.2: {} + unzipper@0.12.5: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.1 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -13884,10 +14991,28 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vite-node@2.1.9(@types/node@25.2.2)(lightningcss@1.30.2): + vite-node@2.1.8(@types/node@26.2.0)(lightningcss@1.30.2)(supports-color@7.2.0): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@26.2.0)(lightningcss@1.30.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite-node@2.1.9(@types/node@25.2.2)(lightningcss@1.30.2)(supports-color@7.2.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@7.2.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@25.2.2)(lightningcss@1.30.2) @@ -13912,7 +15037,54 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.2 - vitest@2.1.9(@types/node@25.2.2)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3)): + vite@5.4.21(@types/node@26.2.0)(lightningcss@1.30.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.60.4 + optionalDependencies: + '@types/node': 26.2.0 + fsevents: 2.3.3 + lightningcss: 1.30.2 + + vitest@2.1.8(@types/node@26.2.0)(@vitest/ui@2.1.9(vitest@2.1.9))(jsdom@25.0.1(supports-color@7.2.0))(lightningcss@1.30.2)(msw@2.12.9(@types/node@26.2.0)(typescript@5.8.3))(supports-color@7.2.0): + dependencies: + '@vitest/expect': 2.1.8 + '@vitest/mocker': 2.1.8(msw@2.12.9(@types/node@26.2.0)(typescript@5.8.3))(vite@5.4.21(@types/node@26.2.0)(lightningcss@1.30.2)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.8 + '@vitest/snapshot': 2.1.8 + '@vitest/spy': 2.1.8 + '@vitest/utils': 2.1.8 + chai: 5.3.3 + debug: 4.4.3(supports-color@7.2.0) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@26.2.0)(lightningcss@1.30.2) + vite-node: 2.1.8(@types/node@26.2.0)(lightningcss@1.30.2)(supports-color@7.2.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + '@vitest/ui': 2.1.9(vitest@2.1.9) + jsdom: 25.0.1(supports-color@7.2.0) + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vitest@2.1.9(@types/node@25.2.2)(@vitest/ui@2.1.9)(jsdom@25.0.1(supports-color@7.2.0))(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3))(supports-color@7.2.0): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(msw@2.12.9(@types/node@25.2.2)(typescript@5.8.3))(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2)) @@ -13922,7 +15094,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -13932,12 +15104,12 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 1.2.0 vite: 5.4.21(@types/node@25.2.2)(lightningcss@1.30.2) - vite-node: 2.1.9(@types/node@25.2.2)(lightningcss@1.30.2) + vite-node: 2.1.9(@types/node@25.2.2)(lightningcss@1.30.2)(supports-color@7.2.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.2 '@vitest/ui': 2.1.9(vitest@2.1.9) - jsdom: 25.0.1 + jsdom: 25.0.1(supports-color@7.2.0) transitivePeerDependencies: - less - lightningcss @@ -14065,7 +15237,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.1: {} + ws@8.21.3: {} wsl-utils@0.3.1: dependencies: @@ -14082,11 +15254,25 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yaml@1.10.3: optional: true + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yargs@17.7.3: dependencies: cliui: 8.0.1 @@ -14107,12 +15293,18 @@ snapshots: dependencies: zod: 3.25.76 - zod-validation-error@4.0.2(zod@3.25.76): + zod-to-json-schema@3.25.1(zod@4.4.3): dependencies: - zod: 3.25.76 + zod: 4.4.3 + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 zod@3.25.76: {} + zod@4.4.3: {} + zustand@5.0.14(@types/react@19.2.13)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.13 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a3014b1ba..d1c2f6fd2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +packages: + - "packages/*" + overrides: mermaid: ">=11.13.0" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 09e74bc16..dc09b8c2c 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,7 +1,7 @@ fn main() { #[cfg(feature = "tauri-runtime")] { - ensure_sidecar_placeholder(); + ensure_sidecar_placeholders(); tauri_build::build(); } } @@ -25,7 +25,7 @@ fn main() { /// without beforeBuildCommand) you'd ship the placeholder, so emit a /// cargo:warning that surfaces in any compile log to make that loud. #[cfg(feature = "tauri-runtime")] -fn ensure_sidecar_placeholder() { +fn ensure_sidecar_placeholders() { use std::fs; use std::path::PathBuf; @@ -39,34 +39,34 @@ fn ensure_sidecar_placeholder() { "" }; let dir = PathBuf::from("binaries"); - let path = dir.join(format!("codeg-mcp-{triple}{ext}")); + for name in ["codeg-mcp", "codeg-browser-sidecar"] { + let path = dir.join(format!("{name}-{triple}{ext}")); + println!("cargo:rerun-if-changed={}", path.display()); + let needs_placeholder = match fs::metadata(&path) { + Ok(meta) => meta.len() == 0, + Err(_) => true, + }; - println!("cargo:rerun-if-changed={}", path.display()); - - let needs_placeholder = match fs::metadata(&path) { - Ok(meta) => meta.len() == 0, - Err(_) => true, - }; - - if needs_placeholder { - if let Err(e) = fs::create_dir_all(&dir) { - panic!("failed to create {}: {e}", dir.display()); - } - if let Err(e) = fs::write(&path, b"") { - panic!( - "failed to write sidecar placeholder {}: {e}", + if needs_placeholder { + if let Err(e) = fs::create_dir_all(&dir) { + panic!("failed to create {}: {e}", dir.display()); + } + if let Err(e) = fs::write(&path, b"") { + panic!( + "failed to write sidecar placeholder {}: {e}", + path.display() + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o755)); + } + println!( + "cargo:warning={name} sidecar missing at {}; wrote 0-byte placeholder. \ + Run `pnpm tauri:prepare-sidecars` before `tauri build` to ship a working binary.", path.display() ); } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o755)); - } - println!( - "cargo:warning=codeg-mcp sidecar missing at {}; wrote 0-byte placeholder. \ - Run `pnpm tauri:prepare-sidecars` before `tauri build` to ship a working binary.", - path.display() - ); } } diff --git a/src-tauri/scripts/prepare-sidecars.mjs b/src-tauri/scripts/prepare-sidecars.mjs index cf255749d..b8707680d 100644 --- a/src-tauri/scripts/prepare-sidecars.mjs +++ b/src-tauri/scripts/prepare-sidecars.mjs @@ -23,15 +23,24 @@ // Windows GitHub runners. import { execFileSync } from "node:child_process" -import { existsSync, copyFileSync, mkdirSync, chmodSync } from "node:fs" +import { + existsSync, + copyFileSync, + mkdirSync, + chmodSync, + writeFileSync, + statSync, +} from "node:fs" import { dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" import process from "node:process" const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const SRC_TAURI = resolve(SCRIPT_DIR, "..") +const APP_ROOT = resolve(SRC_TAURI, "..") const BINARIES_DIR = join(SRC_TAURI, "binaries") const BIN_NAME = "codeg-mcp" +const BROWSER_BIN_NAME = "codeg-browser-sidecar" function log(msg) { console.log(`[prepare-sidecars] ${msg}`) @@ -42,6 +51,32 @@ function die(msg) { process.exit(1) } +function assertNonEmpty(path) { + if (!existsSync(path) || statSync(path).size <= 0) { + die(`expected non-empty sidecar at ${path}`) + } +} + +function runPnpm(args, cwd) { + // Windows cannot exec a .cmd shim directly through spawnSync on every Node + // release. A pnpm-launched lifecycle exposes the real JS entrypoint, which + // we can execute without a shell (and without quoting ambiguity). + const entrypoint = process.env.npm_execpath + if (entrypoint) { + execFileSync(process.execPath, [entrypoint, ...args], { + stdio: "inherit", + cwd, + }) + return + } + if (process.platform === "win32") { + die( + "npm_execpath is unavailable; run this script through `pnpm tauri:prepare-sidecars`" + ) + } + execFileSync("pnpm", args, { stdio: "inherit", cwd }) +} + function parseArgs(argv) { const args = { target: null } for (let i = 0; i < argv.length; i++) { @@ -114,12 +149,65 @@ function main() { mkdirSync(BINARIES_DIR, { recursive: true }) const dest = join(BINARIES_DIR, `${BIN_NAME}-${target}${ext}`) copyFileSync(built, dest) + assertNonEmpty(dest) if (!isWindows) { // copyFileSync preserves modes on POSIX, but be explicit for tarball // sources that may strip the +x bit. chmodSync(dest, 0o755) + assertNonEmpty(dest) } log(`sidecar staged at ${dest}`) + + stageBrowserSidecar(target, isWindows, ext) +} + +function stageBrowserSidecar(target, isWindows, ext) { + log(`building ${BROWSER_BIN_NAME} from fixed workspace dependencies`) + runPnpm(["--filter", "@codeg/browser-mcp", "build"], APP_ROOT) + + mkdirSync(BINARIES_DIR, { recursive: true }) + const dest = join(BINARIES_DIR, `${BROWSER_BIN_NAME}-${target}${ext}`) + if (!isWindows) { + // Stage 2 is intentionally Windows-only. Tauri still validates every + // externalBin on macOS/Linux release jobs, so ship an explicit fail-closed + // stub instead of a zero-byte placeholder or a misleading working runtime. + writeFileSync( + dest, + '#!/bin/sh\necho \'{"event":"fatal","code":"unsupported_platform"}\' >&2\nexit 1\n', + "utf8" + ) + chmodSync(dest, 0o755) + log(`unsupported-platform stub staged at ${dest}`) + return + } + + const pkgTarget = target.startsWith("aarch64-") + ? "node22-win-arm64" + : target.startsWith("i686-") + ? "node22-win-x86" + : "node22-win-x64" + runPnpm( + [ + "--filter", + "@codeg/browser-mcp", + "exec", + "pkg", + "--targets", + pkgTarget, + "--output", + dest, + "--compress", + "Brotli", + "--no-bytecode", + "--public", + "--public-packages", + "*", + "dist/cli.cjs", + ], + APP_ROOT + ) + assertNonEmpty(dest) + log(`Browser sidecar staged at ${dest}`) } main() diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index b82b6f47d..43250bacb 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -789,9 +789,10 @@ fn agent_debug_callback( stdio_debug_enabled: bool, ) -> impl Fn(&str, sacp_tokio::LineDirection) + Send + Sync + 'static { move |line, dir| { + let safe_line = redact_bearer_secrets(line); let (tag, enabled) = match dir { sacp_tokio::LineDirection::Stderr => { - stderr_tail.push(line); + stderr_tail.push(&safe_line); ("stderr", true) } sacp_tokio::LineDirection::Stdout => ("stdout", stdio_debug_enabled), @@ -801,8 +802,8 @@ fn agent_debug_callback( return; } const MAX: usize = 256; - if line.len() > MAX { - let head = line + if safe_line.len() > MAX { + let head = safe_line .char_indices() .take_while(|(i, _)| *i < MAX) .last() @@ -810,15 +811,43 @@ fn agent_debug_callback( .unwrap_or(MAX); tracing::debug!( "[ACP][{agent_name}][{tag}] {}... ", - &line[..head], - line.len() - head + &safe_line[..head], + safe_line.len() - head ); } else { - tracing::debug!("[ACP][{agent_name}][{tag}] {line}"); + tracing::debug!("[ACP][{agent_name}][{tag}] {safe_line}"); } } } +/// Redact bearer credentials before ACP stdio or agent stderr reaches either +/// tracing or the in-memory stderr diagnostic tail. The managed Browser MCP +/// token is necessarily present in `session/*` wire requests, including when +/// `CODEG_ACP_DEBUG=1`; debug mode must not turn that private loopback secret +/// into a persisted log entry. +fn redact_bearer_secrets(line: &str) -> String { + const MARKER: &str = "bearer "; + let lower = line.to_ascii_lowercase(); + let mut output = String::with_capacity(line.len()); + let mut cursor = 0; + while let Some(relative) = lower[cursor..].find(MARKER) { + let marker_start = cursor + relative; + let token_start = marker_start + MARKER.len(); + output.push_str(&line[cursor..token_start]); + output.push_str("[REDACTED]"); + let token_end = line[token_start..] + .char_indices() + .find_map(|(offset, ch)| { + (ch.is_whitespace() || matches!(ch, '"' | '\'' | '\\' | ',' | '}' | ']')) + .then_some(token_start + offset) + }) + .unwrap_or(line.len()); + cursor = token_end; + } + output.push_str(&line[cursor..]); + output +} + async fn build_agent( agent_type: AgentType, runtime_env: &BTreeMap, @@ -1252,6 +1281,7 @@ pub async fn spawn_agent_connection( preferred_mode_id: Option, preferred_config_values: BTreeMap, delegation_injection: Option, + browser_mcp_provider: Option>, terminal_shell_config: TerminalShellRuntimeConfig, ) -> Result, AcpError> { // Create the authoritative session state up front. Subsequent emit_with_state @@ -1412,6 +1442,7 @@ pub async fn spawn_agent_connection( let _cleanup = cleanup_guard; connection_rt.block_on(async move { let delegation_for_cleanup = delegation_injection.clone(); + let browser_for_cleanup = browser_mcp_provider.clone(); let result = run_connection( agent, conn_id.clone(), @@ -1426,6 +1457,7 @@ pub async fn spawn_agent_connection( preferred_mode_id, preferred_config_values, delegation_injection, + browser_mcp_provider, fs_policy, host_tools, stderr_tail, @@ -1455,6 +1487,9 @@ pub async fn spawn_agent_connection( .cancel_plan_approvals_by_parent(&conn_id) .await; } + if let Some(browser) = browser_for_cleanup { + browser.release_session(&conn_id).await; + } if let Err(e) = result { let code = e.code().map(String::from); @@ -3296,6 +3331,41 @@ fn agent_delivers_wire_mcp(agent_type: AgentType) -> bool { !matches!(agent_type, AgentType::Pi) } +fn browser_mcp_injection_allowed( + agent_supports_mcp: bool, + agent_delivers_mcp: bool, + http_capability: bool, +) -> bool { + agent_supports_mcp && agent_delivers_mcp && http_capability +} + +#[cfg(test)] +mod browser_mcp_gate_tests { + use super::{browser_mcp_injection_allowed, redact_bearer_secrets}; + + #[test] + fn browser_requires_registry_delivery_and_http_gates() { + for supports in [false, true] { + for delivers in [false, true] { + for http in [false, true] { + assert_eq!( + browser_mcp_injection_allowed(supports, delivers, http), + supports && delivers && http + ); + } + } + } + } + + #[test] + fn browser_bearer_is_redacted_from_acp_debug_lines() { + let line = r#"{"headers":[{"name":"Authorization","value":"Bearer private-token"}]}"#; + let redacted = redact_bearer_secrets(line); + assert!(!redacted.contains("private-token")); + assert!(redacted.contains("Bearer [REDACTED]")); + } +} + /// Load MCP servers configured for `agent_type` and convert them into the /// ACP wire format. Errors and unsupported entries are logged and skipped so /// a single malformed entry never blocks a session from starting. @@ -3375,6 +3445,25 @@ pub trait AgentAvailabilityLookup: Send + Sync { async fn disabled_agent_wire_slugs(&self) -> Vec; } +/// Readiness-gated source for the managed Browser MCP entry. Desktop startup +/// installs one provider; server mode and unsupported platforms leave it unset. +#[async_trait::async_trait] +pub trait BrowserMcpProvider: Send + Sync { + /// Snapshot the future-session default at Agent connection creation. The + /// caller freezes this value for the logical session so later Settings + /// changes cannot add or revoke Browser on a running session. + async fn enabled_for_new_session(&self) -> bool; + + /// Return the private loopback HTTP MCP server only while the runtime is + /// healthy. Enablement was already frozen by the connection manager; + /// implementations keep endpoint/token out of public status payloads and + /// scope the returned headers to `session_id`. + async fn server_for_session(&self, session_id: &str) -> Option; + + /// Release every tab/download owned by this ACP connection. + async fn release_session(&self, session_id: &str); +} + /// [`AgentAvailabilityLookup`] over the live `AppDatabase`: `agent_setting` /// rows with `enabled = false`. An absent row means enabled (the settings /// default). A read error fails OPEN — the enum then lists everything rather @@ -3854,6 +3943,7 @@ async fn run_connection( preferred_mode_id: Option, preferred_config_values: BTreeMap, delegation_injection: Option, + browser_mcp_provider: Option>, fs_policy: FsAccessPolicy, host_tools: HostToolsPolicy, // Connection-scoped agent stderr buffer, shared with the `with_debug` @@ -4353,6 +4443,23 @@ async fn run_connection( Vec::new() }; + // Browser is an HTTP-only built-in MCP. Inject it at this shared + // new/load/resume chokepoint only when BOTH the registry and the + // live ACP handshake advertise support, and only when the provider + // reports a healthy Ready runtime. Any failed gate leaves the + // agent session fully usable without a phantom endpoint. + if browser_mcp_injection_allowed( + agent_supports_mcp, + agent_delivers_wire_mcp(agent_type), + init_resp.agent_capabilities.mcp_capabilities.http, + ) { + if let Some(provider) = browser_mcp_provider.as_ref() { + if let Some(server) = provider.server_for_session(&conn_id).await { + mcp_servers.push(server); + } + } + } + // Inject the built-in `codeg-mcp` MCP server. Stdio is // unconditionally supported by the ACP wire — no `mcp_caps` // filter needed. The returned token is stashed on the session diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 71be5576f..ba8213748 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -213,6 +213,15 @@ pub struct ConnectionManager { /// init. `Arc` so the inner `Self` cloned from `clone_ref` sees /// the install too — the lock is set once at startup and never mutated. delegation_injection: Arc>, + /// Optional managed Browser MCP provider installed by the Windows desktop + /// bootstrap. Shared across clones; server mode leaves it unset. + browser_mcp_provider: Arc< + std::sync::OnceLock>, + >, + /// Browser capability frozen per logical Agent session. Entries share the + /// same process-lifetime bound as `spawn_locks`: one per distinct resumed + /// session. Fresh sessions are added when their external id arrives. + browser_capability_snapshots: Arc>>, /// Per-agent-type serialization for `probe_agent_options`. Without /// this, rapid agent-tab clicks in the settings UI would fan out one /// real CLI process per click — each one running up to 60s. The @@ -269,6 +278,8 @@ impl ConnectionManager { spawn_handshake_timeout: spawn_handshake_timeout_from_env(), terminal_shell_config: TerminalShellRuntimeConfig::new(), delegation_injection: Arc::new(std::sync::OnceLock::new()), + browser_mcp_provider: Arc::new(std::sync::OnceLock::new()), + browser_capability_snapshots: Arc::new(Mutex::new(HashMap::new())), probe_locks: Arc::new(Mutex::new(HashMap::new())), pending_questions: Arc::new(Mutex::new(HashMap::new())), pending_plan_approvals: Arc::new(Mutex::new(HashMap::new())), @@ -283,6 +294,8 @@ impl ConnectionManager { spawn_handshake_timeout: self.spawn_handshake_timeout, terminal_shell_config: self.terminal_shell_config.clone(), delegation_injection: self.delegation_injection.clone(), + browser_mcp_provider: self.browser_mcp_provider.clone(), + browser_capability_snapshots: self.browser_capability_snapshots.clone(), probe_locks: self.probe_locks.clone(), pending_questions: self.pending_questions.clone(), pending_plan_approvals: self.pending_plan_approvals.clone(), @@ -300,6 +313,54 @@ impl ConnectionManager { self.delegation_injection.get().cloned() } + /// Install the desktop Browser MCP provider exactly once during bootstrap. + pub fn install_browser_mcp_provider( + &self, + provider: Arc, + ) { + let _ = self.browser_mcp_provider.set(provider); + } + + fn browser_mcp_snapshot( + &self, + ) -> Option> { + self.browser_mcp_provider.get().cloned() + } + + async fn browser_mcp_snapshot_for_spawn( + &self, + key: Option<&SpawnDedupKey>, + ) -> ( + Option>, + bool, + ) { + let provider = self.browser_mcp_snapshot(); + if let Some(key) = key { + if let Some(enabled) = self + .browser_capability_snapshots + .lock() + .await + .get(key) + .copied() + { + return (enabled.then_some(provider).flatten(), enabled); + } + } + + let enabled = match provider.as_ref() { + Some(provider) => provider.enabled_for_new_session().await, + None => false, + }; + if let Some(key) = key { + self.browser_capability_snapshots + .lock() + .await + .entry(key.clone()) + .or_insert(enabled); + } + (enabled.then_some(provider).flatten(), enabled) + } + /// Returns the shared terminal-shell setting consumed by ACP terminal /// runtimes. Keeping the handle shared makes saves apply immediately to /// connections that are already running. @@ -317,6 +378,8 @@ impl ConnectionManager { spawn_handshake_timeout: timeout, terminal_shell_config: TerminalShellRuntimeConfig::new(), delegation_injection: Arc::new(std::sync::OnceLock::new()), + browser_mcp_provider: Arc::new(std::sync::OnceLock::new()), + browser_capability_snapshots: Arc::new(Mutex::new(HashMap::new())), probe_locks: Arc::new(Mutex::new(HashMap::new())), pending_questions: Arc::new(Mutex::new(HashMap::new())), pending_plan_approvals: Arc::new(Mutex::new(HashMap::new())), @@ -430,6 +493,11 @@ impl ConnectionManager { // spawning a fresh process — this is what makes a browser refresh // mid-turn re-attach to the existing live state rather than orphan it. let working_dir_path = working_dir.as_ref().map(PathBuf::from); + let session_key = session_id.as_ref().map(|session_id| SpawnDedupKey { + agent_type, + working_dir: working_dir_path.clone(), + session_id: session_id.clone(), + }); // Acquire a per-(agent, working_dir, session_id) async mutex so two // concurrent connects for the same logical session can't both miss @@ -441,16 +509,11 @@ impl ConnectionManager { // is None (fresh sessions can't dedup — by design — since the // agent assigns the id). let session_id_for_log = session_id.clone(); - let dedup_lock = if let Some(sid) = session_id.as_deref() { - let key = SpawnDedupKey { - agent_type, - working_dir: working_dir_path.clone(), - session_id: sid.to_string(), - }; + let dedup_lock = if let Some(key) = session_key.as_ref() { let mu = { let mut locks = self.spawn_locks.lock().await; locks - .entry(key) + .entry(key.clone()) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone() }; @@ -472,6 +535,9 @@ impl ConnectionManager { } let connection_id = uuid::Uuid::new_v4().to_string(); + let (browser_mcp_provider, browser_capability_enabled) = self + .browser_mcp_snapshot_for_spawn(session_key.as_ref()) + .await; tracing::info!( "[ACP] spawning connection id={} owner_window={} agent={:?}", connection_id, owner_window_label, agent_type @@ -493,6 +559,7 @@ impl ConnectionManager { preferred_mode_id, preferred_config_values, self.delegation_snapshot(), + browser_mcp_provider, self.terminal_shell_config.clone(), ) .await?; @@ -502,7 +569,7 @@ impl ConnectionManager { // next waiter), aborted (connection died), or the timeout fires. // Logged on every wait so production can audit real-world handshake // latencies and tune `CODEG_ACP_SPAWN_HANDSHAKE_TIMEOUT_SECS`. - if dedup_lock.is_some() { + if session_key.is_some() { let timeout = self.spawn_handshake_timeout; let (outcome, elapsed) = wait_for_session_started(session_started_rx, timeout).await; tracing::info!( @@ -514,10 +581,34 @@ impl ConnectionManager { elapsed.as_millis(), timeout.as_millis(), ); + } else { + let connections = self.connections.clone(); + let snapshots = self.browser_capability_snapshots.clone(); + let connection_id_for_snapshot = connection_id.clone(); + tokio::spawn(async move { + if session_started_rx.await.is_err() { + return; + } + let state = { + let connections = connections.lock().await; + connections + .get(&connection_id_for_snapshot) + .map(|connection| connection.state.clone()) + }; + let Some(state) = state else { + return; + }; + let external_id = state.read().await.external_id.clone(); + let Some(session_id) = external_id else { + return; + }; + snapshots.lock().await.entry(SpawnDedupKey { + agent_type, + working_dir: working_dir_path, + session_id, + }).or_insert(browser_capability_enabled); + }); } - // session_started_rx (in the no-dedup branch) is dropped here. tx - // staying inside SessionState gets dropped naturally when the - // connection terminates, no leak. drop(dedup_lock); @@ -5809,6 +5900,63 @@ mod tests { ); } + struct ToggleBrowserProvider { + enabled: std::sync::atomic::AtomicBool, + } + + #[async_trait::async_trait] + impl crate::acp::connection::BrowserMcpProvider for ToggleBrowserProvider { + async fn enabled_for_new_session(&self) -> bool { + self.enabled.load(std::sync::atomic::Ordering::SeqCst) + } + + async fn server_for_session(&self, _session_id: &str) -> Option { + None + } + + async fn release_session(&self, _session_id: &str) {} + } + + #[tokio::test] + async fn browser_capability_is_frozen_per_logical_session() { + let mgr = ConnectionManager::new(); + let provider = Arc::new(ToggleBrowserProvider { + enabled: std::sync::atomic::AtomicBool::new(true), + }); + mgr.install_browser_mcp_provider(provider.clone()); + let existing_session = SpawnDedupKey { + agent_type: AgentType::ClaudeCode, + working_dir: Some(PathBuf::from("/tmp/browser-snapshot")), + session_id: "existing-session".into(), + }; + + let (first_provider, first_enabled) = mgr + .browser_mcp_snapshot_for_spawn(Some(&existing_session)) + .await; + assert!(first_enabled); + assert!(first_provider.is_some()); + + provider + .enabled + .store(false, std::sync::atomic::Ordering::SeqCst); + let (resumed_provider, resumed_enabled) = mgr + .clone_ref() + .browser_mcp_snapshot_for_spawn(Some(&existing_session)) + .await; + assert!(resumed_enabled, "reconnect must keep the creation snapshot"); + assert!(resumed_provider.is_some()); + + let new_session = SpawnDedupKey { + session_id: "new-session".into(), + ..existing_session + }; + let (new_provider, new_enabled) = mgr + .browser_mcp_snapshot_for_spawn(Some(&new_session)) + .await; + assert!(!new_enabled, "a new session must read the latest default"); + assert!(new_provider.is_none()); + } + /// Two concurrent `send_prompt_linked` calls on the SAME connection /// must serialize through the per-connection `prompt_lock` so the /// backend-creates branch can't fire twice and produce duplicate diff --git a/src-tauri/src/browser_runtime.rs b/src-tauri/src/browser_runtime.rs new file mode 100644 index 000000000..0ff2ffd8e --- /dev/null +++ b/src-tauri/src/browser_runtime.rs @@ -0,0 +1,1478 @@ +use std::collections::{HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +#[cfg(feature = "tauri-runtime")] +use futures_util::StreamExt; +use sacp::schema::{HttpHeader, McpServer, McpServerHttp}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "tauri-runtime")] +use tauri::ipc::Channel; +use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader, Lines}; +use tokio::process::{Child, ChildStdout}; +use tokio::sync::{oneshot, Mutex}; + +use crate::acp::connection::BrowserMcpProvider; +use crate::db::service::app_metadata_service; +use crate::db::AppDatabase; +use crate::web::event_bridge::{emit_event, EventEmitter}; + +const SETTINGS_KEY: &str = "browser_runtime_settings"; +const STATUS_EVENT: &str = "browser://status"; +const READY_TIMEOUT: Duration = Duration::from_secs(20); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(65); +const HEALTH_POLL_INTERVAL: Duration = Duration::from_secs(2); +const HEALTH_POLL_TIMEOUT: Duration = Duration::from_secs(5); +const LOG_LIMIT: usize = 100; +const EXTERNAL_BACKEND_ID: &str = "external_chromium_cdp"; +const EMBEDDED_BACKEND_ID: &str = "embedded_chromium_cdp"; +const SURFACE_EVENT: &str = "browser://session-activity"; +#[cfg(feature = "tauri-runtime")] +const MAX_SURFACE_EVENT_BYTES: usize = 10 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BrowserRuntimeSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub auto_start: bool, + #[serde(default)] + pub browser_path: Option, + #[serde(default)] + pub backend: BrowserRuntimeBackend, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum BrowserRuntimeBackend { + Embedded, + #[default] + External, +} + +impl BrowserRuntimeBackend { + fn id(self) -> &'static str { + match self { + Self::Embedded => EMBEDDED_BACKEND_ID, + Self::External => EXTERNAL_BACKEND_ID, + } + } + + fn cli_value(self) -> &'static str { + match self { + Self::Embedded => "embedded", + Self::External => "external", + } + } +} + +impl Default for BrowserRuntimeSettings { + fn default() -> Self { + Self { + enabled: false, + auto_start: false, + browser_path: None, + backend: BrowserRuntimeBackend::External, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BrowserRuntimeState { + NotInstalled, + Stopped, + Starting, + Ready, + Error, + Recovering, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserRuntimeStatus { + pub state: BrowserRuntimeState, + pub installed: bool, + pub enabled: bool, + pub auto_start: bool, + pub browser_path: Option, + pub sidecar_pid: Option, + pub browser_pid: Option, + pub runtime_version: Option, + pub backend: String, + pub browser_name: Option, + pub browser_version: Option, + pub profile_path: String, + pub download_path: String, + pub recovery_attempt: u8, + pub last_error_code: Option, + pub recent_logs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserLogEntry { + pub at: String, + pub code: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BrowserSurfaceTab { + pub id: String, + pub url: String, + pub title: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BrowserSurfacePageState { + pub tab: BrowserSurfaceTab, + pub loading: bool, + pub can_go_back: bool, + pub can_go_forward: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BrowserSurfaceSnapshot { + pub session_id: String, + pub tabs: Vec, + pub active_target_id: Option, + pub active: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserSurfaceFrame { + pub target_id: String, + pub data: String, + pub mime_type: String, + pub device_width: u32, + pub device_height: u32, + pub page_scale_factor: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum BrowserSurfaceEvent { + Snapshot { + snapshot: BrowserSurfaceSnapshot, + }, + Frame { + frame: BrowserSurfaceFrame, + }, + Error { + code: String, + message: String, + retryable: bool, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct BrowserSessionActivity { + connection_id: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum BrowserRuntimeError { + #[error("Browser runtime is only available in the Windows desktop client")] + UnsupportedPlatform, + #[error("Browser runtime is disabled in settings")] + Disabled, + #[error("Browser runtime sidecar is not installed")] + NotInstalled, + #[error("Browser runtime failed to start")] + StartFailed, + #[error("Browser runtime did not become ready in time")] + ReadyTimeout, + #[error("Browser runtime control request failed")] + ControlFailed, + #[error("Browser runtime settings could not be saved")] + SettingsFailed, +} + +struct BrowserRuntimePrivate { + status: BrowserRuntimeStatus, + settings: BrowserRuntimeSettings, + child: Option, + endpoint: Option, + token: Option, +} + +struct BrowserSurfaceAttachment { + #[cfg_attr(not(feature = "tauri-runtime"), allow(dead_code))] + generation: u64, + cancel: oneshot::Sender<()>, + done: oneshot::Receiver<()>, +} + +#[derive(Default)] +struct BrowserSurfaceEntry { + generation: u64, + attachment: Option, +} + +#[derive(Default)] +struct BrowserSurfaceRegistry { + entries: HashMap, +} + +impl BrowserSurfaceRegistry { + fn ensure(&mut self, connection_id: &str) -> u64 { + self.entries + .entry(connection_id.to_string()) + .or_default() + .generation + } + + #[cfg_attr(not(feature = "tauri-runtime"), allow(dead_code))] + fn next_generation(&mut self, connection_id: &str) -> u64 { + let entry = self.entries.entry(connection_id.to_string()).or_default(); + entry.generation = entry.generation.saturating_add(1); + entry.generation + } + + #[cfg(test)] + fn contains(&self, connection_id: &str) -> bool { + self.entries.contains_key(connection_id) + } + + fn remove(&mut self, connection_id: &str) -> Option { + self.entries + .remove(connection_id) + .and_then(|entry| entry.attachment) + } +} + +#[derive(Clone)] +pub struct BrowserRuntimeManager { + inner: Arc>, + data_dir: PathBuf, + emitter: EventEmitter, + client: reqwest::Client, + #[cfg(feature = "tauri-runtime")] + stream_client: reqwest::Client, + surface_registry: Arc>, + surface_attach_gate: Arc>, +} + +impl BrowserRuntimeManager { + pub fn new(data_dir: PathBuf, emitter: EventEmitter) -> Self { + let installed = locate_sidecar().is_some(); + let profile_path = data_dir + .join("browser") + .join("profile") + .to_string_lossy() + .into_owned(); + let download_path = data_dir + .join("browser") + .join("downloads") + .to_string_lossy() + .into_owned(); + let state = if installed { + BrowserRuntimeState::Stopped + } else { + BrowserRuntimeState::NotInstalled + }; + Self { + inner: Arc::new(Mutex::new(BrowserRuntimePrivate { + status: BrowserRuntimeStatus { + state, + installed, + enabled: false, + auto_start: false, + browser_path: None, + sidecar_pid: None, + browser_pid: None, + runtime_version: None, + backend: BrowserRuntimeBackend::External.id().to_string(), + browser_name: None, + browser_version: None, + profile_path, + download_path, + recovery_attempt: 0, + last_error_code: None, + recent_logs: Vec::new(), + }, + settings: BrowserRuntimeSettings::default(), + child: None, + endpoint: None, + token: None, + })), + data_dir, + emitter, + client: reqwest::Client::builder() + .no_proxy() + .timeout(REQUEST_TIMEOUT) + .build() + .expect("Browser runtime HTTP client configuration is valid"), + #[cfg(feature = "tauri-runtime")] + stream_client: reqwest::Client::builder() + .no_proxy() + .build() + .expect("Browser surface HTTP client configuration is valid"), + surface_registry: Arc::new(Mutex::new(BrowserSurfaceRegistry::default())), + surface_attach_gate: Arc::new(Mutex::new(())), + } + } + + pub async fn load_settings( + &self, + db: &AppDatabase, + ) -> Result { + let saved = app_metadata_service::get_value(&db.conn, SETTINGS_KEY) + .await + .map_err(|_| BrowserRuntimeError::SettingsFailed)?; + let settings = normalize_settings( + saved + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or_default(), + ); + self.apply_settings_snapshot(settings.clone()).await; + Ok(settings) + } + + pub async fn settings(&self) -> BrowserRuntimeSettings { + self.inner.lock().await.settings.clone() + } + + pub async fn update_settings( + &self, + db: &AppDatabase, + settings: BrowserRuntimeSettings, + ) -> Result { + let settings = normalize_settings(settings); + let previous = self.settings().await; + let was_running = self.inner.lock().await.child.is_some(); + let backend_changed = settings.backend != previous.backend; + + if backend_changed && was_running { + let _ = self.stop().await; + self.apply_settings_snapshot(settings.clone()).await; + if self.start().await.is_err() { + self.rollback_settings(previous, was_running).await; + return Err(BrowserRuntimeError::ControlFailed); + } + if persist_settings(db, &settings).await.is_err() { + self.rollback_settings(previous, was_running).await; + return Err(BrowserRuntimeError::SettingsFailed); + } + return Ok(settings); + } + + persist_settings(db, &settings).await?; + self.apply_settings_snapshot(settings.clone()).await; + Ok(settings) + } + + async fn rollback_settings(&self, settings: BrowserRuntimeSettings, restart: bool) { + let _ = self.stop().await; + self.apply_settings_snapshot(settings).await; + if restart { + let _ = self.start().await; + } + } + + pub async fn status(&self) -> BrowserRuntimeStatus { + self.inner.lock().await.status.clone() + } + + pub async fn start(&self) -> Result { + let already_running = { + let mut inner = self.inner.lock().await; + if inner.status.state == BrowserRuntimeState::Ready { + return Ok(inner.status.clone()); + } + inner.status.state = BrowserRuntimeState::Starting; + inner.status.last_error_code = None; + inner.endpoint.is_some() + }; + self.emit_status().await; + + if !already_running { + if let Err(error) = self.spawn_sidecar().await { + self.set_error(error_code(&error)).await; + return Err(error); + } + } + if let Err(error) = self.control("start").await { + self.set_error(error_code(&error)).await; + return Err(error); + } + self.refresh_health().await?; + Ok(self.status().await) + } + + pub async fn stop(&self) -> Result { + self.detach_all_surfaces(false).await; + let has_endpoint = self.inner.lock().await.endpoint.is_some(); + if has_endpoint { + let _ = self.control("stop").await; + } + let child = { + let mut inner = self.inner.lock().await; + inner.endpoint = None; + inner.token = None; + inner.status.sidecar_pid = None; + inner.status.browser_pid = None; + inner.status.runtime_version = None; + inner.status.browser_name = None; + inner.status.browser_version = None; + inner.status.recovery_attempt = 0; + inner.status.last_error_code = None; + inner.status.state = if inner.status.installed { + BrowserRuntimeState::Stopped + } else { + BrowserRuntimeState::NotInstalled + }; + inner.child.take() + }; + if let Some(mut child) = child { + if let Some(pid) = child.id() { + let _ = kill_tree::tokio::kill_tree(pid).await; + } else { + let _ = child.start_kill(); + } + let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await; + } + self.emit_status().await; + Ok(self.status().await) + } + + pub async fn recover(&self) -> Result { + self.detach_all_surfaces(false).await; + { + let mut inner = self.inner.lock().await; + inner.status.state = BrowserRuntimeState::Recovering; + inner.status.last_error_code = None; + } + self.emit_status().await; + if self.inner.lock().await.endpoint.is_none() { + self.spawn_sidecar().await?; + } + if let Err(error) = self.control("recover").await { + self.set_error(error_code(&error)).await; + return Err(error); + } + self.refresh_health().await?; + Ok(self.status().await) + } + + pub async fn doctor(&self) -> Result { + if self.inner.lock().await.endpoint.is_none() { + self.spawn_sidecar().await?; + } + self.control("doctor").await + } + + pub async fn diagnostics(&self) -> Result { + if self.inner.lock().await.endpoint.is_none() { + return Ok(serde_json::json!({ + "status": self.status().await, + "audit": [], + })); + } + self.request(reqwest::Method::GET, "/admin/diagnostics", None) + .await + } + + pub async fn release_session(&self, session_id: &str) { + let _ = self.detach_surface(session_id).await; + self.surface_registry + .lock() + .await + .entries + .remove(session_id); + let _ = self + .request( + reqwest::Method::POST, + "/admin/release-session", + Some(serde_json::json!({ "sessionId": session_id })), + ) + .await; + } + + pub async fn ensure_surface( + &self, + connection_id: &str, + ) -> Result { + validate_connection_id(connection_id)?; + self.surface_registry.lock().await.ensure(connection_id); + let value = self + .request( + reqwest::Method::POST, + "/admin/surface/ensure", + Some(serde_json::json!({ "sessionId": connection_id })), + ) + .await?; + parse_surface_snapshot(value, connection_id) + } + + pub async fn surface_action( + &self, + connection_id: &str, + action: serde_json::Value, + ) -> Result { + validate_connection_id(connection_id)?; + self.surface_registry.lock().await.ensure(connection_id); + let mut body = action + .as_object() + .cloned() + .ok_or(BrowserRuntimeError::ControlFailed)?; + body.insert( + "sessionId".to_string(), + serde_json::Value::String(connection_id.to_string()), + ); + let value = self + .request( + reqwest::Method::POST, + "/admin/surface/action", + Some(serde_json::Value::Object(body)), + ) + .await?; + parse_surface_snapshot(value, connection_id) + } + + #[cfg(feature = "tauri-runtime")] + pub async fn attach_surface( + &self, + connection_id: &str, + channel: Channel, + ) -> Result { + validate_connection_id(connection_id)?; + let _gate = self.surface_attach_gate.lock().await; + self.stop_surface_attachment(connection_id).await; + let snapshot = self.ensure_surface(connection_id).await?; + let (cancel_tx, cancel_rx) = oneshot::channel(); + let (done_tx, done_rx) = oneshot::channel(); + let generation = { + let mut registry = self.surface_registry.lock().await; + let generation = registry.next_generation(connection_id); + registry + .entries + .get_mut(connection_id) + .expect("surface entry was ensured") + .attachment = Some(BrowserSurfaceAttachment { + generation, + cancel: cancel_tx, + done: done_rx, + }); + generation + }; + let manager = self.clone(); + let owned_connection_id = connection_id.to_string(); + tokio::spawn(async move { + if manager + .run_surface_stream(&owned_connection_id, channel.clone(), cancel_rx) + .await + .is_err() + { + let _ = channel.send(BrowserSurfaceEvent::Error { + code: "SURFACE_STREAM_FAILED".to_string(), + message: "Browser surface stream disconnected".to_string(), + retryable: true, + }); + } + let _ = done_tx.send(()); + let mut registry = manager.surface_registry.lock().await; + let should_clear = registry + .entries + .get(&owned_connection_id) + .and_then(|entry| entry.attachment.as_ref()) + .is_some_and(|attachment| attachment.generation == generation); + if should_clear { + if let Some(entry) = registry.entries.get_mut(&owned_connection_id) { + entry.attachment = None; + } + } + }); + Ok(snapshot) + } + + pub async fn detach_surface(&self, connection_id: &str) -> Result<(), BrowserRuntimeError> { + validate_connection_id(connection_id)?; + let _gate = self.surface_attach_gate.lock().await; + self.stop_surface_attachment(connection_id).await; + Ok(()) + } + + pub async fn close_surface(&self, connection_id: &str) -> Result<(), BrowserRuntimeError> { + validate_connection_id(connection_id)?; + let _gate = self.surface_attach_gate.lock().await; + self.stop_surface_attachment(connection_id).await; + self.surface_registry.lock().await.remove(connection_id); + self.request( + reqwest::Method::POST, + "/admin/release-session", + Some(serde_json::json!({ "sessionId": connection_id })), + ) + .await?; + Ok(()) + } + + async fn stop_surface_attachment(&self, connection_id: &str) { + let attachment = { + let mut registry = self.surface_registry.lock().await; + registry + .entries + .get_mut(connection_id) + .and_then(|entry| entry.attachment.take()) + }; + if let Some(attachment) = attachment { + let _ = attachment.cancel.send(()); + let _ = tokio::time::timeout(Duration::from_secs(2), attachment.done).await; + } + } + + async fn detach_all_surfaces(&self, remove_entries: bool) { + let _gate = self.surface_attach_gate.lock().await; + let connection_ids = { + let registry = self.surface_registry.lock().await; + registry.entries.keys().cloned().collect::>() + }; + for connection_id in connection_ids { + self.stop_surface_attachment(&connection_id).await; + } + if remove_entries { + self.surface_registry.lock().await.entries.clear(); + } + } + + #[cfg(feature = "tauri-runtime")] + async fn run_surface_stream( + &self, + connection_id: &str, + channel: Channel, + mut cancel: oneshot::Receiver<()>, + ) -> Result<(), BrowserRuntimeError> { + let (endpoint, token) = self.private_endpoint().await?; + let response = self + .stream_client + .get(format!("{endpoint}/admin/surface/stream")) + .query(&[("sessionId", connection_id)]) + .bearer_auth(token) + .header(reqwest::header::ACCEPT, "application/x-ndjson") + .send() + .await + .map_err(|_| BrowserRuntimeError::ControlFailed)?; + if !response.status().is_success() { + return Err(BrowserRuntimeError::ControlFailed); + } + let mut stream = response.bytes_stream(); + let mut buffer = Vec::::new(); + let mut mapped_targets = std::collections::HashSet::::new(); + loop { + let chunk = tokio::select! { + _ = &mut cancel => return Ok(()), + chunk = stream.next() => chunk, + }; + let Some(chunk) = chunk else { + return Err(BrowserRuntimeError::ControlFailed); + }; + let chunk = chunk.map_err(|_| BrowserRuntimeError::ControlFailed)?; + buffer.extend_from_slice(&chunk); + if buffer.len() > MAX_SURFACE_EVENT_BYTES { + let _ = self.release_surface_remote(connection_id).await; + return Err(BrowserRuntimeError::ControlFailed); + } + while let Some(newline) = buffer.iter().position(|byte| *byte == b'\n') { + let line: Vec = buffer.drain(..=newline).collect(); + if line.len() <= 1 { + continue; + } + let event = serde_json::from_slice::(&line[..line.len() - 1]) + .map_err(|_| BrowserRuntimeError::ControlFailed)?; + match &event { + BrowserSurfaceEvent::Snapshot { snapshot } => { + validate_surface_snapshot(snapshot, connection_id)?; + mapped_targets = snapshot.tabs.iter().map(|tab| tab.id.clone()).collect(); + } + BrowserSurfaceEvent::Frame { frame } => { + if !mapped_targets.contains(&frame.target_id) { + let _ = self.release_surface_remote(connection_id).await; + return Err(BrowserRuntimeError::ControlFailed); + } + } + BrowserSurfaceEvent::Error { .. } => {} + } + if channel.send(event).is_err() { + return Ok(()); + } + } + } + } + + #[cfg(feature = "tauri-runtime")] + async fn private_endpoint(&self) -> Result<(String, String), BrowserRuntimeError> { + let inner = self.inner.lock().await; + Ok(( + inner + .endpoint + .clone() + .ok_or(BrowserRuntimeError::ControlFailed)?, + inner + .token + .clone() + .ok_or(BrowserRuntimeError::ControlFailed)?, + )) + } + + #[cfg(feature = "tauri-runtime")] + async fn release_surface_remote(&self, connection_id: &str) -> Result<(), BrowserRuntimeError> { + self.request( + reqwest::Method::POST, + "/admin/release-session", + Some(serde_json::json!({ "sessionId": connection_id })), + ) + .await?; + Ok(()) + } + + async fn spawn_sidecar(&self) -> Result<(), BrowserRuntimeError> { + let executable = locate_sidecar().ok_or_else(|| { + if cfg!(windows) { + BrowserRuntimeError::NotInstalled + } else { + BrowserRuntimeError::UnsupportedPlatform + } + })?; + let (browser_path, backend, profile_dir, download_dir) = { + let inner = self.inner.lock().await; + ( + inner.settings.browser_path.clone(), + inner.settings.backend, + self.data_dir.join("browser").join("profile"), + self.data_dir.join("browser").join("downloads"), + ) + }; + std::fs::create_dir_all(&profile_dir).map_err(|_| BrowserRuntimeError::StartFailed)?; + std::fs::create_dir_all(&download_dir).map_err(|_| BrowserRuntimeError::StartFailed)?; + let token = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let mut command = crate::process::tokio_command(&executable); + command + .arg("--profile-dir") + .arg(&profile_dir) + .arg("--download-dir") + .arg(&download_dir) + .arg("--parent-pid") + .arg(std::process::id().to_string()) + .arg("--backend") + .arg(backend.cli_value()) + .env("CODEG_BROWSER_TOKEN", &token) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(path) = browser_path { + command.arg("--browser-path").arg(path); + } + let mut child = command + .spawn() + .map_err(|_| BrowserRuntimeError::StartFailed)?; + let sidecar_pid = child.id(); + let stdout = child + .stdout + .take() + .ok_or(BrowserRuntimeError::StartFailed)?; + let stderr = child.stderr.take(); + let mut lines = BufReader::new(stdout).lines(); + let ready = tokio::time::timeout(READY_TIMEOUT, read_ready(&mut lines)) + .await + .map_err(|_| BrowserRuntimeError::ReadyTimeout)??; + let endpoint = format!("http://127.0.0.1:{}", ready.port); + { + let mut inner = self.inner.lock().await; + inner.child = Some(child); + inner.endpoint = Some(endpoint); + inner.token = Some(token); + inner.status.sidecar_pid = sidecar_pid; + inner.status.runtime_version = Some(ready.version); + inner.status.installed = true; + push_log(&mut inner.status, "sidecar_ready"); + } + self.spawn_stdout_drain(lines); + if let Some(stderr) = stderr { + self.spawn_stderr_drain(stderr); + } + if let Some(pid) = sidecar_pid { + self.spawn_process_monitor(pid); + self.spawn_health_monitor(pid); + } + Ok(()) + } + + fn spawn_stdout_drain(&self, mut lines: Lines>) { + let manager = self.clone(); + tokio::spawn(async move { + while let Ok(Some(line)) = lines.next_line().await { + let Ok(event) = serde_json::from_str::(&line) else { + continue; + }; + if event.get("event").and_then(serde_json::Value::as_str) == Some("browser-status") + { + if let Some(status) = event.get("status") { + manager.apply_runtime_status(status).await; + } + continue; + } + if event.get("event").and_then(serde_json::Value::as_str) + == Some("browser-session-activity") + { + let Some(connection_id) = event + .get("sessionId") + .and_then(serde_json::Value::as_str) + .filter(|value| validate_connection_id(value).is_ok()) + else { + continue; + }; + manager.surface_registry.lock().await.ensure(connection_id); + emit_event( + &manager.emitter, + SURFACE_EVENT, + BrowserSessionActivity { + connection_id: connection_id.to_string(), + }, + ); + } + } + }); + } + + fn spawn_stderr_drain(&self, stderr: tokio::process::ChildStderr) { + let manager = self.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let code = serde_json::from_str::(&line) + .ok() + .and_then(|value| { + value + .get("code") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "sidecar_stderr".to_string()); + let mut inner = manager.inner.lock().await; + push_log(&mut inner.status, &code); + } + }); + } + + fn spawn_process_monitor(&self, expected_pid: u32) { + let manager = self.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + let exited = { + let mut inner = manager.inner.lock().await; + let Some(child) = inner.child.as_mut() else { + break; + }; + if child.id() != Some(expected_pid) { + break; + } + match child.try_wait() { + Ok(Some(_)) => { + inner.child = None; + inner.endpoint = None; + inner.token = None; + inner.status.sidecar_pid = None; + inner.status.browser_pid = None; + inner.status.runtime_version = None; + inner.status.state = BrowserRuntimeState::Error; + inner.status.last_error_code = Some("SIDECAR_EXITED".to_string()); + push_log(&mut inner.status, "sidecar_exited"); + true + } + _ => false, + } + }; + if exited { + manager.detach_all_surfaces(false).await; + manager.emit_status().await; + break; + } + } + }); + } + + fn spawn_health_monitor(&self, expected_pid: u32) { + let manager = self.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(HEALTH_POLL_INTERVAL).await; + let should_poll = { + let inner = manager.inner.lock().await; + let same_process = inner.child.as_ref().and_then(tokio::process::Child::id) + == Some(expected_pid); + if !same_process { + break; + } + matches!( + inner.status.state, + BrowserRuntimeState::Starting + | BrowserRuntimeState::Ready + | BrowserRuntimeState::Recovering + ) + }; + if !should_poll { + continue; + } + let healthy = matches!( + tokio::time::timeout(HEALTH_POLL_TIMEOUT, manager.refresh_health()).await, + Ok(Ok(())) + ); + if healthy { + continue; + } + let same_process = manager + .inner + .lock() + .await + .child + .as_ref() + .and_then(tokio::process::Child::id) + == Some(expected_pid); + if same_process { + manager.set_error("CONTROL_REQUEST_FAILED").await; + } + } + }); + } + + async fn control(&self, action: &str) -> Result { + self.request( + reqwest::Method::POST, + &format!("/admin/{action}"), + Some(serde_json::json!({})), + ) + .await + } + + async fn refresh_health(&self) -> Result<(), BrowserRuntimeError> { + let response = self.request(reqwest::Method::GET, "/health", None).await?; + let runtime = response + .get("runtime") + .ok_or(BrowserRuntimeError::ControlFailed)?; + self.apply_runtime_status(runtime).await; + Ok(()) + } + + async fn request( + &self, + method: reqwest::Method, + path: &str, + body: Option, + ) -> Result { + let (endpoint, token) = { + let inner = self.inner.lock().await; + ( + inner + .endpoint + .clone() + .ok_or(BrowserRuntimeError::ControlFailed)?, + inner + .token + .clone() + .ok_or(BrowserRuntimeError::ControlFailed)?, + ) + }; + let mut request = self + .client + .request(method, format!("{endpoint}{path}")) + .bearer_auth(token) + .header(reqwest::header::ACCEPT, "application/json"); + if let Some(body) = body { + request = request.json(&body); + } + let response = request + .send() + .await + .map_err(|_| BrowserRuntimeError::ControlFailed)?; + let status = response.status(); + let value = response + .json::() + .await + .map_err(|_| BrowserRuntimeError::ControlFailed)?; + if !status.is_success() { + return Err(BrowserRuntimeError::ControlFailed); + } + Ok(value) + } + + async fn apply_runtime_status(&self, value: &serde_json::Value) { + let runtime_state = value.get("state").and_then(serde_json::Value::as_str); + let next_state = match runtime_state { + Some("ready") => BrowserRuntimeState::Ready, + Some("starting") => BrowserRuntimeState::Starting, + Some("recovering") => BrowserRuntimeState::Recovering, + Some("error") => BrowserRuntimeState::Error, + _ => BrowserRuntimeState::Stopped, + }; + let next_browser_pid = value + .get("browserPid") + .and_then(serde_json::Value::as_u64) + .and_then(|pid| u32::try_from(pid).ok()); + let next_browser_name = value + .get("browserName") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let next_browser_version = value + .get("browserVersion") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let next_recovery_attempt = value + .get("recoveryAttempt") + .and_then(serde_json::Value::as_u64) + .and_then(|attempt| u8::try_from(attempt).ok()) + .unwrap_or(0); + let next_error_code = value + .get("lastErrorCode") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let mut inner = self.inner.lock().await; + let changed = inner.status.state != next_state + || inner.status.browser_pid != next_browser_pid + || inner.status.browser_name != next_browser_name + || inner.status.browser_version != next_browser_version + || inner.status.recovery_attempt != next_recovery_attempt + || inner.status.last_error_code != next_error_code; + inner.status.state = next_state; + inner.status.browser_pid = next_browser_pid; + inner.status.browser_name = next_browser_name; + inner.status.browser_version = next_browser_version; + inner.status.recovery_attempt = next_recovery_attempt; + inner.status.last_error_code = next_error_code; + drop(inner); + if changed { + self.emit_status().await; + } + } + + async fn apply_settings_snapshot(&self, settings: BrowserRuntimeSettings) { + let mut inner = self.inner.lock().await; + inner.settings = settings.clone(); + inner.status.enabled = settings.enabled; + inner.status.auto_start = settings.auto_start; + inner.status.browser_path = settings.browser_path; + inner.status.backend = settings.backend.id().to_string(); + drop(inner); + self.emit_status().await; + } + + async fn set_error(&self, code: &str) { + let mut inner = self.inner.lock().await; + inner.status.state = BrowserRuntimeState::Error; + inner.status.last_error_code = Some(code.to_string()); + push_log(&mut inner.status, code); + drop(inner); + self.emit_status().await; + } + + async fn emit_status(&self) { + emit_event(&self.emitter, STATUS_EVENT, self.status().await); + } +} + +#[async_trait] +impl BrowserMcpProvider for BrowserRuntimeManager { + async fn enabled_for_new_session(&self) -> bool { + self.inner.lock().await.settings.enabled + } + + async fn server_for_session(&self, session_id: &str) -> Option { + validate_connection_id(session_id).ok()?; + let inner = self.inner.lock().await; + if inner.status.state != BrowserRuntimeState::Ready { + return None; + } + let endpoint = inner.endpoint.as_ref()?; + let token = inner.token.as_ref()?; + let server = McpServerHttp::new("codeg-browser", format!("{endpoint}/mcp")).headers(vec![ + HttpHeader::new("Authorization", format!("Bearer {token}")), + HttpHeader::new("X-Codeg-Browser-Session", session_id), + ]); + drop(inner); + self.surface_registry.lock().await.ensure(session_id); + Some(McpServer::Http(server)) + } + + async fn release_session(&self, session_id: &str) { + BrowserRuntimeManager::release_session(self, session_id).await; + } +} + +#[derive(Debug, Deserialize)] +struct ReadyEvent { + event: String, + port: u16, + version: String, +} + +async fn read_ready( + lines: &mut Lines>, +) -> Result { + while let Some(line) = lines + .next_line() + .await + .map_err(|_| BrowserRuntimeError::StartFailed)? + { + let Ok(event) = serde_json::from_str::(&line) else { + continue; + }; + if event.event == "ready" && event.port > 0 { + return Ok(event); + } + } + Err(BrowserRuntimeError::StartFailed) +} + +fn push_log(status: &mut BrowserRuntimeStatus, code: &str) { + let mut entries: VecDeque = status.recent_logs.drain(..).collect(); + entries.push_back(BrowserLogEntry { + at: chrono::Utc::now().to_rfc3339(), + code: sanitize_log_code(code), + }); + while entries.len() > LOG_LIMIT { + entries.pop_front(); + } + status.recent_logs = entries.into_iter().collect(); +} + +fn normalize_settings(mut settings: BrowserRuntimeSettings) -> BrowserRuntimeSettings { + // The main Settings UI intentionally exposes one Browser switch. Keep the + // legacy field as a serialized compatibility mirror so an older saved + // autoStart=false value cannot silently block an enabled Browser on launch. + settings.auto_start = settings.enabled; + settings.browser_path = settings + .browser_path + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + settings +} + +async fn persist_settings( + db: &AppDatabase, + settings: &BrowserRuntimeSettings, +) -> Result<(), BrowserRuntimeError> { + let serialized = + serde_json::to_string(settings).map_err(|_| BrowserRuntimeError::SettingsFailed)?; + app_metadata_service::upsert_value(&db.conn, SETTINGS_KEY, &serialized) + .await + .map_err(|_| BrowserRuntimeError::SettingsFailed) +} + +fn sanitize_log_code(code: &str) -> String { + const ALLOWED: &[&str] = &[ + "sidecar_ready", + "sidecar_exited", + "sidecar_stderr", + "missing_or_short_control_token", + "profile_and_download_directories_are_required", + "invalid_argument", + "invalid_parent_pid", + "invalid_backend", + "unsupported_platform", + "unknown_fatal_error", + "UNSUPPORTED_PLATFORM", + "RUNTIME_DISABLED", + "SIDECAR_NOT_INSTALLED", + "SIDECAR_START_FAILED", + "SIDECAR_READY_TIMEOUT", + "CONTROL_REQUEST_FAILED", + "SETTINGS_FAILED", + ]; + if ALLOWED.contains(&code) { + code.to_string() + } else { + "sidecar_error".to_string() + } +} + +fn validate_connection_id(connection_id: &str) -> Result<(), BrowserRuntimeError> { + if connection_id.is_empty() + || connection_id.len() > 256 + || !connection_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte)) + { + return Err(BrowserRuntimeError::ControlFailed); + } + Ok(()) +} + +fn parse_surface_snapshot( + value: serde_json::Value, + connection_id: &str, +) -> Result { + let snapshot = serde_json::from_value::(value) + .map_err(|_| BrowserRuntimeError::ControlFailed)?; + validate_surface_snapshot(&snapshot, connection_id)?; + Ok(snapshot) +} + +fn validate_surface_snapshot( + snapshot: &BrowserSurfaceSnapshot, + connection_id: &str, +) -> Result<(), BrowserRuntimeError> { + if snapshot.session_id != connection_id { + return Err(BrowserRuntimeError::ControlFailed); + } + let target_ids = snapshot + .tabs + .iter() + .map(|tab| tab.id.as_str()) + .collect::>(); + if target_ids.len() != snapshot.tabs.len() + || snapshot.tabs.iter().any(|tab| tab.id.is_empty()) + || snapshot + .active_target_id + .as_deref() + .is_some_and(|target_id| !target_ids.contains(target_id)) + || snapshot.active.as_ref().is_some_and(|active| { + snapshot.active_target_id.as_deref() != Some(active.tab.id.as_str()) + }) + || snapshot.active.is_none() != snapshot.active_target_id.is_none() + { + return Err(BrowserRuntimeError::ControlFailed); + } + Ok(()) +} + +fn error_code(error: &BrowserRuntimeError) -> &'static str { + match error { + BrowserRuntimeError::UnsupportedPlatform => "UNSUPPORTED_PLATFORM", + BrowserRuntimeError::Disabled => "RUNTIME_DISABLED", + BrowserRuntimeError::NotInstalled => "SIDECAR_NOT_INSTALLED", + BrowserRuntimeError::StartFailed => "SIDECAR_START_FAILED", + BrowserRuntimeError::ReadyTimeout => "SIDECAR_READY_TIMEOUT", + BrowserRuntimeError::ControlFailed => "CONTROL_REQUEST_FAILED", + BrowserRuntimeError::SettingsFailed => "SETTINGS_FAILED", + } +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + metadata.is_file() && metadata.len() > 0 +} + +fn locate_sidecar() -> Option { + if !cfg!(windows) { + return None; + } + if let Some(path) = std::env::var_os("CODEG_BROWSER_SIDECAR_BIN").map(PathBuf::from) { + if is_executable_file(&path) { + return Some(path); + } + } + if let Some(directory) = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)) + { + let candidate = directory.join("codeg-browser-sidecar.exe"); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + let triple = match std::env::consts::ARCH { + "aarch64" => "aarch64-pc-windows-msvc", + _ => "x86_64-pc-windows-msvc", + }; + let development = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("binaries") + .join(format!("codeg-browser-sidecar-{triple}.exe")); + is_executable_file(&development).then_some(development) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_codes_are_bounded_and_cannot_carry_urls_or_tokens() { + assert_eq!( + sanitize_log_code("https://example.com/?token=secret"), + "sidecar_error" + ); + assert_eq!(sanitize_log_code(&"x".repeat(100)), "sidecar_error"); + assert_eq!(sanitize_log_code("sidecar_ready"), "sidecar_ready"); + } + + #[test] + fn browser_switch_controls_startup_connection() { + let disabled = normalize_settings(BrowserRuntimeSettings { + enabled: false, + auto_start: true, + browser_path: Some(" C:\\Chrome\\chrome.exe ".to_string()), + backend: BrowserRuntimeBackend::Embedded, + }); + + assert!(!disabled.auto_start); + assert_eq!( + disabled.browser_path.as_deref(), + Some("C:\\Chrome\\chrome.exe") + ); + + let enabled = normalize_settings(BrowserRuntimeSettings { + enabled: true, + auto_start: false, + browser_path: None, + backend: BrowserRuntimeBackend::Embedded, + }); + assert!(enabled.auto_start); + } + + #[test] + fn legacy_settings_without_backend_keep_the_external_browser() { + let settings: BrowserRuntimeSettings = + serde_json::from_str(r#"{"enabled":true,"autoStart":true,"browserPath":null}"#) + .unwrap(); + + assert_eq!(settings.backend, BrowserRuntimeBackend::External); + } + + #[test] + fn public_status_serialization_has_no_endpoint_or_token_fields() { + let status = BrowserRuntimeStatus { + state: BrowserRuntimeState::Ready, + installed: true, + enabled: true, + auto_start: false, + browser_path: None, + sidecar_pid: Some(1), + browser_pid: Some(2), + runtime_version: Some("0.1.0".to_string()), + backend: EXTERNAL_BACKEND_ID.to_string(), + browser_name: Some("Chrome".to_string()), + browser_version: Some("1".to_string()), + profile_path: "C:\\Codeg\\browser\\profile".to_string(), + download_path: "C:\\Codeg\\browser\\downloads".to_string(), + recovery_attempt: 0, + last_error_code: None, + recent_logs: Vec::new(), + }; + let json = serde_json::to_string(&status).unwrap(); + assert!(!json.contains("endpoint")); + assert!(!json.contains("token")); + } + + #[tokio::test] + async fn readiness_captures_the_packaged_runtime_version() { + let input = br#"{"event":"ready","version":"0.1.0","pid":42,"port":43123} +"#; + let mut lines = BufReader::new(&input[..]).lines(); + let ready = read_ready(&mut lines).await.unwrap(); + + assert_eq!(ready.version, "0.1.0"); + assert_eq!(ready.port, 43123); + } + + #[tokio::test] + async fn health_projection_surfaces_a_browser_crash() { + let manager = BrowserRuntimeManager::new(PathBuf::from("unused"), EventEmitter::Noop); + manager + .apply_runtime_status(&serde_json::json!({ + "state": "error", + "browserPid": null, + "browserName": "Google Chrome", + "browserVersion": "140.0", + "recoveryAttempt": 0, + "lastErrorCode": "BROWSER_CRASHED" + })) + .await; + + let status = manager.status().await; + assert_eq!(status.state, BrowserRuntimeState::Error); + assert_eq!(status.last_error_code.as_deref(), Some("BROWSER_CRASHED")); + } + + #[tokio::test] + async fn runtime_lifecycle_is_independent_from_the_future_session_default() { + let manager = BrowserRuntimeManager::new(PathBuf::from("unused"), EventEmitter::Noop); + { + let mut inner = manager.inner.lock().await; + inner.endpoint = Some("http://127.0.0.1:1".to_string()); + inner.token = Some("test-token".to_string()); + } + + assert!(matches!( + manager.start().await, + Err(BrowserRuntimeError::ControlFailed) + )); + assert!(matches!( + manager.recover().await, + Err(BrowserRuntimeError::ControlFailed) + )); + } + + #[tokio::test] + async fn provider_is_fail_closed_and_scopes_headers_to_the_session() { + let manager = BrowserRuntimeManager::new(PathBuf::from("unused"), EventEmitter::Noop); + assert!(!manager.enabled_for_new_session().await); + assert!(manager.server_for_session("session-a").await.is_none()); + + { + let mut inner = manager.inner.lock().await; + inner.status.state = BrowserRuntimeState::Ready; + inner.endpoint = Some("http://127.0.0.1:12345".to_string()); + inner.token = Some("private-token".to_string()); + } + // A provider already frozen into a running Agent session stays usable + // after the future-session default is switched off. + let server = manager + .server_for_session("session-a") + .await + .expect("ready provider"); + assert!(manager.surface_registry.lock().await.contains("session-a")); + let value = serde_json::to_value(server).unwrap(); + assert_eq!(value["name"], "codeg-browser"); + assert_eq!(value["url"], "http://127.0.0.1:12345/mcp"); + assert!(value["headers"] + .as_array() + .unwrap() + .iter() + .any(|header| header["name"] == "X-Codeg-Browser-Session" + && header["value"] == "session-a")); + } + + #[test] + fn surface_registry_is_unique_per_connection_and_isolates_sessions() { + let mut registry = BrowserSurfaceRegistry::default(); + assert_eq!(registry.ensure("connection-a"), 0); + assert_eq!(registry.ensure("connection-a"), 0); + assert_eq!(registry.next_generation("connection-a"), 1); + assert_eq!(registry.ensure("connection-a"), 1); + assert_eq!(registry.ensure("connection-b"), 0); + assert!(registry.contains("connection-a")); + assert!(registry.contains("connection-b")); + } + + #[test] + fn surface_snapshot_rejects_hidden_or_cross_session_targets() { + let hidden = BrowserSurfaceSnapshot { + session_id: "connection-a".to_string(), + tabs: vec![BrowserSurfaceTab { + id: "t1".to_string(), + url: "about:blank".to_string(), + title: "New Tab".to_string(), + }], + active_target_id: Some("t2".to_string()), + active: None, + }; + assert!(validate_surface_snapshot(&hidden, "connection-a").is_err()); + assert!(validate_surface_snapshot(&hidden, "connection-b").is_err()); + } +} diff --git a/src-tauri/src/commands/browser.rs b/src-tauri/src/commands/browser.rs new file mode 100644 index 000000000..a9c976ad8 --- /dev/null +++ b/src-tauri/src/commands/browser.rs @@ -0,0 +1,145 @@ +use tauri::{ipc::Channel, State}; + +use crate::app_error::AppCommandError; +use crate::browser_runtime::{ + BrowserRuntimeError, BrowserRuntimeManager, BrowserRuntimeSettings, BrowserRuntimeStatus, + BrowserSurfaceEvent, BrowserSurfaceSnapshot, +}; +use crate::db::AppDatabase; + +fn map_error(error: BrowserRuntimeError) -> AppCommandError { + AppCommandError::external_command("Browser runtime operation failed", error.to_string()) +} + +#[tauri::command] +pub async fn browser_get_status( + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + Ok(runtime.status().await) +} + +#[tauri::command] +pub async fn browser_get_settings( + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + Ok(runtime.settings().await) +} + +#[tauri::command] +pub async fn browser_update_settings( + settings: BrowserRuntimeSettings, + runtime: State<'_, BrowserRuntimeManager>, + db: State<'_, AppDatabase>, +) -> Result { + runtime + .update_settings(&db, settings) + .await + .map_err(map_error) +} + +#[tauri::command] +pub async fn browser_start( + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + runtime.start().await.map_err(map_error) +} + +#[tauri::command] +pub async fn browser_stop( + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + runtime.stop().await.map_err(map_error) +} + +#[tauri::command] +pub async fn browser_restart( + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + let _ = runtime.stop().await; + runtime.start().await.map_err(map_error) +} + +#[tauri::command] +pub async fn browser_recover( + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + runtime.recover().await.map_err(map_error) +} + +#[tauri::command] +pub async fn browser_doctor( + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + runtime.doctor().await.map_err(map_error) +} + +#[tauri::command] +pub async fn browser_get_diagnostics( + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + runtime.diagnostics().await.map_err(map_error) +} + +#[tauri::command] +pub async fn browser_test_connection( + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + runtime.doctor().await.map_err(map_error) +} + +#[tauri::command] +pub async fn browser_surface_ensure( + connection_id: String, + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + runtime + .ensure_surface(&connection_id) + .await + .map_err(map_error) +} + +#[tauri::command] +pub async fn browser_surface_attach( + connection_id: String, + on_event: Channel, + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + runtime + .attach_surface(&connection_id, on_event) + .await + .map_err(map_error) +} + +#[tauri::command] +pub async fn browser_surface_action( + connection_id: String, + action: serde_json::Value, + runtime: State<'_, BrowserRuntimeManager>, +) -> Result { + runtime + .surface_action(&connection_id, action) + .await + .map_err(map_error) +} + +#[tauri::command] +pub async fn browser_surface_detach( + connection_id: String, + runtime: State<'_, BrowserRuntimeManager>, +) -> Result<(), AppCommandError> { + runtime + .detach_surface(&connection_id) + .await + .map_err(map_error) +} + +#[tauri::command] +pub async fn browser_surface_close( + connection_id: String, + runtime: State<'_, BrowserRuntimeManager>, +) -> Result<(), AppCommandError> { + runtime + .close_surface(&connection_id) + .await + .map_err(map_error) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index dcf009cc3..5044a9af2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,8 @@ pub mod app_update; pub mod automation; pub mod background; pub mod backup; +#[cfg(feature = "tauri-runtime")] +pub mod browser; pub mod chat_authoring; pub mod chat_channel; pub mod conversations; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 490a1f364..115ae14cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ mod app_error; pub mod app_state; pub mod automation; pub mod backgrounds; +pub mod browser_runtime; pub mod chat_channel; pub mod commands; pub mod db; @@ -62,23 +63,19 @@ mod tauri_app { use crate::acp::manager::ConnectionManager; use crate::chat_channel::manager::ChatChannelManager; use crate::commands::{ - acp as acp_commands, app_update as app_update_commands, - automation as automation_commands, background as background_commands, backup, + acp as acp_commands, app_update as app_update_commands, automation as automation_commands, + background as background_commands, backup, browser as browser_commands, chat_authoring as chat_authoring_commands, chat_channel as chat_channel_commands, - conversations, - custom_skills as custom_skills_commands, delegation as delegation_commands, + conversations, custom_skills as custom_skills_commands, delegation as delegation_commands, experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, - folder_links, office_tools as office_tools_commands, - folders, logging as logging_commands, mcp as mcp_commands, - model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, + folder_links, folders, forge as forge_commands, logging as logging_commands, + mcp as mcp_commands, model_provider as model_provider_commands, notification, + office_tools as office_tools_commands, pet as pet_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, - remote_proxy as remote_proxy_commands, - remote_workspace as remote_workspace_commands, science as science_commands, - session_info as session_info_commands, - system_settings, terminal as terminal_commands, - token_usage as token_usage_commands, - forge as forge_commands, version_control, windows, work_task as work_task_commands, - workspace_state as workspace_state_commands, + remote_proxy as remote_proxy_commands, remote_workspace as remote_workspace_commands, + science as science_commands, session_info as session_info_commands, system_settings, + terminal as terminal_commands, token_usage as token_usage_commands, version_control, + windows, work_task as work_task_commands, workspace_state as workspace_state_commands, }; use crate::terminal::manager::TerminalManager; use crate::{db, git_credential, network, paths, process, web}; @@ -351,6 +348,29 @@ mod tauri_app { .map_err(|e| e.to_string())?; app.manage(database); + // Managed Browser MCP runtime. The public status handle never + // contains its loopback endpoint or bearer token; the provider + // installed into ConnectionManager is the only path that can + // materialize those private headers for a healthy ACP session. + let browser_runtime = crate::browser_runtime::BrowserRuntimeManager::new( + effective_data_dir.clone(), + web::event_bridge::EventEmitter::Tauri(app.handle().clone()), + ); + let browser_settings = tauri::async_runtime::block_on( + browser_runtime.load_settings(&app.state::()), + ) + .unwrap_or_default(); + app.state::() + .install_browser_mcp_provider(std::sync::Arc::new(browser_runtime.clone())); + app.manage(browser_runtime.clone()); + if browser_settings.enabled { + tauri::async_runtime::spawn(async move { + if let Err(error) = browser_runtime.start().await { + tracing::warn!("[Browser] auto-start failed: {error}"); + } + }); + } + // Restore and apply saved system proxy settings before any network operation. let db = app.state::(); tauri::async_runtime::block_on(network::proxy::init_proxy_from_db(&db.conn)); @@ -1413,6 +1433,21 @@ mod tauri_app { backup::backup_scan_external_conflicts, backup::backup_restore_stage, backup::backup_cancel, + browser_commands::browser_get_status, + browser_commands::browser_get_settings, + browser_commands::browser_update_settings, + browser_commands::browser_start, + browser_commands::browser_stop, + browser_commands::browser_restart, + browser_commands::browser_recover, + browser_commands::browser_doctor, + browser_commands::browser_get_diagnostics, + browser_commands::browser_test_connection, + browser_commands::browser_surface_ensure, + browser_commands::browser_surface_attach, + browser_commands::browser_surface_action, + browser_commands::browser_surface_detach, + browser_commands::browser_surface_close, chat_channel_commands::list_chat_channels, chat_channel_commands::create_chat_channel, chat_channel_commands::update_chat_channel, @@ -1462,6 +1497,11 @@ mod tauri_app { if let Some(ws) = app.try_state::() { tauri::async_runtime::block_on(web::do_stop_web_server(&ws)); } + if let Some(browser) = + app.try_state::() + { + let _ = tauri::async_runtime::block_on(browser.stop()); + } if let Some(tm) = app.try_state::() { tm.kill_all(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b732b41f3..16056df7d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,9 +28,10 @@ "icons/icon.ico" ], "resources": { - "../out": "web/" + "../out": "web/", + "../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md" }, - "externalBin": ["binaries/codeg-mcp"], + "externalBin": ["binaries/codeg-mcp", "binaries/codeg-browser-sidecar"], "windows": { "nsis": { "installerHooks": "./windows/installer-hooks.nsh" diff --git a/src-tauri/windows/installer-hooks.nsh b/src-tauri/windows/installer-hooks.nsh index 20adfd94f..4956d7c05 100644 --- a/src-tauri/windows/installer-hooks.nsh +++ b/src-tauri/windows/installer-hooks.nsh @@ -1,11 +1,10 @@ ; Tauri NSIS installer hooks. ; ; codeg-mcp.exe is the MCP stdio companion spawned by each agent CLI -; (claude / codex / opencode / ...), which is itself a grandchild of -; codeg.exe. Windows does not propagate parent death to descendants the -; way Unix does, so stale codeg-mcp.exe processes from a previous session -; can keep the binary file locked. The installer then fails to overwrite -; it with: +; (claude / codex / opencode / ...), and codeg-browser-sidecar.exe owns the +; managed Browser runtime. Windows does not propagate parent death to +; descendants the way Unix does, so stale companions from a previous session +; can keep either binary locked. The installer then fails to overwrite it with: ; ; Error opening file for writing: ...\codeg\codeg-mcp.exe ; @@ -14,18 +13,22 @@ ; non-zero when no processes match, which is fine — we ignore the result. !macro NSIS_HOOK_PREINSTALL - DetailPrint "Stopping any running codeg-mcp processes..." + DetailPrint "Stopping any running Codeg companion processes..." nsExec::Exec 'taskkill /F /T /IM codeg-mcp.exe' Pop $0 + nsExec::Exec 'taskkill /F /T /IM codeg-browser-sidecar.exe' + Pop $0 ; Small grace period so the OS releases file handles before the - ; installer attempts to overwrite codeg-mcp.exe. + ; installer attempts to overwrite either companion binary. Sleep 500 !macroend !macro NSIS_HOOK_PREUNINSTALL - DetailPrint "Stopping any running codeg-mcp processes..." + DetailPrint "Stopping any running Codeg companion processes..." nsExec::Exec 'taskkill /F /T /IM codeg-mcp.exe' Pop $0 + nsExec::Exec 'taskkill /F /T /IM codeg-browser-sidecar.exe' + Pop $0 Sleep 500 !macroend diff --git a/src/app/settings/browser/page.tsx b/src/app/settings/browser/page.tsx new file mode 100644 index 000000000..156855a0b --- /dev/null +++ b/src/app/settings/browser/page.tsx @@ -0,0 +1,5 @@ +import { BrowserSettings } from "@/components/settings/browser-settings" + +export default function SettingsBrowserPage() { + return +} diff --git a/src/components/layout/aux-panel-browser-tab.test.tsx b/src/components/layout/aux-panel-browser-tab.test.tsx new file mode 100644 index 000000000..1ba9ad8f6 --- /dev/null +++ b/src/components/layout/aux-panel-browser-tab.test.tsx @@ -0,0 +1,231 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const surface = vi.hoisted(() => ({ + attachBrowserSurface: vi.fn(), + detachBrowserSurface: vi.fn(), + closeBrowserSurface: vi.fn(), + runBrowserSurfaceAction: vi.fn(), +})) +const connection = vi.hoisted(() => ({ connectionId: "connection-a" })) + +vi.mock("@/lib/browser-surface", () => surface) +vi.mock("@/contexts/tab-context", () => ({ + useTabStore: (selector: (state: { activeTabId: string }) => unknown) => + selector({ activeTabId: "tab-a" }), +})) +vi.mock("@/hooks/use-connection", () => ({ + useConnection: () => connection, +})) +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +import { AuxPanelBrowserTab, browserFramePoint } from "./aux-panel-browser-tab" +import type { + BrowserSurfaceEvent, + BrowserSurfaceSnapshot, +} from "@/lib/browser-surface" + +const snapshot: BrowserSurfaceSnapshot = { + sessionId: "connection-a", + tabs: [ + { id: "t1", title: "One", url: "https://one.example/" }, + { id: "t2", title: "Two", url: "https://two.example/" }, + ], + activeTargetId: "t1", + active: { + tab: { id: "t1", title: "One", url: "https://one.example/" }, + loading: false, + canGoBack: true, + canGoForward: false, + }, +} + +describe("AuxPanelBrowserTab", () => { + let emit: ((event: BrowserSurfaceEvent) => void) | null + + beforeEach(() => { + vi.clearAllMocks() + emit = null + connection.connectionId = "connection-a" + surface.attachBrowserSurface.mockImplementation( + async ( + _connectionId: string, + listener: (event: BrowserSurfaceEvent) => void + ) => { + emit = listener + return snapshot + } + ) + surface.detachBrowserSurface.mockResolvedValue(undefined) + surface.closeBrowserSurface.mockResolvedValue(undefined) + surface.runBrowserSurfaceAction.mockResolvedValue(snapshot) + }) + + it("attaches once to the real connection and renders every live target", async () => { + const { rerender } = render( + undefined} /> + ) + + expect(await screen.findByText("One")).toBeInTheDocument() + expect(screen.getByText("Two")).toBeInTheDocument() + expect(surface.attachBrowserSurface).toHaveBeenCalledTimes(1) + expect(surface.attachBrowserSurface).toHaveBeenCalledWith( + "connection-a", + expect.any(Function) + ) + + rerender( undefined} />) + expect(surface.attachBrowserSurface).toHaveBeenCalledTimes(1) + }) + + it("focuses inner targets and navigates through the same surface", async () => { + render( undefined} />) + await screen.findByText("Two") + fireEvent.click(screen.getByRole("button", { name: "Two" })) + await waitFor(() => { + expect(surface.runBrowserSurfaceAction).toHaveBeenCalledWith( + "connection-a", + { action: "focus", targetId: "t2" } + ) + }) + + const address = screen.getByRole("textbox", { name: "addressBar" }) + fireEvent.change(address, { target: { value: "example.com" } }) + fireEvent.keyDown(address, { key: "Enter" }) + await waitFor(() => { + expect(surface.runBrowserSurfaceAction).toHaveBeenCalledWith( + "connection-a", + { action: "navigate", url: "https://example.com" } + ) + }) + }) + + it("detaches on hide without releasing targets", async () => { + const { rerender } = render( + undefined} /> + ) + await screen.findByText("One") + rerender( + undefined} /> + ) + + await waitFor(() => { + expect(surface.detachBrowserSurface).toHaveBeenCalledWith("connection-a") + }) + expect(surface.closeBrowserSurface).not.toHaveBeenCalled() + }) + + it("explicit close releases the whole Browser session", async () => { + const onExplicitClose = vi.fn() + render() + await screen.findByText("One") + fireEvent.click(screen.getByRole("button", { name: "closeBrowser" })) + + await waitFor(() => { + expect(surface.closeBrowserSurface).toHaveBeenCalledWith("connection-a") + expect(onExplicitClose).toHaveBeenCalledWith("connection-a") + }) + }) + + it("accepts authoritative full snapshots from the stream", async () => { + render( undefined} />) + await screen.findByText("Two") + act(() => { + emit?.({ + type: "snapshot", + snapshot: { + ...snapshot, + tabs: [snapshot.tabs[1]!], + activeTargetId: "t2", + active: { + ...snapshot.active!, + tab: snapshot.tabs[1]!, + }, + }, + }) + }) + + expect(await screen.findByText("Two")).toBeInTheDocument() + expect(screen.queryByText("One")).not.toBeInTheDocument() + }) + + it("resizes the real browser viewport to the rendered page region", async () => { + const rect = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect") + rect.mockReturnValue({ + width: 320, + height: 620, + top: 0, + right: 320, + bottom: 620, + left: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) + + render( undefined} />) + + await waitFor(() => { + expect(surface.runBrowserSurfaceAction).toHaveBeenCalledWith( + "connection-a", + { action: "resize", width: 320, height: 620 } + ) + }) + rect.mockRestore() + }) + + it("renders frames without non-uniform stretching", async () => { + render( undefined} />) + await screen.findByText("One") + act(() => { + emit?.({ + type: "frame", + frame: { + targetId: "t1", + data: "AA==", + mimeType: "image/jpeg", + deviceWidth: 1_600, + deviceHeight: 900, + pageScaleFactor: 1, + }, + }) + }) + + expect(screen.getByRole("application").querySelector("img")).toHaveClass( + "object-contain" + ) + }) + + it.each([ + [320, 620], + [320, 200], + [620, 320], + ])( + "maps input directly when the page viewport matches a %d x %d surface", + (width, height) => { + expect(browserFramePoint(width, height, width, height, 0, 0)).toEqual({ + x: 0, + y: 0, + }) + expect( + browserFramePoint(width, height, width, height, width / 2, height / 2) + ).toEqual({ x: width / 2, y: height / 2 }) + expect( + browserFramePoint(width, height, width, height, width, height) + ).toEqual({ x: width, y: height }) + } + ) + + it("maps the fitted frame and rejects temporary letterbox coordinates", () => { + expect(browserFramePoint(320, 620, 1_600, 900, 160, 310)).toEqual({ + x: 800, + y: 450, + }) + expect(browserFramePoint(320, 620, 1_600, 900, 160, 100)).toBeNull() + expect(browserFramePoint(320, 200, 1_600, 900, -1, 100)).toBeNull() + expect(browserFramePoint(320, 200, 1_600, 900, 321, 100)).toBeNull() + expect(browserFramePoint(320, 200, 1_600, 900, 160, 201)).toBeNull() + }) +}) diff --git a/src/components/layout/aux-panel-browser-tab.tsx b/src/components/layout/aux-panel-browser-tab.tsx new file mode 100644 index 000000000..e2fe4ddbe --- /dev/null +++ b/src/components/layout/aux-panel-browser-tab.tsx @@ -0,0 +1,516 @@ +"use client" + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type PointerEvent, + type WheelEvent, +} from "react" +import { + ArrowLeft, + ArrowRight, + LoaderCircle, + Plus, + RefreshCw, + RotateCcw, + Square, + X, +} from "lucide-react" +import { useTranslations } from "next-intl" +import { recoverBrowserRuntime } from "@/lib/api" +import { + attachBrowserSurface, + closeBrowserSurface, + detachBrowserSurface, + runBrowserSurfaceAction, + type BrowserSurfaceAction, + type BrowserSurfaceEvent, + type BrowserSurfaceFrame, + type BrowserSurfaceInput, + type BrowserSurfaceSnapshot, +} from "@/lib/browser-surface" +import { toErrorMessage } from "@/lib/app-error" +import { useTabStore } from "@/contexts/tab-context" +import { useConnection } from "@/hooks/use-connection" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" + +interface AuxPanelBrowserTabProps { + visible: boolean + onExplicitClose: (connectionId: string) => void + onSessionAttached?: (connectionId: string) => void +} + +function normalizeAddress(value: string): string { + const trimmed = value.trim() + if (!trimmed || /^[a-z][a-z\d+.-]*:/i.test(trimmed)) return trimmed + return `https://${trimmed}` +} + +function modifiers(event: { + altKey: boolean + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean +}): number { + return ( + (event.altKey ? 1 : 0) | + (event.ctrlKey ? 2 : 0) | + (event.metaKey ? 4 : 0) | + (event.shiftKey ? 8 : 0) + ) +} + +export function browserFramePoint( + containerWidth: number, + containerHeight: number, + frameWidth: number, + frameHeight: number, + offsetX: number, + offsetY: number +): { x: number; y: number } | null { + if ( + containerWidth <= 0 || + containerHeight <= 0 || + frameWidth <= 0 || + frameHeight <= 0 + ) { + return null + } + + if ( + offsetX < 0 || + offsetX > containerWidth || + offsetY < 0 || + offsetY > containerHeight + ) { + return null + } + + const scale = Math.min( + containerWidth / frameWidth, + containerHeight / frameHeight + ) + const renderedWidth = frameWidth * scale + const renderedHeight = frameHeight * scale + const insetX = (containerWidth - renderedWidth) / 2 + const insetY = (containerHeight - renderedHeight) / 2 + if ( + offsetX < insetX || + offsetX > insetX + renderedWidth || + offsetY < insetY || + offsetY > insetY + renderedHeight + ) { + return null + } + + return { + x: (offsetX - insetX) / scale, + y: (offsetY - insetY) / scale, + } +} + +export function AuxPanelBrowserTab({ + visible, + onExplicitClose, + onSessionAttached, +}: AuxPanelBrowserTabProps) { + const t = useTranslations("BrowserSurface") + const activeTabId = useTabStore((state) => state.activeTabId) + const { connectionId } = useConnection(activeTabId ?? "browser-no-active-tab") + const [snapshot, setSnapshot] = useState(null) + const [frame, setFrame] = useState(null) + const [address, setAddress] = useState("") + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [retryKey, setRetryKey] = useState(0) + const generation = useRef(0) + const surfaceRef = useRef(null) + const lastViewport = useRef<{ + connectionId: string + width: number + height: number + } | null>(null) + const onSessionAttachedRef = useRef(onSessionAttached) + onSessionAttachedRef.current = onSessionAttached + + useEffect(() => { + if (!visible || !connectionId) return + const currentGeneration = ++generation.current + let disposed = false + let resizeObserver: ResizeObserver | null = null + let resizeTimer: ReturnType | null = null + setError(null) + const acceptSnapshot = (next: BrowserSurfaceSnapshot) => { + if ( + disposed || + generation.current !== currentGeneration || + next.sessionId !== connectionId + ) { + return + } + setSnapshot(next) + } + const onEvent = (event: BrowserSurfaceEvent) => { + if (event.type === "snapshot") { + acceptSnapshot(event.snapshot) + } else if (event.type === "frame") { + if (!disposed && generation.current === currentGeneration) { + setFrame(event.frame) + } + } else if (!disposed && generation.current === currentGeneration) { + setError(`${event.code}: ${event.message}`) + } + } + void attachBrowserSurface(connectionId, onEvent) + .then((next) => { + if (disposed || generation.current !== currentGeneration) return + acceptSnapshot(next) + onSessionAttachedRef.current?.(connectionId) + const syncViewport = () => { + if (disposed || generation.current !== currentGeneration) return + const element = surfaceRef.current + if (!element) return + const rect = element.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return + const width = Math.min(4_096, Math.max(1, Math.round(rect.width))) + const height = Math.min(4_096, Math.max(1, Math.round(rect.height))) + const previous = lastViewport.current + if ( + previous?.connectionId === connectionId && + previous.width === width && + previous.height === height + ) { + return + } + lastViewport.current = { connectionId, width, height } + void runBrowserSurfaceAction(connectionId, { + action: "resize", + width, + height, + }).catch((cause) => { + if (!disposed && generation.current === currentGeneration) { + setError(toErrorMessage(cause)) + } + }) + } + lastViewport.current = null + syncViewport() + const element = surfaceRef.current + if (element) { + resizeObserver = new ResizeObserver(() => { + if (resizeTimer) clearTimeout(resizeTimer) + resizeTimer = setTimeout(syncViewport, 50) + }) + resizeObserver.observe(element) + } + }) + .catch((cause) => { + if (!disposed && generation.current === currentGeneration) { + setError(toErrorMessage(cause)) + } + }) + return () => { + disposed = true + generation.current += 1 + resizeObserver?.disconnect() + if (resizeTimer) clearTimeout(resizeTimer) + void detachBrowserSurface(connectionId).catch(() => undefined) + } + }, [connectionId, retryKey, visible]) + + const active = snapshot?.active ?? null + useEffect(() => { + setAddress(active?.tab.url ?? "") + }, [active?.tab.url]) + + const run = useCallback( + async (action: BrowserSurfaceAction) => { + if (!connectionId) return + setBusy(true) + setError(null) + try { + setSnapshot(await runBrowserSurfaceAction(connectionId, action)) + } catch (cause) { + setError(toErrorMessage(cause)) + } finally { + setBusy(false) + } + }, + [connectionId] + ) + + const sendInput = useCallback( + (input: BrowserSurfaceInput) => { + if (!connectionId) return + void runBrowserSurfaceAction(connectionId, { + action: "input", + input, + }).catch((cause) => setError(toErrorMessage(cause))) + }, + [connectionId] + ) + + const frameSource = useMemo(() => { + if (!frame || frame.targetId !== snapshot?.activeTargetId) return null + return `data:${frame.mimeType};base64,${frame.data}` + }, [frame, snapshot?.activeTargetId]) + + const pointerInput = useCallback( + (event: PointerEvent, phase: "pressed" | "released") => { + if (!frame) return + const rect = event.currentTarget.getBoundingClientRect() + const point = browserFramePoint( + rect.width, + rect.height, + frame.deviceWidth, + frame.deviceHeight, + event.clientX - rect.left, + event.clientY - rect.top + ) + if (!point) return + const button = + event.button === 1 ? "middle" : event.button === 2 ? "right" : "left" + sendInput({ + kind: "mouse", + event: phase, + x: point.x, + y: point.y, + button, + modifiers: modifiers(event), + }) + event.currentTarget.focus() + event.preventDefault() + }, + [frame, sendInput] + ) + + const wheelInput = useCallback( + (event: WheelEvent) => { + if (!frame) return + const rect = event.currentTarget.getBoundingClientRect() + const point = browserFramePoint( + rect.width, + rect.height, + frame.deviceWidth, + frame.deviceHeight, + event.clientX - rect.left, + event.clientY - rect.top + ) + if (!point) return + sendInput({ + kind: "mouse", + event: "wheel", + x: point.x, + y: point.y, + button: "none", + deltaX: event.deltaX, + deltaY: event.deltaY, + modifiers: modifiers(event), + }) + event.preventDefault() + }, + [frame, sendInput] + ) + + const keyInput = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Tab") return + const common = { + kind: "key" as const, + key: event.key, + code: event.code, + modifiers: modifiers(event), + } + sendInput({ + ...common, + event: "down", + text: + event.key.length === 1 && !event.ctrlKey && !event.metaKey + ? event.key + : undefined, + }) + sendInput({ ...common, event: "up" }) + event.preventDefault() + }, + [sendInput] + ) + + if (!connectionId) { + return ( +
+ {t("noActiveSession")} +
+ ) + } + + return ( +
+
+ + + + setAddress(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") return + const url = normalizeAddress(address) + if (url) void run({ action: "navigate", url }) + }} + /> + +
+ +
+ {snapshot?.tabs.map((tab) => ( +
+ + +
+ ))} + +
+ + {error ? ( +
+ {error} + +
+ ) : null} + +
pointerInput(event, "pressed")} + onPointerUp={(event) => pointerInput(event, "released")} + onWheel={wheelInput} + onKeyDown={keyInput} + onPaste={(event) => { + const text = event.clipboardData.getData("text") + if (text) sendInput({ kind: "text", text }) + event.preventDefault() + }} + onContextMenu={(event) => event.preventDefault()} + > + {frameSource ? ( + // The frame is the same CDP target the Agent controls. The target's + // viewport follows this display region; contain is only a safe fallback + // while a resized frame is in flight. Input uses the same fitted rect. + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ {busy || active?.loading ? ( + + + {t("loading")} + + ) : ( + t("waitingForFrame") + )} +
+ )} +
+
+ ) +} diff --git a/src/components/layout/aux-panel.test.tsx b/src/components/layout/aux-panel.test.tsx index 138f12c24..a66511958 100644 --- a/src/components/layout/aux-panel.test.tsx +++ b/src/components/layout/aux-panel.test.tsx @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest" -import { resolveAuxTabView, shouldCollapseAuxTabs } from "./aux-panel" +import { + resolveAvailableAuxTabs, + resolveAuxTabView, + shouldCollapseAuxTabs, +} from "./aux-panel" describe("resolveAuxTabView", () => { it("shows all tabs and keeps the selection in a folder workspace", () => { @@ -22,6 +26,21 @@ describe("resolveAuxTabView", () => { }) }) + it("keeps Browser alongside Session Details in every context", () => { + expect(resolveAuxTabView("browser", 1, false)).toEqual({ + showFolderTabs: true, + effectiveTab: "browser", + }) + expect(resolveAuxTabView("browser", 1, true)).toEqual({ + showFolderTabs: false, + effectiveTab: "browser", + }) + expect(resolveAuxTabView("browser", null, false)).toEqual({ + showFolderTabs: false, + effectiveTab: "browser", + }) + }) + it("collapses to Session Details when no folder is open", () => { expect(resolveAuxTabView("changes", null, false)).toEqual({ showFolderTabs: false, @@ -38,6 +57,22 @@ describe("resolveAuxTabView", () => { }) }) +describe("resolveAvailableAuxTabs", () => { + it("keeps Browser while filtering only repository tabs", () => { + expect(resolveAvailableAuxTabs(true)).toEqual([ + "session_details", + "file_tree", + "changes", + "git_log", + "browser", + ]) + expect(resolveAvailableAuxTabs(false)).toEqual([ + "session_details", + "browser", + ]) + }) +}) + describe("shouldCollapseAuxTabs", () => { // rightReserve mirrors rightChromeReserve(): 116 on macOS/web (chrome only), // 254 on desktop Windows/Linux (chrome 116 + native caption 138). diff --git a/src/components/layout/aux-panel.tsx b/src/components/layout/aux-panel.tsx index e45f5936b..494a78f22 100644 --- a/src/components/layout/aux-panel.tsx +++ b/src/components/layout/aux-panel.tsx @@ -5,6 +5,7 @@ import { Folder, FolderPen, GitCommit, + Globe2, ReceiptText, type LucideIcon, } from "lucide-react" @@ -34,8 +35,14 @@ import { SessionDetailsTab } from "./aux-panel-session-details-tab" import { FileTreeTab } from "./aux-panel-file-tree-tab" import { GitChangesTab } from "./aux-panel-git-changes-tab" import { GitLogTab } from "./aux-panel-git-log-tab" +import { AuxPanelBrowserTab } from "./aux-panel-browser-tab" -const LAZY_TABS: AuxPanelTab[] = ["file_tree", "changes", "git_log"] +const LAZY_TABS: AuxPanelTab[] = ["file_tree", "changes", "git_log", "browser"] +const FOLDER_SCOPED_TABS = new Set([ + "file_tree", + "changes", + "git_log", +]) // Visible order + icon for every aux tab. Both the desktop segmented control // and the collapsed picker map over this, so the two surfaces can never drift. @@ -44,18 +51,20 @@ const TAB_ORDER: AuxPanelTab[] = [ "file_tree", "changes", "git_log", + "browser", ] const TAB_ICONS: Record = { session_details: ReceiptText, file_tree: Folder, changes: FolderPen, git_log: GitCommit, + browser: Globe2, } -// The three folder-scoped tabs share one label namespace (Folder.auxPanel.tabs); -// session details resolves from its own (Folder.sessionDetails.menuLabel). The -// value type is the literal key union so next-intl's typed `t()` accepts it. +// The three repository tabs share one label namespace (Folder.auxPanel.tabs); +// session details and Browser resolve from their own namespaces. The value type +// is the literal key union so next-intl's typed `t()` accepts it. const FOLDER_TAB_LABEL_KEY: Record< - Exclude, + "file_tree" | "changes" | "git_log", "files" | "changes" | "commits" > = { file_tree: "files", @@ -68,7 +77,7 @@ const FOLDER_TAB_LABEL_KEY: Record< // overlay (terminal/aux/settings, plus the native caption on Windows/Linux) // floats over the RIGHT edge. Once the panel is too narrow to seat the control // left of that reserved region, we swap it for a single icon-button + dropdown. -const SEGMENTED_TABS_WIDTH = 130 +const SEGMENTED_TABS_WIDTH = 162 const TAB_STRIP_GUTTER = 12 // pl-3 const TAB_STRIP_GAP = 12 // breathing room before the chrome overlay @@ -93,10 +102,10 @@ export function shouldCollapseAuxTabs( /** * Decide which aux-panel tabs are available and which to actually show. * - * The folder-scoped tabs (files/changes/commits) only make sense with a real - * folder workspace open, so chat sessions and the folderless state collapse to - * just the Session Details tab. `effectiveTab` keeps the rendered selection - * valid even when the stored `activeTab` is a now-hidden folder tab, avoiding a + * The repository tabs (files/changes/commits) only make sense with a real + * folder workspace open. Session Details and Browser remain available in chat + * sessions and the folderless state. `effectiveTab` keeps the rendered + * selection valid even when the stored `activeTab` is now hidden, avoiding a * one-frame flash before the reconciling effect corrects the stored value. */ export function resolveAuxTabView( @@ -107,14 +116,27 @@ export function resolveAuxTabView( const showFolderTabs = activeFolderId != null && !isChatMode return { showFolderTabs, - effectiveTab: showFolderTabs ? activeTab : "session_details", + effectiveTab: + showFolderTabs || !FOLDER_SCOPED_TABS.has(activeTab) + ? activeTab + : "session_details", } } +export function resolveAvailableAuxTabs( + showFolderTabs: boolean +): AuxPanelTab[] { + return TAB_ORDER.filter( + (tab) => !FOLDER_SCOPED_TABS.has(tab) || showFolderTabs + ) +} + export function AuxPanel() { const t = useTranslations("Folder.auxPanel.tabs") const tDetails = useTranslations("Folder.sessionDetails") - const { isOpen, width, activeTab, setActiveTab } = useAuxPanelContext() + const tBrowser = useTranslations("BrowserSurface") + const { isOpen, width, activeTab, setActiveTab, setBrowserSessionOpen } = + useAuxPanelContext() const { activeFolderId } = useActiveFolder() const isChatMode = useIsActiveChatMode() const isMobile = useIsMobile() @@ -163,7 +185,7 @@ export function AuxPanel() { // chat session), so other consumers of `activeTab` stay in sync with what's // shown. Done in an effect — never a render-time setState on the provider. useEffect(() => { - if (!showFolderTabs && activeTab !== "session_details") { + if (!showFolderTabs && FOLDER_SCOPED_TABS.has(activeTab)) { setActiveTab("session_details") } }, [showFolderTabs, activeTab, setActiveTab]) @@ -184,7 +206,6 @@ export function AuxPanel() { const rightReserve = rightChromeReserve(winLinuxControls, zoomLevel) const collapsed = !isMobile && - showFolderTabs && shouldCollapseAuxTabs( measuredWidth > 0 ? measuredWidth : width, rightReserve @@ -194,10 +215,14 @@ export function AuxPanel() { (tab: AuxPanelTab) => tab === "session_details" ? tDetails("menuLabel") - : t(FOLDER_TAB_LABEL_KEY[tab]), - [t, tDetails] + : tab === "browser" + ? tBrowser("tabLabel") + : t(FOLDER_TAB_LABEL_KEY[tab]), + [t, tBrowser, tDetails] ) + const availableTabs = resolveAvailableAuxTabs(showFolderTabs) + // Shared across the mobile underline row and the desktop segmented control. // `compact` overrides the base full-height, equal-flex trigger into a short, // content-width pill for the segmented look; mobile keeps the base styling. @@ -205,9 +230,7 @@ export function AuxPanel() { const triggerClassName = compact ? "h-6 flex-none rounded-md px-2" : undefined - return TAB_ORDER.filter( - (tab) => tab === "session_details" || showFolderTabs - ).map((tab) => { + return availableTabs.map((tab) => { const Icon = TAB_ICONS[tab] const label = tabLabel(tab) return ( @@ -249,7 +272,7 @@ export function AuxPanel() { value={effectiveTab} onValueChange={handleTabValueChange} > - {TAB_ORDER.map((tab) => { + {availableTabs.map((tab) => { const Icon = TAB_ICONS[tab] return ( @@ -303,14 +326,9 @@ export function AuxPanel() { // comfortable widths they clear each other; when the panel is too // narrow for the control to stay left of that overlay, it collapses // into a single dropdown picker (see `collapsed`). The strip is - // always h-10 (reserving the overlay's height); when Session Details - // is the only tab (chat / folderless) the control is `hidden` - // (display:none) — that drops the lone trigger out of the tab order - // (unlike `sr-only`, which stays keyboard - // focusable and would trap Tab on an invisible control) while the - // TabsContent's aria-labelledby still resolves the panel's name from - // the directly-referenced hidden trigger, so it stays labelled without - // showing a pointless single-tab control. + // always h-10 (reserving the overlay's height). Chat and folderless + // contexts still show Session Details + Browser; repository tabs are + // added only for a real folder workspace.
{/* Off-image `bg-muted` matches the conversation/file strips + bottom StatusBar. With a workspace background image on, the @@ -334,7 +352,7 @@ export function AuxPanel() { variant="default" className={cn( "h-7 gap-0.5 rounded-lg bg-foreground/[0.06] p-0.5 group-data-horizontal/tabs:h-7", - (!showFolderTabs || collapsed) && "hidden" + collapsed && "hidden" )} > {renderTabTriggers(true)} @@ -373,6 +391,24 @@ export function AuxPanel() { > {mountedTabs.has("git_log") ? : null} + + {mountedTabs.has("browser") ? ( + + setBrowserSessionOpen(connectionId, true) + } + onExplicitClose={(connectionId) => { + setBrowserSessionOpen(connectionId, false) + setActiveTab("session_details") + }} + /> + ) : null} + ) diff --git a/src/components/message/browser-tool-card.test.tsx b/src/components/message/browser-tool-card.test.tsx new file mode 100644 index 000000000..5afd22209 --- /dev/null +++ b/src/components/message/browser-tool-card.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it, vi } from "vitest" + +vi.mock("@/components/ui/image-preview-dialog", () => ({ + ImagePreviewDialog: () => null, +})) + +import { ContentPartsRenderer } from "./content-parts-renderer" +import enMessages from "@/i18n/messages/en.json" +import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" + +function renderPart(part: AdaptedContentPart) { + return render( + + + + ) +} + +describe("Browser tool card", () => { + it("shows URL, title, tab, and screenshot media without dumping base64", () => { + renderPart({ + type: "tool-call", + toolCallId: "browser-shot", + toolName: "page.screenshot", + input: JSON.stringify({ format: "png" }), + state: "output-available", + output: JSON.stringify({ + ok: true, + action: "page.screenshot", + tab: { + id: "tab-1", + url: "https://example.com/page", + title: "Example page", + }, + data: { captured: true }, + }), + images: [{ data: "QUJD", mime_type: "image/png" }], + }) + + expect(screen.getByText("Browser · page.screenshot")).toBeInTheDocument() + expect(screen.getByText("Example page")).toBeInTheDocument() + expect(screen.getByText("https://example.com/page")).toBeInTheDocument() + expect(screen.getByText("Tab tab-1")).toBeInTheDocument() + expect(screen.getByAltText("Browser screenshot 1")).toHaveAttribute( + "src", + "data:image/png;base64,QUJD" + ) + expect(document.body.textContent).not.toContain("QUJD") + }) + + it("shows download location and actionable recovery errors", () => { + const { rerender } = renderPart({ + type: "tool-call", + toolCallId: "download", + toolName: "download.wait", + input: "{}", + state: "output-available", + output: JSON.stringify({ + ok: true, + action: "download.wait", + data: { + download: { + guid: "d-1", + state: "completed", + filename: "report.pdf", + path: "C:\\Codeg\\browser\\downloads\\report.pdf", + }, + }, + }), + }) + + expect(screen.getByText("report.pdf")).toBeInTheDocument() + expect( + screen.getByText("C:\\Codeg\\browser\\downloads\\report.pdf") + ).toBeInTheDocument() + + rerender( + + + + ) + + expect(screen.getByRole("alert")).toHaveTextContent("SSRF_BLOCKED") + expect(screen.getByRole("alert")).toHaveTextContent("check_settings") + }) +}) diff --git a/src/components/message/browser-tool-card.tsx b/src/components/message/browser-tool-card.tsx new file mode 100644 index 000000000..e1363b7d3 --- /dev/null +++ b/src/components/message/browser-tool-card.tsx @@ -0,0 +1,266 @@ +"use client" + +import { memo, useCallback, useMemo, useState } from "react" +import Image from "next/image" +import { + AlertCircle, + Download, + Globe2, + ImageIcon, + RotateCcw, +} from "lucide-react" +import { useTranslations } from "next-intl" + +import { Tool, ToolContent, ToolHeader } from "@/components/ai-elements/tool" +import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog" +import type { AdaptedToolCallPart } from "@/lib/adapters/ai-elements-adapter" +import { + browserScreenshotImages, + parseBrowserToolView, +} from "@/lib/browser-tool" +import { toErrorMessage } from "@/lib/app-error" +import { downloadImage } from "@/lib/image-download" + +function asObject(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null +} + +export const BrowserToolCard = memo(function BrowserToolCard({ + part, +}: { + part: AdaptedToolCallPart +}) { + const t = useTranslations("Folder.chat.messageList") + const [manualOpen, setManualOpen] = useState(false) + const [previewIndex, setPreviewIndex] = useState(null) + const view = useMemo( + () => + parseBrowserToolView({ + toolName: part.toolName, + input: part.input, + output: part.output, + errorText: part.errorText, + }), + [part.toolName, part.input, part.output, part.errorText] + ) + const images = useMemo( + () => browserScreenshotImages(part.images), + [part.images] + ) + + const handleDownload = useCallback( + async (index: number) => { + const image = images[index] + if (!image) return + try { + await downloadImage({ + data: image.data, + mime_type: image.mime_type, + suggestedName: `browser-screenshot-${index + 1}.${ + image.mime_type === "image/jpeg" ? "jpg" : "png" + }`, + }) + } catch (error) { + window.alert(t("downloadFailed", { message: toErrorMessage(error) })) + } + }, + [images, t] + ) + + if (!view) return null + + const inFlight = + part.state === "input-available" || part.state === "input-streaming" + const outputObject = asObject(view.output) + const data = asObject(outputObject?.data) + const tabs = Array.isArray(data?.tabs) ? data.tabs.length : null + const nodes = Array.isArray(data?.nodes) ? data.nodes.length : null + const runtimeState = + typeof data?.state === "string" + ? data.state + : typeof outputObject?.state === "string" + ? outputObject.state + : null + const open = + manualOpen || + inFlight || + view.error !== null || + images.length > 0 || + view.downloads.length > 0 + const previewImage = + previewIndex === null ? null : (images[previewIndex] ?? null) + + return ( + + + ) +}) diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 33fc644d6..50b5363d4 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -76,9 +76,11 @@ import { DelegationStatusCard } from "./delegation-status-card" import { DelegationStatusGroupCard } from "./delegation-status-group-card" import { BackgroundTaskCard } from "./background-task-card" import { GeneratedImagesBlock } from "./generated-images-block" +import { BrowserToolCard } from "./browser-tool-card" import { GoalRunPart, GoalToolCallPart } from "./goal-tool-call" import { PlanCard, PlanEntriesList } from "./plan-card" import { PlanModeCard } from "./plan-mode-card" +import { browserToolNameFromCall } from "@/lib/browser-tool" import { PlainTextWithBadges } from "./plain-text-with-badges" import { FileTextIcon, @@ -2573,6 +2575,14 @@ const ToolCallPart = memo(function ToolCallPart({ toolNameLower === "plan_review" || isFileTool) && !part.errorText + const browserToolName = browserToolNameFromCall( + part.toolName, + part.input, + part.output ?? part.errorText + ) + if (browserToolName) { + return + } // codex-acp #288: the context-compaction lifecycle is a `tool_call` tagged // with `_meta.contextCompaction` (not addressed by tool name) → a subtle // status card instead of the generic tool shell. diff --git a/src/components/settings/browser-settings.test.tsx b/src/components/settings/browser-settings.test.tsx new file mode 100644 index 000000000..c00858c87 --- /dev/null +++ b/src/components/settings/browser-settings.test.tsx @@ -0,0 +1,342 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const api = vi.hoisted(() => ({ + doctorBrowserRuntime: vi.fn(), + getBrowserRuntimeSettings: vi.fn(), + getBrowserRuntimeStatus: vi.fn(), + recoverBrowserRuntime: vi.fn(), + restartBrowserRuntime: vi.fn(), + startBrowserRuntime: vi.fn(), + updateBrowserRuntimeSettings: vi.fn(), +})) + +const platform = vi.hoisted(() => ({ + openUrl: vi.fn(), + subscribe: vi.fn(async () => () => undefined), +})) + +vi.mock("@/lib/api", () => api) +vi.mock("@/lib/platform", () => platform) + +import { + browserAvailabilityFromDoctor, + BrowserSettings, +} from "./browser-settings" +import enMessages from "@/i18n/messages/en.json" +import type { BrowserRuntimeSettings, BrowserRuntimeStatus } from "@/lib/api" + +const embeddedSettings: BrowserRuntimeSettings = { + enabled: false, + autoStart: false, + browserPath: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + backend: "embedded", +} + +const embeddedStoppedStatus: BrowserRuntimeStatus = { + state: "stopped", + installed: true, + enabled: false, + autoStart: false, + browserPath: embeddedSettings.browserPath, + sidecarPid: null, + browserPid: null, + runtimeVersion: "0.1.0", + backend: "embedded_chromium_cdp", + browserName: null, + browserVersion: null, + profilePath: "C:\\Codeg\\browser\\profile", + downloadPath: "C:\\Codeg\\browser\\downloads", + recoveryAttempt: 0, + lastErrorCode: null, + recentLogs: [], +} + +const chromeAvailable = { + ok: false, + checks: [ + { name: "browser", ok: true, detail: "chrome.exe" }, + { name: "process", ok: false, detail: "stopped" }, + ], +} + +function renderSettings() { + return render( + + + + ) +} + +describe("Browser settings", () => { + beforeEach(() => { + vi.clearAllMocks() + api.getBrowserRuntimeSettings.mockResolvedValue(embeddedSettings) + api.getBrowserRuntimeStatus.mockResolvedValue(embeddedStoppedStatus) + api.doctorBrowserRuntime.mockResolvedValue(chromeAvailable) + api.updateBrowserRuntimeSettings.mockImplementation(async (settings) => + Promise.resolve(settings) + ) + api.startBrowserRuntime.mockResolvedValue(embeddedStoppedStatus) + api.restartBrowserRuntime.mockResolvedValue(embeddedStoppedStatus) + api.recoverBrowserRuntime.mockResolvedValue(embeddedStoppedStatus) + }) + + it("waits for settings, backend, and health truth before rendering the card", async () => { + let resolveDoctor: (value: unknown) => void = () => undefined + api.getBrowserRuntimeSettings.mockResolvedValue({ + ...embeddedSettings, + backend: "external", + }) + api.getBrowserRuntimeStatus.mockResolvedValue({ + ...embeddedStoppedStatus, + backend: "external_chromium_cdp", + }) + api.doctorBrowserRuntime.mockReturnValue( + new Promise((resolve) => { + resolveDoctor = resolve + }) + ) + + renderSettings() + + expect(screen.getByText("Loading Browser runtime…")).toBeInTheDocument() + expect( + screen.queryByTestId("browser-automation-block") + ).not.toBeInTheDocument() + await waitFor(() => expect(api.doctorBrowserRuntime).toHaveBeenCalledOnce()) + resolveDoctor(chromeAvailable) + + expect( + await screen.findByTestId("browser-automation-block") + ).toBeInTheDocument() + }) + + it("renders one Cindy-aligned card without operational details", async () => { + renderSettings() + + expect( + await screen.findByRole("heading", { name: "Browser" }) + ).toBeInTheDocument() + expect(screen.getAllByTestId("browser-automation-block")).toHaveLength(1) + expect( + screen.getByRole("switch", { name: "Enable Browser MCP" }) + ).not.toBeChecked() + expect( + screen.getByRole("tab", { name: "Embedded browser" }) + ).toHaveAttribute("aria-selected", "true") + expect( + screen.getByText("Embedded browser is not connected") + ).toBeInTheDocument() + expect( + screen.getByText( + "Changes to Browser access apply to new Agent sessions. Running sessions keep the capability they started with." + ) + ).toBeInTheDocument() + + for (const removedText of [ + "Runtime version", + "Managed profile", + "Browser executable", + "Start with Codeg", + "Start", + "Stop", + "Run doctor", + "Test connection", + "View diagnostics", + "Recent runtime codes", + embeddedStoppedStatus.profilePath, + ]) { + expect(screen.queryByText(removedText)).not.toBeInTheDocument() + } + }) + + it("changes the future-session toggle only after persistence succeeds", async () => { + let confirmUpdate: (settings: BrowserRuntimeSettings) => void = () => + undefined + api.updateBrowserRuntimeSettings.mockReturnValue( + new Promise((resolve) => { + confirmUpdate = resolve + }) + ) + renderSettings() + const enabled = await screen.findByRole("switch", { + name: "Enable Browser MCP", + }) + + fireEvent.click(enabled) + + expect(api.updateBrowserRuntimeSettings).toHaveBeenCalledWith({ + ...embeddedSettings, + enabled: true, + autoStart: true, + }) + expect(enabled).not.toBeChecked() + confirmUpdate({ ...embeddedSettings, enabled: true, autoStart: true }) + + await waitFor(() => expect(enabled).toBeChecked()) + }) + + it("keeps the old future-session value when persistence fails", async () => { + api.updateBrowserRuntimeSettings.mockRejectedValueOnce( + new Error("settings write failed") + ) + renderSettings() + const enabled = await screen.findByRole("switch", { + name: "Enable Browser MCP", + }) + + fireEvent.click(enabled) + + expect( + await screen.findByText("Browser operation failed: settings write failed") + ).toBeInTheDocument() + expect(enabled).not.toBeChecked() + }) + + it("switches backend immediately and rolls back to Tauri truth on failure", async () => { + api.updateBrowserRuntimeSettings.mockRejectedValueOnce( + new Error("restart failed") + ) + renderSettings() + const embedded = await screen.findByRole("tab", { + name: "Embedded browser", + }) + const external = screen.getByRole("tab", { name: "Separate browser" }) + + fireEvent.click(external) + expect(external).toHaveAttribute("aria-selected", "true") + + await waitFor(() => { + expect(api.updateBrowserRuntimeSettings).toHaveBeenCalledWith({ + ...embeddedSettings, + backend: "external", + }) + expect(embedded).toHaveAttribute("aria-selected", "true") + }) + expect( + screen.getByText("Browser operation failed: restart failed") + ).toBeInTheDocument() + }) + + it("shows only embedded health and uses reconnect or recover for its real state", async () => { + const readyStatus = { + ...embeddedStoppedStatus, + state: "ready" as const, + enabled: true, + } + api.getBrowserRuntimeSettings.mockResolvedValue({ + ...embeddedSettings, + enabled: true, + }) + api.getBrowserRuntimeStatus.mockResolvedValue(readyStatus) + api.restartBrowserRuntime.mockResolvedValue(readyStatus) + renderSettings() + + fireEvent.click(await screen.findByRole("button", { name: "Reconnect" })) + await waitFor(() => + expect(api.restartBrowserRuntime).toHaveBeenCalledOnce() + ) + expect( + screen.queryByText("Google Chrome is available") + ).not.toBeInTheDocument() + }) + + it("uses recover for an embedded runtime error", async () => { + const failedStatus = { + ...embeddedStoppedStatus, + state: "error" as const, + enabled: true, + lastErrorCode: "BROWSER_CRASHED", + } + api.getBrowserRuntimeSettings.mockResolvedValue({ + ...embeddedSettings, + enabled: true, + }) + api.getBrowserRuntimeStatus.mockResolvedValue(failedStatus) + api.recoverBrowserRuntime.mockResolvedValue({ + ...failedStatus, + state: "ready", + lastErrorCode: null, + }) + renderSettings() + + expect( + await screen.findByText("Embedded browser error: BROWSER_CRASHED") + ).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Recover" })) + + await waitFor(() => + expect(api.recoverBrowserRuntime).toHaveBeenCalledOnce() + ) + }) + + it("shows real external availability and opens the managed Agent browser", async () => { + const externalSettings = { + ...embeddedSettings, + enabled: true, + backend: "external" as const, + } + const externalStatus = { + ...embeddedStoppedStatus, + enabled: true, + backend: "external_chromium_cdp", + } + api.getBrowserRuntimeSettings.mockResolvedValue(externalSettings) + api.getBrowserRuntimeStatus.mockResolvedValue(externalStatus) + api.startBrowserRuntime.mockResolvedValue({ + ...externalStatus, + state: "ready", + }) + renderSettings() + + expect( + await screen.findByText("Google Chrome is available") + ).toBeInTheDocument() + expect( + screen.queryByText("Embedded browser is not connected") + ).not.toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Open Agent browser" })) + + await waitFor(() => expect(api.startBrowserRuntime).toHaveBeenCalledOnce()) + }) + + it("offers only the official Chrome link when no executable is detected", async () => { + api.getBrowserRuntimeSettings.mockResolvedValue({ + ...embeddedSettings, + backend: "external", + }) + api.getBrowserRuntimeStatus.mockResolvedValue({ + ...embeddedStoppedStatus, + backend: "external_chromium_cdp", + }) + api.doctorBrowserRuntime.mockResolvedValue({ + ok: false, + checks: [{ name: "browser", ok: false, detail: "not_found" }], + }) + renderSettings() + + expect( + await screen.findByText("No supported browser detected") + ).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Get Chrome" })) + + expect(platform.openUrl).toHaveBeenCalledWith( + "https://www.google.com/chrome/" + ) + expect(api.startBrowserRuntime).not.toHaveBeenCalled() + }) +}) + +describe("browserAvailabilityFromDoctor", () => { + it("accepts the Tauri command envelope and normalizes Edge", () => { + expect( + browserAvailabilityFromDoctor({ + data: { + checks: [{ name: "browser", ok: true, detail: "msedge.exe" }], + }, + }) + ).toEqual({ detected: true, browser: "Microsoft Edge" }) + }) +}) diff --git a/src/components/settings/browser-settings.tsx b/src/components/settings/browser-settings.tsx new file mode 100644 index 000000000..89865d464 --- /dev/null +++ b/src/components/settings/browser-settings.tsx @@ -0,0 +1,491 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { + AlertTriangle, + CheckCircle2, + Download, + Globe2, + Loader2, + RefreshCw, +} from "lucide-react" +import { useTranslations } from "next-intl" + +import { SettingCard } from "@/components/shared/setting-card" +import { SettingsError } from "@/components/shared/settings-section" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Switch } from "@/components/ui/switch" +import { + doctorBrowserRuntime, + getBrowserRuntimeSettings, + getBrowserRuntimeStatus, + recoverBrowserRuntime, + restartBrowserRuntime, + startBrowserRuntime, + updateBrowserRuntimeSettings, + type BrowserRuntimeSettings, + type BrowserRuntimeStatus, +} from "@/lib/api" +import { toErrorMessage } from "@/lib/app-error" +import { openUrl, subscribe } from "@/lib/platform" +import { cn } from "@/lib/utils" + +const CHROME_DOWNLOAD_URL = "https://www.google.com/chrome/" + +interface BrowserAvailability { + detected: boolean + browser: string | null +} + +interface RuntimeTruth { + status: BrowserRuntimeStatus + availability: BrowserAvailability | null +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null +} + +export function browserAvailabilityFromDoctor( + report: unknown +): BrowserAvailability { + const root = asRecord(report) + const payload = asRecord(root?.data) ?? root + const checks = Array.isArray(payload?.checks) ? payload.checks : [] + const browserCheck = checks + .map(asRecord) + .find((check) => check?.name === "browser") + + if (browserCheck?.ok !== true) { + return { detected: false, browser: null } + } + + const detail = + typeof browserCheck.detail === "string" ? browserCheck.detail : "" + const executable = detail.toLowerCase() + const browser = executable.includes("msedge") + ? "Microsoft Edge" + : executable.includes("chromium") + ? "Chromium" + : executable.includes("chrome") + ? "Google Chrome" + : detail || null + + return { detected: true, browser } +} + +async function probeExternalBrowser(): Promise { + try { + return browserAvailabilityFromDoctor(await doctorBrowserRuntime()) + } catch { + return { detected: false, browser: null } + } +} + +async function readRuntimeTruth( + settings: BrowserRuntimeSettings +): Promise { + const [status, availability] = await Promise.all([ + getBrowserRuntimeStatus(), + settings.backend === "external" + ? probeExternalBrowser() + : Promise.resolve(null), + ]) + return { status, availability } +} + +export function BrowserSettings() { + const t = useTranslations("BrowserSettings") + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [settings, setSettings] = useState(null) + const [status, setStatus] = useState(null) + const [availability, setAvailability] = useState( + null + ) + const [pendingSetting, setPendingSetting] = useState< + "enabled" | "backend" | null + >(null) + const [busyAction, setBusyAction] = useState(false) + const [operationError, setOperationError] = useState(null) + const settingsMutationPending = useRef(false) + + const load = useCallback(async () => { + setLoading(true) + setLoadError(null) + try { + const nextSettings = await getBrowserRuntimeSettings() + const truth = await readRuntimeTruth(nextSettings) + setSettings(nextSettings) + setStatus(truth.status) + setAvailability(truth.availability) + } catch (error) { + setSettings(null) + setStatus(null) + setAvailability(null) + setLoadError(toErrorMessage(error)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + let disposed = false + let unlisten: (() => void) | null = null + void subscribe("browser://status", (next) => { + if (!disposed) setStatus(next) + }).then((unsubscribe) => { + if (disposed) unsubscribe() + else unlisten = unsubscribe + }) + return () => { + disposed = true + unlisten?.() + } + }, []) + + const reconcileAuthoritative = useCallback( + async (nextSettings: BrowserRuntimeSettings) => { + const truth = await readRuntimeTruth(nextSettings) + setSettings(nextSettings) + setStatus(truth.status) + setAvailability(truth.availability) + }, + [] + ) + + const updateEnabled = useCallback( + async (enabled: boolean) => { + if (!settings || settingsMutationPending.current) return + settingsMutationPending.current = true + setPendingSetting("enabled") + setOperationError(null) + try { + const confirmed = await updateBrowserRuntimeSettings({ + ...settings, + enabled, + autoStart: enabled, + }) + await reconcileAuthoritative(confirmed) + } catch (error) { + setOperationError(toErrorMessage(error)) + } finally { + settingsMutationPending.current = false + setPendingSetting(null) + } + }, + [reconcileAuthoritative, settings] + ) + + const updateBackend = useCallback( + async (backend: BrowserRuntimeSettings["backend"]) => { + if ( + !settings || + settings.backend === backend || + settingsMutationPending.current + ) { + return + } + + const previousSettings = settings + const previousStatus = status + const previousAvailability = availability + settingsMutationPending.current = true + setPendingSetting("backend") + setOperationError(null) + setSettings({ ...settings, backend }) + if (backend === "embedded") setAvailability(null) + + try { + const confirmed = await updateBrowserRuntimeSettings({ + ...settings, + backend, + }) + await reconcileAuthoritative(confirmed) + } catch (error) { + setOperationError(toErrorMessage(error)) + try { + const confirmed = await getBrowserRuntimeSettings() + await reconcileAuthoritative(confirmed) + } catch { + setSettings(previousSettings) + setStatus(previousStatus) + setAvailability(previousAvailability) + } + } finally { + settingsMutationPending.current = false + setPendingSetting(null) + } + }, + [availability, reconcileAuthoritative, settings, status] + ) + + const runRuntimeAction = useCallback( + async (action: () => Promise) => { + setBusyAction(true) + setOperationError(null) + try { + setStatus(await action()) + if (settings?.backend === "external") { + setAvailability(await probeExternalBrowser()) + } + } catch (error) { + setOperationError(toErrorMessage(error)) + try { + setStatus(await getBrowserRuntimeStatus()) + } catch { + // The operation error remains the authoritative user-visible result. + } + } finally { + setBusyAction(false) + } + }, + [settings?.backend] + ) + + if (loading) { + return ( +
+
+ ) + } + + if (!settings || !status) { + return ( +
+ + {t("loadFailed", { message: loadError ?? t("unavailable") })} + + +
+ ) + } + + const transitional = + status.state === "starting" || status.state === "recovering" + const settingsPending = pendingSetting !== null + const embeddedHealthMatches = + settings.backend === "embedded" && + status.backend === "embedded_chromium_cdp" + const externalDetected = availability?.detected === true + + return ( + +
+ +
+
+ + +
+

+ {t("browserBlockTitle")} +

+

+ {t("browserBlockDescription")} +

+
+
+ void updateEnabled(enabled)} + /> +
+ +
+
{t("backendChoice")}
+
+ {(["embedded", "external"] as const).map((backend) => ( + + ))} +
+
+ + {settings.backend === "embedded" && embeddedHealthMatches ? ( +
+
+ {status.state === "ready" ? ( +
+ {status.state === "ready" ? ( + + ) : status.state === "error" ? ( + + ) : null} +
+ ) : null} + + {settings.backend === "external" ? ( +
+
+ {externalDetected ? ( +
+ {externalDetected ? ( + + ) : ( + + )} +
+ ) : null} + + {operationError ? ( +
+ + {t("operationFailed", { message: operationError })} + +
+ ) : null} +
+ +

+ {t("futureSessionHint")} +

+
+
+ ) +} diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index d889fa072..f4f1a012d 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -17,6 +17,7 @@ import { Keyboard, Menu, MessageSquareText, + MonitorCog, SendHorizontal, Palette, PlugZap, @@ -32,8 +33,10 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { AppToaster } from "@/components/ui/app-toaster" import { cn } from "@/lib/utils" import { detectEnvironment } from "@/lib/transport/detect" +import { getActiveRemoteConnectionId } from "@/lib/transport" import { AppTitleBar } from "@/components/layout/app-title-bar" import { useIsMobile } from "@/hooks/use-mobile" +import { usePlatform } from "@/hooks/use-platform" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" interface SettingsNavItem { @@ -52,6 +55,7 @@ interface SettingsNavItem { | "chat_channels" | "system" | "web_service" + | "browser" | "logs" icon: ComponentType<{ className?: string }> } @@ -117,6 +121,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [ labelKey: "web_service", icon: Globe, }, + { + href: "/settings/browser", + labelKey: "browser", + icon: MonitorCog, + }, { href: "/settings/logs", labelKey: "logs", @@ -152,6 +161,7 @@ export function SettingsShell({ children }: SettingsShellProps) { const router = useRouter() const normalizedPathname = normalizePath(pathname) const isMobile = useIsMobile() + const { isWindows } = usePlatform() const [navOpen, setNavOpen] = useState(false) useEffect(() => { @@ -189,7 +199,13 @@ export function SettingsShell({ children }: SettingsShellProps) { const filteredNavItems = SETTINGS_NAV_ITEMS.filter( (item) => - !(item.labelKey === "web_service" && detectEnvironment() === "web") + !(item.labelKey === "web_service" && detectEnvironment() === "web") && + !( + item.labelKey === "browser" && + (detectEnvironment() !== "tauri" || + !isWindows || + getActiveRemoteConnectionId() !== null) + ) ) const navContent = ( diff --git a/src/contexts/aux-panel-context.tsx b/src/contexts/aux-panel-context.tsx index c7dfe8940..aff729854 100644 --- a/src/contexts/aux-panel-context.tsx +++ b/src/contexts/aux-panel-context.tsx @@ -16,14 +16,17 @@ import { import { useActiveFolder } from "@/contexts/active-folder-context" import { detectPlatform } from "@/hooks/use-platform" import { isDesktop } from "@/lib/platform" +import { subscribe } from "@/lib/platform" export type AuxPanelTab = | "session_details" | "file_tree" | "changes" | "git_log" + | "browser" const STORAGE_KEY = "workspace:right-sidebar" +const BROWSER_SESSIONS_STORAGE_KEY = "workspace:right-sidebar:browser-sessions" const DEFAULT_WIDTH = 320 const MIN_WIDTH = 200 @@ -59,6 +62,8 @@ interface AuxPanelContextValue { pendingRevealPath: string | null revealInFileTree: (path: string) => void consumePendingRevealPath: () => void + browserSessionIds: ReadonlySet + setBrowserSessionOpen: (connectionId: string, open: boolean) => void } const AuxPanelContext = createContext(null) @@ -89,6 +94,9 @@ export function AuxPanelProvider({ children }: AuxPanelProviderProps) { const [pendingRevealPath, setPendingRevealPath] = useState( null ) + const [browserSessionIds, setBrowserSessionIds] = useState>( + () => new Set() + ) // Platform-derived minimum (see resolveAuxMinWidth); stable for the session. const minWidth = useMemo(() => resolveAuxMinWidth(), []) @@ -118,6 +126,27 @@ export function AuxPanelProvider({ children }: AuxPanelProviderProps) { setPendingRevealPath(null) }, []) + const setBrowserSessionOpen = useCallback( + (connectionId: string, open: boolean) => { + setBrowserSessionIds((current) => { + if (current.has(connectionId) === open) return current + const next = new Set(current) + if (open) next.add(connectionId) + else next.delete(connectionId) + try { + localStorage.setItem( + BROWSER_SESSIONS_STORAGE_KEY, + JSON.stringify([...next].slice(-100)) + ) + } catch { + // Private mode or storage quota: the Tauri registry remains authoritative. + } + return next + }) + }, + [] + ) + useEffect(() => { const stored = loadPersistedPanelState(storageKey) const isMobileViewport = window.innerWidth < 768 @@ -126,6 +155,26 @@ export function AuxPanelProvider({ children }: AuxPanelProviderProps) { // eslint-disable-next-line react-hooks/set-state-in-effect setIsOpen(isMobileViewport ? false : (stored?.isOpen ?? defaultOpen)) setWidthState(clampWidth(stored?.width ?? DEFAULT_WIDTH, minWidth)) + try { + const parsed = JSON.parse( + localStorage.getItem(BROWSER_SESSIONS_STORAGE_KEY) ?? "[]" + ) as unknown + if (Array.isArray(parsed)) { + setBrowserSessionIds( + new Set( + parsed.filter( + (value): value is string => + typeof value === "string" && + value.length > 0 && + value.length <= 256 && + /^[A-Za-z0-9._:-]+$/.test(value) + ) + ) + ) + } + } catch { + setBrowserSessionIds(new Set()) + } setRestored(true) }, [storageKey, minWidth]) @@ -134,6 +183,24 @@ export function AuxPanelProvider({ children }: AuxPanelProviderProps) { savePersistedPanelState(storageKey, { isOpen, width }) }, [isOpen, restored, storageKey, width]) + useEffect(() => { + let disposed = false + let unsubscribe: (() => void) | null = null + void subscribe<{ connectionId: string }>( + "browser://session-activity", + ({ connectionId }) => { + if (!disposed) setBrowserSessionOpen(connectionId, true) + } + ).then((next) => { + if (disposed) next() + else unsubscribe = next + }) + return () => { + disposed = true + unsubscribe?.() + } + }, [setBrowserSessionOpen]) + // Reset pending reveal path when the active folder changes; file tree // state is content-driven by the workspace contexts and will refetch // naturally via its folder-path dependency. @@ -158,6 +225,8 @@ export function AuxPanelProvider({ children }: AuxPanelProviderProps) { pendingRevealPath, revealInFileTree, consumePendingRevealPath, + browserSessionIds, + setBrowserSessionOpen, }), [ isOpen, @@ -172,6 +241,8 @@ export function AuxPanelProvider({ children }: AuxPanelProviderProps) { pendingRevealPath, revealInFileTree, consumePendingRevealPath, + browserSessionIds, + setBrowserSessionOpen, ] ) diff --git a/src/contexts/conversation-runtime-context.test.tsx b/src/contexts/conversation-runtime-context.test.tsx index d0a56bf8e..9162ff2bd 100644 --- a/src/contexts/conversation-runtime-context.test.tsx +++ b/src/contexts/conversation-runtime-context.test.tsx @@ -1773,6 +1773,45 @@ describe("buildStreamingTurnsFromLiveMessage — orphan status provenance", () = }) }) +describe("buildStreamingTurnsFromLiveMessage — Browser screenshot media", () => { + it("keeps Browser images on the tool result instead of relabeling them as image generation", () => { + const blocks = buildStreamingTurnsFromLiveMessage(1, { + id: "lm-browser-shot", + role: "assistant", + startedAt: 0, + content: [ + { + type: "tool_call", + info: { + tool_call_id: "browser-shot", + title: "page.screenshot", + kind: "other", + status: "completed", + content: JSON.stringify({ + ok: true, + action: "page.screenshot", + }), + raw_input: JSON.stringify({ format: "png" }), + raw_output_chunks: [], + raw_output_total_bytes: 0, + locations: null, + meta: null, + images: [{ data: "QUJD", mime_type: "image/png" }], + }, + }, + ], + }).turns.flatMap((turn) => turn.blocks) + + expect(blocks.some((block) => block.type === "image_generation")).toBe( + false + ) + const result = blocks.find((block) => block.type === "tool_result") + expect( + result?.type === "tool_result" ? result.images?.[0]?.data : null + ).toBe("QUJD") + }) +}) + describe("buildStreamingTurnsFromLiveMessage — subagent transcript routing (claude-agent-acp ≥0.63)", () => { function toolInfo(overrides: Partial = {}): ToolCallInfo { return { diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index c3c48f9c8..60e1c7878 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -47,6 +47,7 @@ "system": "النظام", "chat_channels": "قنوات المحادثة", "web_service": "خدمة الويب", + "browser": "الأتمتة", "model_providers": "مزودو النماذج", "experts": "الخبراء", "science": "البحث العلمي", @@ -56,6 +57,95 @@ "logs": "سجلات التشغيل" } }, + "BrowserSettings": { + "title": "الأتمتة", + "description": "إدارة إمكانات الأتمتة المحلية المتاحة لوكلاء Agent المدعومين.", + "browserBlockTitle": "المتصفح", + "browserBlockDescription": "تستخدم أدوات Agent والمتصفح المضمن بيئة Chromium المعزولة نفسها.", + "loading": "Loading Browser runtime…", + "loadFailed": "Could not load Browser settings: {message}", + "statusTitle": "Runtime status", + "statusDescription": "The service is local to this Windows client. Credentials and the private endpoint are never shown here.", + "lastError": "Last error: {code}", + "recoveryAttempt": "Recovery attempt {count} of 3", + "notInstalledHint": "The packaged Browser sidecar is missing. Reinstall Codeg before retrying.", + "disabledHint": "Enable Browser MCP and save the setting before starting the runtime.", + "errorHint": "Run diagnostics, check the browser path, then use Recover.", + "profilePath": "Managed profile", + "downloadPath": "Controlled downloads", + "runtimeVersion": "Runtime version", + "backend": "Backend", + "browserVersion": "Browser", + "unavailable": "Unavailable", + "embeddedReady": "المتصفح المضمن متصل", + "embeddedStopped": "المتصفح المضمن غير متصل", + "embeddedError": "خطأ في المتصفح المضمن: {code}", + "externalDetected": "يتوفر {browser}", + "externalNotDetected": "لم يتم اكتشاف متصفح مدعوم", + "futureSessionHint": "تنطبق تغييرات الوصول إلى المتصفح على جلسات Agent الجديدة. تحتفظ الجلسات قيد التشغيل بالإمكانات التي بدأت بها.", + "backends": { + "embedded_chromium_cdp": "Chromium مضمن (CDP)", + "external_chromium_cdp": "Chromium مستقل (CDP)", + "embedded": "متصفح مضمن", + "external": "متصفح مستقل" + }, + "backendChoice": "خلفية المتصفح", + "backendChoiceDescription": "يعرض المضمن target الخاص بـ Agent داخل Codeg؛ ويفتح المستقل نافذة Chromium المُدارة.", + "settingsTitle": "Availability", + "settingsDescription": "Browser access is off by default and is injected only into compatible agent sessions while the runtime is ready.", + "enabled": "Enable Browser MCP", + "enabledDescription": "Allow compatible agents to receive the managed Browser MCP server.", + "autoStart": "Start with Codeg", + "autoStartDescription": "Start the isolated Browser runtime when the desktop client opens.", + "browserPath": "Browser executable", + "browserPathDescription": "Optional absolute path to Chrome, Edge, or Chromium. Leave blank for automatic discovery.", + "browserPathPlaceholder": "Auto-detect installed browser", + "save": "Save settings", + "saving": "Saving…", + "saved": "Browser settings saved", + "operationFailed": "Browser operation failed: {message}", + "diagnosticsTitle": "Diagnostics", + "diagnosticsDescription": "Run bounded health checks and inspect sanitized runtime data. Secrets and private endpoints are excluded.", + "recentLogs": "Recent runtime codes", + "states": { + "not_installed": "Not installed", + "stopped": "Stopped", + "starting": "Starting", + "ready": "Ready", + "error": "Error", + "recovering": "Recovering" + }, + "actions": { + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "recover": "Recover", + "doctor": "Run doctor", + "test": "Test connection", + "diagnostics": "View diagnostics", + "retry": "إعادة المحاولة", + "reconnect": "إعادة الاتصال", + "openManagedBrowser": "فتح متصفح Agent", + "getChrome": "الحصول على Chrome" + } + }, + "BrowserSurface": { + "tabLabel": "المتصفح", + "noActiveSession": "ابدأ جلسة Agent أو اخترها لاستخدام متصفحها.", + "back": "رجوع", + "forward": "تقدم", + "reload": "إعادة تحميل", + "stop": "إيقاف التحميل", + "addressBar": "شريط العنوان", + "closeBrowser": "إغلاق متصفح هذه الجلسة", + "newTab": "علامة تبويب جديدة", + "closeTab": "إغلاق {title}", + "openTab": "فتح علامة تبويب جديدة", + "recover": "استرداد", + "page": "صفحة متصفح تفاعلية", + "loading": "جارٍ تحميل الصفحة…", + "waitingForFrame": "في انتظار صفحة المتصفح المُدار…" + }, "AppearanceSettings": { "sectionTitle": "مظهر السمة", "sectionDescription": "اختر الفاتح أو الداكن أو اتباع النظام. يتم حفظ الإعدادات تلقائيًا.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index affbffb83..4cdf25bfe 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -47,6 +47,7 @@ "system": "Systemeinstellungen", "chat_channels": "Chat-Kanäle", "web_service": "Webdienst", + "browser": "Automatisierung", "model_providers": "Modellanbieter", "experts": "Experten", "science": "Wissenschaft", @@ -56,6 +57,95 @@ "logs": "Laufzeitprotokolle" } }, + "BrowserSettings": { + "title": "Automatisierung", + "description": "Lokale Automatisierungsfunktionen für unterstützte Agents verwalten.", + "browserBlockTitle": "Browser", + "browserBlockDescription": "Agent-Tools und eingebetteter Browser verwenden dieselbe isolierte Chromium-Laufzeit.", + "loading": "Loading Browser runtime…", + "loadFailed": "Could not load Browser settings: {message}", + "statusTitle": "Runtime status", + "statusDescription": "The service is local to this Windows client. Credentials and the private endpoint are never shown here.", + "lastError": "Last error: {code}", + "recoveryAttempt": "Recovery attempt {count} of 3", + "notInstalledHint": "The packaged Browser sidecar is missing. Reinstall Codeg before retrying.", + "disabledHint": "Enable Browser MCP and save the setting before starting the runtime.", + "errorHint": "Run diagnostics, check the browser path, then use Recover.", + "profilePath": "Managed profile", + "downloadPath": "Controlled downloads", + "runtimeVersion": "Runtime version", + "backend": "Backend", + "browserVersion": "Browser", + "unavailable": "Unavailable", + "embeddedReady": "Der eingebettete Browser ist verbunden", + "embeddedStopped": "Der eingebettete Browser ist nicht verbunden", + "embeddedError": "Fehler im eingebetteten Browser: {code}", + "externalDetected": "{browser} ist verfügbar", + "externalNotDetected": "Kein unterstützter Browser erkannt", + "futureSessionHint": "Änderungen am Browserzugriff gelten für neue Agent-Sitzungen. Laufende Sitzungen behalten die beim Start verfügbare Funktion.", + "backends": { + "embedded_chromium_cdp": "Eingebettetes Chromium (CDP)", + "external_chromium_cdp": "Separates Chromium (CDP)", + "embedded": "Eingebetteter Browser", + "external": "Separater Browser" + }, + "backendChoice": "Browser-Backend", + "backendChoiceDescription": "Eingebettet zeigt das Agent-target in Codeg; Separat öffnet das verwaltete Chromium-Fenster.", + "settingsTitle": "Availability", + "settingsDescription": "Browser access is off by default and is injected only into compatible agent sessions while the runtime is ready.", + "enabled": "Enable Browser MCP", + "enabledDescription": "Allow compatible agents to receive the managed Browser MCP server.", + "autoStart": "Start with Codeg", + "autoStartDescription": "Start the isolated Browser runtime when the desktop client opens.", + "browserPath": "Browser executable", + "browserPathDescription": "Optional absolute path to Chrome, Edge, or Chromium. Leave blank for automatic discovery.", + "browserPathPlaceholder": "Auto-detect installed browser", + "save": "Save settings", + "saving": "Saving…", + "saved": "Browser settings saved", + "operationFailed": "Browser operation failed: {message}", + "diagnosticsTitle": "Diagnostics", + "diagnosticsDescription": "Run bounded health checks and inspect sanitized runtime data. Secrets and private endpoints are excluded.", + "recentLogs": "Recent runtime codes", + "states": { + "not_installed": "Not installed", + "stopped": "Stopped", + "starting": "Starting", + "ready": "Ready", + "error": "Error", + "recovering": "Recovering" + }, + "actions": { + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "recover": "Recover", + "doctor": "Run doctor", + "test": "Test connection", + "diagnostics": "View diagnostics", + "retry": "Erneut versuchen", + "reconnect": "Neu verbinden", + "openManagedBrowser": "Agent-Browser öffnen", + "getChrome": "Chrome herunterladen" + } + }, + "BrowserSurface": { + "tabLabel": "Browser", + "noActiveSession": "Starten oder wählen Sie eine Agent-Sitzung, um deren Browser zu verwenden.", + "back": "Zurück", + "forward": "Vorwärts", + "reload": "Neu laden", + "stop": "Laden stoppen", + "addressBar": "Adressleiste", + "closeBrowser": "Browser für diese Sitzung schließen", + "newTab": "Neuer Tab", + "closeTab": "{title} schließen", + "openTab": "Neuen Tab öffnen", + "recover": "Wiederherstellen", + "page": "Interaktive Browserseite", + "loading": "Seite wird geladen…", + "waitingForFrame": "Warten auf die verwaltete Browserseite…" + }, "AppearanceSettings": { "sectionTitle": "Design-Erscheinungsbild", "sectionDescription": "Wähle hell, dunkel oder Systemvorgabe. Einstellungen werden automatisch gespeichert.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c9ac0f7f6..c3402c9ae 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -47,6 +47,7 @@ "system": "System", "chat_channels": "Chat Channels", "web_service": "Web Service", + "browser": "Automation", "model_providers": "Model Providers", "experts": "Experts", "science": "Science", @@ -56,6 +57,95 @@ "logs": "Runtime Logs" } }, + "BrowserSettings": { + "title": "Automation", + "description": "Manage the local automation capabilities available to supported agents.", + "browserBlockTitle": "Browser", + "browserBlockDescription": "Use one isolated Chromium runtime for Agent tools and the embedded Browser surface.", + "loading": "Loading Browser runtime…", + "loadFailed": "Could not load Browser settings: {message}", + "statusTitle": "Runtime status", + "statusDescription": "The service is local to this Windows client. Credentials and the private endpoint are never shown here.", + "lastError": "Last error: {code}", + "recoveryAttempt": "Recovery attempt {count} of 3", + "notInstalledHint": "The packaged Browser sidecar is missing. Reinstall Codeg before retrying.", + "disabledHint": "Enable Browser MCP and save the setting before starting the runtime.", + "errorHint": "Run diagnostics, check the browser path, then use Recover.", + "profilePath": "Managed profile", + "downloadPath": "Controlled downloads", + "runtimeVersion": "Runtime version", + "backend": "Backend", + "browserVersion": "Browser", + "unavailable": "Unavailable", + "embeddedReady": "Embedded browser is connected", + "embeddedStopped": "Embedded browser is not connected", + "embeddedError": "Embedded browser error: {code}", + "externalDetected": "{browser} is available", + "externalNotDetected": "No supported browser detected", + "futureSessionHint": "Changes to Browser access apply to new Agent sessions. Running sessions keep the capability they started with.", + "backends": { + "embedded_chromium_cdp": "Embedded Chromium (CDP)", + "external_chromium_cdp": "External Chromium (CDP)", + "embedded": "Embedded browser", + "external": "Separate browser" + }, + "backendChoice": "Browser backend", + "backendChoiceDescription": "Embedded streams the Agent target inside Codeg; Separate opens the managed Chromium window.", + "settingsTitle": "Availability", + "settingsDescription": "Browser access is off by default and is injected only into compatible agent sessions while the runtime is ready.", + "enabled": "Enable Browser MCP", + "enabledDescription": "Allow compatible agents to receive the managed Browser MCP server.", + "autoStart": "Start with Codeg", + "autoStartDescription": "Start the isolated Browser runtime when the desktop client opens.", + "browserPath": "Browser executable", + "browserPathDescription": "Optional absolute path to Chrome, Edge, or Chromium. Leave blank for automatic discovery.", + "browserPathPlaceholder": "Auto-detect installed browser", + "save": "Save settings", + "saving": "Saving…", + "saved": "Browser settings saved", + "operationFailed": "Browser operation failed: {message}", + "diagnosticsTitle": "Diagnostics", + "diagnosticsDescription": "Run bounded health checks and inspect sanitized runtime data. Secrets and private endpoints are excluded.", + "recentLogs": "Recent runtime codes", + "states": { + "not_installed": "Not installed", + "stopped": "Stopped", + "starting": "Starting", + "ready": "Ready", + "error": "Error", + "recovering": "Recovering" + }, + "actions": { + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "recover": "Recover", + "doctor": "Run doctor", + "test": "Test connection", + "diagnostics": "View diagnostics", + "retry": "Retry", + "reconnect": "Reconnect", + "openManagedBrowser": "Open Agent browser", + "getChrome": "Get Chrome" + } + }, + "BrowserSurface": { + "tabLabel": "Browser", + "noActiveSession": "Start or select an Agent session to use its Browser.", + "back": "Back", + "forward": "Forward", + "reload": "Reload", + "stop": "Stop loading", + "addressBar": "Address bar", + "closeBrowser": "Close Browser for this session", + "newTab": "New tab", + "closeTab": "Close {title}", + "openTab": "Open new tab", + "recover": "Recover", + "page": "Interactive browser page", + "loading": "Loading page…", + "waitingForFrame": "Waiting for the managed Browser page…" + }, "AppearanceSettings": { "sectionTitle": "Theme Appearance", "sectionDescription": "Choose light, dark, or follow system. Settings are saved automatically.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index c991f746c..ee730c4d2 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -47,6 +47,7 @@ "system": "Sistema", "chat_channels": "Canales de chat", "web_service": "Servicio Web", + "browser": "Automatización", "model_providers": "Proveedores de Modelos", "experts": "Expertos", "science": "Ciencia", @@ -56,6 +57,95 @@ "logs": "Registros de ejecución" } }, + "BrowserSettings": { + "title": "Automatización", + "description": "Administra las funciones de automatización local disponibles para los Agent compatibles.", + "browserBlockTitle": "Navegador", + "browserBlockDescription": "Las herramientas Agent y el navegador integrado comparten un entorno Chromium aislado.", + "loading": "Loading Browser runtime…", + "loadFailed": "Could not load Browser settings: {message}", + "statusTitle": "Runtime status", + "statusDescription": "The service is local to this Windows client. Credentials and the private endpoint are never shown here.", + "lastError": "Last error: {code}", + "recoveryAttempt": "Recovery attempt {count} of 3", + "notInstalledHint": "The packaged Browser sidecar is missing. Reinstall Codeg before retrying.", + "disabledHint": "Enable Browser MCP and save the setting before starting the runtime.", + "errorHint": "Run diagnostics, check the browser path, then use Recover.", + "profilePath": "Managed profile", + "downloadPath": "Controlled downloads", + "runtimeVersion": "Runtime version", + "backend": "Backend", + "browserVersion": "Browser", + "unavailable": "Unavailable", + "embeddedReady": "El navegador integrado está conectado", + "embeddedStopped": "El navegador integrado no está conectado", + "embeddedError": "Error del navegador integrado: {code}", + "externalDetected": "{browser} está disponible", + "externalNotDetected": "No se detectó un navegador compatible", + "futureSessionHint": "Los cambios de acceso al navegador se aplican a las nuevas sesiones de Agent. Las sesiones en curso conservan la capacidad con la que se iniciaron.", + "backends": { + "embedded_chromium_cdp": "Chromium integrado (CDP)", + "external_chromium_cdp": "Chromium independiente (CDP)", + "embedded": "Navegador integrado", + "external": "Navegador independiente" + }, + "backendChoice": "Backend del navegador", + "backendChoiceDescription": "Integrado muestra el target del Agent en Codeg; Independiente abre la ventana administrada de Chromium.", + "settingsTitle": "Availability", + "settingsDescription": "Browser access is off by default and is injected only into compatible agent sessions while the runtime is ready.", + "enabled": "Enable Browser MCP", + "enabledDescription": "Allow compatible agents to receive the managed Browser MCP server.", + "autoStart": "Start with Codeg", + "autoStartDescription": "Start the isolated Browser runtime when the desktop client opens.", + "browserPath": "Browser executable", + "browserPathDescription": "Optional absolute path to Chrome, Edge, or Chromium. Leave blank for automatic discovery.", + "browserPathPlaceholder": "Auto-detect installed browser", + "save": "Save settings", + "saving": "Saving…", + "saved": "Browser settings saved", + "operationFailed": "Browser operation failed: {message}", + "diagnosticsTitle": "Diagnostics", + "diagnosticsDescription": "Run bounded health checks and inspect sanitized runtime data. Secrets and private endpoints are excluded.", + "recentLogs": "Recent runtime codes", + "states": { + "not_installed": "Not installed", + "stopped": "Stopped", + "starting": "Starting", + "ready": "Ready", + "error": "Error", + "recovering": "Recovering" + }, + "actions": { + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "recover": "Recover", + "doctor": "Run doctor", + "test": "Test connection", + "diagnostics": "View diagnostics", + "retry": "Reintentar", + "reconnect": "Reconectar", + "openManagedBrowser": "Abrir navegador de Agent", + "getChrome": "Obtener Chrome" + } + }, + "BrowserSurface": { + "tabLabel": "Navegador", + "noActiveSession": "Inicia o selecciona una sesión de Agent para usar su navegador.", + "back": "Atrás", + "forward": "Adelante", + "reload": "Recargar", + "stop": "Detener carga", + "addressBar": "Barra de direcciones", + "closeBrowser": "Cerrar el navegador de esta sesión", + "newTab": "Nueva pestaña", + "closeTab": "Cerrar {title}", + "openTab": "Abrir nueva pestaña", + "recover": "Recuperar", + "page": "Página interactiva del navegador", + "loading": "Cargando página…", + "waitingForFrame": "Esperando la página del navegador administrado…" + }, "AppearanceSettings": { "sectionTitle": "Apariencia del tema", "sectionDescription": "Elige claro, oscuro o seguir el sistema. La configuración se guarda automáticamente.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 7cf018a6b..230ba3bf1 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -47,6 +47,7 @@ "system": "Système", "chat_channels": "Canaux de chat", "web_service": "Service Web", + "browser": "Automatisation", "model_providers": "Fournisseurs de Modèles", "experts": "Experts", "science": "Science", @@ -56,6 +57,95 @@ "logs": "Journaux d'exécution" } }, + "BrowserSettings": { + "title": "Automatisation", + "description": "Gérez les capacités d’automatisation locale disponibles pour les Agent compatibles.", + "browserBlockTitle": "Navigateur", + "browserBlockDescription": "Les outils Agent et le navigateur intégré partagent le même environnement Chromium isolé.", + "loading": "Loading Browser runtime…", + "loadFailed": "Could not load Browser settings: {message}", + "statusTitle": "Runtime status", + "statusDescription": "The service is local to this Windows client. Credentials and the private endpoint are never shown here.", + "lastError": "Last error: {code}", + "recoveryAttempt": "Recovery attempt {count} of 3", + "notInstalledHint": "The packaged Browser sidecar is missing. Reinstall Codeg before retrying.", + "disabledHint": "Enable Browser MCP and save the setting before starting the runtime.", + "errorHint": "Run diagnostics, check the browser path, then use Recover.", + "profilePath": "Managed profile", + "downloadPath": "Controlled downloads", + "runtimeVersion": "Runtime version", + "backend": "Backend", + "browserVersion": "Browser", + "unavailable": "Unavailable", + "embeddedReady": "Le navigateur intégré est connecté", + "embeddedStopped": "Le navigateur intégré n’est pas connecté", + "embeddedError": "Erreur du navigateur intégré : {code}", + "externalDetected": "{browser} est disponible", + "externalNotDetected": "Aucun navigateur compatible détecté", + "futureSessionHint": "Les modifications de l’accès au navigateur s’appliquent aux nouvelles sessions Agent. Les sessions en cours conservent la capacité disponible à leur démarrage.", + "backends": { + "embedded_chromium_cdp": "Chromium intégré (CDP)", + "external_chromium_cdp": "Chromium séparé (CDP)", + "embedded": "Navigateur intégré", + "external": "Navigateur séparé" + }, + "backendChoice": "Backend du navigateur", + "backendChoiceDescription": "Intégré affiche le target Agent dans Codeg ; Séparé ouvre la fenêtre Chromium gérée.", + "settingsTitle": "Availability", + "settingsDescription": "Browser access is off by default and is injected only into compatible agent sessions while the runtime is ready.", + "enabled": "Enable Browser MCP", + "enabledDescription": "Allow compatible agents to receive the managed Browser MCP server.", + "autoStart": "Start with Codeg", + "autoStartDescription": "Start the isolated Browser runtime when the desktop client opens.", + "browserPath": "Browser executable", + "browserPathDescription": "Optional absolute path to Chrome, Edge, or Chromium. Leave blank for automatic discovery.", + "browserPathPlaceholder": "Auto-detect installed browser", + "save": "Save settings", + "saving": "Saving…", + "saved": "Browser settings saved", + "operationFailed": "Browser operation failed: {message}", + "diagnosticsTitle": "Diagnostics", + "diagnosticsDescription": "Run bounded health checks and inspect sanitized runtime data. Secrets and private endpoints are excluded.", + "recentLogs": "Recent runtime codes", + "states": { + "not_installed": "Not installed", + "stopped": "Stopped", + "starting": "Starting", + "ready": "Ready", + "error": "Error", + "recovering": "Recovering" + }, + "actions": { + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "recover": "Recover", + "doctor": "Run doctor", + "test": "Test connection", + "diagnostics": "View diagnostics", + "retry": "Réessayer", + "reconnect": "Reconnecter", + "openManagedBrowser": "Ouvrir le navigateur Agent", + "getChrome": "Obtenir Chrome" + } + }, + "BrowserSurface": { + "tabLabel": "Navigateur", + "noActiveSession": "Démarrez ou sélectionnez une session Agent pour utiliser son navigateur.", + "back": "Précédent", + "forward": "Suivant", + "reload": "Actualiser", + "stop": "Arrêter le chargement", + "addressBar": "Barre d’adresse", + "closeBrowser": "Fermer le navigateur de cette session", + "newTab": "Nouvel onglet", + "closeTab": "Fermer {title}", + "openTab": "Ouvrir un nouvel onglet", + "recover": "Récupérer", + "page": "Page de navigateur interactive", + "loading": "Chargement de la page…", + "waitingForFrame": "En attente de la page du navigateur géré…" + }, "AppearanceSettings": { "sectionTitle": "Apparence du thème", "sectionDescription": "Choisissez clair, sombre ou suivre le système. Les paramètres sont enregistrés automatiquement.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e5c0f6e67..2a78f0f6a 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -47,6 +47,7 @@ "system": "システム", "chat_channels": "チャットチャンネル", "web_service": "Webサービス", + "browser": "自動操作", "model_providers": "モデルプロバイダー", "experts": "エキスパート", "science": "科学研究", @@ -56,6 +57,95 @@ "logs": "実行ログ" } }, + "BrowserSettings": { + "title": "自動操作", + "description": "対応する Agent が使用できるローカル自動操作機能を管理します。", + "browserBlockTitle": "ブラウザー", + "browserBlockDescription": "Agent ツールと内蔵ブラウザーで同じ隔離 Chromium ランタイムを使用します。", + "loading": "Loading Browser runtime…", + "loadFailed": "Could not load Browser settings: {message}", + "statusTitle": "Runtime status", + "statusDescription": "The service is local to this Windows client. Credentials and the private endpoint are never shown here.", + "lastError": "Last error: {code}", + "recoveryAttempt": "Recovery attempt {count} of 3", + "notInstalledHint": "The packaged Browser sidecar is missing. Reinstall Codeg before retrying.", + "disabledHint": "Enable Browser MCP and save the setting before starting the runtime.", + "errorHint": "Run diagnostics, check the browser path, then use Recover.", + "profilePath": "Managed profile", + "downloadPath": "Controlled downloads", + "runtimeVersion": "Runtime version", + "backend": "Backend", + "browserVersion": "Browser", + "unavailable": "Unavailable", + "embeddedReady": "内蔵ブラウザーは接続済みです", + "embeddedStopped": "内蔵ブラウザーは接続されていません", + "embeddedError": "内蔵ブラウザーのエラー: {code}", + "externalDetected": "{browser} を利用できます", + "externalNotDetected": "対応ブラウザーが見つかりません", + "futureSessionHint": "ブラウザーアクセスの変更は、新しく作成する Agent セッションに適用されます。実行中のセッションは開始時の機能を維持します。", + "backends": { + "embedded_chromium_cdp": "内蔵 Chromium (CDP)", + "external_chromium_cdp": "独立 Chromium (CDP)", + "embedded": "内蔵ブラウザー", + "external": "独立ブラウザー" + }, + "backendChoice": "ブラウザー バックエンド", + "backendChoiceDescription": "内蔵では Agent target を Codeg 内に表示し、独立では管理対象 Chromium ウィンドウを開きます。", + "settingsTitle": "Availability", + "settingsDescription": "Browser access is off by default and is injected only into compatible agent sessions while the runtime is ready.", + "enabled": "Enable Browser MCP", + "enabledDescription": "Allow compatible agents to receive the managed Browser MCP server.", + "autoStart": "Start with Codeg", + "autoStartDescription": "Start the isolated Browser runtime when the desktop client opens.", + "browserPath": "Browser executable", + "browserPathDescription": "Optional absolute path to Chrome, Edge, or Chromium. Leave blank for automatic discovery.", + "browserPathPlaceholder": "Auto-detect installed browser", + "save": "Save settings", + "saving": "Saving…", + "saved": "Browser settings saved", + "operationFailed": "Browser operation failed: {message}", + "diagnosticsTitle": "Diagnostics", + "diagnosticsDescription": "Run bounded health checks and inspect sanitized runtime data. Secrets and private endpoints are excluded.", + "recentLogs": "Recent runtime codes", + "states": { + "not_installed": "Not installed", + "stopped": "Stopped", + "starting": "Starting", + "ready": "Ready", + "error": "Error", + "recovering": "Recovering" + }, + "actions": { + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "recover": "Recover", + "doctor": "Run doctor", + "test": "Test connection", + "diagnostics": "View diagnostics", + "retry": "再試行", + "reconnect": "再接続", + "openManagedBrowser": "Agent 専用ブラウザーを開く", + "getChrome": "Chrome を入手" + } + }, + "BrowserSurface": { + "tabLabel": "ブラウザー", + "noActiveSession": "ブラウザーを使用するには Agent セッションを開始または選択してください。", + "back": "戻る", + "forward": "進む", + "reload": "再読み込み", + "stop": "読み込みを停止", + "addressBar": "アドレスバー", + "closeBrowser": "このセッションのブラウザーを閉じる", + "newTab": "新しいタブ", + "closeTab": "{title} を閉じる", + "openTab": "新しいタブを開く", + "recover": "復旧", + "page": "操作可能なブラウザーページ", + "loading": "ページを読み込み中…", + "waitingForFrame": "管理ブラウザーのページを待機中…" + }, "AppearanceSettings": { "sectionTitle": "テーマ外観", "sectionDescription": "ライト、ダーク、またはシステム追従を選択できます。設定は自動保存されます。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d4180e375..44b4565eb 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -47,6 +47,7 @@ "system": "시스템", "chat_channels": "채팅 채널", "web_service": "웹 서비스", + "browser": "자동화", "model_providers": "모델 제공업체", "experts": "전문가", "science": "과학 연구", @@ -56,6 +57,95 @@ "logs": "실행 로그" } }, + "BrowserSettings": { + "title": "자동화", + "description": "지원되는 Agent가 사용할 수 있는 로컬 자동화 기능을 관리합니다.", + "browserBlockTitle": "브라우저", + "browserBlockDescription": "Agent 도구와 내장 브라우저가 동일한 격리 Chromium 런타임을 사용합니다.", + "loading": "Loading Browser runtime…", + "loadFailed": "Could not load Browser settings: {message}", + "statusTitle": "Runtime status", + "statusDescription": "The service is local to this Windows client. Credentials and the private endpoint are never shown here.", + "lastError": "Last error: {code}", + "recoveryAttempt": "Recovery attempt {count} of 3", + "notInstalledHint": "The packaged Browser sidecar is missing. Reinstall Codeg before retrying.", + "disabledHint": "Enable Browser MCP and save the setting before starting the runtime.", + "errorHint": "Run diagnostics, check the browser path, then use Recover.", + "profilePath": "Managed profile", + "downloadPath": "Controlled downloads", + "runtimeVersion": "Runtime version", + "backend": "Backend", + "browserVersion": "Browser", + "unavailable": "Unavailable", + "embeddedReady": "내장 브라우저가 연결되었습니다", + "embeddedStopped": "내장 브라우저가 연결되지 않았습니다", + "embeddedError": "내장 브라우저 오류: {code}", + "externalDetected": "{browser}을(를) 사용할 수 있습니다", + "externalNotDetected": "지원되는 브라우저를 찾지 못했습니다", + "futureSessionHint": "브라우저 접근 변경 사항은 새 Agent 세션에 적용됩니다. 실행 중인 세션은 시작할 때의 기능을 유지합니다.", + "backends": { + "embedded_chromium_cdp": "내장 Chromium (CDP)", + "external_chromium_cdp": "독립 Chromium (CDP)", + "embedded": "내장 브라우저", + "external": "독립 브라우저" + }, + "backendChoice": "브라우저 백엔드", + "backendChoiceDescription": "내장은 Agent target을 Codeg에 표시하고, 독립은 관리형 Chromium 창을 엽니다.", + "settingsTitle": "Availability", + "settingsDescription": "Browser access is off by default and is injected only into compatible agent sessions while the runtime is ready.", + "enabled": "Enable Browser MCP", + "enabledDescription": "Allow compatible agents to receive the managed Browser MCP server.", + "autoStart": "Start with Codeg", + "autoStartDescription": "Start the isolated Browser runtime when the desktop client opens.", + "browserPath": "Browser executable", + "browserPathDescription": "Optional absolute path to Chrome, Edge, or Chromium. Leave blank for automatic discovery.", + "browserPathPlaceholder": "Auto-detect installed browser", + "save": "Save settings", + "saving": "Saving…", + "saved": "Browser settings saved", + "operationFailed": "Browser operation failed: {message}", + "diagnosticsTitle": "Diagnostics", + "diagnosticsDescription": "Run bounded health checks and inspect sanitized runtime data. Secrets and private endpoints are excluded.", + "recentLogs": "Recent runtime codes", + "states": { + "not_installed": "Not installed", + "stopped": "Stopped", + "starting": "Starting", + "ready": "Ready", + "error": "Error", + "recovering": "Recovering" + }, + "actions": { + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "recover": "Recover", + "doctor": "Run doctor", + "test": "Test connection", + "diagnostics": "View diagnostics", + "retry": "다시 시도", + "reconnect": "다시 연결", + "openManagedBrowser": "Agent 전용 브라우저 열기", + "getChrome": "Chrome 받기" + } + }, + "BrowserSurface": { + "tabLabel": "브라우저", + "noActiveSession": "브라우저를 사용하려면 Agent 세션을 시작하거나 선택하세요.", + "back": "뒤로", + "forward": "앞으로", + "reload": "새로고침", + "stop": "로딩 중지", + "addressBar": "주소 표시줄", + "closeBrowser": "이 세션의 브라우저 닫기", + "newTab": "새 탭", + "closeTab": "{title} 닫기", + "openTab": "새 탭 열기", + "recover": "복구", + "page": "대화형 브라우저 페이지", + "loading": "페이지 로딩 중…", + "waitingForFrame": "관리형 브라우저 페이지를 기다리는 중…" + }, "AppearanceSettings": { "sectionTitle": "테마 모양", "sectionDescription": "라이트, 다크 또는 시스템 설정을 선택할 수 있습니다. 설정은 자동으로 저장됩니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index a9c82f88c..87992bc43 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -47,6 +47,7 @@ "system": "Sistema", "chat_channels": "Canais de chat", "web_service": "Serviço Web", + "browser": "Automação", "model_providers": "Provedores de Modelos", "experts": "Especialistas", "science": "Ciência", @@ -56,6 +57,95 @@ "logs": "Registros de execução" } }, + "BrowserSettings": { + "title": "Automação", + "description": "Gerencie os recursos de automação local disponíveis para Agents compatíveis.", + "browserBlockTitle": "Navegador", + "browserBlockDescription": "As ferramentas do Agent e o navegador integrado compartilham o mesmo Chromium isolado.", + "loading": "Loading Browser runtime…", + "loadFailed": "Could not load Browser settings: {message}", + "statusTitle": "Runtime status", + "statusDescription": "The service is local to this Windows client. Credentials and the private endpoint are never shown here.", + "lastError": "Last error: {code}", + "recoveryAttempt": "Recovery attempt {count} of 3", + "notInstalledHint": "The packaged Browser sidecar is missing. Reinstall Codeg before retrying.", + "disabledHint": "Enable Browser MCP and save the setting before starting the runtime.", + "errorHint": "Run diagnostics, check the browser path, then use Recover.", + "profilePath": "Managed profile", + "downloadPath": "Controlled downloads", + "runtimeVersion": "Runtime version", + "backend": "Backend", + "browserVersion": "Browser", + "unavailable": "Unavailable", + "embeddedReady": "O navegador integrado está conectado", + "embeddedStopped": "O navegador integrado não está conectado", + "embeddedError": "Erro do navegador integrado: {code}", + "externalDetected": "{browser} está disponível", + "externalNotDetected": "Nenhum navegador compatível foi detectado", + "futureSessionHint": "As alterações no acesso ao navegador se aplicam às novas sessões do Agent. As sessões em execução mantêm o recurso com que foram iniciadas.", + "backends": { + "embedded_chromium_cdp": "Chromium integrado (CDP)", + "external_chromium_cdp": "Chromium separado (CDP)", + "embedded": "Navegador integrado", + "external": "Navegador separado" + }, + "backendChoice": "Backend do navegador", + "backendChoiceDescription": "Integrado mostra o target do Agent no Codeg; Separado abre a janela gerenciada do Chromium.", + "settingsTitle": "Availability", + "settingsDescription": "Browser access is off by default and is injected only into compatible agent sessions while the runtime is ready.", + "enabled": "Enable Browser MCP", + "enabledDescription": "Allow compatible agents to receive the managed Browser MCP server.", + "autoStart": "Start with Codeg", + "autoStartDescription": "Start the isolated Browser runtime when the desktop client opens.", + "browserPath": "Browser executable", + "browserPathDescription": "Optional absolute path to Chrome, Edge, or Chromium. Leave blank for automatic discovery.", + "browserPathPlaceholder": "Auto-detect installed browser", + "save": "Save settings", + "saving": "Saving…", + "saved": "Browser settings saved", + "operationFailed": "Browser operation failed: {message}", + "diagnosticsTitle": "Diagnostics", + "diagnosticsDescription": "Run bounded health checks and inspect sanitized runtime data. Secrets and private endpoints are excluded.", + "recentLogs": "Recent runtime codes", + "states": { + "not_installed": "Not installed", + "stopped": "Stopped", + "starting": "Starting", + "ready": "Ready", + "error": "Error", + "recovering": "Recovering" + }, + "actions": { + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "recover": "Recover", + "doctor": "Run doctor", + "test": "Test connection", + "diagnostics": "View diagnostics", + "retry": "Tentar novamente", + "reconnect": "Reconectar", + "openManagedBrowser": "Abrir navegador do Agent", + "getChrome": "Obter Chrome" + } + }, + "BrowserSurface": { + "tabLabel": "Navegador", + "noActiveSession": "Inicie ou selecione uma sessão do Agent para usar o navegador.", + "back": "Voltar", + "forward": "Avançar", + "reload": "Recarregar", + "stop": "Parar carregamento", + "addressBar": "Barra de endereços", + "closeBrowser": "Fechar o navegador desta sessão", + "newTab": "Nova aba", + "closeTab": "Fechar {title}", + "openTab": "Abrir nova aba", + "recover": "Recuperar", + "page": "Página interativa do navegador", + "loading": "Carregando página…", + "waitingForFrame": "Aguardando a página do navegador gerenciado…" + }, "AppearanceSettings": { "sectionTitle": "Aparência do tema", "sectionDescription": "Escolha claro, escuro ou seguir o sistema. As configurações são salvas automaticamente.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 7e4859e6b..3253a4c74 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -47,6 +47,7 @@ "system": "系统", "chat_channels": "消息渠道", "web_service": "Web 服务", + "browser": "自动操作", "model_providers": "模型供应商", "experts": "专家", "science": "科学研究", @@ -56,6 +57,95 @@ "logs": "运行日志" } }, + "BrowserSettings": { + "title": "自动操作", + "description": "管理支持的 Agent 可使用的本地自动操作能力。", + "browserBlockTitle": "浏览器", + "browserBlockDescription": "让 Agent 工具与辅助面板内置浏览器共用同一个隔离 Chromium 运行时。", + "loading": "正在加载浏览器运行时…", + "loadFailed": "无法加载浏览器设置:{message}", + "statusTitle": "运行时状态", + "statusDescription": "此服务仅属于当前 Windows 客户端;凭据和私有端点不会在这里显示。", + "lastError": "最近错误:{code}", + "recoveryAttempt": "第 {count}/3 次恢复尝试", + "notInstalledHint": "缺少随应用打包的浏览器 sidecar,请重新安装 Codeg 后重试。", + "disabledHint": "请先启用浏览器 MCP 并保存设置,再启动运行时。", + "errorHint": "请运行诊断、检查浏览器路径,然后使用“恢复”。", + "profilePath": "托管 Profile", + "downloadPath": "受控下载目录", + "runtimeVersion": "运行时版本", + "backend": "后端", + "browserVersion": "浏览器", + "unavailable": "暂不可用", + "embeddedReady": "内置浏览器已连接", + "embeddedStopped": "内置浏览器未连接", + "embeddedError": "内置浏览器错误:{code}", + "externalDetected": "已检测到 {browser}", + "externalNotDetected": "未检测到受支持的浏览器", + "futureSessionHint": "Browser 访问设置仅影响之后新建的 Agent 会话。运行中的会话保留创建时的能力。", + "backends": { + "embedded_chromium_cdp": "内置 Chromium (CDP)", + "external_chromium_cdp": "独立 Chromium (CDP)", + "embedded": "内置浏览器", + "external": "独立浏览器" + }, + "backendChoice": "浏览器后端", + "backendChoiceDescription": "内置模式在 Codeg 中显示 Agent target;独立模式打开托管 Chromium 窗口。", + "settingsTitle": "可用性", + "settingsDescription": "浏览器访问默认关闭;只有运行时就绪时,才会注入到兼容的 Agent 会话。", + "enabled": "启用浏览器 MCP", + "enabledDescription": "允许兼容的 Agent 获得托管浏览器 MCP 服务。", + "autoStart": "随 Codeg 启动", + "autoStartDescription": "桌面客户端打开时启动隔离的浏览器运行时。", + "browserPath": "浏览器可执行文件", + "browserPathDescription": "可选:Chrome、Edge 或 Chromium 的绝对路径。留空将自动查找。", + "browserPathPlaceholder": "自动查找已安装浏览器", + "save": "保存设置", + "saving": "正在保存…", + "saved": "浏览器设置已保存", + "operationFailed": "浏览器操作失败:{message}", + "diagnosticsTitle": "诊断", + "diagnosticsDescription": "运行有界健康检查并查看已脱敏的运行数据;不会包含密钥或私有端点。", + "recentLogs": "最近运行代码", + "states": { + "not_installed": "未安装", + "stopped": "已停止", + "starting": "正在启动", + "ready": "已就绪", + "error": "错误", + "recovering": "正在恢复" + }, + "actions": { + "start": "启动", + "stop": "停止", + "restart": "重启", + "recover": "恢复", + "doctor": "运行诊断检查", + "test": "测试连接", + "diagnostics": "查看诊断信息", + "retry": "重试", + "reconnect": "重新连接", + "openManagedBrowser": "打开 Agent 专用浏览器", + "getChrome": "获取 Chrome" + } + }, + "BrowserSurface": { + "tabLabel": "浏览器", + "noActiveSession": "请启动或选择一个 Agent 会话以使用其浏览器。", + "back": "后退", + "forward": "前进", + "reload": "刷新", + "stop": "停止加载", + "addressBar": "地址栏", + "closeBrowser": "关闭此会话的浏览器", + "newTab": "新标签页", + "closeTab": "关闭 {title}", + "openTab": "打开新标签页", + "recover": "恢复", + "page": "可交互浏览器页面", + "loading": "正在加载页面…", + "waitingForFrame": "正在等待托管浏览器页面…" + }, "AppearanceSettings": { "sectionTitle": "主题外观", "sectionDescription": "选择浅色、深色或跟随系统主题,设置会自动保存。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index e68f8e016..c43a565c6 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -47,6 +47,7 @@ "system": "系統", "chat_channels": "訊息頻道", "web_service": "Web 服務", + "browser": "自動操作", "model_providers": "模型供應商", "experts": "專家", "science": "科學研究", @@ -56,6 +57,95 @@ "logs": "執行日誌" } }, + "BrowserSettings": { + "title": "自動操作", + "description": "管理支援的 Agent 可使用的本機自動操作能力。", + "browserBlockTitle": "瀏覽器", + "browserBlockDescription": "讓 Agent 工具與輔助面板內建瀏覽器共用同一個隔離 Chromium 執行環境。", + "loading": "正在載入瀏覽器執行階段…", + "loadFailed": "無法載入瀏覽器設定:{message}", + "statusTitle": "執行階段狀態", + "statusDescription": "此服務僅屬於目前 Windows 用戶端;憑證與私人端點不會顯示。", + "lastError": "最近錯誤:{code}", + "recoveryAttempt": "第 {count}/3 次復原嘗試", + "notInstalledHint": "缺少隨應用程式封裝的瀏覽器 sidecar,請重新安裝 Codeg 後再試。", + "disabledHint": "請先啟用瀏覽器 MCP 並儲存設定,再啟動執行階段。", + "errorHint": "請執行診斷、檢查瀏覽器路徑,然後使用「復原」。", + "profilePath": "受控 Profile", + "downloadPath": "受控下載目錄", + "runtimeVersion": "執行階段版本", + "backend": "後端", + "browserVersion": "瀏覽器", + "unavailable": "暫時無法使用", + "embeddedReady": "內建瀏覽器已連線", + "embeddedStopped": "內建瀏覽器尚未連線", + "embeddedError": "內建瀏覽器錯誤:{code}", + "externalDetected": "已偵測到 {browser}", + "externalNotDetected": "未偵測到支援的瀏覽器", + "futureSessionHint": "瀏覽器存取設定只影響之後新建的 Agent 工作階段。執行中的工作階段會保留建立時的能力。", + "backends": { + "embedded_chromium_cdp": "內建 Chromium (CDP)", + "external_chromium_cdp": "獨立 Chromium (CDP)", + "embedded": "內建瀏覽器", + "external": "獨立瀏覽器" + }, + "backendChoice": "瀏覽器後端", + "backendChoiceDescription": "內建模式在 Codeg 中顯示 Agent target;獨立模式開啟受管理的 Chromium 視窗。", + "settingsTitle": "可用性", + "settingsDescription": "瀏覽器存取預設關閉;只有執行階段就緒時才注入相容的 Agent 工作階段。", + "enabled": "啟用瀏覽器 MCP", + "enabledDescription": "允許相容的 Agent 使用受控瀏覽器 MCP 服務。", + "autoStart": "隨 Codeg 啟動", + "autoStartDescription": "桌面用戶端開啟時啟動隔離的瀏覽器執行階段。", + "browserPath": "瀏覽器執行檔", + "browserPathDescription": "選填:Chrome、Edge 或 Chromium 的絕對路徑。留空將自動偵測。", + "browserPathPlaceholder": "自動偵測已安裝瀏覽器", + "save": "儲存設定", + "saving": "正在儲存…", + "saved": "瀏覽器設定已儲存", + "operationFailed": "瀏覽器操作失敗:{message}", + "diagnosticsTitle": "診斷", + "diagnosticsDescription": "執行有界健康檢查並檢視已去識別化的執行資料;不含密鑰或私人端點。", + "recentLogs": "最近執行代碼", + "states": { + "not_installed": "未安裝", + "stopped": "已停止", + "starting": "正在啟動", + "ready": "已就緒", + "error": "錯誤", + "recovering": "正在復原" + }, + "actions": { + "start": "啟動", + "stop": "停止", + "restart": "重新啟動", + "recover": "復原", + "doctor": "執行診斷檢查", + "test": "測試連線", + "diagnostics": "檢視診斷資訊", + "retry": "重試", + "reconnect": "重新連線", + "openManagedBrowser": "開啟 Agent 專用瀏覽器", + "getChrome": "取得 Chrome" + } + }, + "BrowserSurface": { + "tabLabel": "瀏覽器", + "noActiveSession": "請啟動或選取 Agent 工作階段以使用其瀏覽器。", + "back": "上一頁", + "forward": "下一頁", + "reload": "重新載入", + "stop": "停止載入", + "addressBar": "網址列", + "closeBrowser": "關閉此工作階段的瀏覽器", + "newTab": "新分頁", + "closeTab": "關閉 {title}", + "openTab": "開啟新分頁", + "recover": "復原", + "page": "可互動瀏覽器頁面", + "loading": "正在載入頁面…", + "waitingForFrame": "正在等待受管理的瀏覽器頁面…" + }, "AppearanceSettings": { "sectionTitle": "主題外觀", "sectionDescription": "選擇淺色、深色或跟隨系統主題,設定會自動儲存。", diff --git a/src/lib/adapters/ai-elements-adapter.test.ts b/src/lib/adapters/ai-elements-adapter.test.ts index fdf9605f4..0f2078cd9 100644 --- a/src/lib/adapters/ai-elements-adapter.test.ts +++ b/src/lib/adapters/ai-elements-adapter.test.ts @@ -1386,6 +1386,48 @@ describe("adaptMessageTurn — image tool results", () => { if (group?.type !== "tool-group") throw new Error("expected tool-group") expect(group.items[0]?.state).toBe("input-available") }) + + it("keeps a Browser screenshot as a standalone Browser tool card with media", () => { + const adapted = adaptMessageTurn( + { + id: "browser-shot", + role: "assistant", + timestamp: "2026-08-20T00:00:00.000Z", + blocks: [ + { + type: "tool_use", + tool_use_id: "browser_1", + tool_name: "page.screenshot", + input_preview: JSON.stringify({ format: "png" }), + }, + { + type: "tool_result", + tool_use_id: "browser_1", + output_preview: JSON.stringify({ + ok: true, + action: "page.screenshot", + tab: { + id: "tab-1", + url: "https://example.com", + title: "Example", + }, + }), + is_error: false, + images: [{ data: "QUJD", mime_type: "image/png" }], + }, + ], + }, + msgText, + false + ) + + expect(adapted.content).toHaveLength(1) + expect(adapted.content[0]?.type).toBe("tool-call") + if (adapted.content[0]?.type !== "tool-call") { + throw new Error("expected Browser tool-call") + } + expect(adapted.content[0].images?.[0]?.data).toBe("QUJD") + }) }) describe("extractUserResourcesFromText — codeg references stay inline", () => { diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index b5bcca0f0..864e89461 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -18,6 +18,7 @@ import { isBackgroundTaskToolCall } from "@/lib/background-task" import { isContextCompactionMeta } from "@/lib/context-compaction" import { isUnsettledToolCall } from "@/lib/tool-call-lifecycle" import { feedbackCheckHasContent } from "@/lib/feedback-check" +import { browserToolNameFromCall } from "@/lib/browser-tool" import { isPlanLikeToolName, isPlanModeToolName, @@ -72,6 +73,8 @@ export type AdaptedToolCallPart = { * Agent card during streaming — promotion and history never carry it. */ agentTranscript?: AgentTranscriptEntry[] | null + /** Images returned by a non-image-generation tool (for Browser screenshots). */ + images?: ImageData[] | null } /** @@ -1105,12 +1108,11 @@ function deriveImageNameFromImageData(img: { * image, or a multi-page PDF read returning one image per page) into one * `generated-image` part per image. * - * Mirrors the live ACP path: there, an image-bearing ToolCall is detected by - * `isImageGenerationToolCall` (`images.length > 0`) and rendered as - * `image_generation` block(s) in place of a generic tool card. Doing the same - * here means the historical (JSONL replay) view of that Read shows the picture - * in-position instead of degrading to a bare "Read foo.png" row — closing the - * live/historical asymmetry. + * Live ACP now forwards ordinary tool images on this same `tool_result` shape, + * so live and historical Read/PDF results both pass through this adapter. A + * Browser tool is deliberately excluded by the caller: its screenshot remains + * attached to the dedicated Browser tool card rather than being relabeled as + * generated art. * * Returns `null` when the result carries no usable images, so callers fall * through to the normal tool-card path. Images missing `data`/`mime_type` are @@ -1206,6 +1208,14 @@ export function groupConsecutiveToolCalls( // dedicated that merges a task's repeated polls, so // they break the run instead of folding into a "执行 N 个任务" tool-group. !isBackgroundTaskToolCall(part) && + // Browser calls carry URL/tab/download/screenshot media in a dedicated + // card. Keep them standalone so a screenshot is visible immediately + // instead of being hidden inside the generic collapsed tool summary. + !browserToolNameFromCall( + part.toolName, + part.input, + part.output ?? part.errorText + ) && // Context-compaction items (codex `_meta.contextCompaction`, and Grok's // synthesized auto_compact card) render through the dedicated subtle // , so they break the run and render standalone @@ -1869,9 +1879,15 @@ export function adaptMessageTurn( // image card(s) (matching the live ACP path) instead of a generic // "Read foo.png" tool card. Only when the tool is no longer running — // mid-stream we keep the spinner via the normal tool-call path. - const imageParts = isToolStillRunning - ? null - : adaptImageToolResultParts(matchedResult) + const isBrowserTool = browserToolNameFromCall( + block.tool_name, + block.input_preview, + matchedResult.output_preview + ) + const imageParts = + isToolStillRunning || isBrowserTool + ? null + : adaptImageToolResultParts(matchedResult) if (imageParts) { adaptedContent.push(...imageParts) continue @@ -1893,6 +1909,7 @@ export function adaptMessageTurn( agentStats: matchedResult.agent_stats ?? undefined, meta: block.meta ?? null, agentTranscript: matchedResult.agent_transcript ?? undefined, + images: matchedResult.images ?? undefined, }) } else { // Position-based matching: if this tool_use has no ID, check next block @@ -1908,7 +1925,14 @@ export function adaptMessageTurn( positionMatchedIndices.add(index + 1) // Same image-result handling as the id-matched branch above: a Read // returning image bytes renders as image card(s) in-position. - const imageParts = adaptImageToolResultParts(positionalResult) + const isBrowserTool = browserToolNameFromCall( + block.tool_name, + block.input_preview, + positionalResult.output_preview + ) + const imageParts = isBrowserTool + ? null + : adaptImageToolResultParts(positionalResult) if (imageParts) { adaptedContent.push(...imageParts) continue @@ -1928,6 +1952,7 @@ export function adaptMessageTurn( agentStats: positionalResult.agent_stats ?? undefined, meta: block.meta ?? null, agentTranscript: positionalResult.agent_transcript ?? undefined, + images: positionalResult.images ?? undefined, }) } else { // For live streaming, unmatched tools are still running. diff --git a/src/lib/api.ts b/src/lib/api.ts index fc023ab27..769164c95 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4969,3 +4969,86 @@ export async function workTaskLookupBySource( ): Promise { return getTransport().call("work_task_lookup_by_source", { sourceKeys }) } + +// --- Managed Browser MCP runtime (local Windows desktop only) --- + +export type BrowserRuntimeState = + | "not_installed" + | "stopped" + | "starting" + | "ready" + | "error" + | "recovering" + +export interface BrowserRuntimeSettings { + enabled: boolean + autoStart: boolean + browserPath: string | null + backend: "embedded" | "external" +} + +export interface BrowserRuntimeLogEntry { + at: string + code: string +} + +export interface BrowserRuntimeStatus { + state: BrowserRuntimeState + installed: boolean + enabled: boolean + autoStart: boolean + browserPath: string | null + sidecarPid: number | null + browserPid: number | null + runtimeVersion: string | null + backend: string + browserName: string | null + browserVersion: string | null + profilePath: string + downloadPath: string + recoveryAttempt: number + lastErrorCode: string | null + recentLogs: BrowserRuntimeLogEntry[] +} + +export function getBrowserRuntimeStatus(): Promise { + return getTransport().call("browser_get_status") +} + +export function getBrowserRuntimeSettings(): Promise { + return getTransport().call("browser_get_settings") +} + +export function updateBrowserRuntimeSettings( + settings: BrowserRuntimeSettings +): Promise { + return getTransport().call("browser_update_settings", { settings }) +} + +export function startBrowserRuntime(): Promise { + return getTransport().call("browser_start") +} + +export function stopBrowserRuntime(): Promise { + return getTransport().call("browser_stop") +} + +export function restartBrowserRuntime(): Promise { + return getTransport().call("browser_restart") +} + +export function recoverBrowserRuntime(): Promise { + return getTransport().call("browser_recover") +} + +export function doctorBrowserRuntime(): Promise { + return getTransport().call("browser_doctor") +} + +export function getBrowserRuntimeDiagnostics(): Promise { + return getTransport().call("browser_get_diagnostics") +} + +export function testBrowserRuntimeConnection(): Promise { + return getTransport().call("browser_test_connection") +} diff --git a/src/lib/browser-surface.ts b/src/lib/browser-surface.ts new file mode 100644 index 000000000..d70eee9ea --- /dev/null +++ b/src/lib/browser-surface.ts @@ -0,0 +1,115 @@ +import { isLocalDesktop } from "@/lib/platform" + +export interface BrowserSurfaceTab { + id: string + url: string + title: string +} + +export interface BrowserSurfaceSnapshot { + sessionId: string + tabs: BrowserSurfaceTab[] + activeTargetId: string | null + active: { + tab: BrowserSurfaceTab + loading: boolean + canGoBack: boolean + canGoForward: boolean + } | null +} + +export interface BrowserSurfaceFrame { + targetId: string + data: string + mimeType: "image/jpeg" + deviceWidth: number + deviceHeight: number + pageScaleFactor: number +} + +export type BrowserSurfaceEvent = + | { type: "snapshot"; snapshot: BrowserSurfaceSnapshot } + | { type: "frame"; frame: BrowserSurfaceFrame } + | { + type: "error" + code: string + message: string + retryable: boolean + } + +export type BrowserSurfaceInput = + | { + kind: "mouse" + event: "pressed" | "released" | "moved" | "wheel" + x: number + y: number + button?: "none" | "left" | "middle" | "right" + deltaX?: number + deltaY?: number + modifiers?: number + } + | { + kind: "key" + event: "down" | "up" + key: string + code?: string + text?: string + modifiers?: number + } + | { kind: "text"; text: string } + +export type BrowserSurfaceAction = + | { action: "open"; url?: string } + | { action: "focus"; targetId: string } + | { action: "close"; targetId: string } + | { action: "resize"; width: number; height: number } + | { action: "navigate"; url: string } + | { action: "back" } + | { action: "forward" } + | { action: "reload" } + | { action: "stop" } + | { action: "input"; input: BrowserSurfaceInput } + +async function core() { + if (!isLocalDesktop()) { + throw new Error( + "Browser surface is only available in the local desktop client" + ) + } + return await import("@tauri-apps/api/core") +} + +export async function attachBrowserSurface( + connectionId: string, + onEvent: (event: BrowserSurfaceEvent) => void +): Promise { + const { Channel, invoke } = await core() + const channel = new Channel(onEvent) + return await invoke("browser_surface_attach", { + connectionId, + onEvent: channel, + }) +} + +export async function detachBrowserSurface( + connectionId: string +): Promise { + const { invoke } = await core() + await invoke("browser_surface_detach", { connectionId }) +} + +export async function closeBrowserSurface(connectionId: string): Promise { + const { invoke } = await core() + await invoke("browser_surface_close", { connectionId }) +} + +export async function runBrowserSurfaceAction( + connectionId: string, + action: BrowserSurfaceAction +): Promise { + const { invoke } = await core() + return await invoke("browser_surface_action", { + connectionId, + action, + }) +} diff --git a/src/lib/browser-tool.test.ts b/src/lib/browser-tool.test.ts new file mode 100644 index 000000000..03333fb7f --- /dev/null +++ b/src/lib/browser-tool.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest" + +import { + BROWSER_TOOL_NAMES, + browserScreenshotImages, + browserToolNameFromCall, + parseBrowserToolView, +} from "./browser-tool" + +describe("browser tool parsing", () => { + it("keeps the frozen 20-tool public surface", () => { + expect(BROWSER_TOOL_NAMES).toHaveLength(20) + expect(new Set(BROWSER_TOOL_NAMES).size).toBe(20) + }) + + it("recognizes direct and codeg-browser-prefixed tool calls", () => { + expect(browserToolNameFromCall("page.screenshot")).toBe("page.screenshot") + expect(browserToolNameFromCall("mcp__codeg-browser__action.click")).toBe( + "action.click" + ) + expect(browserToolNameFromCall("other-browser/page.screenshot")).toBeNull() + }) + + it("recognizes a call_tool wrapper only with a browser server identity", () => { + expect( + browserToolNameFromCall( + "call_tool", + JSON.stringify({ server: "codeg-browser", name: "tabs.open" }) + ) + ).toBe("tabs.open") + expect( + browserToolNameFromCall( + "call_tool", + JSON.stringify({ server: "other", name: "tabs.open" }) + ) + ).toBeNull() + }) + + it("unwraps MCP text content and extracts tab, error, and download details", () => { + const envelope = { + ok: true, + action: "download.wait", + tab: { id: "tab-1", url: "https://example.com", title: "Example" }, + data: { + download: { + guid: "d-1", + state: "completed", + filename: "report.pdf", + path: "C:\\controlled\\report.pdf", + receivedBytes: 10, + totalBytes: 10, + }, + }, + } + const view = parseBrowserToolView({ + toolName: "call_tool", + input: JSON.stringify({ + serverName: "codeg-browser", + name: "download.wait", + }), + output: JSON.stringify({ + content: [{ type: "text", text: JSON.stringify(envelope) }], + }), + }) + expect(view?.tab?.title).toBe("Example") + expect(view?.downloads[0]?.filename).toBe("report.pdf") + expect(view?.ok).toBe(true) + }) + + it("summarizes inputs without exposing typed text or URL query values", () => { + const view = parseBrowserToolView({ + toolName: "action.fill", + input: JSON.stringify({ + ref: "e4", + text: "private password", + url: "https://example.com/login?token=secret", + }), + }) + expect(view?.input).toEqual([ + { label: "url", value: "https://example.com/login?…" }, + { label: "ref", value: "e4" }, + { label: "text", value: "<16 characters>" }, + ]) + expect(JSON.stringify(view?.input)).not.toContain("private password") + expect(JSON.stringify(view?.input)).not.toContain("secret") + }) + + it("accepts only bounded screenshot mime types", () => { + expect( + browserScreenshotImages([ + { data: "a", mime_type: "image/png", uri: null }, + { data: "b", mime_type: "image/svg+xml", uri: null }, + ]) + ).toHaveLength(1) + }) +}) diff --git a/src/lib/browser-tool.ts b/src/lib/browser-tool.ts new file mode 100644 index 000000000..729a8b664 --- /dev/null +++ b/src/lib/browser-tool.ts @@ -0,0 +1,372 @@ +import type { ImageData } from "@/lib/types" + +export const BROWSER_TOOL_NAMES = [ + "runtime.status", + "runtime.doctor", + "runtime.start", + "runtime.stop", + "runtime.recover", + "tabs.list", + "tabs.open", + "tabs.focus", + "tabs.close", + "page.navigate", + "page.snapshot", + "page.screenshot", + "action.click", + "action.type", + "action.fill", + "action.press", + "action.scroll", + "action.wait", + "download.list", + "download.wait", +] as const + +export type BrowserToolName = (typeof BROWSER_TOOL_NAMES)[number] + +const BROWSER_TOOL_SET = new Set(BROWSER_TOOL_NAMES) + +interface BrowserTabView { + id: string + url: string + title: string +} + +export interface BrowserDownloadView { + guid: string | null + state: string | null + filename: string | null + path: string | null + receivedBytes: number | null + totalBytes: number | null + errorCode: string | null +} + +export interface BrowserToolView { + name: BrowserToolName + input: Array<{ label: string; value: string }> + ok: boolean | null + tab: BrowserTabView | null + error: { + code: string | null + message: string | null + recovery: string | null + retryable: boolean | null + } | null + downloads: BrowserDownloadView[] + output: unknown +} + +const SAFE_INPUT_KEYS = [ + "tabId", + "tab_id", + "ref", + "key", + "format", + "fullPage", + "full_page", + "quality", + "interactive", + "limit", + "deltaX", + "delta_x", + "deltaY", + "delta_y", + "milliseconds", + "timeoutMs", + "timeout_ms", + "guid", + "urlIncludes", + "url_includes", +] as const + +function asObject(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null +} + +function parseJson(value: string | null | undefined): unknown { + if (!value) return null + try { + return JSON.parse(value) + } catch { + return null + } +} + +function stringField( + object: Record | null, + ...keys: string[] +): string | null { + if (!object) return null + for (const key of keys) { + const value = object[key] + if (typeof value === "string" && value.trim()) return value.trim() + } + return null +} + +function numberField( + object: Record | null, + ...keys: string[] +): number | null { + if (!object) return null + for (const key of keys) { + const value = object[key] + if (typeof value === "number" && Number.isFinite(value)) return value + } + return null +} + +function boolField( + object: Record | null, + ...keys: string[] +): boolean | null { + if (!object) return null + for (const key of keys) { + const value = object[key] + if (typeof value === "boolean") return value + } + return null +} + +function directToolName(value: string | null): BrowserToolName | null { + if (!value) return null + const normalized = value.trim().toLowerCase() + return BROWSER_TOOL_SET.has(normalized) + ? (normalized as BrowserToolName) + : null +} + +function prefixedToolName(value: string): BrowserToolName | null { + const normalized = value.trim().toLowerCase() + if (!/(?:codeg[-_ ]?browser|browser[-_ ]?mcp)/.test(normalized)) { + return null + } + return BROWSER_TOOL_NAMES.find((name) => normalized.endsWith(name)) ?? null +} + +function findNamedValue( + value: unknown, + keys: Set, + depth = 0 +): string | null { + if (depth > 4) return null + const object = asObject(value) + if (!object) return null + for (const [key, candidate] of Object.entries(object)) { + if (keys.has(key.toLowerCase()) && typeof candidate === "string") { + return candidate + } + } + for (const nested of Object.values(object)) { + if (Array.isArray(nested)) { + for (const item of nested) { + const found = findNamedValue(item, keys, depth + 1) + if (found) return found + } + } else { + const found = findNamedValue(nested, keys, depth + 1) + if (found) return found + } + } + return null +} + +function unwrapOutput(value: unknown, depth = 0): unknown { + if (depth > 5) return value + if (typeof value === "string") { + const parsed = parseJson(value) + return parsed === null ? value : unwrapOutput(parsed, depth + 1) + } + const object = asObject(value) + if (!object) return value + if (typeof object.action === "string" && typeof object.ok === "boolean") { + return object + } + const content = object.content + if (Array.isArray(content)) { + for (const item of content) { + const itemObject = asObject(item) + if (itemObject?.type === "text" && typeof itemObject.text === "string") { + const unwrapped = unwrapOutput(itemObject.text, depth + 1) + const unwrappedObject = asObject(unwrapped) + if (unwrappedObject?.action || unwrappedObject?.ok !== undefined) { + return unwrapped + } + } + } + } + for (const key of ["result", "output", "data"]) { + if (key in object) { + const unwrapped = unwrapOutput(object[key], depth + 1) + const unwrappedObject = asObject(unwrapped) + if (unwrappedObject?.action || unwrappedObject?.ok !== undefined) { + return unwrapped + } + } + } + return object +} + +function toolArguments(value: unknown): Record | null { + const object = asObject(value) + if (!object) return null + for (const key of ["arguments", "args", "params", "input"]) { + const nested = object[key] + const parsed = + typeof nested === "string" + ? asObject(parseJson(nested)) + : asObject(nested) + if (parsed) return parsed + } + return object +} + +function safeUrlLabel(value: string): string { + try { + const url = new URL(value) + return `${url.origin}${url.pathname}${url.search ? "?…" : ""}` + } catch { + return "" + } +} + +function summarizeInput( + value: unknown +): Array<{ label: string; value: string }> { + const args = toolArguments(value) + if (!args) return [] + const summary: Array<{ label: string; value: string }> = [] + if (typeof args.url === "string") { + summary.push({ label: "url", value: safeUrlLabel(args.url) }) + } + for (const key of SAFE_INPUT_KEYS) { + const candidate = args[key] + if ( + typeof candidate === "string" || + typeof candidate === "number" || + typeof candidate === "boolean" + ) { + summary.push({ label: key, value: String(candidate) }) + } + } + if (typeof args.text === "string") { + summary.push({ label: "text", value: `<${args.text.length} characters>` }) + } + return summary +} + +export function browserToolNameFromCall( + toolName: string, + input?: string | null, + output?: string | null +): BrowserToolName | null { + const exact = directToolName(toolName) + if (exact) return exact + const prefixed = prefixedToolName(toolName) + if (prefixed) return prefixed + + const parsedInput = parseJson(input) + const parsedOutput = unwrapOutput(parseJson(output) ?? output) + const outputAction = directToolName( + stringField(asObject(parsedOutput), "action") + ) + if (outputAction) return outputAction + + const server = findNamedValue( + parsedInput, + new Set(["server", "servername", "server_name", "mcpserver", "mcp_server"]) + ) + if (!server || !/(?:codeg[-_ ]?browser|browser[-_ ]?mcp)/i.test(server)) { + return null + } + return directToolName( + findNamedValue( + parsedInput, + new Set(["name", "tool", "toolname", "tool_name"]) + ) + ) +} + +function parseTab(value: unknown): BrowserTabView | null { + const object = asObject(value) + const id = stringField(object, "id", "tabId", "tab_id") + const url = stringField(object, "url") + const title = stringField(object, "title") + if (!id && !url && !title) return null + return { id: id ?? "", url: url ?? "", title: title ?? "" } +} + +function parseDownload(value: unknown): BrowserDownloadView | null { + const object = asObject(value) + if (!object) return null + const filename = stringField(object, "filename") + const guid = stringField(object, "guid") + if (!filename && !guid) return null + return { + guid, + state: stringField(object, "state"), + filename, + path: stringField(object, "path"), + receivedBytes: numberField(object, "receivedBytes", "received_bytes"), + totalBytes: numberField(object, "totalBytes", "total_bytes"), + errorCode: stringField(object, "errorCode", "error_code"), + } +} + +function collectDownloads(value: unknown): BrowserDownloadView[] { + const object = asObject(value) + if (!object) return [] + const data = asObject(object.data) + const candidates = data?.downloads ?? data?.download + if (Array.isArray(candidates)) { + return candidates + .map(parseDownload) + .filter((item): item is BrowserDownloadView => item !== null) + } + const single = parseDownload(candidates) + return single ? [single] : [] +} + +export function parseBrowserToolView(params: { + toolName: string + input?: string | null + output?: string | null + errorText?: string | null +}): BrowserToolView | null { + const source = params.output ?? params.errorText ?? null + const name = browserToolNameFromCall(params.toolName, params.input, source) + if (!name) return null + + const output = unwrapOutput(parseJson(source) ?? source) + const envelope = asObject(output) + const error = asObject(envelope?.error) + return { + name, + input: summarizeInput(parseJson(params.input)), + ok: boolField(envelope, "ok"), + tab: parseTab(envelope?.tab), + error: error + ? { + code: stringField(error, "code"), + message: stringField(error, "message"), + recovery: stringField(error, "recovery"), + retryable: boolField(error, "retryable"), + } + : null, + downloads: collectDownloads(output), + output, + } +} + +export function browserScreenshotImages( + images: ImageData[] | null | undefined +): ImageData[] { + return (images ?? []).filter( + (image) => + Boolean(image.data) && /^image\/(?:png|jpeg)$/.test(image.mime_type) + ) +} diff --git a/src/stores/conversation-runtime-store.ts b/src/stores/conversation-runtime-store.ts index 9c0afea48..17b873bd1 100644 --- a/src/stores/conversation-runtime-store.ts +++ b/src/stores/conversation-runtime-store.ts @@ -665,7 +665,8 @@ function cleanAgentOutput(output: string | null): string | null { * Decide whether a live `ToolCallInfo` is codex-acp's image-generation * tool call. Detection has to fire during the in-flight window * (ImageGenerationBegin, no images yet) so we can't rely on `images.length` - * alone — the load-bearing signal during that window is the title. + * at all — the load-bearing signal during that window is the title, and + * ordinary tools such as Browser screenshots also carry images. * * Layered detection: * 1. `title === "Image generation"` — codex-acp PR #271 hardcodes this @@ -673,20 +674,15 @@ function cleanAgentOutput(output: string | null): string | null { * `end_image_generation`. Primary path. * 2. Case-insensitive title match — defensive for any future codex-acp * casing/whitespace drift. - * 3. `images.length > 0` — defensive when title is somehow lost but - * images are present (e.g. a snapshot replay that drops the title). - * * The function is intentionally NOT a generic `kind === "other"` matcher - * because many tools surface as ToolKind::Other. + * because many tools surface as ToolKind::Other, and ordinary tools such as + * Browser `page.screenshot` also return images. */ -function isImageGenerationToolCall(info: { - title?: string | null - images?: { length: number } | null -}): boolean { +function isImageGenerationToolCall(info: { title?: string | null }): boolean { const title = (info.title ?? "").trim() if (title === "Image generation") return true if (title.toLowerCase() === "image generation") return true - return (info.images?.length ?? 0) > 0 + return false } /** @@ -1223,6 +1219,7 @@ export function buildStreamingTurnsFromLiveMessage( : resolvedOutput, is_error: block.info.status === "failed", ...(agentStats ? { agent_stats: agentStats } : {}), + ...(block.info.images?.length ? { images: block.info.images } : {}), }) currentGroupHasCompletedTool = true } else if ( @@ -1247,6 +1244,7 @@ export function buildStreamingTurnsFromLiveMessage( is_error: false, ...(agentStats ? { agent_stats: agentStats } : {}), ...(transcript?.length ? { agent_transcript: transcript } : {}), + ...(block.info.images?.length ? { images: block.info.images } : {}), }) inProgressToolCallIds.add(block.info.tool_call_id) }