From fea6d96378cb4933e4ea012ab1650a8fb6b34728 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 03:29:34 -0700 Subject: [PATCH 01/14] fix(qa): artifact write failure preserves previous files (#122580) * fix(qa): publish suite artifacts atomically * test(qa): consolidate artifact durability coverage --- extensions/qa-lab/src/suite-artifacts.ts | 59 +++++--- .../qa-lab/src/suite-launch.runtime.test.ts | 127 +++++++++++++++++- extensions/qa-lab/src/suite-launch.runtime.ts | 13 +- extensions/qa-lab/src/suite.test.ts | 9 ++ 4 files changed, 187 insertions(+), 21 deletions(-) diff --git a/extensions/qa-lab/src/suite-artifacts.ts b/extensions/qa-lab/src/suite-artifacts.ts index be040d32a3ae..8363c49fcc16 100644 --- a/extensions/qa-lab/src/suite-artifacts.ts +++ b/extensions/qa-lab/src/suite-artifacts.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline"; +import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; import { assertQaSuiteArtifactWritten } from "./artifact-assertion.js"; import { hasQaCrablineArtifactPath, @@ -25,6 +26,28 @@ type QaCrablineChannelDriverSmokeResult = Awaited< ReturnType >; +/** Atomically replaces each file in order; summary-last is a completion signal, not a set transaction. */ +export async function publishQaSuiteArtifactFiles(params: { + outputDir: string; + files: readonly { content: string | Uint8Array; filePath: string }[]; +}) { + await fs.mkdir(params.outputDir, { recursive: true }); + const dirMode = (await fs.stat(params.outputDir)).mode & 0o7777; + for (const file of params.files) { + await replaceFileAtomic({ + filePath: file.filePath, + content: file.content, + dirMode, + mode: 0o600, + preserveExistingMode: true, + tempPrefix: `${path.basename(file.filePath)}.qa-artifact`, + syncTempFile: true, + syncParentDir: true, + throwOnCleanupError: true, + }); + } +} + export type QaSuiteSummaryJsonParams = { scenarios: QaSuiteScenarioResult[]; startedAt: Date; @@ -264,22 +287,26 @@ export async function writeQaSuiteArtifacts(params: { ); } const writeEvidenceFile = params.writeEvidenceFile ?? true; - await fs.writeFile(reportPath, report, "utf8"); - if (evidence && writeEvidenceFile) { - await fs.writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, "utf8"); - } - await fs.writeFile( - summaryPath, - `${JSON.stringify( - buildQaSuiteSummaryJson({ - ...params, - channelDriverSelection: effectiveChannelDriverSelection, - }), - null, - 2, - )}\n`, - "utf8", - ); + await publishQaSuiteArtifactFiles({ + outputDir: params.outputDir, + files: [ + { filePath: reportPath, content: report }, + ...(evidence && writeEvidenceFile + ? [{ filePath: evidencePath, content: `${JSON.stringify(evidence, null, 2)}\n` }] + : []), + { + filePath: summaryPath, + content: `${JSON.stringify( + buildQaSuiteSummaryJson({ + ...params, + channelDriverSelection: effectiveChannelDriverSelection, + }), + null, + 2, + )}\n`, + }, + ], + }); await assertQaSuiteArtifactWritten("report", reportPath); await assertQaSuiteArtifactWritten("summary", summaryPath); if (evidence && writeEvidenceFile) { diff --git a/extensions/qa-lab/src/suite-launch.runtime.test.ts b/extensions/qa-lab/src/suite-launch.runtime.test.ts index 0ac53e5dfea5..ac2c9f57f54b 100644 --- a/extensions/qa-lab/src/suite-launch.runtime.test.ts +++ b/extensions/qa-lab/src/suite-launch.runtime.test.ts @@ -4,8 +4,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QaSuiteInfraError } from "./errors.js"; import type { QaLabServerHandle } from "./lab-server.types.js"; +import type { QaTransportAdapter } from "./qa-transport.js"; +import { makeQaSuiteTestScenario } from "./suite-test-helpers.js"; import type { QaSuiteScenarioResult } from "./suite.js"; -import { throwQaSuiteCleanupErrors } from "./suite.js"; +import { qaSuiteProgressTesting, throwQaSuiteCleanupErrors } from "./suite.js"; import type { QaTestFileScenario, QaTestFileScenarioRunResult, @@ -14,11 +16,13 @@ import type { const { crablineRuntimeLoads, prepareDockerE2eEnvironment, + replaceFileAtomicMock, runQaFlowSuite, runQaTestFileScenarios, } = vi.hoisted(() => ({ crablineRuntimeLoads: vi.fn(), prepareDockerE2eEnvironment: vi.fn(), + replaceFileAtomicMock: vi.fn(), runQaFlowSuite: vi.fn(), runQaTestFileScenarios: vi.fn(), })); @@ -43,6 +47,12 @@ vi.mock("./test-file-scenario-docker-batch.js", async (importOriginal) => ({ prepareDockerE2eEnvironment, })); +vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { + const actual = await importOriginal(); + replaceFileAtomicMock.mockImplementation(actual.replaceFileAtomic); + return { ...actual, replaceFileAtomic: replaceFileAtomicMock }; +}); + import { runQaSuite, runQaSuiteWithInfraRetry } from "./suite-launch.runtime.js"; const tempRoots: string[] = []; @@ -120,8 +130,67 @@ function mockFlowPartitionFailures(failuresByScenarioId: ReadonlyMap Promise; +}) { + const sentinels = new Map( + params.canonicalFileNames.map((fileName) => [fileName, `prior ${fileName}\n`]), + ); + await fs.mkdir(params.outputDir, { recursive: true, mode: 0o750 }); + await fs.chmod(params.outputDir, 0o750); + for (const [fileName, sentinel] of sentinels) { + const finalPath = path.join(params.outputDir, fileName); + await fs.writeFile(finalPath, sentinel, { encoding: "utf8", mode: 0o640 }); + await fs.chmod(finalPath, 0o640); + } + const actualSecurityRuntime = await vi.importActual< + typeof import("openclaw/plugin-sdk/security-runtime") + >("openclaw/plugin-sdk/security-runtime"); + const publicationOrder: string[] = []; + const failSelectedArtifact = async (options: Parameters[0]) => { + publicationOrder.push(path.basename(options.filePath)); + return await actualSecurityRuntime.replaceFileAtomic({ + ...options, + ...(path.basename(options.filePath) === params.failedFileName + ? { + beforeRename: async ({ tempPath }: { tempPath: string }) => { + await fs.writeFile(tempPath, "partial replacement\n", "utf8"); + throw Object.assign(new Error("injected QA artifact publication failure"), { + code: "EIO", + }); + }, + } + : {}), + }); + }; + + await replaceFileAtomicMock.withImplementation(failSelectedArtifact, async () => { + await expect(params.publish()).rejects.toMatchObject({ code: "EIO" }); + }); + + const selectedPath = path.join(params.outputDir, params.failedFileName); + await expect(fs.readFile(selectedPath, "utf8")).resolves.toBe( + sentinels.get(params.failedFileName), + ); + if (process.platform !== "win32") { + expect((await fs.stat(selectedPath)).mode & 0o777).toBe(0o640); + expect((await fs.stat(params.outputDir)).mode & 0o7777).toBe(0o750); + } + const selectedIndex = params.canonicalFileNames.indexOf(params.failedFileName); + expect(publicationOrder).toEqual(params.canonicalFileNames.slice(0, selectedIndex + 1)); + expect( + (await fs.readdir(params.outputDir)).filter((entry) => + entry.startsWith(`${params.failedFileName}.qa-artifact.`), + ), + ).toEqual([]); +} + describe("qa suite runtime launcher", () => { beforeEach(() => { + replaceFileAtomicMock.mockClear(); runQaFlowSuite.mockReset(); runQaTestFileScenarios.mockReset(); prepareDockerE2eEnvironment.mockReset(); @@ -1225,6 +1294,62 @@ describe("qa suite runtime launcher", () => { ); }); + it.each([ + { kind: "report", fileName: "qa-suite-report.md" }, + { kind: "evidence", fileName: "qa-evidence.json" }, + { kind: "summary", fileName: "qa-suite-summary.json" }, + ])( + "preserves the prior standard $kind artifact when atomic publication fails", + async ({ fileName }) => { + const outputDir = await makeTempRepo("qa-suite-standard-artifact-atomic-"); + await expectArtifactPublicationFailurePreservesPrior({ + canonicalFileNames: ["qa-suite-report.md", "qa-evidence.json", "qa-suite-summary.json"], + failedFileName: fileName, + outputDir, + publish: async () => + await qaSuiteProgressTesting.writeQaSuiteArtifacts({ + outputDir, + startedAt: new Date("2026-08-12T00:00:00.000Z"), + finishedAt: new Date("2026-08-12T00:01:00.000Z"), + scenarios: [{ name: "Atomic publication", status: "pass", steps: [] }], + scenarioDefinitions: [makeQaSuiteTestScenario("channel-chat-baseline")], + transport: { + id: "qa-channel", + createReportNotes: () => [], + } as unknown as QaTransportAdapter, + providerMode: "mock-openai", + primaryModel: "mock-openai/gpt-5.6-luna", + alternateModel: "mock-openai/gpt-5.6-luna-alt", + fastMode: true, + concurrency: 1, + }), + }); + }, + ); + + it.each([ + { kind: "evidence", fileName: "qa-evidence.json" }, + { kind: "report", fileName: "qa-suite-report.md" }, + { kind: "summary", fileName: "qa-suite-summary.json" }, + ])( + "preserves the prior unified $kind artifact when atomic publication fails", + async ({ fileName }) => { + const repoRoot = await makeTempRepo("qa-suite-unified-artifact-atomic-"); + const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "artifact-atomic"); + await expectArtifactPublicationFailurePreservesPrior({ + canonicalFileNames: ["qa-evidence.json", "qa-suite-report.md", "qa-suite-summary.json"], + failedFileName: fileName, + outputDir, + publish: async () => + await runQaSuite({ + repoRoot, + outputDir: ".artifacts/qa-e2e/artifact-atomic", + scenarioIds: ["control-ui-chat-flow-playwright"], + }), + }); + }, + ); + it("aggregates mixed-kind progress through the parent lab", async () => { const repoRoot = await makeTempRepo("qa-suite-mixed-progress-"); const scenarioRuns: Array[0]> = []; diff --git a/extensions/qa-lab/src/suite-launch.runtime.ts b/extensions/qa-lab/src/suite-launch.runtime.ts index 7523c5819f70..b17093546c33 100644 --- a/extensions/qa-lab/src/suite-launch.runtime.ts +++ b/extensions/qa-lab/src/suite-launch.runtime.ts @@ -28,6 +28,7 @@ import { type QaSeedScenarioWithSource, } from "./scenario-catalog.js"; import { expandQaScenarioExecutionCells, type QaScenarioExecutionCell } from "./scenario-lane.js"; +import { publishQaSuiteArtifactFiles } from "./suite-artifacts.js"; import { mapQaSuiteWithConcurrency, normalizeQaSuiteConcurrency, @@ -705,7 +706,6 @@ async function writeUnifiedQaSuiteArtifacts(params: { scenarios: readonly QaSuiteScenarioResult[]; startedAt: Date; }) { - await fs.mkdir(params.outputDir, { recursive: true }); const evidencePath = path.join(params.outputDir, QA_EVIDENCE_FILENAME); const reportPath = path.join(params.outputDir, "qa-suite-report.md"); const summaryPath = path.join(params.outputDir, "qa-suite-summary.json"); @@ -729,9 +729,14 @@ async function writeUnifiedQaSuiteArtifacts(params: { scenarios: [...params.scenarios], startedAt: params.startedAt, }) satisfies QaSuiteSummaryJson; - await fs.writeFile(evidencePath, `${JSON.stringify(params.evidence, null, 2)}\n`, "utf8"); - await fs.writeFile(reportPath, report, "utf8"); - await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + await publishQaSuiteArtifactFiles({ + outputDir: params.outputDir, + files: [ + { filePath: evidencePath, content: `${JSON.stringify(params.evidence, null, 2)}\n` }, + { filePath: reportPath, content: report }, + { filePath: summaryPath, content: `${JSON.stringify(summary, null, 2)}\n` }, + ], + }); return { evidencePath, outputDir: params.outputDir, diff --git a/extensions/qa-lab/src/suite.test.ts b/extensions/qa-lab/src/suite.test.ts index 32475f56a4c0..ed51a6c8f4a6 100644 --- a/extensions/qa-lab/src/suite.test.ts +++ b/extensions/qa-lab/src/suite.test.ts @@ -608,6 +608,15 @@ describe("qa suite", () => { evidence?: unknown; }; expect(summary.evidence).toBeUndefined(); + if (process.platform !== "win32") { + for (const artifactPath of [ + artifacts.reportPath, + artifacts.evidencePath, + artifacts.summaryPath, + ]) { + expect((await fs.stat(artifactPath)).mode & 0o777).toBe(0o600); + } + } } finally { await fs.rm(outputDir, { recursive: true, force: true }); } From 786c646d88949a89c0419eae094c1956d67a1731 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 03:41:34 -0700 Subject: [PATCH 02/14] improve(ci): rebalance compact Node shards from hosted medians (#122547) * ci: refresh compact Node shard timings Amp-Thread-ID: https://ampcode.com/threads/T-019ff3db-c467-70ad-8ed3-81f2ba94b0c0 * docs(ci): clarify compact planner counts Amp-Thread-ID: https://ampcode.com/threads/T-019ff3db-c467-70ad-8ed3-81f2ba94b0c0 --------- Co-authored-by: Amp --- docs/ci.md | 2 +- scripts/lib/ci-node-test-plan.mts | 216 ++++++++++++------------- test/scripts/ci-node-test-plan.test.ts | 39 +++++ 3 files changed, 142 insertions(+), 115 deletions(-) diff --git a/docs/ci.md b/docs/ci.md index 67cd1137dd5e..e1bf61d6bf4d 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -119,7 +119,7 @@ The slowest Node test families are split or balanced so each job stays small wit - Auto-reply runs as balanced workers, with the reply subtree split into agent-runner, commands, dispatch, session, and state-routing shards. - Agentic gateway/server (control-plane) configs split across chat, auth, model, HTTP/plugin, runtime, and startup lanes instead of waiting on built artifacts. - Normal CI packs only isolated infra include-pattern shards into deterministic bundles of at most 64 test files, reducing the Node matrix without merging non-isolated command/cron, stateful agents-core, or gateway/server suites. Heavy fixed suites stay on 8 vCPU while the bundled and lower-weight lanes use 4 vCPU. -- Pull requests on the canonical repository reuse the changed-test resolver against the synthetic merged-tree diff. Precise changes run one targeted Node job; each selected test file gets its own process so stateful suite isolation remains intact. The planner combines sibling tests with import-graph dependents and falls back to the existing 14-job compact full-suite plan for workspace package, package/lockfile, shared harness, split-config, renamed, or deleted changes, public extension-contract changes, tests with special shard setup, partially resolved or empty targets, oversized path or target plans, and planner errors. Targeted plans always retain the full built-artifact boundary gate because its repository scanners cannot be derived from imports. `main` pushes run the same full compact suite: pending intermediate push events can be coalesced, so the newest surviving run must validate the complete integration tree rather than only its final single-push diff. Manual dispatches and release gates retain the full named per-shard matrix. Compact packing uses fleet timing hints without changing the bounded job count; the high-variance source/security group remains isolated so its tail does not serialize unrelated groups. +- Pull requests on the canonical repository reuse the changed-test resolver against the synthetic merged-tree diff. Precise changes run one targeted Node job; each selected test file gets its own process so stateful suite isolation remains intact. The planner combines sibling tests with import-graph dependents and falls back to the existing 23-job compact full-suite plan for workspace package, package/lockfile, shared harness, split-config, renamed, or deleted changes, public extension-contract changes, tests with special shard setup, partially resolved or empty targets, oversized path or target plans, and planner errors. Targeted plans always retain the full built-artifact boundary gate because its repository scanners cannot be derived from imports. `main` pushes run the same full compact suite: pending intermediate push events can be coalesced, so the newest surviving run must validate the complete integration tree rather than only its final single-push diff. Manual dispatches and release gates retain the full named per-shard matrix. Compact packing uses median group walls refreshed from multiple successful hosted runs without changing the bounded job count; the high-variance source/security group remains isolated so its tail does not serialize unrelated groups. - The full Node matrix admits the consistently slow serial tooling, auto-reply command shards, and broad core-fast cache writer first. This keeps the 28-job cap while preventing critical-path work and the next run's transform seed from slipping into a later wave. - The three serial Control UI browser shards greedily pack discovered test files by source byte size. This zero-state duration proxy avoids Vitest's equal-file-count hash clustering, automatically accounts for new and changed files, and preserves the same complete test inventory without adding runners. - Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. diff --git a/scripts/lib/ci-node-test-plan.mts b/scripts/lib/ci-node-test-plan.mts index f8b085ce9000..7a1c71f912d8 100644 --- a/scripts/lib/ci-node-test-plan.mts +++ b/scripts/lib/ci-node-test-plan.mts @@ -168,139 +168,127 @@ const COMPACT_WHOLE_NODE_TEST_TIMEOUT_MINUTES = 120; const AUTO_REPLY_COMMANDS_STRIPES = 3; const AGENTS_CORE_RUNNER_CLI_STRIPES = 3; const UNIT_FAST_NODE_TEST_STRIPES = 2; -// Advisory runtime estimates (seconds) per split shard: [shard:*] begin->end -// wall clock across seven green Blacksmith compact PR runs after the -// cli-runner reliability whale fix (29605136624, 29605203485, 29605983019, -// 29606701461, 29611308972, 29611457693, 29611500865), averaged after -// dropping cache-warm/contention outliers outside [median/1.5, median*1.5]. +// Advisory runtime estimates (seconds) per split shard: median [shard:*] +// begin->end wall across nine successful hosted compact runs (31568650453, +// 31569157374, 31569912984, 31570693513, 31571644856, 31572044913, +// 31572489294, 31574210928, 31574367637). // Packing only: a stale entry skews job balance but never correctness. // Unknown shards fall back to a per-file estimate. -// Outlier hints were refreshed from child-process walls in runs 31453973052 -// and 31455822921. const COMPACT_GROUP_SECONDS_HINTS = new Map([ - ["agentic-agents-core-auth", 27], - ["agentic-agents-core-isolated", 9], - // Model catalog and full UI both cold-load broad graphs; preserve their - // measured separation when striping the expanded groups. - ["agentic-agents-core-models", 37], - // Reliability's runtime-free provider check dropped its wall time from - // ~245s to ~5s; the narrow anthropic cli-api artifact removes the same - // full-barrel evaluation for the remaining facade importers (spawn). - // The live-session extraction rebalanced these stripes without changing the - // fleet-scale import wall that dominates each compact group. - ["agentic-agents-core-runner-cli-1", 8], - ["agentic-agents-core-runner-cli-2", 8], - ["agentic-agents-core-runner-cli-3", 8], + ["agentic-agents-core-auth", 28], + ["agentic-agents-core-isolated", 16], + ["agentic-agents-core-models", 39], + ["agentic-agents-core-runner-cli-1", 7], + ["agentic-agents-core-runner-cli-2", 17], + ["agentic-agents-core-runner-cli-3", 13], ["agentic-agents-core-runner-commands", 27], ["agentic-agents-core-runner-embedded", 20], - ["agentic-agents-core-runner-sessions", 13], - ["agentic-agents-core-runtime", 104], - ["agentic-agents-core-subagents", 10], - ["agentic-agents-core-tools", 52], + ["agentic-agents-core-runner-sessions", 18], + ["agentic-agents-core-runtime", 113], + ["agentic-agents-core-subagents", 17], + ["agentic-agents-core-tools", 45], // The composite hint sets the job count before its independent configs are - // striped across those jobs. Split hints use the same loaded-fleet run as - // the rest of this map rather than older 2-core measurements. - ["agentic-agents-embedded", 150], - ["agentic-agents-embedded-base", 88], - ["agentic-agents-embedded-incomplete-turn", 14], - ["agentic-agents-embedded-overflow-compaction", 12], - ["agentic-agents-embedded-run", 30], - ["agentic-agents-support", 201], - ["agentic-agents-tools", 42], - ["agentic-cli", 145], - ["agentic-command-support", 65], - ["agentic-commands-agent-channel", 74], - ["agentic-commands-doctor", 19], - ["agentic-commands-doctor-auth", 11], - ["agentic-commands-doctor-config-state", 112], - ["agentic-commands-doctor-device", 2], - ["agentic-commands-doctor-gateway", 4], - ["agentic-commands-doctor-platform", 3], - ["agentic-commands-doctor-plugins-tools", 11], - ["agentic-commands-doctor-sessions-cron", 24], - ["agentic-commands-doctor-shared", 16], + // striped across those jobs; its estimate is the sum of the split medians. + ["agentic-agents-embedded", 162], + ["agentic-agents-embedded-base", 90], + ["agentic-agents-embedded-incomplete-turn", 17], + ["agentic-agents-embedded-overflow-compaction", 18], + ["agentic-agents-embedded-run", 37], + ["agentic-agents-support", 144], + ["agentic-agents-tools", 76], + ["agentic-cli", 111], + ["agentic-command-support", 61], + ["agentic-commands-agent-channel", 71], + ["agentic-commands-doctor", 23], + ["agentic-commands-doctor-auth", 19], + ["agentic-commands-doctor-config-state", 69], + ["agentic-commands-doctor-device", 3], + ["agentic-commands-doctor-gateway", 3], + ["agentic-commands-doctor-platform", 4], + ["agentic-commands-doctor-plugins-tools", 27], + ["agentic-commands-doctor-sessions-cron", 21], + ["agentic-commands-doctor-shared", 27], ["agentic-commands-doctor-whatsapp", 1], ["agentic-commands-doctor-workspace", 1], - ["agentic-commands-models", 16], - ["agentic-commands-onboard-config", 11], - ["agentic-commands-status-tools", 21], - ["agentic-control-plane-agent-chat", 123], - ["agentic-control-plane-auth-node", 128], - ["agentic-control-plane-http-models", 33], - ["agentic-control-plane-http-plugin-ws", 39], - ["agentic-control-plane-runtime-config", 14], - ["agentic-control-plane-runtime-cron", 15], + ["agentic-commands-models", 24], + ["agentic-commands-onboard-config", 26], + ["agentic-commands-status-tools", 28], + ["agentic-control-plane-agent-chat", 140], + ["agentic-control-plane-auth-node", 153], + ["agentic-control-plane-http-models", 25], + ["agentic-control-plane-http-plugin-ws", 49], + ["agentic-control-plane-runtime", 20], + ["agentic-control-plane-runtime-config", 8], + ["agentic-control-plane-runtime-cron", 31], ["agentic-control-plane-runtime-network", 1], - ["agentic-control-plane-runtime-server", 29], - ["agentic-control-plane-runtime-shared-token", 22], - ["agentic-control-plane-runtime-state", 13], - ["agentic-control-plane-runtime-ui-tools", 11], - ["agentic-control-plane-startup-core", 28], - ["agentic-control-plane-startup-health-runtime", 22], - ["agentic-control-plane-startup-restart-close", 8], - ["agentic-gateway-core", 197], - ["agentic-gateway-methods", 136], - ["agentic-plugin-sdk", 47], - ["auto-reply-core-top-level", 30], - ["auto-reply-reply-agent-runner", 40], - ["auto-reply-reply-commands-1", 44], - ["auto-reply-reply-commands-2", 18], - ["auto-reply-reply-commands-3", 36], - ["auto-reply-reply-dispatch", 64], - ["auto-reply-reply-session", 19], - ["auto-reply-reply-state-routing", 54], - ["core-runtime-cron-core", 16], - ["core-runtime-cron-isolated-agent", 94], - ["core-runtime-cron-service", 49], - ["core-runtime-hooks", 9], - ["core-runtime-infra-approval-exec", 30], - ["core-runtime-infra-channel-plugin", 17], - ["core-runtime-infra-cli-ui", 1], - ["core-runtime-infra-core-utils", 3], - ["core-runtime-infra-diagnostics-state", 19], - ["core-runtime-infra-events-runtime", 4], + ["agentic-control-plane-runtime-server", 25], + ["agentic-control-plane-runtime-shared-token", 8], + ["agentic-control-plane-runtime-state", 34], + ["agentic-control-plane-runtime-ui-tools", 9], + ["agentic-control-plane-startup-config", 5], + ["agentic-control-plane-startup-core", 27], + ["agentic-control-plane-startup-health-runtime", 11], + ["agentic-control-plane-startup-restart-close", 16], + ["agentic-gateway-core", 214], + ["agentic-gateway-methods", 119], + ["agentic-plugin-sdk", 44], + ["auto-reply-core-top-level", 27], + ["auto-reply-reply-agent-runner", 68], + ["auto-reply-reply-commands-1", 27], + ["auto-reply-reply-commands-2", 16], + ["auto-reply-reply-commands-3", 27], + ["auto-reply-reply-dispatch", 65], + ["auto-reply-reply-session", 40], + ["auto-reply-reply-state-routing", 48], + ["core-runtime-cron-core", 24], + ["core-runtime-cron-isolated-agent", 110], + ["core-runtime-cron-service", 51], + ["core-runtime-hooks", 18], + ["core-runtime-infra-approval-exec", 23], + ["core-runtime-infra-channel-plugin", 7], + ["core-runtime-infra-cli-ui", 2], + ["core-runtime-infra-core-utils", 4], + ["core-runtime-infra-device", 8], + ["core-runtime-infra-diagnostics-state", 12], + ["core-runtime-infra-env-auth", 5], + ["core-runtime-infra-events-runtime", 7], ["core-runtime-infra-file-safety", 2], - ["core-runtime-infra-files-commands", 5], + ["core-runtime-infra-files-commands", 4], ["core-runtime-infra-gateway-lock-argv", 2], ["core-runtime-infra-gateway-processes", 1], ["core-runtime-infra-gateway-watch", 1], - ["core-runtime-infra-heartbeat-core", 4], - ["core-runtime-infra-heartbeat-runner", 123], - ["core-runtime-infra-misc", 9], + ["core-runtime-infra-heartbeat-core", 6], + ["core-runtime-infra-heartbeat-runner", 54], + ["core-runtime-infra-misc", 12], ["core-runtime-infra-misc-dedupe-disk", 1], ["core-runtime-infra-misc-os", 1], ["core-runtime-infra-misc-values", 1], - ["core-runtime-infra-net-install", 13], - ["core-runtime-infra-network-node", 2], + ["core-runtime-infra-net-install", 9], + ["core-runtime-infra-network-node", 4], ["core-runtime-infra-network-platform", 4], - ["core-runtime-infra-outbound-actions", 19], - ["core-runtime-infra-outbound-core", 45], - ["core-runtime-infra-process", 118], - ["core-runtime-infra-provider-push", 17], + ["core-runtime-infra-outbound-actions", 31], + ["core-runtime-infra-outbound-core", 57], + ["core-runtime-infra-process", 134], + ["core-runtime-infra-provider-push", 15], ["core-runtime-infra-repo-tooling", 4], - ["core-runtime-infra-storage-state", 96], - ["core-runtime-infra-system-runtime", 40], - ["core-runtime-media-ui", 174], - ["core-runtime-secrets", 37], - ["core-runtime-shared", 48], - // PTY timing suites still need a lightly packed lane; the exclusive-bin cap - // leaves only trivial co-groups next to this measured runtime. + ["core-runtime-infra-storage-state", 86], + ["core-runtime-infra-system-runtime", 35], + ["core-runtime-media-ui", 196], + ["core-runtime-secrets", 58], + ["core-runtime-shared", 52], + // This dist-only group is outside the sampled nondist logs and retains its + // prior measured hint. The exclusive-bin cap keeps its lane lightly packed. ["core-runtime-tui-pty", 116], - ["core-tooling-1", 94], - ["core-tooling-2", 95], - ["core-tooling-3", 108], - ["core-tooling-4", 125], - ["core-tooling-isolated", 49], - ["core-unit-fast-1", 89], - ["core-unit-fast-2", 92], - // Fork-per-file isolation parallelizes poorly on 4 vCPU; keep it on the - // 8 vCPU class, where it still runs a measured ~90s under fleet load. - ["core-unit-fast-isolated", 90], - // In 35 green main runs on 2026-08-11/12, compact large jobs owned the - // critical tail 15 times and reached p90=457s. This group's former 205s - // hint repeatedly packed another 58s of serial work beside that tail. - ["core-unit-src-security", 295], - ["core-unit-support", 17], + ["core-tooling-1", 112], + ["core-tooling-2", 128], + ["core-tooling-3", 163], + ["core-tooling-4", 123], + ["core-tooling-isolated", 34], + ["core-unit-fast-1", 54], + ["core-unit-fast-2", 60], + ["core-unit-fast-isolated", 79], + ["core-unit-src-security", 252], + ["core-unit-support", 18], ]); // Advisory per-file wall-clock hints (seconds) for stripe balancing, measured // from single-file local runs (M4 Max) and static import-graph size. Packing diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index ef681c84c5aa..480e2ec4de10 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -332,6 +332,45 @@ describe("scripts/lib/ci-node-test-plan.mts", () => { expect(largeJobs).toHaveLength(7); expect(smallJobs).toHaveLength(14); expect(distJobs).toHaveLength(2); + const regularSmallJobs = smallJobs.filter((shard) => + shard.groups.every((group) => !exclusiveGroupRe.test(group.shard_name)), + ); + expect(regularSmallJobs).toHaveLength(10); + // The refreshed hosted medians give every regular bin one known tail + // anchor. Stale hints paired two of these slow groups in each runner class. + const largeTailAnchors = [ + "core-unit-src-security", + "agentic-gateway-core", + "core-runtime-media-ui", + "agentic-agents-support", + "agentic-gateway-methods", + "agentic-agents-core-runtime", + "agentic-agents-embedded-base", + ]; + const smallTailAnchors = [ + "agentic-control-plane-auth-node", + "agentic-control-plane-agent-chat", + "core-runtime-infra-process", + "agentic-cli", + "core-runtime-cron-isolated-agent", + "core-runtime-infra-storage-state", + "agentic-agents-tools", + "agentic-commands-agent-channel", + "agentic-commands-doctor-config-state", + "auto-reply-reply-agent-runner", + ]; + expect( + largeJobs.map( + (shard) => + shard.groups.filter((group) => largeTailAnchors.includes(group.shard_name)).length, + ), + ).toEqual(Array.from({ length: largeTailAnchors.length }, () => 1)); + expect( + regularSmallJobs.map( + (shard) => + shard.groups.filter((group) => smallTailAnchors.includes(group.shard_name)).length, + ), + ).toEqual(Array.from({ length: smallTailAnchors.length }, () => 1)); expect(compact).toEqual( createNodeTestShardBundles({ includeReleaseOnlyPluginShards: false, From 92f5f07da175a97090b82192d71effeab2c9753e Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Wed, 12 Aug 2026 16:16:12 +0530 Subject: [PATCH 03/14] fix(browser): keep guarded CDP sockets on validated endpoints [AI] (#114506) * fix(browser): keep guarded CDP sockets on validated endpoints * fix(browser): avoid retrying rate-limited CDP discovery * fix(browser): close Chrome MCP CDP policy gaps * test(browser): match Chrome MCP policy error * test(browser): satisfy transport type and lint checks * fix(browser): narrow guarded CDP transport changes * fix(browser): separate upload filesystem locality * fix(browser): keep strict remote CDP hosts untrusted --- docs/tools/browser.md | 15 + extensions/browser/src/browser/cdp-auth.ts | 45 ++ .../browser/src/browser/cdp-page-session.ts | 3 +- .../src/browser/cdp-reachability-policy.ts | 69 ++- .../browser/src/browser/cdp-websocket.ts | 419 ++++++++++++++++++ .../src/browser/cdp.helpers.internal.test.ts | 253 ++++++++++- .../browser/src/browser/cdp.helpers.test.ts | 199 ++++++++- extensions/browser/src/browser/cdp.helpers.ts | 354 ++------------- extensions/browser/src/browser/cdp.ts | 21 +- .../src/browser/chrome-mcp-contracts.ts | 12 +- .../browser/src/browser/chrome-mcp.test.ts | 21 + .../browser/src/browser/chrome.diagnostics.ts | 39 +- .../browser/src/browser/chrome.graphics.ts | 25 +- .../src/browser/chrome.internal.test.ts | 8 +- .../chrome.loopback-ssrf.integration.test.ts | 8 +- extensions/browser/src/browser/chrome.test.ts | 47 +- extensions/browser/src/browser/chrome.ts | 51 ++- .../browser/src/browser/client.types.ts | 6 + .../src/browser/playwright-core.runtime.ts | 6 + .../src/browser/profile-capabilities.ts | 8 + .../browser/src/browser/pw-ai.e2e.test.ts | 4 +- .../browser/src/browser/pw-session-actions.ts | 4 +- .../src/browser/pw-session-cdp-transport.ts | 138 ++++++ .../src/browser/pw-session-connection.ts | 56 ++- .../browser/pw-session.connections.test.ts | 41 +- ...ssion.create-page.navigation-guard.test.ts | 9 +- .../pw-session.get-page-for-targetid.test.ts | 14 +- .../src/browser/pw-session.mock-setup.ts | 5 +- .../pw-session.pinned-transport.test.ts | 373 ++++++++++++++++ .../pw-session.termination-cdp-ssrf.test.ts | 72 ++- .../src/browser/pw-tools-core.downloads.ts | 29 +- .../pw-tools-core.interactions.content.ts | 56 ++- .../pw-tools-core.interactions.navigation.ts | 1 + ...-core.interactions.set-input-files.test.ts | 245 +++++++++- .../pw-tools-core.upload-paths.test.ts | 7 +- ...-core.waits-next-download-saves-it.test.ts | 4 +- .../agent.act.hooks.current-url-guard.test.ts | 76 +++- .../src/browser/routes/agent.act.hooks.ts | 8 +- .../agent.snapshot.local-managed.test.ts | 22 +- .../routes/agent.snapshot.timeout.test.ts | 8 +- .../src/browser/routes/agent.snapshot.ts | 4 + .../browser/src/browser/routes/basic.ts | 6 +- .../src/browser/routes/permissions.test.ts | 20 +- .../browser/src/browser/routes/permissions.ts | 12 +- .../browser/server-context.availability.ts | 8 +- .../server-context.existing-session.test.ts | 46 ++ ...xt.remote-profile-tab-ops.fallback.test.ts | 2 + .../src/browser/server-context.selection.ts | 9 +- .../src/browser/server-context.tab-ops.ts | 23 +- ...erver-context.tab-selection-lookup.test.ts | 139 ++++++ ...server-context.tab-selection-state.test.ts | 41 -- .../src/browser/ssrf-policy-helpers.ts | 22 + 52 files changed, 2584 insertions(+), 529 deletions(-) create mode 100644 extensions/browser/src/browser/cdp-auth.ts create mode 100644 extensions/browser/src/browser/cdp-websocket.ts create mode 100644 extensions/browser/src/browser/pw-session-cdp-transport.ts create mode 100644 extensions/browser/src/browser/pw-session.pinned-transport.test.ts create mode 100644 extensions/browser/src/browser/server-context.tab-selection-lookup.test.ts diff --git a/docs/tools/browser.md b/docs/tools/browser.md index af74b2a704a7..a3d6b4141d53 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -316,6 +316,21 @@ main model can read the screenshot directly. - Browser navigation and open-tab requests are preflight checked. During the action and bounded post-action grace, guarded Playwright interactions (click, coordinate click, hover, drag, scroll, select, press, type, form fill, and evaluate) intercept policy-denied top-level and subframe document loads before HTTP request bytes, then best-effort re-check the final `http(s)` URL. - Before each fresh OpenClaw-managed Chrome launch, OpenClaw best-effort disables network prediction, suppressing Chromium's observed speculative preconnect for those denied loads. This is defense in depth, not a policy boundary: a browser reused across a control-service restart and other browser backends may not share the hardening. Playwright routing is still not a network firewall and does not intercept redirect hops, a popup's first request, Service Worker traffic, page code that runs after the bounded guard window, or every background/subresource path. Complete egress isolation requires owner-side isolation or a policy-enforcing proxy. - In strict SSRF mode, remote CDP endpoint discovery and `/json/version` probes (`cdpUrl`) are checked too. +- Guarded remote CDP connections now fail closed when the selected driver cannot + keep the approved endpoint bound to the actual socket. Use the regular + `openclaw` driver for Browserless, Browserbase, Notte, or other guarded + remote CDP providers. `existing-session`/Chrome MCP profiles with an explicit + `cdpUrl` or `--browserUrl`/`--wsEndpoint` MCP argument are rejected under the + default strict Browser policy because Chrome MCP cannot carry OpenClaw's + pinned DNS lookup or guarded discovery result across its subprocess boundary. + They remain supported only when private-network Browser access is explicitly + trusted. Otherwise, omit the explicit endpoint and attach Chrome MCP to a + host-local Chrome profile, or switch the profile to the regular driver for + guarded CDP. +- Redirecting CDP discovery to a different authority remains unsupported unless + the active policy explicitly allows that authority change. Revalidating a + returned hostname is not enough; the WebSocket transport must use the endpoint + that passed policy validation. - Gateway/provider `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` environment variables do not automatically proxy the OpenClaw-managed browser. Managed Chrome launches direct by default so provider proxy settings do not weaken browser SSRF checks. - OpenClaw-managed local CDP readiness probes and DevTools WebSocket connections bypass the managed network proxy for the exact launched loopback endpoint, so `openclaw browser start` still works when an operator proxy blocks loopback egress. - To proxy the managed browser itself, pass explicit Chrome proxy flags through `browser.extraArgs`, such as `--proxy-server=...` or `--proxy-pac-url=...`. Strict SSRF mode blocks explicit browser proxy routing unless private-network browser access is intentionally enabled. diff --git a/extensions/browser/src/browser/cdp-auth.ts b/extensions/browser/src/browser/cdp-auth.ts new file mode 100644 index 000000000000..6296e2fe912a --- /dev/null +++ b/extensions/browser/src/browser/cdp-auth.ts @@ -0,0 +1,45 @@ +function decodeUrlUserInfo(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** Merge URL basic-auth credentials into headers without overriding explicit auth. */ +export function getHeadersWithAuth(url: string, headers: Record = {}) { + const mergedHeaders = { ...headers }; + try { + const parsed = new URL(url); + const hasAuthHeader = Object.keys(mergedHeaders).some( + (key) => key.trim().toLowerCase() === "authorization", + ); + if (hasAuthHeader) { + return mergedHeaders; + } + if (parsed.username || parsed.password) { + const username = decodeUrlUserInfo(parsed.username); + const password = decodeUrlUserInfo(parsed.password); + const auth = Buffer.from(`${username}:${password}`).toString("base64"); + return { ...mergedHeaders, Authorization: `Basic ${auth}` }; + } + } catch { + // ignore + } + return mergedHeaders; +} + +/** Remove URL userinfo after callers have converted it to an Authorization header. */ +export function stripCdpUrlCredentials(url: string): string { + try { + const parsed = new URL(url); + if (!parsed.username && !parsed.password) { + return url; + } + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); + } catch { + return url; + } +} diff --git a/extensions/browser/src/browser/cdp-page-session.ts b/extensions/browser/src/browser/cdp-page-session.ts index 0d2348e53350..8760a8fa0124 100644 --- a/extensions/browser/src/browser/cdp-page-session.ts +++ b/extensions/browser/src/browser/cdp-page-session.ts @@ -143,7 +143,7 @@ export async function waitForCdpCommittedNavigationUrl(opts: { signal?: AbortSignal; timeouts?: CdpActionTimeouts; }): Promise { - await assertCdpEndpointAllowed(opts.wsUrl, opts.cdpPolicy, { + const pinned = await assertCdpEndpointAllowed(opts.wsUrl, opts.cdpPolicy, { source: "discovered", configuredUrl: opts.configuredCdpUrl, }); @@ -160,6 +160,7 @@ export async function waitForCdpCommittedNavigationUrl(opts: { commandTimeoutMs: opts.timeouts?.httpTimeoutMs ?? CDP_TARGET_NAVIGATION_RESULT_TIMEOUT_MS, handshakeTimeoutMs: opts.timeouts?.handshakeTimeoutMs, handshakeRetries: 0, + lookup: pinned?.lookup, }, ); } catch { diff --git a/extensions/browser/src/browser/cdp-reachability-policy.ts b/extensions/browser/src/browser/cdp-reachability-policy.ts index 38266d17bf33..54ea8c2a3408 100644 --- a/extensions/browser/src/browser/cdp-reachability-policy.ts +++ b/extensions/browser/src/browser/cdp-reachability-policy.ts @@ -5,10 +5,17 @@ * is stricter, so this module scopes the exception to browser control only. */ import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { matchesHostnameAllowlist, normalizeHostname } from "../sdk-security-runtime.js"; +import { normalizeHostname } from "../sdk-security-runtime.js"; +import { CHROME_MCP_ENDPOINT_FLAGS } from "./chrome-mcp-contracts.js"; import type { ResolvedBrowserProfile } from "./config.js"; +import { BrowserProfileUnavailableError } from "./errors.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; -import { withExactHostnamePolicy } from "./ssrf-policy-helpers.js"; +import { isCdpHostnameTrustedByPolicy, withExactHostnamePolicy } from "./ssrf-policy-helpers.js"; + +// Synthetic exact-host CDP policies must retain the operator's original intent; +// otherwise Chrome MCP cannot distinguish default control-plane scoping from a +// user-authored restriction that genuinely requires pinned transport. +const cdpControlSourcePolicyByScopedPolicy = new WeakMap(); function withCdpControlHostname( profile: ResolvedBrowserProfile, @@ -19,17 +26,41 @@ function withCdpControlHostname( if (!ssrfPolicy || !cdpHost) { return ssrfPolicy; } - const allowedHostnames = (ssrfPolicy.allowedHostnames ?? []) - .map((pattern) => normalizeHostname(pattern)) - .filter((pattern) => pattern && pattern !== "*" && pattern !== "*."); - if ( - requireAllowlistMatch && - allowedHostnames.length > 0 && - !matchesHostnameAllowlist(cdpHost, allowedHostnames) - ) { + if (requireAllowlistMatch && !isCdpHostnameTrustedByPolicy(ssrfPolicy, cdpHost)) { return ssrfPolicy; } - return withExactHostnamePolicy(ssrfPolicy, cdpHost); + const scopedPolicy = withExactHostnamePolicy(ssrfPolicy, cdpHost); + cdpControlSourcePolicyByScopedPolicy.set(scopedPolicy, ssrfPolicy); + return scopedPolicy; +} + +function hasPolicyEntries(values?: string[]): boolean { + return (values ?? []).some((value) => value.trim().length > 0); +} + +function requiresPinnedChromeMcpCdpTransport(cdpPolicy?: SsrFPolicy): boolean { + if (!cdpPolicy) { + return false; + } + const policyIntent = cdpControlSourcePolicyByScopedPolicy.get(cdpPolicy) ?? cdpPolicy; + const hasScopedPolicy = + policyIntent.allowRfc2544BenchmarkRange === true || + policyIntent.allowIpv6UniqueLocalRange === true || + hasPolicyEntries(policyIntent.allowedHostnames) || + hasPolicyEntries(policyIntent.hostnameAllowlist) || + hasPolicyEntries(policyIntent.allowedOrigins); + return !( + !hasScopedPolicy && + (policyIntent.dangerouslyAllowPrivateNetwork === true || + policyIntent.allowPrivateNetwork === true) + ); +} + +function hasChromeMcpEndpointArg(args?: string[]): boolean { + return (args ?? []).some((arg) => { + const [name] = arg.split("=", 1); + return CHROME_MCP_ENDPOINT_FLAGS.has(name ?? arg); + }); } export function resolveCdpReachabilityPolicy( @@ -51,3 +82,19 @@ export function resolveCdpReachabilityPolicy( /** Alias used by callers that treat reachability and control as one CDP policy. */ export const resolveCdpControlPolicy = resolveCdpReachabilityPolicy; + +export function assertChromeMcpCdpTransportAllowed( + profile: ResolvedBrowserProfile, + cdpPolicy?: SsrFPolicy, +): void { + const hasExplicitEndpoint = Boolean(profile.cdpUrl) || hasChromeMcpEndpointArg(profile.mcpArgs); + if (profile.driver !== "existing-session" || !hasExplicitEndpoint) { + return; + } + if (!requiresPinnedChromeMcpCdpTransport(cdpPolicy)) { + return; + } + throw new BrowserProfileUnavailableError( + `Browser profile "${profile.name}" uses Chrome MCP with an explicit CDP endpoint, but the active Browser CDP policy requires OpenClaw to pin the approved endpoint. Chrome MCP cannot carry that pinned transport across its subprocess boundary. Use driver "openclaw" for guarded CDP endpoints, or remove cdpUrl and browserUrl/wsEndpoint mcpArgs from this existing-session profile so Chrome MCP attaches to a host-local Chrome profile.`, + ); +} diff --git a/extensions/browser/src/browser/cdp-websocket.ts b/extensions/browser/src/browser/cdp-websocket.ts new file mode 100644 index 000000000000..a9f382a1368b --- /dev/null +++ b/extensions/browser/src/browser/cdp-websocket.ts @@ -0,0 +1,419 @@ +import type { lookup as dnsLookupCb } from "node:dns"; +import type { ClientRequest } from "node:http"; +import http from "node:http"; +import https from "node:https"; +import net from "node:net"; +import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; +import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; +import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; +import WebSocket from "ws"; +import { getHeadersWithAuth, stripCdpUrlCredentials } from "./cdp-auth.js"; +import { getDirectAgentForCdp, withManagedProxyForCdpUrl } from "./cdp-proxy-bypass.js"; +import { CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; +import { getPlaywrightUserAgent } from "./playwright-core.runtime.js"; +import { normalizeBrowserTimerDelayMs } from "./timer-delay.js"; + +const PLAYWRIGHT_CDP_MAX_PAYLOAD_BYTES = 256 * 1024 * 1024; +const PLAYWRIGHT_CDP_PER_MESSAGE_DEFLATE = { + clientNoContextTakeover: true, + zlibDeflateOptions: { level: 3 }, + zlibInflateOptions: { chunkSize: 10 * 1024 }, + threshold: 10 * 1024, +} as const; +const PLAYWRIGHT_CDP_MAX_REDIRECTS = 10; +type CdpSocketLookup = typeof dnsLookupCb; + +type CdpResponse = { + id: number; + result?: unknown; + error?: { message?: string }; +}; + +type Pending = { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timer?: ReturnType; +}; + +export type CdpSendFn = ( + method: string, + params?: Record, + sessionId?: string, +) => Promise; + +function withDefaultPlaywrightUserAgent(headers: Record): Record { + if (Object.keys(headers).some((key) => key.trim().toLowerCase() === "user-agent")) { + return headers; + } + return { ...headers, "User-Agent": getPlaywrightUserAgent() }; +} + +function cdpWebSocketAuthority(url: string): string { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}`; +} + +function assertSameAuthorityWebSocketRedirect( + originalUrl: string, + redirectedUrl: string, + request: ClientRequest, +): void { + if (cdpWebSocketAuthority(originalUrl) === cdpWebSocketAuthority(redirectedUrl)) { + return; + } + request.destroy(new Error("CDP WebSocket redirect changed authority")); +} + +function defaultPortForWebSocketProtocol(protocol: string): string { + return protocol === "wss:" || protocol === "https:" ? "443" : "80"; +} + +function normalizeAuthorityHostname(hostname: string): string { + return hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase(); +} + +function hostnameFromAgentOptions(options: unknown): string | undefined { + if (options instanceof URL) { + return options.hostname; + } + if (!options || typeof options !== "object") { + return undefined; + } + if ("hostname" in options && typeof options.hostname === "string") { + return options.hostname; + } + const rawHost = "host" in options && typeof options.host === "string" ? options.host : undefined; + if (!rawHost) { + return undefined; + } + if (rawHost.startsWith("[")) { + const end = rawHost.indexOf("]"); + return end > 0 ? rawHost.slice(1, end) : rawHost; + } + if ((rawHost.match(/:/g) ?? []).length > 1) { + return rawHost; + } + return rawHost.includes(":") ? rawHost.split(":")[0] : rawHost; +} + +function portFromAgentOptions(options: unknown, fallbackProtocol: string): string { + if (options instanceof URL) { + return options.port || defaultPortForWebSocketProtocol(options.protocol); + } + if (!options || typeof options !== "object") { + return defaultPortForWebSocketProtocol(fallbackProtocol); + } + if ("port" in options) { + const rawPort = options.port; + if (typeof rawPort === "string" || typeof rawPort === "number") { + return String(rawPort); + } + } + return defaultPortForWebSocketProtocol(fallbackProtocol); +} + +function assertPinnedAgentAuthority(originalUrl: string, options: unknown): void { + const parsed = new URL(originalUrl); + const expectedHostname = normalizeAuthorityHostname(parsed.hostname); + const expectedPort = parsed.port || defaultPortForWebSocketProtocol(parsed.protocol); + const requestedHostname = hostnameFromAgentOptions(options); + const requestedPort = portFromAgentOptions(options, parsed.protocol); + if ( + !requestedHostname || + normalizeAuthorityHostname(requestedHostname) !== expectedHostname || + requestedPort !== expectedPort + ) { + throw new Error("CDP WebSocket redirect changed authority"); + } +} + +function createPinnedAgentForCdpUrl( + url: string, + lookup: CdpSocketLookup, +): http.Agent | https.Agent { + const parsed = new URL(url); + const options = { keepAlive: false, lookup }; + const agent = + parsed.protocol === "https:" || parsed.protocol === "wss:" + ? new https.Agent(options) + : new http.Agent(options); + const createConnection = agent.createConnection.bind(agent); + agent.createConnection = ((connectionOptions, callback) => { + try { + assertPinnedAgentAuthority(url, connectionOptions); + } catch (err) { + const socket = new net.Socket(); + const error = err instanceof Error ? err : new Error(String(err)); + process.nextTick(() => { + callback?.(error, socket); + socket.destroy(error); + }); + return socket; + } + return createConnection(connectionOptions, callback); + }) as typeof agent.createConnection; + return agent; +} + +function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) { + let nextId = 1; + const pending = new Map(); + const commandTimeoutMs = + typeof opts?.commandTimeoutMs === "number" && Number.isFinite(opts.commandTimeoutMs) + ? normalizeBrowserTimerDelayMs(opts.commandTimeoutMs) + : undefined; + + const clearPendingTimer = (p: Pending) => { + if (p.timer !== undefined) { + clearTimeout(p.timer); + } + }; + + const send: CdpSendFn = ( + method: string, + params?: Record, + sessionId?: string, + ) => { + const id = nextId++; + const msg = { id, method, params, sessionId }; + return new Promise((resolve, reject) => { + if (ws.readyState !== WebSocket.OPEN) { + reject(new Error("CDP socket closed")); + return; + } + const entry: Pending = { resolve, reject }; + if (commandTimeoutMs !== undefined) { + // A timed-out command closes the whole socket so pending calls do not + // hang on a connection whose CDP command stream is no longer reliable. + entry.timer = setTimeout(() => { + closeWithError(new Error(`CDP command ${method} timed out after ${commandTimeoutMs}ms`)); + }, commandTimeoutMs); + } + pending.set(id, entry); + try { + ws.send(JSON.stringify(msg)); + } catch (err) { + pending.delete(id); + clearPendingTimer(entry); + reject(toStringifiedError(err)); + } + }); + }; + + const closeWithError = (err: Error) => { + for (const [, p] of pending) { + clearPendingTimer(p); + p.reject(err); + } + pending.clear(); + ws.close(); + }; + + ws.on("error", (err) => { + // The `err instanceof Error` guard is defensive: Node's `ws` library + // always emits Error instances on the 'error' event. Triggering the + // non-Error branch would require synthetically emitting on the socket, + // which the library treats as an unhandled error and hangs the test. + /* c8 ignore next */ + closeWithError(toStringifiedError(err)); + }); + + ws.on("message", (data) => { + try { + const parsed = JSON.parse(rawDataToString(data)) as CdpResponse; + if (typeof parsed.id !== "number") { + return; + } + const p = pending.get(parsed.id); + if (!p) { + return; + } + pending.delete(parsed.id); + clearPendingTimer(p); + if (parsed.error?.message) { + p.reject(new Error(parsed.error.message)); + return; + } + p.resolve(parsed.result); + } catch { + // ignore + } + }); + + ws.on("close", () => { + closeWithError(new Error("CDP socket closed")); + }); + + return { send, closeWithError }; +} + +/** Open a CDP WebSocket with URL basic-auth and proxy bypass handling. */ +export function openCdpWebSocket( + wsUrl: string, + opts?: { + headers?: Record; + handshakeTimeoutMs?: number; + lookup?: CdpSocketLookup; + playwrightTransportDefaults?: boolean; + }, +): WebSocket { + const headersWithAuth = getHeadersWithAuth(wsUrl, opts?.headers ?? {}); + const headers = opts?.playwrightTransportDefaults + ? withDefaultPlaywrightUserAgent(headersWithAuth) + : headersWithAuth; + const handshakeTimeoutMs = + typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs) + ? Math.max(1, Math.floor(opts.handshakeTimeoutMs)) + : CDP_WS_HANDSHAKE_TIMEOUT_MS; + const connectionUrl = stripCdpUrlCredentials(wsUrl); + const agent = opts?.lookup + ? createPinnedAgentForCdpUrl(connectionUrl, opts.lookup) + : getDirectAgentForCdp(connectionUrl); + return withManagedProxyForCdpUrl(connectionUrl, () => { + const ws = new WebSocket(connectionUrl, { + handshakeTimeout: handshakeTimeoutMs, + ...(opts?.playwrightTransportDefaults + ? { + followRedirects: true, + maxRedirects: PLAYWRIGHT_CDP_MAX_REDIRECTS, + maxPayload: PLAYWRIGHT_CDP_MAX_PAYLOAD_BYTES, + perMessageDeflate: PLAYWRIGHT_CDP_PER_MESSAGE_DEFLATE, + } + : {}), + ...(Object.keys(headers).length ? { headers } : {}), + ...(agent ? { agent } : {}), + }); + if (opts?.playwrightTransportDefaults) { + ws.on("redirect", (redirectedUrl, request) => { + assertSameAuthorityWebSocketRedirect(connectionUrl, redirectedUrl, request); + }); + } + return ws; + }); +} + +type CdpSocketOptions = { + headers?: Record; + handshakeTimeoutMs?: number; + commandTimeoutMs?: number; + handshakeRetries?: number; + handshakeRetryDelayMs?: number; + handshakeMaxRetryDelayMs?: number; + lookup?: CdpSocketLookup; + signal?: AbortSignal; +}; + +function normalizeRetryCount(value: number | undefined, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + return Math.max(0, Math.floor(value)); +} + +function computeHandshakeRetryDelayMs(attempt: number, opts?: CdpSocketOptions): number { + const baseDelayMs = + typeof opts?.handshakeRetryDelayMs === "number" && Number.isFinite(opts.handshakeRetryDelayMs) + ? Math.max(1, Math.floor(opts.handshakeRetryDelayMs)) + : 200; + const maxDelayMs = + typeof opts?.handshakeMaxRetryDelayMs === "number" && + Number.isFinite(opts.handshakeMaxRetryDelayMs) + ? Math.max(baseDelayMs, Math.floor(opts.handshakeMaxRetryDelayMs)) + : 3000; + const raw = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1)); + // Jitter keeps several browser sessions from retrying handshakes in lockstep + // after a shared Chrome or network hiccup. + const jitterScale = 0.8 + Math.random() * 0.4; + return Math.max(1, Math.floor(raw * jitterScale)); +} + +function shouldRetryCdpHandshakeError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false; + } + const msg = err.message.toLowerCase(); + if (!msg) { + return false; + } + if (msg.includes("rate limit")) { + return false; + } + const statusMatch = msg.match(/(?:unexpected server response|response):\s*(\d{3})/); + if (statusMatch?.[1]) { + return Number(statusMatch[1]) >= 500; + } + return ( + msg.includes("cdp socket closed") || + msg.includes("econnreset") || + msg.includes("econnrefused") || + msg.includes("econnaborted") || + msg.includes("ehostunreach") || + msg.includes("enetunreach") || + msg.includes("etimedout") || + msg.includes("socket hang up") || + msg.includes("websocket error") || + msg.includes("closed before") + ); +} + +export async function withCdpSocket( + wsUrl: string, + fn: (send: CdpSendFn) => Promise, + opts?: CdpSocketOptions, +): Promise { + const maxHandshakeRetries = normalizeRetryCount(opts?.handshakeRetries, 2); + for (let attempt = 0; ; attempt += 1) { + opts?.signal?.throwIfAborted(); + const ws = openCdpWebSocket(wsUrl, opts); + const { send, closeWithError } = createCdpSender(ws, opts); + + const openPromise = new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", (err) => reject(err)); + ws.once("close", () => reject(new Error("CDP socket closed"))); + }); + // A stalled HTTP upgrade must release its TCP socket on cancellation. + const abortHandshake = () => ws.terminate(); + opts?.signal?.addEventListener("abort", abortHandshake, { once: true }); + if (opts?.signal?.aborted) { + abortHandshake(); + } + + try { + await openPromise; + } catch (err) { + // openPromise is only rejected via `ws.once('error', err => reject(err))` + // or the close event's `new Error(...)`; the former always carries an + // Error from Node's `ws` library, the latter is already an Error. The + // non-Error wrap is defensive and structurally unreachable. + /* c8 ignore next */ + closeWithError(toStringifiedError(err)); + // Cancellation on the final attempt must not become a handshake error. + opts?.signal?.throwIfAborted(); + if (attempt >= maxHandshakeRetries || !shouldRetryCdpHandshakeError(err)) { + throw err; + } + // Retry only handshake failures. Once CDP commands are flowing, callers + // own retry semantics because commands may already have side effects. + // Cancelled route requests must not keep retrying Chrome handshakes. + await sleepWithAbort(computeHandshakeRetryDelayMs(attempt + 1, opts), opts?.signal).catch( + (error: unknown) => { + opts?.signal?.throwIfAborted(); + throw error; + }, + ); + continue; + } finally { + opts?.signal?.removeEventListener("abort", abortHandshake); + } + + try { + return await fn(send); + } catch (err) { + closeWithError(toStringifiedError(err)); + throw err; + } finally { + ws.close(); + } + } +} diff --git a/extensions/browser/src/browser/cdp.helpers.internal.test.ts b/extensions/browser/src/browser/cdp.helpers.internal.test.ts index 0596a9a6cb2d..b3398faa17c0 100644 --- a/extensions/browser/src/browser/cdp.helpers.internal.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.internal.test.ts @@ -1,5 +1,5 @@ // Browser tests cover cdp.helpers.internal plugin behavior. -import { createServer } from "node:http"; +import http, { createServer } from "node:http"; import type { Socket } from "node:net"; import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -115,7 +115,10 @@ describe("cdp.helpers internal", () => { assertCdpEndpointAllowed("http://93.184.216.34:443/cdp", { allowPrivateNetwork: true, }), - ).resolves.toBeUndefined(); + ).resolves.toMatchObject({ + addresses: ["93.184.216.34"], + hostname: "93.184.216.34", + }); }); }); @@ -256,6 +259,176 @@ describe("cdp.helpers internal", () => { }); describe("createCdpSender (via withCdpSocket)", () => { + function pinnedLookupMock() { + return vi.fn((hostname: string, options: unknown, callback?: unknown) => { + const cb = typeof options === "function" ? options : callback; + if (typeof cb === "function") { + if (typeof options === "object" && options !== null && "all" in options) { + cb(null, [{ address: "127.0.0.1", family: 4 }]); + return undefined as never; + } + cb(null, "127.0.0.1", 4); + } + return undefined as never; + }); + } + + it("uses a per-connection agent for pinned WebSocket handshakes", async () => { + const server = await startWsServer(); + wss = server.wss; + const lookup = pinnedLookupMock(); + const globalCreateConnection = vi + .spyOn(http.globalAgent, "createConnection") + .mockImplementation(() => { + throw new Error("global agent must not be used for pinned CDP sockets"); + }); + server.wss.on("connection", (socket) => { + socket.close(); + }); + + try { + const ws = openCdpWebSocket(`ws://cdp-pinned.test:${server.port}/devtools/browser/TEST`, { + lookup: lookup as never, + }); + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + + expect(lookup).toHaveBeenCalled(); + expect(globalCreateConnection).not.toHaveBeenCalled(); + ws.close(); + } finally { + globalCreateConnection.mockRestore(); + } + }); + + it.each([ + { playwrightTransportDefaults: false, expectedMaxPayload: 100 * 1024 * 1024 }, + { playwrightTransportDefaults: true, expectedMaxPayload: 256 * 1024 * 1024 }, + ])( + "uses the expected payload limit when Playwright transport defaults are $playwrightTransportDefaults", + async ({ playwrightTransportDefaults, expectedMaxPayload }) => { + const server = await startWsServer(); + wss = server.wss; + const ws = openCdpWebSocket(server.url, { playwrightTransportDefaults }); + + try { + await new Promise((resolve, reject) => { + ws.once("open", resolve); + ws.once("error", reject); + }); + const receiver = Reflect.get(ws, "_receiver") as object | undefined; + const maxPayload = receiver ? Reflect.get(receiver, "_maxPayload") : undefined; + + expect(maxPayload).toBe(expectedMaxPayload); + } finally { + ws.close(); + } + }, + ); + + it("preserves IPv6 hostnames in pinned WebSocket agent checks", async () => { + const server = new WebSocketServer({ port: 0, host: "::1" }); + try { + await new Promise((resolve, reject) => { + server.once("listening", () => resolve()); + server.once("error", reject); + }); + } catch { + return; + } + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("IPv6 test server did not expose a TCP port"); + } + server.on("connection", (socket) => { + socket.close(); + }); + const lookup = vi.fn((_hostname: string, options: unknown, callback?: unknown) => { + const cb = typeof options === "function" ? options : callback; + if (typeof cb === "function") { + if (typeof options === "object" && options !== null && "all" in options) { + cb(null, [{ address: "::1", family: 6 }]); + return undefined as never; + } + cb(null, "::1", 6); + } + return undefined as never; + }); + + try { + const ws = openCdpWebSocket(`ws://[::1]:${address.port}/devtools/browser/TEST`, { + lookup: lookup as never, + }); + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + + ws.close(); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + + it("blocks pinned WebSocket redirects before connecting to a new authority", async () => { + const redirectServer = http.createServer(); + const targetServer = http.createServer(); + let targetConnections = 0; + targetServer.on("connection", () => { + targetConnections += 1; + }); + await new Promise((resolve) => { + targetServer.listen(0, "127.0.0.1", () => resolve()); + }); + const targetAddress = targetServer.address(); + if (!targetAddress || typeof targetAddress === "string") { + throw new Error("target server did not expose a TCP port"); + } + redirectServer.on("upgrade", (_request, socket) => { + socket.write( + `HTTP/1.1 302 Found\r\nLocation: ws://127.0.0.1:${targetAddress.port}/devtools/browser/redirected\r\nConnection: close\r\n\r\n`, + ); + socket.destroy(); + }); + await new Promise((resolve) => { + redirectServer.listen(0, "127.0.0.1", () => resolve()); + }); + const redirectAddress = redirectServer.address(); + if (!redirectAddress || typeof redirectAddress === "string") { + throw new Error("redirect server did not expose a TCP port"); + } + const ws = openCdpWebSocket( + `ws://cdp-pinned.test:${redirectAddress.port}/devtools/browser/start`, + { + lookup: pinnedLookupMock() as never, + playwrightTransportDefaults: true, + }, + ); + + try { + const error = await new Promise((resolve, reject) => { + ws.once("open", () => reject(new Error("redirect unexpectedly opened"))); + ws.once("error", (err) => resolve(err instanceof Error ? err : new Error(String(err)))); + }); + expect(error.message).toContain("CDP WebSocket redirect changed authority"); + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + expect(targetConnections).toBe(0); + } finally { + ws.close(); + await new Promise((resolve) => { + redirectServer.close(() => { + targetServer.close(() => resolve()); + }); + }); + } + }); + it("ignores messages with a non-numeric id", async () => { const server = await startWsServer(); wss = server.wss; @@ -732,4 +905,80 @@ describe("openCdpWebSocket option handling", () => { ws.once("error", () => {}); ws.close(); }); + + it("uses a pinned lookup for websocket connections", async () => { + const server = await startWsServer(); + try { + const url = server.url.replace("127.0.0.1", "cdp.test.local"); + const lookup = vi.fn((hostname: string, options: unknown, callback?: unknown) => { + const cb = typeof options === "function" ? options : callback; + expect(hostname).toBe("cdp.test.local"); + if (typeof cb === "function") { + const wantsAll = + typeof options === "object" && options !== null && (options as { all?: boolean }).all; + if (wantsAll) { + cb(null, [{ address: "127.0.0.1", family: 4 }]); + return; + } + cb(null, "127.0.0.1", 4); + } + }); + + const ws = openCdpWebSocket(url, { + handshakeTimeoutMs: 500, + lookup: lookup as never, + }); + + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + + expect(lookup).toHaveBeenCalled(); + ws.close(); + } finally { + await new Promise((resolve) => { + server.wss.close(() => resolve()); + }); + } + }); + + it("forwards pinned lookup options through withCdpSocket", async () => { + const server = await startWsServer(); + server.wss.on("connection", (socket) => { + socket.on("message", (data) => { + const msg = JSON.parse(rawDataToString(data)) as { id?: number }; + socket.send(JSON.stringify({ id: msg.id, result: { ok: true } })); + }); + }); + try { + const url = server.url.replace("127.0.0.1", "cdp.test.local"); + const lookup = vi.fn((hostname: string, options: unknown, callback?: unknown) => { + const cb = typeof options === "function" ? options : callback; + expect(hostname).toBe("cdp.test.local"); + if (typeof cb === "function") { + const wantsAll = + typeof options === "object" && options !== null && (options as { all?: boolean }).all; + if (wantsAll) { + cb(null, [{ address: "127.0.0.1", family: 4 }]); + return; + } + cb(null, "127.0.0.1", 4); + } + }); + + const result = await withCdpSocket(url, async (send) => await send("Browser.getVersion"), { + handshakeTimeoutMs: 500, + handshakeRetries: 0, + lookup: lookup as never, + }); + + expect(result).toStrictEqual({ ok: true }); + expect(lookup).toHaveBeenCalled(); + } finally { + await new Promise((resolve) => { + server.wss.close(() => resolve()); + }); + } + }); }); diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index 447a9e028db0..ebd2401951ec 100644 --- a/extensions/browser/src/browser/cdp.helpers.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.test.ts @@ -1,7 +1,10 @@ // Browser tests cover cdp.helpers plugin behavior. import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js"; +import { + assertChromeMcpCdpTransportAllowed, + resolveCdpReachabilityPolicy, +} from "./cdp-reachability-policy.js"; import { resolveCdpReachabilityTimeouts } from "./cdp-timeouts.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { assertBrowserNavigationAllowed } from "./navigation-guard.js"; @@ -87,7 +90,9 @@ describe("cdp helpers", () => { assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", { dangerouslyAllowPrivateNetwork: false, }), - ).resolves.toBeUndefined(); + ).resolves.toEqual( + expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }), + ); }); it("adds exact loopback hosts to the CDP hostname allowlist", async () => { @@ -96,7 +101,9 @@ describe("cdp helpers", () => { dangerouslyAllowPrivateNetwork: false, allowedHostnames: ["*.corp.example"], }), - ).resolves.toBeUndefined(); + ).resolves.toEqual( + expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }), + ); }); it("still enforces hostname allowlist for non-loopback CDP endpoints", async () => { @@ -131,7 +138,9 @@ describe("cdp helpers", () => { source: "discovered", configuredUrl: "http://127.0.0.1:9222", }), - ).resolves.toBeUndefined(); + ).resolves.toEqual( + expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }), + ); }); it("preserves broad private authority permission through exact-host scoping", async () => { @@ -143,7 +152,45 @@ describe("cdp helpers", () => { source: "discovered", configuredUrl: "http://127.0.0.1:9222", }), - ).resolves.toBeUndefined(); + ).resolves.toEqual( + expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }), + ); + }); + + it("does not turn a strict remote CDP hostname into a private-network grant", async () => { + const policy = { dangerouslyAllowPrivateNetwork: false }; + const scoped = scopeCdpPolicyToConfiguredEndpoint("https://browser.example:9222", policy); + const { resolvePinnedHostnameWithPolicy } = + await vi.importActual("../infra/net/ssrf.js"); + + expect(scoped).toBe(policy); + await expect( + resolvePinnedHostnameWithPolicy("browser.example", { + policy: scoped, + lookupFn: async () => [{ address: "10.0.0.8", family: 4 }], + }), + ).rejects.toThrow(/private\/internal\/special-use ip address/i); + }); + + it("keeps explicit remote CDP hostname grants available", async () => { + const policy = { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["browser.example"], + }; + const scoped = scopeCdpPolicyToConfiguredEndpoint("https://browser.example:9222", policy); + const { resolvePinnedHostnameWithPolicy } = + await vi.importActual("../infra/net/ssrf.js"); + + expect(scoped).toEqual({ + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["browser.example"], + }); + await expect( + resolvePinnedHostnameWithPolicy("browser.example", { + policy: scoped, + lookupFn: async () => [{ address: "10.0.0.8", family: 4 }], + }), + ).resolves.toEqual(expect.objectContaining({ addresses: ["10.0.0.8"] })); }); it("blocks a discovered endpoint on another port in strict SSRF mode", async () => { @@ -161,7 +208,9 @@ describe("cdp helpers", () => { assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", { allowedHostnames: ["api.example.com"], }), - ).resolves.toBeUndefined(); + ).resolves.toEqual( + expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }), + ); }); it("releases guarded CDP fetches for bodyless requests", async () => { @@ -344,6 +393,27 @@ describe("cdp helpers", () => { expect(release).toHaveBeenCalledTimes(1); }); + it("passes the default remote CDP policy object into guarded discovery fetches", async () => { + const release = vi.fn(async () => {}); + const policy = {}; + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { + ok: true, + status: 200, + }, + release, + }); + + await expect( + fetchOk("https://browserless.example:9222/json/version", 250, undefined, policy), + ).resolves.toBeUndefined(); + + const request = requireGuardedFetchRequest(); + expect(request?.url).toBe("https://browserless.example:9222/json/version"); + expect(request?.policy).toBe(policy); + expect(release).toHaveBeenCalledOnce(); + }); + it("replaces navigation grants with the exact loopback CDP host", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValueOnce({ @@ -463,13 +533,11 @@ describe("resolveCdpReachabilityTimeouts", () => { }); describe("CDP reachability policy", () => { - it("allows the selected remote profile CDP host without widening browser navigation policy", async () => { + it("keeps the default remote CDP policy strict without widening browser navigation policy", async () => { const browserPolicy = {}; const profile = createProfile({}); - expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toEqual({ - allowedHostnames: ["172.29.128.1"], - }); + expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toBe(browserPolicy); expect(browserPolicy).toStrictEqual({}); await expect( assertBrowserNavigationAllowed({ @@ -583,4 +651,115 @@ describe("CDP reachability policy", () => { allowedHostnames: ["127.0.0.1"], }); }); + + it.each([ + ["cdpUrl", { cdpUrl: "http://127.0.0.1:9222" }], + ["--browserUrl", { cdpUrl: "", mcpArgs: ["--browserUrl", "http://127.0.0.1:9222"] }], + ["-u", { cdpUrl: "", mcpArgs: ["-u", "http://127.0.0.1:9222"] }], + ["--u", { cdpUrl: "", mcpArgs: ["--u", "http://127.0.0.1:9222"] }], + ["--wsEndpoint", { cdpUrl: "", mcpArgs: ["--wsEndpoint=ws://127.0.0.1:9222"] }], + ["-w", { cdpUrl: "", mcpArgs: ["-w", "ws://127.0.0.1:9222"] }], + ["--w", { cdpUrl: "", mcpArgs: ["--w=ws://127.0.0.1:9222"] }], + ])("rejects Chrome MCP explicit %s endpoints under the default policy", (_source, endpoint) => { + const profile = createProfile({ + driver: "existing-session", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + ...endpoint, + }); + + expect(() => assertChromeMcpCdpTransportAllowed(profile, {})).toThrow( + /cannot carry that pinned transport/i, + ); + }); + + it("rejects Chrome MCP explicit CDP URL profiles after default CDP scoping", () => { + const profile = createProfile({ + driver: "existing-session", + cdpUrl: "http://127.0.0.1:9222", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + }); + const cdpPolicy = resolveCdpReachabilityPolicy(profile, {}); + + expect(cdpPolicy).toEqual({ allowedHostnames: ["127.0.0.1"] }); + expect(() => assertChromeMcpCdpTransportAllowed(profile, cdpPolicy)).toThrow( + /cannot carry that pinned transport/i, + ); + }); + + it("preserves Chrome MCP explicit CDP URL profiles when private CDP endpoints are trusted", () => { + const profile = createProfile({ + driver: "existing-session", + cdpUrl: "http://127.0.0.1:9222", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + }); + + expect(() => + assertChromeMcpCdpTransportAllowed(profile, { dangerouslyAllowPrivateNetwork: true }), + ).not.toThrow(); + }); + + it("rejects Chrome MCP explicit CDP URL profiles under explicit strict policy", () => { + const profile = createProfile({ + driver: "existing-session", + cdpUrl: "http://127.0.0.1:9222", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + }); + + expect(() => + assertChromeMcpCdpTransportAllowed(profile, { dangerouslyAllowPrivateNetwork: false }), + ).toThrow(/cannot carry that pinned transport/i); + }); + + it("rejects Chrome MCP explicit CDP URL profiles after explicit strict CDP scoping", () => { + const profile = createProfile({ + driver: "existing-session", + cdpUrl: "http://127.0.0.1:9222", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + }); + const cdpPolicy = resolveCdpReachabilityPolicy(profile, { + dangerouslyAllowPrivateNetwork: false, + }); + + expect(cdpPolicy).toEqual({ + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["127.0.0.1"], + }); + expect(() => assertChromeMcpCdpTransportAllowed(profile, cdpPolicy)).toThrow( + /cannot carry that pinned transport/i, + ); + }); + + it("rejects Chrome MCP explicit CDP URL profiles under endpoint allowlists", () => { + const profile = createProfile({ + driver: "existing-session", + cdpUrl: "http://127.0.0.1:9222", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + }); + + expect(() => + assertChromeMcpCdpTransportAllowed(profile, { allowedHostnames: ["127.0.0.1"] }), + ).toThrow(/cannot carry that pinned transport/i); + }); + + it("does not let trusted private CDP policy override endpoint allowlists for Chrome MCP", () => { + const profile = createProfile({ + driver: "existing-session", + cdpUrl: "http://127.0.0.1:9222", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + }); + + expect(() => + assertChromeMcpCdpTransportAllowed(profile, { + dangerouslyAllowPrivateNetwork: true, + allowedHostnames: ["127.0.0.1"], + }), + ).toThrow(/cannot carry that pinned transport/i); + }); }); diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index 5b624822f100..f992ba81a908 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -1,16 +1,13 @@ /** * Chrome DevTools Protocol URL, fetch, and socket helpers. + * * Handles CDP URL normalization, SSRF-guarded HTTP discovery, credential * redaction/headers, and request/response correlation over WebSocket. */ import { createHash } from "node:crypto"; import { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-config"; -import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; -import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; -import WebSocket from "ws"; import { isLoopbackHost } from "../gateway/net.js"; import { SsrFBlockedError, @@ -18,24 +15,27 @@ import { resolvePinnedHostnameWithPolicy, } from "../infra/net/ssrf.js"; import { redactToolPayloadText } from "../logging/redact.js"; -import { - getDirectAgentForCdp, - withManagedProxyForCdpUrl, - withNoProxyForCdpUrl, -} from "./cdp-proxy-bypass.js"; -import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; +import { getHeadersWithAuth, stripCdpUrlCredentials } from "./cdp-auth.js"; +import { withManagedProxyForCdpUrl, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; +import { CDP_HTTP_REQUEST_TIMEOUT_MS } from "./cdp-timeouts.js"; +import { withCdpSocket } from "./cdp-websocket.js"; import type { BrowserTabOwnership } from "./client.types.js"; import { BrowserCdpEndpointBlockedError } from "./errors.js"; import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js"; import { allowsDiscoveredCdpAuthorityChange, + isCdpHostnameTrustedByPolicy, withExactHostnamePolicy, } from "./ssrf-policy-helpers.js"; import { normalizeBrowserTimerDelayMs } from "./timer-delay.js"; const CDP_URL_IN_TEXT_RE = /\b(?:https?|wss?):\/\/[^\s"'<>`]+/gi; -export { isLoopbackHost, parseBrowserHttpUrl, redactCdpUrl }; +export { isLoopbackHost }; +export { getHeadersWithAuth, stripCdpUrlCredentials } from "./cdp-auth.js"; +export { openCdpWebSocket, withCdpSocket } from "./cdp-websocket.js"; +export type { CdpSendFn } from "./cdp-websocket.js"; +export { parseBrowserHttpUrl, redactCdpUrl }; /** * Returns true when the URL uses a WebSocket protocol (ws: or wss:). @@ -82,7 +82,7 @@ export function isDirectCdpWebSocketEndpoint(url: string): boolean { /* c8 ignore stop */ } -/** Restricts discovered CDP endpoints to the configured control-plane host. */ +/** Restrict a trusted CDP endpoint to its configured control-plane host. */ export function scopeCdpPolicyToConfiguredEndpoint( cdpUrl: string, ssrfPolicy?: SsrFPolicy, @@ -90,12 +90,18 @@ export function scopeCdpPolicyToConfiguredEndpoint( if (!ssrfPolicy) { return undefined; } - return withExactHostnamePolicy(ssrfPolicy, new URL(cdpUrl).hostname); + const hostname = new URL(cdpUrl).hostname; + // Never turn an otherwise strict remote hostname into a private-network grant. + if (!isLoopbackHost(hostname) && !isCdpHostnameTrustedByPolicy(ssrfPolicy, hostname)) { + return ssrfPolicy; + } + return withExactHostnamePolicy(ssrfPolicy, hostname); } type CdpEndpointSource = | { source?: "configured" } | { source: "discovered"; configuredUrl: string }; +type CdpEndpointPin = Awaited>; function cdpEndpointAuthority(url: string): string { const parsed = new URL(url); @@ -124,12 +130,12 @@ export async function assertCdpEndpointAllowed( cdpUrl: string, ssrfPolicy?: SsrFPolicy, options?: CdpEndpointSource, -): Promise { +): Promise { if (options?.source === "discovered") { assertDiscoveredCdpEndpointMatchesConfigured(cdpUrl, options.configuredUrl, ssrfPolicy); } if (!ssrfPolicy) { - return; + return undefined; } const parsed = new URL(cdpUrl); if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) { @@ -143,7 +149,7 @@ export async function assertCdpEndpointAllowed( isLoopbackHost(parsed.hostname) && options?.source !== "discovered" ? withExactHostnamePolicy(ssrfPolicy, parsed.hostname) : ssrfPolicy; - await resolvePinnedHostnameWithPolicy(parsed.hostname, { + return await resolvePinnedHostnameWithPolicy(parsed.hostname, { policy, }); } catch (error) { @@ -151,70 +157,6 @@ export async function assertCdpEndpointAllowed( } } -type CdpResponse = { - id: number; - result?: unknown; - error?: { message?: string }; -}; - -type Pending = { - resolve: (value: unknown) => void; - reject: (err: Error) => void; - timer?: ReturnType; -}; - -export type CdpSendFn = ( - method: string, - params?: Record, - sessionId?: string, -) => Promise; - -function decodeUrlUserInfo(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - -/** Merge URL basic-auth credentials into headers without overriding explicit auth. */ -export function getHeadersWithAuth(url: string, headers: Record = {}) { - const mergedHeaders = { ...headers }; - try { - const parsed = new URL(url); - const hasAuthHeader = Object.keys(mergedHeaders).some( - (key) => key.trim().toLowerCase() === "authorization", - ); - if (hasAuthHeader) { - return mergedHeaders; - } - if (parsed.username || parsed.password) { - const username = decodeUrlUserInfo(parsed.username); - const password = decodeUrlUserInfo(parsed.password); - const auth = Buffer.from(`${username}:${password}`).toString("base64"); - return { ...mergedHeaders, Authorization: `Basic ${auth}` }; - } - } catch { - // ignore - } - return mergedHeaders; -} - -/** Remove URL userinfo after callers have converted it to an Authorization header. */ -export function stripCdpUrlCredentials(url: string): string { - try { - const parsed = new URL(url); - if (!parsed.username && !parsed.password) { - return url; - } - parsed.username = ""; - parsed.password = ""; - return parsed.toString(); - } catch { - return url; - } -} - /** Redact CDP URLs and credential-shaped text before dependency errors leave Browser. */ export function redactCdpErrorText(text: string): string { const redactedUrls = text.replace(CDP_URL_IN_TEXT_RE, (match) => redactCdpUrl(match) ?? match); @@ -321,9 +263,11 @@ type CdpTabOwnershipParams = { ssrfPolicy?: SsrFPolicy; }; -async function resolveCdpTabOwnershipContext( - params: CdpTabOwnershipParams, -): Promise<{ ownership: BrowserTabOwnership; browserWebSocketUrl?: string }> { +async function resolveCdpTabOwnershipContext(params: CdpTabOwnershipParams): Promise<{ + ownership: BrowserTabOwnership; + browserWebSocketUrl?: string; + browserWebSocketLookup?: CdpEndpointPin["lookup"]; +}> { params.signal?.throwIfAborted(); const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(params.cdpUrl); let version: { webSocketDebuggerUrl?: unknown }; @@ -352,7 +296,7 @@ async function resolveCdpTabOwnershipContext( return { ownership: { status: "non-durable", reason: "browser-identity-unavailable" } }; } try { - await assertCdpEndpointAllowed(browserWebSocketUrl, params.ssrfPolicy, { + const pinned = await assertCdpEndpointAllowed(browserWebSocketUrl, params.ssrfPolicy, { source: "discovered", configuredUrl: params.cdpUrl, }); @@ -367,6 +311,7 @@ async function resolveCdpTabOwnershipContext( }), }, browserWebSocketUrl, + browserWebSocketLookup: pinned?.lookup, }; } catch (error) { if (error instanceof BrowserCdpEndpointBlockedError) { @@ -470,6 +415,7 @@ export async function closeTrackedCdpTarget( commandTimeoutMs: params.timeoutMs, handshakeTimeoutMs: params.timeoutMs, handshakeRetries: 0, + lookup: resolved.browserWebSocketLookup, }, ); } catch (error) { @@ -488,98 +434,6 @@ type CdpFetchResult = { release: () => Promise; }; -function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) { - let nextId = 1; - const pending = new Map(); - const commandTimeoutMs = - typeof opts?.commandTimeoutMs === "number" && Number.isFinite(opts.commandTimeoutMs) - ? normalizeBrowserTimerDelayMs(opts.commandTimeoutMs) - : undefined; - - const clearPendingTimer = (p: Pending) => { - if (p.timer !== undefined) { - clearTimeout(p.timer); - } - }; - - const send: CdpSendFn = ( - method: string, - params?: Record, - sessionId?: string, - ) => { - const id = nextId++; - const msg = { id, method, params, sessionId }; - return new Promise((resolve, reject) => { - if (ws.readyState !== WebSocket.OPEN) { - reject(new Error("CDP socket closed")); - return; - } - const entry: Pending = { resolve, reject }; - if (commandTimeoutMs !== undefined) { - // A timed-out command closes the whole socket so pending calls do not - // hang on a connection whose CDP command stream is no longer reliable. - entry.timer = setTimeout(() => { - closeWithError(new Error(`CDP command ${method} timed out after ${commandTimeoutMs}ms`)); - }, commandTimeoutMs); - } - pending.set(id, entry); - try { - ws.send(JSON.stringify(msg)); - } catch (err) { - pending.delete(id); - clearPendingTimer(entry); - reject(toStringifiedError(err)); - } - }); - }; - - const closeWithError = (err: Error) => { - for (const [, p] of pending) { - clearPendingTimer(p); - p.reject(err); - } - pending.clear(); - ws.close(); - }; - - ws.on("error", (err) => { - // The `err instanceof Error` guard is defensive: Node's `ws` library - // always emits Error instances on the 'error' event. Triggering the - // non-Error branch would require synthetically emitting on the socket, - // which the library treats as an unhandled error and hangs the test. - /* c8 ignore next */ - closeWithError(toStringifiedError(err)); - }); - - ws.on("message", (data) => { - try { - const parsed = JSON.parse(rawDataToString(data)) as CdpResponse; - if (typeof parsed.id !== "number") { - return; - } - const p = pending.get(parsed.id); - if (!p) { - return; - } - pending.delete(parsed.id); - clearPendingTimer(p); - if (parsed.error?.message) { - p.reject(new Error(parsed.error.message)); - return; - } - p.resolve(parsed.result); - } catch { - // ignore - } - }); - - ws.on("close", () => { - closeWithError(new Error("CDP socket closed")); - }); - - return { send, closeWithError }; -} - /** Fetch and parse a CDP JSON endpoint through the configured SSRF guard. */ export async function fetchJson( url: string, @@ -679,151 +533,3 @@ export async function fetchOk( const { release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy); await release(); } - -/** Open a CDP WebSocket with URL basic-auth and proxy bypass handling. */ -export function openCdpWebSocket( - wsUrl: string, - opts?: { headers?: Record; handshakeTimeoutMs?: number }, -): WebSocket { - const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {}); - const handshakeTimeoutMs = - typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs) - ? Math.max(1, Math.floor(opts.handshakeTimeoutMs)) - : CDP_WS_HANDSHAKE_TIMEOUT_MS; - const connectionUrl = stripCdpUrlCredentials(wsUrl); - const agent = getDirectAgentForCdp(connectionUrl); - return withManagedProxyForCdpUrl( - connectionUrl, - () => - new WebSocket(connectionUrl, { - handshakeTimeout: handshakeTimeoutMs, - ...(Object.keys(headers).length ? { headers } : {}), - ...(agent ? { agent } : {}), - }), - ); -} - -type CdpSocketOptions = { - headers?: Record; - handshakeTimeoutMs?: number; - commandTimeoutMs?: number; - handshakeRetries?: number; - handshakeRetryDelayMs?: number; - handshakeMaxRetryDelayMs?: number; - signal?: AbortSignal; -}; - -function normalizeRetryCount(value: number | undefined, fallback: number): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return fallback; - } - return Math.max(0, Math.floor(value)); -} - -function computeHandshakeRetryDelayMs(attempt: number, opts?: CdpSocketOptions): number { - const baseDelayMs = - typeof opts?.handshakeRetryDelayMs === "number" && Number.isFinite(opts.handshakeRetryDelayMs) - ? Math.max(1, Math.floor(opts.handshakeRetryDelayMs)) - : 200; - const maxDelayMs = - typeof opts?.handshakeMaxRetryDelayMs === "number" && - Number.isFinite(opts.handshakeMaxRetryDelayMs) - ? Math.max(baseDelayMs, Math.floor(opts.handshakeMaxRetryDelayMs)) - : 3000; - const raw = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1)); - // Jitter keeps several browser sessions from retrying handshakes in lockstep - // after a shared Chrome or network hiccup. - const jitterScale = 0.8 + Math.random() * 0.4; - return Math.max(1, Math.floor(raw * jitterScale)); -} - -function shouldRetryCdpHandshakeError(err: unknown): boolean { - if (!(err instanceof Error)) { - return false; - } - const msg = err.message.toLowerCase(); - if (!msg) { - return false; - } - if (msg.includes("rate limit")) { - return false; - } - const statusMatch = msg.match(/(?:unexpected server response|response):\s*(\d{3})/); - if (statusMatch?.[1]) { - return Number(statusMatch[1]) >= 500; - } - return ( - msg.includes("cdp socket closed") || - msg.includes("econnreset") || - msg.includes("econnrefused") || - msg.includes("econnaborted") || - msg.includes("ehostunreach") || - msg.includes("enetunreach") || - msg.includes("etimedout") || - msg.includes("socket hang up") || - msg.includes("websocket error") || - msg.includes("closed before") - ); -} - -export async function withCdpSocket( - wsUrl: string, - fn: (send: CdpSendFn) => Promise, - opts?: CdpSocketOptions, -): Promise { - const maxHandshakeRetries = normalizeRetryCount(opts?.handshakeRetries, 2); - for (let attempt = 0; ; attempt += 1) { - opts?.signal?.throwIfAborted(); - const ws = openCdpWebSocket(wsUrl, opts); - const { send, closeWithError } = createCdpSender(ws, opts); - - const openPromise = new Promise((resolve, reject) => { - ws.once("open", () => resolve()); - ws.once("error", (err) => reject(err)); - ws.once("close", () => reject(new Error("CDP socket closed"))); - }); - // A stalled HTTP upgrade must release its TCP socket on cancellation. - const abortHandshake = () => ws.terminate(); - opts?.signal?.addEventListener("abort", abortHandshake, { once: true }); - if (opts?.signal?.aborted) { - abortHandshake(); - } - - try { - await openPromise; - } catch (err) { - // openPromise is only rejected via `ws.once('error', err => reject(err))` - // or the close event's `new Error(...)`; the former always carries an - // Error from Node's `ws` library, the latter is already an Error. The - // non-Error wrap is defensive and structurally unreachable. - /* c8 ignore next */ - closeWithError(toStringifiedError(err)); - // Cancellation on the final attempt must not become a handshake error. - opts?.signal?.throwIfAborted(); - if (attempt >= maxHandshakeRetries || !shouldRetryCdpHandshakeError(err)) { - throw err; - } - // Retry only handshake failures. Once CDP commands are flowing, callers - // own retry semantics because commands may already have side effects. - // Cancelled route requests must not keep retrying Chrome handshakes. - await sleepWithAbort(computeHandshakeRetryDelayMs(attempt + 1, opts), opts?.signal).catch( - (error: unknown) => { - opts?.signal?.throwIfAborted(); - throw error; - }, - ); - continue; - } finally { - opts?.signal?.removeEventListener("abort", abortHandshake); - } - - try { - return await fn(send); - } catch (err) { - closeWithError(toStringifiedError(err)); - throw err; - } finally { - ws.close(); - } - } -} diff --git a/extensions/browser/src/browser/cdp.ts b/extensions/browser/src/browser/cdp.ts index bdef8c5be6be..1f245e303139 100644 --- a/extensions/browser/src/browser/cdp.ts +++ b/extensions/browser/src/browser/cdp.ts @@ -1,3 +1,4 @@ +import type { lookup as dnsLookupCb } from "node:dns"; /** * Chrome DevTools Protocol browser operations. * @@ -40,12 +41,13 @@ export { type CdpActionTimeouts, waitForCdpCommittedNavigationUrl } from "./cdp- /** Read the current main-frame loader identity from a page-level CDP target. */ export async function getMainFrameDocumentIdentityViaCdp(opts: { wsUrl: string; + lookup?: typeof dnsLookupCb; timeoutMs?: number; }): Promise { return await withCdpSocket( opts.wsUrl, async (send) => await readCdpMainFrameDocumentIdentity(send), - { commandTimeoutMs: opts.timeoutMs ?? 5000 }, + { commandTimeoutMs: opts.timeoutMs ?? 5000, ...(opts.lookup ? { lookup: opts.lookup } : {}) }, ); } @@ -92,6 +94,7 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string { /** Capture a PNG or JPEG screenshot through CDP, optionally full-page. */ export async function captureScreenshot(opts: { wsUrl: string; + lookup?: typeof dnsLookupCb; fullPage?: boolean; format?: "png" | "jpeg"; quality?: number; // jpeg only (0..100) @@ -202,7 +205,7 @@ export async function captureScreenshot(opts: { } } }, - { commandTimeoutMs: opts.timeoutMs }, + { commandTimeoutMs: opts.timeoutMs, lookup: opts.lookup }, ); } @@ -221,7 +224,7 @@ export async function createTargetViaCdp(opts: { url: opts.url, ...withBrowserNavigationPolicy(opts.ssrfPolicy), }); - await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy); + const configuredCdpPin = await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy); const cdpControlPolicy = scopeCdpPolicyToConfiguredEndpoint(opts.cdpUrl, opts.ssrfPolicy); let wsUrl: string; @@ -274,7 +277,10 @@ export async function createTargetViaCdp(opts: { candidateWsUrl === opts.cdpUrl ? ({ source: "configured" } as const) : ({ source: "discovered", configuredUrl: opts.cdpUrl } as const); - await assertCdpEndpointAllowed(candidateWsUrl, cdpControlPolicy, endpointSource); + const candidateCdpPin = + candidateWsUrl === opts.cdpUrl + ? configuredCdpPin + : await assertCdpEndpointAllowed(candidateWsUrl, cdpControlPolicy, endpointSource); opts.signal?.throwIfAborted(); return await withCdpSocket( candidateWsUrl, @@ -299,6 +305,7 @@ export async function createTargetViaCdp(opts: { { commandTimeoutMs: opts.timeouts?.httpTimeoutMs ?? 5000, handshakeTimeoutMs: opts.timeouts?.handshakeTimeoutMs, + lookup: candidateCdpPin?.lookup, }, ); } catch (err) { @@ -424,6 +431,7 @@ export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnaps /** Capture an accessibility-tree snapshot through CDP. */ export async function snapshotAria(opts: { wsUrl: string; + lookup?: typeof dnsLookupCb; limit?: number; timeoutMs?: number; }): Promise<{ nodes: AriaSnapshotNode[] }> { @@ -438,7 +446,7 @@ export async function snapshotAria(opts: { const nodes = Array.isArray(res?.nodes) ? res.nodes : []; return { nodes: formatAriaSnapshot(nodes, limit) }; }, - { commandTimeoutMs: opts.timeoutMs ?? 5000 }, + { commandTimeoutMs: opts.timeoutMs ?? 5000, lookup: opts.lookup }, ); } @@ -917,6 +925,7 @@ async function buildCdpRoleSnapshot(params: { /** Build a role/name text snapshot with stable refs from CDP DOM and AX data. */ export async function snapshotRoleViaCdp(opts: { wsUrl: string; + lookup?: typeof dnsLookupCb; options?: CdpRoleSnapshotOptions; urls?: boolean; timeoutMs?: number; @@ -955,7 +964,7 @@ export async function snapshotRoleViaCdp(opts: { ? { ...finalized, truncated: true } : finalized; }, - { commandTimeoutMs: opts.timeoutMs ?? 5000 }, + { commandTimeoutMs: opts.timeoutMs ?? 5000, lookup: opts.lookup }, ); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/browser/src/browser/chrome-mcp-contracts.ts b/extensions/browser/src/browser/chrome-mcp-contracts.ts index 21860a0634bc..b1c9ae519886 100644 --- a/extensions/browser/src/browser/chrome-mcp-contracts.ts +++ b/extensions/browser/src/browser/chrome-mcp-contracts.ts @@ -169,14 +169,20 @@ export const DEFAULT_CHROME_MCP_FEATURE_ARGS = [ "--experimental-page-id-routing", ]; export const CHROME_MCP_USAGE_STATISTICS_FLAG_RE = /^--(?:no-)?usage-?statistics(?:=.*)?$/i; -export const CHROME_MCP_CONNECTION_FLAGS = new Set([ - "--autoConnect", - "--auto-connect", +export const CHROME_MCP_ENDPOINT_FLAGS = new Set([ "--browserUrl", "--browser-url", + "-u", + "--u", "--wsEndpoint", "--ws-endpoint", "-w", + "--w", +]); +export const CHROME_MCP_CONNECTION_FLAGS = new Set([ + "--autoConnect", + "--auto-connect", + ...CHROME_MCP_ENDPOINT_FLAGS, ]); export const CHROME_MCP_USER_DATA_DIR_FLAGS = new Set(["--userDataDir", "--user-data-dir"]); export const CHROME_MCP_NEW_PAGE_TIMEOUT_MS = 5_000; diff --git a/extensions/browser/src/browser/chrome-mcp.test.ts b/extensions/browser/src/browser/chrome-mcp.test.ts index ea9fba037185..c0debc1139c0 100644 --- a/extensions/browser/src/browser/chrome-mcp.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.test.ts @@ -6,6 +6,7 @@ import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { createOpenClawTestState } from "openclaw/plugin-sdk/test-state"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildChromeMcpArgsFromOptions, normalizeChromeMcpOptions } from "./chrome-mcp-options.js"; import { ChromeMcpDocumentUnavailableError, clickChromeMcpCoords, @@ -243,6 +244,26 @@ describe("chrome MCP page parsing", () => { vi.unstubAllEnvs(); }); + it("passes HTTP CDP endpoints to Chrome MCP as browserUrl discovery endpoints", () => { + const args = buildChromeMcpArgsFromOptions( + normalizeChromeMcpOptions({ cdpUrl: "http://127.0.0.1:9222" }), + ); + + expect(args).toContain("--browserUrl"); + expect(args).toContain("http://127.0.0.1:9222"); + expect(args).not.toContain("--wsEndpoint"); + }); + + it("passes direct WebSocket CDP endpoints to Chrome MCP as wsEndpoint attachments", () => { + const args = buildChromeMcpArgsFromOptions( + normalizeChromeMcpOptions({ cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc" }), + ); + + expect(args).toContain("--wsEndpoint"); + expect(args).toContain("ws://127.0.0.1:9222/devtools/browser/abc"); + expect(args).not.toContain("--browserUrl"); + }); + it("keeps document-bound evaluations on one pinned target and raw snapshot uid", async () => { const session = createPageSession({ pid: 139, diff --git a/extensions/browser/src/browser/chrome.diagnostics.ts b/extensions/browser/src/browser/chrome.diagnostics.ts index 654fa25fd533..56d77a3025ec 100644 --- a/extensions/browser/src/browser/chrome.diagnostics.ts +++ b/extensions/browser/src/browser/chrome.diagnostics.ts @@ -20,11 +20,12 @@ import { openCdpWebSocket, redactCdpUrl, scopeCdpPolicyToConfiguredEndpoint, - stripCdpUrlCredentials, } from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import { BrowserCdpEndpointBlockedError } from "./errors.js"; +type ChromeCdpEndpointPin = NonNullable>>; + /** Machine-readable failure codes for Chrome CDP diagnostics. */ type ChromeCdpDiagnosticCode = | "ssrf_blocked" @@ -127,7 +128,7 @@ async function readChromeVersion( } } -/** Preserve authenticated providers that expose only Playwright's trailing-slash route. */ +/** Preserve providers that expose only Playwright's trailing-slash route. */ export async function readChromeVersionWithCredentialFallback( cdpUrl: string, timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, @@ -135,10 +136,7 @@ export async function readChromeVersionWithCredentialFallback( ): Promise { try { const primaryVersion = await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy); - if ( - normalizeOptionalString(primaryVersion.webSocketDebuggerUrl) || - stripCdpUrlCredentials(cdpUrl) === cdpUrl - ) { + if (normalizeOptionalString(primaryVersion.webSocketDebuggerUrl)) { return primaryVersion; } try { @@ -147,9 +145,6 @@ export async function readChromeVersionWithCredentialFallback( return primaryVersion; } } catch (primaryError) { - if (stripCdpUrlCredentials(cdpUrl) === cdpUrl) { - throw primaryError; - } try { return await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy, "/json/version/"); } catch { @@ -191,10 +186,12 @@ function chromeVersionFromCdpResult(result: unknown): ChromeVersion | undefined async function diagnoseCdpHealthCommand( wsUrl: string, timeoutMs = CHROME_WS_READY_TIMEOUT_MS, + lookup?: ChromeCdpEndpointPin["lookup"], ): Promise { return await new Promise((resolve) => { const ws = openCdpWebSocket(wsUrl, { handshakeTimeoutMs: timeoutMs, + lookup, }); let settled = false; let opened = false; @@ -343,9 +340,14 @@ async function diagnoseCdpWebSocketEndpoint(params: { wsUrl: string; startedAt: number; handshakeTimeoutMs: number; + lookup?: ChromeCdpEndpointPin["lookup"]; version?: ChromeVersion; }): Promise { - const health = await diagnoseCdpHealthCommand(params.wsUrl, params.handshakeTimeoutMs); + const health = await diagnoseCdpHealthCommand( + params.wsUrl, + params.handshakeTimeoutMs, + params.lookup, + ); if (!health.ok) { return failureDiagnostic({ cdpUrl: params.cdpUrl, @@ -373,8 +375,9 @@ export async function diagnoseChromeCdp( ssrfPolicy?: SsrFPolicy, ): Promise { const startedAt = Date.now(); + let configuredPin: ChromeCdpEndpointPin | undefined; try { - await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); + configuredPin = await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); } catch (err) { return failureDiagnostic({ cdpUrl, @@ -391,6 +394,7 @@ export async function diagnoseChromeCdp( wsUrl: cdpUrl, startedAt, handshakeTimeoutMs, + lookup: configuredPin?.lookup, }); } @@ -411,6 +415,7 @@ export async function diagnoseChromeCdp( wsUrl: cdpUrl, startedAt, handshakeTimeoutMs, + lookup: configuredPin?.lookup, }); } const classified = classifyChromeVersionError(err); @@ -430,6 +435,7 @@ export async function diagnoseChromeCdp( wsUrl: cdpUrl, startedAt, handshakeTimeoutMs, + lookup: configuredPin?.lookup, version, }); } @@ -441,8 +447,9 @@ export async function diagnoseChromeCdp( }); } const wsUrl = normalizeCdpWsUrl(wsUrlRaw, discoveryUrl); + let discoveredPin: ChromeCdpEndpointPin | undefined; try { - await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, { + discoveredPin = await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, { source: "discovered", configuredUrl: cdpUrl, }); @@ -456,10 +463,14 @@ export async function diagnoseChromeCdp( }); } - const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs); + const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs, discoveredPin?.lookup); if (!health.ok) { if (isWebSocketUrl(cdpUrl) && wsUrl !== cdpUrl) { - const directHealth = await diagnoseCdpHealthCommand(cdpUrl, handshakeTimeoutMs); + const directHealth = await diagnoseCdpHealthCommand( + cdpUrl, + handshakeTimeoutMs, + configuredPin?.lookup, + ); if (directHealth.ok) { return { ok: true, diff --git a/extensions/browser/src/browser/chrome.graphics.ts b/extensions/browser/src/browser/chrome.graphics.ts index 0814b6d86abc..e0bbd7a93390 100644 --- a/extensions/browser/src/browser/chrome.graphics.ts +++ b/extensions/browser/src/browser/chrome.graphics.ts @@ -13,7 +13,7 @@ import { */ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { redactCdpErrorText, withCdpSocket } from "./cdp.helpers.js"; -import { getChromeWebSocketUrl, type RunningChrome } from "./chrome.js"; +import { getChromeWebSocketEndpoint, type RunningChrome } from "./chrome.js"; import type { BrowserGraphicsAcceleration, BrowserGraphicsDevice, @@ -174,19 +174,28 @@ export async function inspectChromeGraphicsDiagnostics( ): Promise { const observedAt = Date.now(); try { - const wsUrl = await getChromeWebSocketUrl(cdpUrl, options.httpTimeoutMs, options.ssrfPolicy); - if (!wsUrl) { + const endpoint = await getChromeWebSocketEndpoint( + cdpUrl, + options.httpTimeoutMs, + options.ssrfPolicy, + ); + if (!endpoint) { return { status: "unavailable", observedAt, reason: "browser-level CDP WebSocket was not advertised", }; } - const result = await withCdpSocket(wsUrl, async (send) => await send("SystemInfo.getInfo"), { - handshakeTimeoutMs: options.handshakeTimeoutMs, - commandTimeoutMs: options.commandTimeoutMs, - handshakeRetries: 0, - }); + const result = await withCdpSocket( + endpoint.url, + async (send) => await send("SystemInfo.getInfo"), + { + handshakeTimeoutMs: options.handshakeTimeoutMs, + commandTimeoutMs: options.commandTimeoutMs, + handshakeRetries: 0, + lookup: endpoint.lookup, + }, + ); return normalizeChromeGraphicsInfo(result, observedAt); } catch (error) { return { diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index 8bc028deeeb5..4c8070677a7c 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -60,7 +60,7 @@ vi.mock("./cdp-timeouts.js", async () => { import { CHROME_STDERR_HINT_MAX_CHARS } from "./cdp-timeouts.js"; import { - getChromeWebSocketUrl, + getChromeWebSocketEndpoint, isChromeCdpReady, isChromeReachable, launchOpenClawChrome, @@ -72,6 +72,12 @@ import { BROWSER_ERROR_REASONS, BrowserProfileUnavailableError } from "./errors. const CHROME_TEST_WS_MAX_PAYLOAD_BYTES = 1024 * 1024; +async function getChromeWebSocketUrl( + ...args: Parameters +): Promise { + return (await getChromeWebSocketEndpoint(...args))?.url ?? null; +} + /** * Covers the parts of chrome.ts that the mainline chrome.test.ts does * not exercise: launchOpenClawChrome (with child_process.spawn mocked), diff --git a/extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts b/extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts index 3ace79918e7e..91a088694d05 100644 --- a/extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts +++ b/extensions/browser/src/browser/chrome.loopback-ssrf.integration.test.ts @@ -2,7 +2,7 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; -import { getChromeWebSocketUrl, isChromeReachable } from "./chrome.js"; +import { getChromeWebSocketEndpoint, isChromeReachable } from "./chrome.js"; type RunningServer = { server: Server; @@ -62,8 +62,8 @@ describe("chrome loopback SSRF integration", () => { it("returns the loopback websocket URL under strict default SSRF policy", async () => { const { baseUrl } = await startLoopbackCdpServer(); - await expect(getChromeWebSocketUrl(baseUrl, 500, {})).resolves.toMatch( - /\/devtools\/browser\/TEST$/, - ); + await expect( + getChromeWebSocketEndpoint(baseUrl, 500, {}).then((endpoint) => endpoint?.url ?? null), + ).resolves.toMatch(/\/devtools\/browser\/TEST$/); }); }); diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index 26f57cbb6329..dbb3a561c5a8 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -13,7 +13,7 @@ import { resolveGoogleChromeExecutableForPlatform, } from "./chrome.executables.js"; import { - getChromeWebSocketUrl, + getChromeWebSocketEndpoint, isChromeCdpOwnedByPid, isChromeCdpReady, isChromeReachable, @@ -52,6 +52,12 @@ function jsonResponse(payload: unknown, status = 200): Response { }); } +async function getChromeWebSocketUrl( + ...args: Parameters +): Promise { + return (await getChromeWebSocketEndpoint(...args))?.url ?? null; +} + async function withMockChromeCdpServer(params: { wsPath: string; onConnection?: (wss: WebSocketServer) => void; @@ -289,6 +295,45 @@ describe("browser chrome helpers", () => { } }); + it("keeps trailing-slash discovery inside the guarded fetch path for HTTP endpoints", async () => { + const requests: string[] = []; + const server = createServer((req, res) => { + requests.push(req.url ?? ""); + if (req.url === "/json/version/") { + const addr = server.address() as AddressInfo; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + webSocketDebuggerUrl: `ws://127.0.0.1:${addr.port}/devtools/browser/trailing`, + }), + ); + return; + } + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => resolve()); + server.once("error", reject); + }); + + try { + const addr = server.address() as AddressInfo; + await expect( + getChromeWebSocketUrl(`http://127.0.0.1:${addr.port}`, 1000, { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["127.0.0.1"], + }), + ).resolves.toBe(`ws://127.0.0.1:${addr.port}/devtools/browser/trailing`); + expect(requests).toEqual(["/json/version", "/json/version/"]); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + it("reports cdpReady only when Browser.getVersion command succeeds", async () => { await withMockChromeCdpServer({ wsPath: "/devtools/browser/health", diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index b762871a210c..2931fe91c363 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -849,9 +849,20 @@ function buildOpenClawChromeLaunchArgs(params: { return args; } -async function canOpenWebSocket(url: string, timeoutMs: number): Promise { +type ChromeCdpEndpointPin = NonNullable>>; + +export type ChromeWebSocketEndpoint = { + url: string; + lookup?: ChromeCdpEndpointPin["lookup"]; +}; + +async function canOpenWebSocket( + url: string, + timeoutMs: number, + lookup?: ChromeCdpEndpointPin["lookup"], +): Promise { return new Promise((resolve) => { - const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs }); + const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs, lookup }); ws.once("open", () => { ws.close(); resolve(true); @@ -868,10 +879,10 @@ export async function isChromeReachable( ssrfPolicy?: SsrFPolicy, ): Promise { try { - await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); + const configuredPin = await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); if (isDirectCdpWebSocketEndpoint(cdpUrl)) { // Handshake-ready direct WS endpoint — probe via WS handshake. - return await canOpenWebSocket(cdpUrl, timeoutMs); + return await canOpenWebSocket(cdpUrl, timeoutMs, configuredPin?.lookup); } // Either an http(s) discovery URL or a bare ws/wss root. Try // /json/version discovery first. For bare ws/wss URLs, fall back to a @@ -886,7 +897,7 @@ export async function isChromeReachable( return true; } if (isWebSocketUrl(cdpUrl)) { - return await canOpenWebSocket(cdpUrl, timeoutMs); + return await canOpenWebSocket(cdpUrl, timeoutMs, configuredPin?.lookup); } return false; } catch { @@ -906,18 +917,18 @@ async function fetchChromeVersion( } } -/** Resolve a usable Chrome DevTools WebSocket URL from a CDP endpoint. */ -export async function getChromeWebSocketUrl( +/** Resolve a usable Chrome DevTools WebSocket endpoint from a CDP endpoint. */ +export async function getChromeWebSocketEndpoint( cdpUrl: string, timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, ssrfPolicy?: SsrFPolicy, -): Promise { - await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); +): Promise { + const configuredPin = await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); const cdpControlPolicy = scopeCdpPolicyToConfiguredEndpoint(cdpUrl, ssrfPolicy); if (isDirectCdpWebSocketEndpoint(cdpUrl)) { // Handshake-ready direct WebSocket endpoint — the cdpUrl is already // the WebSocket URL. - return cdpUrl; + return { url: cdpUrl, lookup: configuredPin?.lookup }; } // Either an http(s) endpoint or a bare ws/wss root; discover the // actual WebSocket URL via /json/version. Normalise the scheme so @@ -934,16 +945,16 @@ export async function getChromeWebSocketUrl( // The SSRF check on cdpUrl was already performed at the start of this // function, so we can return it directly. if (isWebSocketUrl(cdpUrl)) { - return cdpUrl; + return { url: cdpUrl, lookup: configuredPin?.lookup }; } return null; } const normalizedWsUrl = normalizeCdpWsUrl(wsUrl, discoveryUrl); - await assertCdpEndpointAllowed(normalizedWsUrl, cdpControlPolicy, { + const discoveredPin = await assertCdpEndpointAllowed(normalizedWsUrl, cdpControlPolicy, { source: "discovered", configuredUrl: cdpUrl, }); - return normalizedWsUrl; + return { url: normalizedWsUrl, lookup: discoveredPin?.lookup }; } /** Return true when a Chrome CDP endpoint has a healthy WebSocket command path. */ @@ -1355,13 +1366,13 @@ export async function isChromeCdpOwnedByPid( ssrfPolicy?: SsrFPolicy, ): Promise { try { - const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy); - if (!wsUrl) { + const endpoint = await getChromeWebSocketEndpoint(cdpUrl, timeoutMs, ssrfPolicy); + if (!endpoint) { return false; } let owned = false; await withCdpSocket( - wsUrl, + endpoint.url, async (send) => { owned = cdpProcessListOwnsBrowser(await send("SystemInfo.getProcessInfo"), pid); }, @@ -1369,6 +1380,7 @@ export async function isChromeCdpOwnedByPid( commandTimeoutMs: timeoutMs, handshakeRetries: 0, handshakeTimeoutMs: timeoutMs, + lookup: endpoint.lookup, }, ); return owned; @@ -1387,15 +1399,15 @@ async function requestGracefulChromeClose( ); let commandSent = false; try { - const wsUrl = await getChromeWebSocketUrl( + const endpoint = await getChromeWebSocketEndpoint( cdpUrlForPort(running.cdpPort), Math.min(commandTimeoutMs, CHROME_STOP_PROBE_TIMEOUT_MS), ); - if (!wsUrl) { + if (!endpoint) { return false; } await withCdpSocket( - wsUrl, + endpoint.url, async (send) => { // The fixed port can be rebound while this handle remains retained. // Never ask a replacement browser to close on behalf of the old child. @@ -1410,6 +1422,7 @@ async function requestGracefulChromeClose( commandTimeoutMs, handshakeTimeoutMs: commandTimeoutMs, handshakeRetries: 0, + lookup: endpoint.lookup, }, ); return commandSent; diff --git a/extensions/browser/src/browser/client.types.ts b/extensions/browser/src/browser/client.types.ts index 659fee3001a8..24c448860668 100644 --- a/extensions/browser/src/browser/client.types.ts +++ b/extensions/browser/src/browser/client.types.ts @@ -3,6 +3,10 @@ * * Shared by the browser control client, CLI, and Browser agent tool. */ +import type { lookup as dnsLookupCb } from "node:dns"; + +type BrowserCdpLookup = typeof dnsLookupCb; + /** Browser transport backing the selected profile. */ export type BrowserTransport = "cdp" | "chrome-mcp" | "extension"; type BrowserHeadlessSource = @@ -126,6 +130,8 @@ export type BrowserTab = { title: string; url: string; wsUrl?: string; + /** Internal CDP lookup pin paired with wsUrl; omitted from model-facing summaries. */ + wsLookup?: BrowserCdpLookup; type?: string; }; diff --git a/extensions/browser/src/browser/playwright-core.runtime.ts b/extensions/browser/src/browser/playwright-core.runtime.ts index 4b932c9b46fe..c01c29fa3cdb 100644 --- a/extensions/browser/src/browser/playwright-core.runtime.ts +++ b/extensions/browser/src/browser/playwright-core.runtime.ts @@ -8,6 +8,12 @@ import { createRequire } from "node:module"; import type * as PlaywrightCore from "playwright-core"; const require = createRequire(import.meta.url); +const playwrightCoreBundle = require("playwright-core/lib/coreBundle") as { + getUserAgent: () => string; +}; /** Runtime playwright-core module instance. */ export const playwrightCore = require("playwright-core") as typeof PlaywrightCore; + +/** Dependency-owned User-Agent used by Playwright's native CDP WebSocket transport. */ +export const getPlaywrightUserAgent = playwrightCoreBundle.getUserAgent; diff --git a/extensions/browser/src/browser/profile-capabilities.ts b/extensions/browser/src/browser/profile-capabilities.ts index 428518bc6ed2..b93c4dc2630f 100644 --- a/extensions/browser/src/browser/profile-capabilities.ts +++ b/extensions/browser/src/browser/profile-capabilities.ts @@ -15,6 +15,8 @@ type BrowserProfileMode = type BrowserProfileCapabilities = { mode: BrowserProfileMode; isRemote: boolean; + /** Browser process reads paths from the same filesystem as OpenClaw. */ + browserFilesystemLocal: boolean; /** Profile uses the Chrome DevTools MCP server (existing-session driver). */ usesChromeMcp: boolean; usesPersistentPlaywright: boolean; @@ -32,6 +34,7 @@ export function getBrowserProfileCapabilities( return { mode: "local-existing-session", isRemote: false, + browserFilesystemLocal: false, usesChromeMcp: true, usesPersistentPlaywright: false, supportsPerTabWs: false, @@ -48,6 +51,7 @@ export function getBrowserProfileCapabilities( return { mode: "local-extension", isRemote: false, + browserFilesystemLocal: true, usesChromeMcp: false, usesPersistentPlaywright: true, supportsPerTabWs: false, @@ -61,6 +65,7 @@ export function getBrowserProfileCapabilities( return { mode: "remote-cdp", isRemote: true, + browserFilesystemLocal: false, usesChromeMcp: false, usesPersistentPlaywright: true, supportsPerTabWs: false, @@ -73,6 +78,9 @@ export function getBrowserProfileCapabilities( return { mode: "local-managed", isRemote: false, + // A loopback attach-only endpoint can terminate in Docker or a tunnel. + // Only an OpenClaw-owned browser is known to share this filesystem. + browserFilesystemLocal: !profile.attachOnly, usesChromeMcp: false, usesPersistentPlaywright: false, supportsPerTabWs: true, diff --git a/extensions/browser/src/browser/pw-ai.e2e.test.ts b/extensions/browser/src/browser/pw-ai.e2e.test.ts index 1627d76e4009..711cfc381250 100644 --- a/extensions/browser/src/browser/pw-ai.e2e.test.ts +++ b/extensions/browser/src/browser/pw-ai.e2e.test.ts @@ -1,6 +1,6 @@ // Browser tests cover pw ai plugin behavior. import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { connectOverCdpMock, getChromeWebSocketUrlMock } from "./pw-session.mock-setup.js"; +import { connectOverCdpMock, getChromeWebSocketEndpointMock } from "./pw-session.mock-setup.js"; type FakeSession = { send: ReturnType; @@ -57,7 +57,7 @@ let clickViaPlaywright: typeof import("./pw-tools-core.interactions.js").clickVi let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection; beforeAll(async () => { - getChromeWebSocketUrlMock.mockResolvedValue(null); + getChromeWebSocketEndpointMock.mockResolvedValue(null); ({ snapshotAiViaPlaywright } = await import("./pw-tools-core.snapshot.js")); ({ clickViaPlaywright } = await import("./pw-tools-core.interactions.js")); ({ closePlaywrightBrowserConnection } = await import("./pw-session.js")); diff --git a/extensions/browser/src/browser/pw-session-actions.ts b/extensions/browser/src/browser/pw-session-actions.ts index 58e89b5e10e1..ce4aa490644b 100644 --- a/extensions/browser/src/browser/pw-session-actions.ts +++ b/extensions/browser/src/browser/pw-session-actions.ts @@ -201,7 +201,7 @@ async function tryTerminateExecutionViaCdp(opts: { return; } const wsUrl = normalizeCdpWsUrl(wsUrlRaw, cdpHttpBase); - await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, { + const wsPin = await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, { source: "discovered", configuredUrl: opts.cdpUrl, }); @@ -245,7 +245,7 @@ async function tryTerminateExecutionViaCdp(opts: { // Best-effort; ignore } }, - { handshakeTimeoutMs: 2000 }, + { handshakeTimeoutMs: 2000, ...(wsPin?.lookup ? { lookup: wsPin.lookup } : {}) }, ).catch(() => {}); } diff --git a/extensions/browser/src/browser/pw-session-cdp-transport.ts b/extensions/browser/src/browser/pw-session-cdp-transport.ts new file mode 100644 index 000000000000..f5e9f1828049 --- /dev/null +++ b/extensions/browser/src/browser/pw-session-cdp-transport.ts @@ -0,0 +1,138 @@ +import type { lookup as dnsLookupCb } from "node:dns"; +import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; +import type { Browser, ConnectOverCDPTransport } from "playwright-core"; +import WebSocket from "ws"; +import { formatErrorMessage } from "../infra/errors.js"; +import { openCdpWebSocket } from "./cdp.helpers.js"; +import { playwrightCore } from "./playwright-core.runtime.js"; + +const { chromium } = playwrightCore; +type CdpSocketLookup = typeof dnsLookupCb; + +export async function connectOverCdpPinnedTransport( + connectionUrl: string, + opts: { + timeout: number; + headers: Record; + lookup: CdpSocketLookup; + }, +): Promise { + const ws = openCdpWebSocket(connectionUrl, { + headers: opts.headers, + handshakeTimeoutMs: opts.timeout, + lookup: opts.lookup, + playwrightTransportDefaults: true, + }); + try { + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + ws.once("close", () => reject(new Error("CDP socket closed"))); + }); + let onMessage: ((message: object) => void) | undefined; + let onClose: ((reason?: string) => void) | undefined; + const pendingMessages: object[] = []; + let pendingCloseReason: string | undefined; + let transportClosed = false; + let transportCloseScheduled = false; + const notifyTransportClosed = (reason: string) => { + if (transportClosed) { + return; + } + transportClosed = true; + if (onClose) { + onClose(reason); + return; + } + pendingCloseReason = reason; + }; + const scheduleTransportClosed = (reason: string) => { + if (transportClosed || transportCloseScheduled) { + return; + } + transportCloseScheduled = true; + setImmediate(() => { + transportCloseScheduled = false; + notifyTransportClosed(reason); + }); + }; + const closeTransportSocket = (reason = "CDP socket closed") => { + notifyTransportClosed(reason); + ws.close(); + const terminateTimer = setTimeout(() => { + if (ws.readyState !== WebSocket.CLOSED) { + ws.terminate(); + } + }, 100); + terminateTimer.unref?.(); + }; + const scheduleMessage = (message: object) => { + setImmediate(() => { + if (transportClosed) { + return; + } + if (!onMessage) { + pendingMessages.push(message); + return; + } + try { + onMessage(message); + } catch (error) { + closeTransportSocket(formatErrorMessage(error)); + } + }); + }; + const transport: ConnectOverCDPTransport = { + send: (message) => { + ws.send(JSON.stringify(message)); + }, + close: () => { + closeTransportSocket(); + }, + get onmessage() { + return onMessage; + }, + set onmessage(handler) { + onMessage = handler; + if (!handler) { + return; + } + while (pendingMessages.length > 0) { + const pending = pendingMessages.shift(); + if (pending) { + scheduleMessage(pending); + } + } + }, + get onclose() { + return onClose; + }, + set onclose(handler) { + onClose = handler; + if (handler && pendingCloseReason !== undefined) { + const reason = pendingCloseReason; + pendingCloseReason = undefined; + handler(reason); + } + }, + }; + ws.on("message", (raw) => { + try { + const parsed = JSON.parse(rawDataToString(raw)) as object; + scheduleMessage(parsed); + } catch { + closeTransportSocket(); + } + }); + ws.on("close", () => { + scheduleTransportClosed("CDP socket closed"); + }); + ws.on("error", (error) => { + scheduleTransportClosed(formatErrorMessage(error)); + }); + return await chromium.connectOverCDP(transport, { timeout: opts.timeout }); + } catch (error) { + ws.close(); + throw error; + } +} diff --git a/extensions/browser/src/browser/pw-session-connection.ts b/extensions/browser/src/browser/pw-session-connection.ts index 078213f6370f..ab62a5384100 100644 --- a/extensions/browser/src/browser/pw-session-connection.ts +++ b/extensions/browser/src/browser/pw-session-connection.ts @@ -8,13 +8,15 @@ import { PLAYWRIGHT_TARGET_INFO_TIMEOUT_MS } from "./cdp-timeouts.js"; import { assertCdpEndpointAllowed, getHeadersWithAuth, + isLoopbackHost, isWebSocketUrl, redactCdpErrorText, stripCdpUrlCredentials, } from "./cdp.helpers.js"; -import { getChromeWebSocketUrl } from "./chrome.js"; +import { getChromeWebSocketEndpoint } from "./chrome.js"; import { BrowserTabNotFoundError } from "./errors.js"; import { playwrightCore } from "./playwright-core.runtime.js"; +import { connectOverCdpPinnedTransport } from "./pw-session-cdp-transport.js"; import { blockedPageRefsByCdpUrl, blockedTargetsByCdpUrl, @@ -39,6 +41,7 @@ import { } from "./pw-session-state.js"; const { chromium } = playwrightCore; +type CdpEndpointPin = NonNullable>>; function resolveCdpConnectRetryDelayMs(attempt: number): number { return 250 + attempt * 250; @@ -393,7 +396,7 @@ export async function connectBrowser( } // Run SSRF policy check only on cache miss so transient DNS failures // do not break active sessions that already hold a live CDP connection. - await assertCdpEndpointAllowed(normalized, ssrfPolicy); + const configuredPin = await assertCdpEndpointAllowed(normalized, ssrfPolicy); const connecting = connectingByCdpUrl.get(normalized); if (connecting) { return await connecting.promise; @@ -408,34 +411,59 @@ export async function connectBrowser( } try { const timeout = 5000 + attempt * 2000; - const wsUrl = await getChromeWebSocketUrl(normalized, timeout, ssrfPolicy).catch( - () => null, - ); + let endpointDiscoveryError: unknown; + const resolvedEndpoint = await getChromeWebSocketEndpoint( + normalized, + timeout, + ssrfPolicy, + ).catch((err: unknown) => { + endpointDiscoveryError = err; + return null; + }); const hasUrlCredentials = stripCdpUrlCredentials(normalized) !== normalized; - if (!wsUrl && hasUrlCredentials && !isWebSocketUrl(normalized)) { + if (!resolvedEndpoint && hasUrlCredentials && !isWebSocketUrl(normalized)) { // Playwright preserves explicit headers across HTTP discovery redirects. // Keep credentialed discovery in OpenClaw's guarded fetch path instead. throw new Error("Authenticated CDP HTTP endpoint did not expose a usable WebSocket URL."); } - const endpoint = wsUrl ?? normalized; - const connectEndpoint = async (target: string) => { + if (!resolvedEndpoint && ssrfPolicy && !isWebSocketUrl(normalized)) { + const detail = endpointDiscoveryError + ? ` Reason: ${redactCdpErrorText(formatErrorMessage(endpointDiscoveryError))}` + : ""; + throw new Error(`Guarded CDP endpoint did not expose a usable WebSocket URL.${detail}`); + } + const normalizedCdpHostname = new URL(normalized).hostname; + const needsPinnedDependencyConnect = + Boolean(configuredPin?.lookup) && !isLoopbackHost(normalizedCdpHostname); + const endpointUrl = resolvedEndpoint?.url ?? normalized; + const endpointLookup = + resolvedEndpoint?.lookup ?? + (needsPinnedDependencyConnect ? configuredPin?.lookup : undefined); + const connectEndpoint = async (target: string, lookup?: CdpEndpointPin["lookup"]) => { const headers = getHeadersWithAuth(target); const connectionUrl = stripCdpUrlCredentials(target); // Keep both loopback bypasses active until the Playwright handshake settles. return await withManagedProxyForCdpUrl(connectionUrl, () => - withNoProxyForCdpUrl(connectionUrl, () => - chromium.connectOverCDP(connectionUrl, { timeout, headers }), - ), + withNoProxyForCdpUrl(connectionUrl, async () => { + if (lookup) { + return await connectOverCdpPinnedTransport(connectionUrl, { + timeout, + headers, + lookup, + }); + } + return await chromium.connectOverCDP(connectionUrl, { timeout, headers }); + }), ); }; let browser: Browser; try { - browser = await connectEndpoint(endpoint); + browser = await connectEndpoint(endpointUrl, endpointLookup); } catch (err) { - if (!isWebSocketUrl(normalized) || endpoint === normalized) { + if (!isWebSocketUrl(normalized) || endpointUrl === normalized) { throw err; } - browser = await connectEndpoint(normalized); + browser = await connectEndpoint(normalized, configuredPin?.lookup); } if (connectionAttempt.cancelled) { connectionAttempt.retired = { browser, cdpUrl: normalized }; diff --git a/extensions/browser/src/browser/pw-session.connections.test.ts b/extensions/browser/src/browser/pw-session.connections.test.ts index ff2486e5de55..137aabd19ba5 100644 --- a/extensions/browser/src/browser/pw-session.connections.test.ts +++ b/extensions/browser/src/browser/pw-session.connections.test.ts @@ -24,7 +24,8 @@ const { } = pwAi; const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); -const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); +const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint"); +const getChromeWebSocketUrlSpy = getChromeWebSocketEndpointSpy; type BrowserMockBundle = { browser: import("playwright-core").Browser; @@ -244,7 +245,7 @@ describe("pw-session connection scoping", () => { const wsUrl = "ws://127.0.0.1:9222/devtools/browser/discovered"; const release = vi.fn(); registerManagedProxyBrowserCdpBypassMock.mockReturnValue(release); - getChromeWebSocketUrlSpy.mockResolvedValue(wsUrl); + getChromeWebSocketUrlSpy.mockResolvedValue({ url: wsUrl }); connectOverCdpSpy.mockImplementationOnce(async () => { expect(registerManagedProxyBrowserCdpBypassMock).toHaveBeenCalledWith(wsUrl); expect(release).not.toHaveBeenCalled(); @@ -303,7 +304,7 @@ describe("pw-session connection scoping", () => { releases.push(release); return release; }); - getChromeWebSocketUrlSpy.mockResolvedValue(discoveredUrl); + getChromeWebSocketUrlSpy.mockResolvedValue({ url: discoveredUrl }); connectOverCdpSpy .mockRejectedValueOnce(new Error("stale discovered endpoint")) .mockResolvedValueOnce(browser.browser); @@ -362,10 +363,42 @@ describe("pw-session connection scoping", () => { expect(connectOverCdpSpy).not.toHaveBeenCalled(); }); + it("does not fall back to Playwright discovery for guarded non-loopback CDP hosts", async () => { + getChromeWebSocketEndpointSpy.mockRejectedValue(new Error("discovery unavailable")); + + const connection = listPagesViaPlaywright({ + cdpUrl: "http://93.184.216.34:9222", + ssrfPolicy: { allowPrivateNetwork: true }, + }); + await expect(connection).rejects.toThrow( + "Guarded CDP endpoint did not expose a usable WebSocket URL.", + ); + await expect(connection).rejects.toThrow("discovery unavailable"); + + expect(connectOverCdpSpy).not.toHaveBeenCalled(); + }); + + it("does not fall back to Playwright discovery for guarded loopback HTTP CDP hosts", async () => { + getChromeWebSocketEndpointSpy.mockRejectedValue(new Error("loopback discovery blocked")); + + const connection = listPagesViaPlaywright({ + cdpUrl: "http://127.0.0.1:9222", + ssrfPolicy: {}, + }); + await expect(connection).rejects.toThrow( + "Guarded CDP endpoint did not expose a usable WebSocket URL.", + ); + await expect(connection).rejects.toThrow("loopback discovery blocked"); + + expect(connectOverCdpSpy).not.toHaveBeenCalled(); + }); + it("allows loopback CDP control without widening the navigation allowlist", async () => { const browser = makeBrowser("A", "https://example.com"); connectOverCdpSpy.mockResolvedValue(browser.browser); - getChromeWebSocketUrlSpy.mockResolvedValue(null); + getChromeWebSocketUrlSpy.mockResolvedValue({ + url: "ws://127.0.0.1:9222/devtools/browser/local", + }); const ssrfPolicy = { dangerouslyAllowPrivateNetwork: true, allowedHostnames: ["example.com"], diff --git a/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts b/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts index 3c6762dba30f..5a193526f409 100644 --- a/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts +++ b/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts @@ -23,7 +23,7 @@ const { } = pwAi; const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); -const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); +const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint"); const PROXY_ENV_KEYS = [ "ALL_PROXY", @@ -115,7 +115,7 @@ function installBrowserMocks() { } as unknown as import("playwright-core").Browser; connectOverCdpSpy.mockResolvedValue(browser); - getChromeWebSocketUrlSpy.mockResolvedValue(null); + getChromeWebSocketEndpointSpy.mockResolvedValue(null); const getBrowserDisconnectedHandler = () => browserOn.mock.calls.find((call) => call[0] === "disconnected")?.[1] as @@ -214,7 +214,7 @@ beforeEach(() => { afterEach(async () => { vi.unstubAllEnvs(); connectOverCdpSpy.mockClear(); - getChromeWebSocketUrlSpy.mockClear(); + getChromeWebSocketEndpointSpy.mockClear(); await closePlaywrightBrowserConnection().catch(() => {}); }); @@ -246,6 +246,9 @@ describe("pw-session createPageViaPlaywright navigation guard", () => { it("blocks hostname navigation when strict SSRF policy is configured", async () => { const { pageGoto } = installBrowserMocks(); + getChromeWebSocketEndpointSpy.mockResolvedValue({ + url: "ws://127.0.0.1:18792/devtools/browser/ROOT", + }); await expect( createPageViaPlaywright({ diff --git a/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts b/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts index 7ad1e0e53504..68f3200246c5 100644 --- a/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts +++ b/extensions/browser/src/browser/pw-session.get-page-for-targetid.test.ts @@ -14,7 +14,7 @@ const { } = pwAi; const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); -const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); +const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint"); type MockPageSpec = { targetId?: string; @@ -107,13 +107,13 @@ function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle { function installBrowser(pages: MockPageSpec[]): BrowserMockBundle { const bundle = makeBrowser(pages); connectOverCdpSpy.mockResolvedValue(bundle.browser); - getChromeWebSocketUrlSpy.mockResolvedValue(null); + getChromeWebSocketEndpointSpy.mockResolvedValue(null); return bundle; } afterEach(async () => { connectOverCdpSpy.mockReset(); - getChromeWebSocketUrlSpy.mockReset(); + getChromeWebSocketEndpointSpy.mockReset(); await closePlaywrightBrowserConnection().catch(() => {}); }); @@ -227,7 +227,7 @@ describe("pw-session getPageForTargetId", () => { const fresh = makeBrowser([{ targetId: "TARGET_OK", url: "https://fresh.example" }]); connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser); - getChromeWebSocketUrlSpy.mockResolvedValue(null); + getChromeWebSocketEndpointSpy.mockResolvedValue(null); await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" }); @@ -249,7 +249,7 @@ describe("pw-session getPageForTargetId", () => { ]); connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser); - getChromeWebSocketUrlSpy.mockResolvedValue(null); + getChromeWebSocketEndpointSpy.mockResolvedValue(null); await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" }); @@ -270,7 +270,7 @@ describe("pw-session getPageForTargetId", () => { connectOverCdpSpy .mockResolvedValueOnce(stale.browser) .mockResolvedValueOnce(stillBroken.browser); - getChromeWebSocketUrlSpy.mockResolvedValue(null); + getChromeWebSocketEndpointSpy.mockResolvedValue(null); await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9444" }); @@ -283,7 +283,7 @@ describe("pw-session getPageForTargetId", () => { it("does not add an extra top-level retry for non-recoverable connect failures", async () => { connectOverCdpSpy.mockRejectedValue(new Error("connectOverCDP exploded")); - getChromeWebSocketUrlSpy.mockResolvedValue(null); + getChromeWebSocketEndpointSpy.mockResolvedValue(null); await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9555" })).rejects.toThrow( "connectOverCDP exploded", diff --git a/extensions/browser/src/browser/pw-session.mock-setup.ts b/extensions/browser/src/browser/pw-session.mock-setup.ts index da596ed85722..72b389b86127 100644 --- a/extensions/browser/src/browser/pw-session.mock-setup.ts +++ b/extensions/browser/src/browser/pw-session.mock-setup.ts @@ -10,9 +10,10 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js"; /** Mock for playwright.chromium.connectOverCDP. */ export const connectOverCdpMock: MockFn = vi.fn(); /** Mock for Chrome CDP WebSocket URL discovery. */ -export const getChromeWebSocketUrlMock: MockFn = vi.fn(); +export const getChromeWebSocketEndpointMock: MockFn = vi.fn(); vi.mock("./playwright-core.runtime.js", () => ({ + getPlaywrightUserAgent: () => "Playwright/test", playwrightCore: { chromium: { connectOverCDP: (...args: unknown[]) => connectOverCdpMock(...args), @@ -22,5 +23,5 @@ vi.mock("./playwright-core.runtime.js", () => ({ })); vi.mock("./chrome.js", () => ({ - getChromeWebSocketUrl: (...args: unknown[]) => getChromeWebSocketUrlMock(...args), + getChromeWebSocketEndpoint: (...args: unknown[]) => getChromeWebSocketEndpointMock(...args), })); diff --git a/extensions/browser/src/browser/pw-session.pinned-transport.test.ts b/extensions/browser/src/browser/pw-session.pinned-transport.test.ts new file mode 100644 index 000000000000..ae8eae991884 --- /dev/null +++ b/extensions/browser/src/browser/pw-session.pinned-transport.test.ts @@ -0,0 +1,373 @@ +// Browser tests cover pinned Playwright CDP transport behavior. +import { createServer } from "node:http"; +import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; +import { chromium } from "playwright-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebSocketServer } from "ws"; +import * as chromeModule from "./chrome.js"; +import { pwAi } from "./pw-ai.js"; + +const { registerManagedProxyBrowserCdpBypassMock } = vi.hoisted(() => ({ + registerManagedProxyBrowserCdpBypassMock: vi.fn<(url: string) => (() => void) | undefined>( + () => undefined, + ), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({ + registerManagedProxyBrowserCdpBypass: registerManagedProxyBrowserCdpBypassMock, +})); + +const { closePlaywrightBrowserConnection, listPagesViaPlaywright } = pwAi; + +const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); +const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint"); +const TEST_CDP_WS_MAX_PAYLOAD_BYTES = 1024 * 1024; + +function webSocketMessageToString(data: import("ws").Data): string { + return typeof data === "string" ? data : rawDataToString(data); +} + +function makeBrowser( + targetId: string, + url: string, +): { browser: import("playwright-core").Browser } { + const page = { + on: vi.fn(), + context: () => context, + title: vi.fn(async () => `title:${targetId}`), + url: vi.fn(() => url), + } as unknown as import("playwright-core").Page; + + const context: import("playwright-core").BrowserContext = { + pages: () => [page], + on: vi.fn(), + newCDPSession: vi.fn(async () => ({ + send: vi.fn(async (method: string) => + method === "Target.getTargetInfo" + ? { targetInfo: { targetId, title: `title:${targetId}` } } + : {}, + ), + detach: vi.fn(async () => {}), + })), + } as unknown as import("playwright-core").BrowserContext; + + const browser = { + contexts: () => [context], + on: vi.fn(), + off: vi.fn(), + close: vi.fn(async () => {}), + } as unknown as import("playwright-core").Browser; + + return { browser }; +} + +function pinnedLoopbackLookup() { + return ((_hostname: string, options: unknown, callback?: unknown) => { + const cb = typeof options === "function" ? options : callback; + if (typeof cb === "function") { + cb(null, "127.0.0.1", 4); + } + }) as never; +} + +afterEach(async () => { + connectOverCdpSpy.mockReset(); + getChromeWebSocketEndpointSpy.mockReset(); + registerManagedProxyBrowserCdpBypassMock.mockReset(); + registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined); + await closePlaywrightBrowserConnection().catch(() => {}); +}); + +describe("pw-session pinned Playwright transport", () => { + it("connects guarded Playwright CDP through the pinned WebSocket transport", async () => { + const server = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => { + server.once("listening", () => resolve()); + }); + const port = (server.address() as { port: number }).port; + const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`; + const requestHeaders: Array> = []; + server.on("connection", (socket, request) => { + requestHeaders.push(request.headers); + socket.addEventListener("message", (event) => { + const msg = JSON.parse(webSocketMessageToString(event.data)) as { id?: number }; + socket.send(JSON.stringify({ id: msg.id, result: { ok: true } })); + }); + }); + getChromeWebSocketEndpointSpy.mockResolvedValue({ + url: cdpUrl, + lookup: pinnedLoopbackLookup(), + }); + const browser = makeBrowser("A", "https://example.com"); + connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => { + expect(typeof transportArg).not.toBe("string"); + const transport = transportArg as import("playwright-core").ConnectOverCDPTransport; + let delivered = false; + const message = new Promise((resolve) => { + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property. + transport.onmessage = (value) => { + delivered = true; + resolve(value); + }; + }); + transport.send({ id: 7, method: "Browser.getVersion" }); + expect(delivered).toBe(false); + await expect(message).resolves.toStrictEqual({ id: 7, result: { ok: true } }); + transport.close(); + return browser.browser; + }) as never); + + try { + const pages = await listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} }); + + expect(pages.map((page) => page.targetId)).toStrictEqual(["A"]); + expect(connectOverCdpSpy).toHaveBeenCalledTimes(1); + expect(requestHeaders[0]?.["user-agent"]).toContain("Playwright/"); + expect(requestHeaders[0]?.["sec-websocket-extensions"]).toContain("permessage-deflate"); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + + it("follows same-authority redirects in the pinned Playwright CDP transport", async () => { + const server = createServer(); + const wss = new WebSocketServer({ + noServer: true, + maxPayload: TEST_CDP_WS_MAX_PAYLOAD_BYTES, + }); + const redirectedUpgradePaths: string[] = []; + wss.on("connection", (socket) => { + socket.addEventListener("message", (event) => { + const msg = JSON.parse(webSocketMessageToString(event.data)) as { id?: number }; + socket.send(JSON.stringify({ id: msg.id, result: { ok: true } })); + }); + }); + server.on("upgrade", (request, socket, head) => { + if (request.url === "/start") { + socket.write( + "HTTP/1.1 302 Found\r\nLocation: /devtools/browser/redirected\r\nConnection: close\r\n\r\n", + ); + socket.destroy(); + return; + } + redirectedUpgradePaths.push(request.url ?? ""); + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); + }); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("test server did not expose a TCP port"); + } + const cdpUrl = `ws://127.0.0.1:${address.port}/start`; + getChromeWebSocketEndpointSpy.mockResolvedValue({ + url: cdpUrl, + lookup: pinnedLoopbackLookup(), + }); + const browser = makeBrowser("A", "https://example.com"); + connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => { + const transport = transportArg as import("playwright-core").ConnectOverCDPTransport; + const message = new Promise((resolve) => { + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property. + transport.onmessage = (value) => resolve(value); + }); + transport.send({ id: 8, method: "Browser.getVersion" }); + await expect(message).resolves.toStrictEqual({ id: 8, result: { ok: true } }); + transport.close(); + return browser.browser; + }) as never); + + try { + await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([ + expect.objectContaining({ targetId: "A" }), + ]); + expect(redirectedUpgradePaths).toStrictEqual(["/devtools/browser/redirected"]); + } finally { + await new Promise((resolve) => { + wss.close(() => { + server.close(() => resolve()); + }); + }); + } + }); + + it("closes the pinned Playwright transport on malformed CDP JSON", async () => { + const server = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => { + server.once("listening", () => resolve()); + }); + const port = (server.address() as { port: number }).port; + const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`; + const serverSocket = new Promise((resolve) => { + server.on("connection", (socket) => resolve(socket)); + }); + getChromeWebSocketEndpointSpy.mockResolvedValue({ + url: cdpUrl, + lookup: pinnedLoopbackLookup(), + }); + const browser = makeBrowser("A", "https://example.com"); + connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => { + const transport = transportArg as import("playwright-core").ConnectOverCDPTransport; + const closed = new Promise((resolve) => { + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property. + transport.onclose = (reason) => resolve(reason); + }); + (await serverSocket).send("{not-json"); + await expect(closed).resolves.toBe("CDP socket closed"); + return browser.browser; + }) as never); + + try { + await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([ + expect.objectContaining({ targetId: "A" }), + ]); + expect(connectOverCdpSpy).toHaveBeenCalledOnce(); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + + it("delivers queued CDP messages before reporting pinned transport closure", async () => { + const server = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => { + server.once("listening", () => resolve()); + }); + const port = (server.address() as { port: number }).port; + const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`; + const serverSocket = new Promise((resolve) => { + server.on("connection", (socket) => resolve(socket)); + }); + getChromeWebSocketEndpointSpy.mockResolvedValue({ + url: cdpUrl, + lookup: pinnedLoopbackLookup(), + }); + const browser = makeBrowser("A", "https://example.com"); + connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => { + const transport = transportArg as import("playwright-core").ConnectOverCDPTransport; + const events: string[] = []; + const message = new Promise((resolve) => { + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property. + transport.onmessage = () => { + events.push("message"); + resolve(); + }; + }); + const closed = new Promise((resolve) => { + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property. + transport.onclose = () => { + events.push("close"); + resolve(); + }; + }); + const socket = await serverSocket; + socket.send(JSON.stringify({ id: 1, result: { ok: true } })); + socket.close(); + + await message; + await closed; + expect(events).toStrictEqual(["message", "close"]); + return browser.browser; + }) as never); + + try { + await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([ + expect.objectContaining({ targetId: "A" }), + ]); + expect(connectOverCdpSpy).toHaveBeenCalledOnce(); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + + it("closes the pinned Playwright transport when message delivery fails", async () => { + const server = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => { + server.once("listening", () => resolve()); + }); + const port = (server.address() as { port: number }).port; + const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`; + const serverSocket = new Promise((resolve) => { + server.on("connection", (socket) => resolve(socket)); + }); + getChromeWebSocketEndpointSpy.mockResolvedValue({ + url: cdpUrl, + lookup: pinnedLoopbackLookup(), + }); + const browser = makeBrowser("A", "https://example.com"); + connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => { + const transport = transportArg as import("playwright-core").ConnectOverCDPTransport; + const closed = new Promise((resolve) => { + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property. + transport.onclose = (reason) => resolve(reason); + }); + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property. + transport.onmessage = () => { + throw new Error("handler failed"); + }; + (await serverSocket).send(JSON.stringify({ id: 1, result: {} })); + await expect(closed).resolves.toContain("handler failed"); + return browser.browser; + }) as never); + + try { + await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([ + expect.objectContaining({ targetId: "A" }), + ]); + expect(connectOverCdpSpy).toHaveBeenCalledOnce(); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); + + it("propagates pinned WebSocket protocol errors through transport closure", async () => { + const server = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => { + server.once("listening", () => resolve()); + }); + const port = (server.address() as { port: number }).port; + const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`; + const serverSocket = new Promise((resolve) => { + server.on("connection", (socket) => resolve(socket)); + }); + getChromeWebSocketEndpointSpy.mockResolvedValue({ + url: cdpUrl, + lookup: pinnedLoopbackLookup(), + }); + const browser = makeBrowser("A", "https://example.com"); + connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => { + const transport = transportArg as import("playwright-core").ConnectOverCDPTransport; + const closed = new Promise((resolve) => { + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property. + transport.onclose = (reason) => resolve(reason); + }); + const socket = await serverSocket; + const rawSocket = Reflect.get(socket, "_socket") as { write(data: Buffer): void }; + // Send an invalid reserved opcode so the real ws client emits an error. + rawSocket.write(Buffer.from([0x83, 0x00])); + await expect(closed).resolves.toContain("Invalid WebSocket frame"); + return browser.browser; + }) as never); + + try { + await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([ + expect.objectContaining({ targetId: "A" }), + ]); + expect(connectOverCdpSpy).toHaveBeenCalledOnce(); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); +}); diff --git a/extensions/browser/src/browser/pw-session.termination-cdp-ssrf.test.ts b/extensions/browser/src/browser/pw-session.termination-cdp-ssrf.test.ts index 4dde25386ce0..9d43173ff118 100644 --- a/extensions/browser/src/browser/pw-session.termination-cdp-ssrf.test.ts +++ b/extensions/browser/src/browser/pw-session.termination-cdp-ssrf.test.ts @@ -12,6 +12,7 @@ const { const wsMockState = vi.hoisted(() => ({ constructorUrls: [] as string[], + constructorOptions: [] as Array<{ agent?: unknown } | undefined>, })); vi.mock("ws", () => { @@ -21,8 +22,9 @@ vi.mock("ws", () => { readyState = 0; private readonly handlers = new Map void>(); - constructor(url: string) { + constructor(url: string, options?: { agent?: unknown }) { wsMockState.constructorUrls.push(url); + wsMockState.constructorOptions.push(options); setTimeout(() => { this.handlers.get("error")?.(new Error("test socket should not open")); }, 0); @@ -34,6 +36,9 @@ vi.mock("ws", () => { } close() { + if (this.readyState === 3) { + return; + } this.readyState = 3; this.handlers.get("close")?.(); } @@ -45,7 +50,7 @@ vi.mock("ws", () => { }); const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); -const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); +const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint"); function installBrowserMock() { const sessionSend = vi.fn(async (method: string) => { @@ -78,14 +83,17 @@ function installBrowserMock() { } as unknown as import("playwright-core").Browser; connectOverCdpSpy.mockResolvedValue(browser); - getChromeWebSocketUrlSpy.mockResolvedValue(null); + getChromeWebSocketEndpointSpy.mockResolvedValue({ + url: "ws://127.0.0.1:18792/devtools/browser/ROOT", + }); return { browserClose }; } afterEach(async () => { connectOverCdpSpy.mockReset(); - getChromeWebSocketUrlSpy.mockReset(); + getChromeWebSocketEndpointSpy.mockReset(); wsMockState.constructorUrls = []; + wsMockState.constructorOptions = []; await closePlaywrightBrowserConnection().catch(() => {}); }); @@ -116,12 +124,64 @@ describe("pw-session termination CDP SSRF guard", () => { ssrfPolicy: { dangerouslyAllowPrivateNetwork: false }, }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy.mock.calls[0]?.[0]).toBe("http://127.0.0.1:18792/json/list"); + const fetchUrls = fetchSpy.mock.calls.map((call) => call[0]); + expect(fetchUrls).toContain("http://127.0.0.1:18792/json/list"); + expect(fetchUrls).not.toContain("http://169.254.169.254/json/list"); expect(wsMockState.constructorUrls).toEqual([]); expect(browserClose).toHaveBeenCalledTimes(1); } finally { fetchSpy.mockRestore(); } }); + + it("uses the discovered target lookup pin for best-effort termination sockets", async () => { + installBrowserMock(); + const lookup = vi.fn((_hostname: string, options: unknown, callback?: unknown) => { + const cb = typeof options === "function" ? options : callback; + if (typeof cb === "function") { + cb(null, "127.0.0.1", 4); + } + }); + const assertAllowedSpy = vi + .spyOn(await import("./cdp.helpers.js"), "assertCdpEndpointAllowed") + .mockImplementation(async (url: string) => + url.includes("/devtools/page/") + ? { + hostname: "cdp-pinned.test", + addresses: ["127.0.0.1"], + lookup: lookup as never, + } + : undefined, + ); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify([ + { + id: "TARGET_1", + webSocketDebuggerUrl: "ws://cdp-pinned.test/devtools/page/TARGET_1", + }, + ]), + { status: 200 }, + ), + ); + + try { + await listPagesViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + ssrfPolicy: {}, + }); + + await forceDisconnectPlaywrightForTarget({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "TARGET_1", + ssrfPolicy: {}, + }); + + expect(wsMockState.constructorUrls).toEqual(["ws://cdp-pinned.test/devtools/page/TARGET_1"]); + expect(wsMockState.constructorOptions[0]?.agent).toBeDefined(); + } finally { + assertAllowedSpy.mockRestore(); + fetchSpy.mockRestore(); + } + }); }); diff --git a/extensions/browser/src/browser/pw-tools-core.downloads.ts b/extensions/browser/src/browser/pw-tools-core.downloads.ts index 4f82fd1f47b7..36fd86d30cb0 100644 --- a/extensions/browser/src/browser/pw-tools-core.downloads.ts +++ b/extensions/browser/src/browser/pw-tools-core.downloads.ts @@ -69,12 +69,15 @@ function resolveImplicitDownloadRoot(): string { } /** Arms the next page file chooser and fills it with strict existing paths. */ -export async function armFileUploadViaPlaywright(opts: { - cdpUrl: string; - targetId?: string; - paths?: string[]; - timeoutMs?: number; -}): Promise { +export async function armFileUploadViaPlaywright( + opts: { + cdpUrl: string; + browserFilesystemLocal?: boolean; + targetId?: string; + paths?: string[]; + timeoutMs?: number; + } & BrowserNavigationPolicyOptions, +): Promise { const key = opts.cdpUrl; const armId = bumpUploadArmId(); pendingUploadClaims.set(key, armId); @@ -115,7 +118,17 @@ export async function armFileUploadViaPlaywright(opts: { await dismissFileChooser(page); return; } - await fileChooser.setFiles(uploadPathsResult.paths); + await setFileChooserFilesViaPlaywright({ + cdpUrl: opts.cdpUrl, + targetId: opts.targetId, + page, + fileChooser, + paths: uploadPathsResult.paths, + timeoutMs: timeout, + browserFilesystemLocal: opts.browserFilesystemLocal, + ssrfPolicy: opts.ssrfPolicy, + browserProxyMode: opts.browserProxyMode, + }); }) .catch(() => { // Ignore timeouts; the chooser may never appear. @@ -131,6 +144,7 @@ export async function armFileUploadViaPlaywright(opts: { export async function uploadViaPlaywright( opts: { cdpUrl: string; + browserFilesystemLocal?: boolean; targetId?: string; ref: string; paths: string[]; @@ -274,6 +288,7 @@ export async function uploadViaPlaywright( fileChooser: chooser, paths: uploadPathsResult.paths, timeoutMs: Math.max(1, deadline - Date.now()), + browserFilesystemLocal: opts.browserFilesystemLocal, ssrfPolicy: opts.ssrfPolicy, browserProxyMode: opts.browserProxyMode, }); diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.content.ts b/extensions/browser/src/browser/pw-tools-core.interactions.content.ts index 7c596ae76a57..f3557cbebc66 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.content.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.content.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { detectMime } from "openclaw/plugin-sdk/media-mime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { FileChooser, Page } from "playwright-core"; import { ACT_MAX_WAIT_TIME_MS, resolveActWaitTimeoutMs } from "./act-policy.js"; @@ -32,6 +35,43 @@ import { type RawAnnotationInput, } from "./screenshot-annotate.js"; +const DEFAULT_UPLOAD_MIME_TYPE = "application/octet-stream"; +const PLAYWRIGHT_FILE_PAYLOAD_SIZE_LIMIT_BYTES = 50 * 1024 * 1024; + +type PlaywrightFilePayload = { + name: string; + mimeType: string; + buffer: Buffer; + lastModifiedMs?: number; +}; + +async function toPlaywrightFilePayloads(paths: string[]): Promise { + const stats = await Promise.all(paths.map(async (filePath) => await fs.stat(filePath))); + const totalSize = stats.reduce((size, stat) => size + stat.size, 0); + if (totalSize >= PLAYWRIGHT_FILE_PAYLOAD_SIZE_LIMIT_BYTES) { + throw new Error( + "Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.", + ); + } + return await Promise.all( + paths.map(async (filePath, index) => { + const buffer = await fs.readFile(filePath); + return { + name: path.basename(filePath), + mimeType: (await detectMime({ buffer, filePath })) ?? DEFAULT_UPLOAD_MIME_TYPE, + buffer, + lastModifiedMs: stats[index]?.mtimeMs, + }; + }), + ); +} + +function shouldUsePlaywrightFilePayloads( + opts: Pick, +): boolean { + return Boolean(opts.ssrfPolicy) && opts.browserFilesystemLocal !== true; +} + type BrowserWaitPredicateState = { document: unknown; pending?: boolean; @@ -402,9 +442,18 @@ export async function setFileChooserFilesViaPlaywright( timeoutMs: number; }, ): Promise { + const resolvedResult = await resolveStrictExistingUploadPaths({ requestedPaths: opts.paths }); + if (!resolvedResult.ok) { + throw new Error(resolvedResult.error); + } + const resolvedPaths = resolvedResult.paths; + const resolvedFiles = shouldUsePlaywrightFilePayloads(opts) + ? await toPlaywrightFilePayloads(resolvedPaths) + : resolvedPaths; + await awaitNavigationGuardedInteraction({ action: async () => { - await opts.fileChooser.setFiles(opts.paths, { timeout: opts.timeoutMs }); + await opts.fileChooser.setFiles(resolvedFiles, { timeout: opts.timeoutMs }); }, cdpUrl: opts.cdpUrl, page: opts.page, @@ -441,11 +490,14 @@ export async function setInputFilesViaPlaywright( throw new Error(resolvedResult.error); } const resolvedPaths = resolvedResult.paths; + const resolvedFiles = shouldUsePlaywrightFilePayloads(opts) + ? await toPlaywrightFilePayloads(resolvedPaths) + : resolvedPaths; try { await awaitNavigationGuardedInteraction({ action: async () => { - await locator.setInputFiles(resolvedPaths); + await locator.setInputFiles(resolvedFiles); }, cdpUrl: opts.cdpUrl, page, diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.navigation.ts b/extensions/browser/src/browser/pw-tools-core.interactions.navigation.ts index 5fe4ae15101e..e5f638d6083e 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.navigation.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.navigation.ts @@ -22,6 +22,7 @@ import { toAIFriendlyError } from "./pw-tools-core.shared.js"; export type InteractionTargetOptions = { cdpUrl: string; + browserFilesystemLocal?: boolean; targetId?: string; }; diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.set-input-files.test.ts b/extensions/browser/src/browser/pw-tools-core.interactions.set-input-files.test.ts index 5fc56b8e38b7..3270ccd73f5b 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.set-input-files.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.set-input-files.test.ts @@ -1,6 +1,10 @@ // Browser tests cover pw tools core.interactions.set input files plugin behavior. import { beforeEach, describe, expect, it, vi } from "vitest"; +const readFile = vi.fn(); +const stat = vi.fn(); +const detectMime = vi.fn(); + let page: Record | null = null; let locator: Record | null = null; @@ -58,7 +62,19 @@ vi.mock("./paths.js", () => { }; }); -const { setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js"); +vi.mock("node:fs/promises", () => ({ + default: { + readFile, + stat, + }, +})); + +vi.mock("openclaw/plugin-sdk/media-mime", () => ({ + detectMime, +})); + +const { setFileChooserFilesViaPlaywright, setInputFilesViaPlaywright } = + await import("./pw-tools-core.interactions.js"); function seedSingleLocatorPage(): { setInputFiles: ReturnType; @@ -79,11 +95,81 @@ function seedSingleLocatorPage(): { return { setInputFiles, elementHandle }; } +describe("setFileChooserFilesViaPlaywright", () => { + beforeEach(() => { + vi.clearAllMocks(); + page = { + url: vi.fn(() => "https://allowed.example/form"), + }; + locator = null; + readFile.mockResolvedValue(Buffer.from("upload contents")); + stat.mockResolvedValue({ size: Buffer.byteLength("upload contents"), mtimeMs: 1700000000000 }); + detectMime.mockResolvedValue("text/plain"); + resolveStrictExistingUploadPaths.mockResolvedValue({ + ok: true, + paths: ["/private/tmp/openclaw/uploads/ok.txt"], + }); + }); + + it("keeps chooser path handoff for unguarded local sessions", async () => { + const fileChooser = { setFiles: vi.fn(async () => {}) }; + + await setFileChooserFilesViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + page: page as never, + fileChooser: fileChooser as never, + paths: ["/tmp/openclaw/uploads/ok.txt"], + timeoutMs: 250, + }); + + expect(resolveStrictExistingUploadPaths).toHaveBeenCalledWith({ + requestedPaths: ["/tmp/openclaw/uploads/ok.txt"], + }); + expect(stat).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + expect(fileChooser.setFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"], { + timeout: 250, + }); + }); + + it("converts guarded chooser uploads to payloads before Playwright path handoff", async () => { + const fileChooser = { setFiles: vi.fn(async () => {}) }; + + await setFileChooserFilesViaPlaywright({ + cdpUrl: "https://browser.example/cdp", + targetId: "T1", + page: page as never, + fileChooser: fileChooser as never, + paths: ["/tmp/openclaw/uploads/ok.txt"], + timeoutMs: 250, + ssrfPolicy: {}, + }); + + expect(stat).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt"); + expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt"); + expect(fileChooser.setFiles).toHaveBeenCalledWith( + [ + { + name: "ok.txt", + mimeType: "text/plain", + buffer: Buffer.from("upload contents"), + lastModifiedMs: 1700000000000, + }, + ], + { timeout: 250 }, + ); + }); +}); + describe("setInputFilesViaPlaywright", () => { beforeEach(() => { vi.clearAllMocks(); page = null; locator = null; + readFile.mockResolvedValue(Buffer.from("upload contents")); + stat.mockResolvedValue({ size: Buffer.byteLength("upload contents"), mtimeMs: 1700000000000 }); + detectMime.mockResolvedValue("text/plain"); resolveStrictExistingUploadPaths.mockResolvedValue({ ok: true, paths: ["/private/tmp/openclaw/uploads/ok.txt"], @@ -104,27 +190,182 @@ describe("setInputFilesViaPlaywright", () => { requestedPaths: ["/tmp/openclaw/uploads/ok.txt"], }); expect(refLocator).toHaveBeenCalledWith(page, "e7"); + expect(stat).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + expect(detectMime).not.toHaveBeenCalled(); expect(setInputFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"]); expect(setInputFiles).toHaveBeenCalledTimes(1); expect(elementHandle).not.toHaveBeenCalled(); }); - it("keeps assignment-triggered navigation inside the browser policy guard", async () => { + it("converts guarded remote uploads to payloads before Playwright path handoff", async () => { + const { setInputFiles, elementHandle } = seedSingleLocatorPage(); + + await setInputFilesViaPlaywright({ + cdpUrl: "https://browser.example/cdp", + targetId: "T1", + inputRef: "e7", + paths: ["/tmp/openclaw/uploads/ok.txt"], + ssrfPolicy: {}, + }); + + expect(stat).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt"); + expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt"); + expect(detectMime).toHaveBeenCalledWith({ + buffer: Buffer.from("upload contents"), + filePath: "/private/tmp/openclaw/uploads/ok.txt", + }); + expect(setInputFiles).toHaveBeenCalledWith([ + { + name: "ok.txt", + mimeType: "text/plain", + buffer: Buffer.from("upload contents"), + lastModifiedMs: 1700000000000, + }, + ]); + expect(setInputFiles).toHaveBeenCalledTimes(1); + expect(elementHandle).not.toHaveBeenCalled(); + }); + + it("falls back to an octet-stream payload when mime detection has no answer", async () => { + detectMime.mockResolvedValueOnce(undefined); + const { setInputFiles } = seedSingleLocatorPage(); + + await setInputFilesViaPlaywright({ + cdpUrl: "https://browser.example/cdp", + targetId: "T1", + inputRef: "e7", + paths: ["/tmp/openclaw/uploads/ok.txt"], + ssrfPolicy: {}, + }); + + expect(setInputFiles).toHaveBeenCalledWith([ + { + name: "ok.txt", + mimeType: "application/octet-stream", + buffer: Buffer.from("upload contents"), + lastModifiedMs: 1700000000000, + }, + ]); + }); + + it("checks the Playwright aggregate payload size cap before reading guarded remote upload files", async () => { + stat.mockResolvedValueOnce({ size: 50 * 1024 * 1024 }); + const { setInputFiles } = seedSingleLocatorPage(); + + await expect( + setInputFilesViaPlaywright({ + cdpUrl: "https://browser.example/cdp", + targetId: "T1", + inputRef: "e7", + paths: ["/tmp/openclaw/uploads/too-large.bin"], + ssrfPolicy: {}, + }), + ).rejects.toThrow("Cannot set buffer larger than 50Mb"); + + expect(readFile).not.toHaveBeenCalled(); + expect(setInputFiles).not.toHaveBeenCalled(); + }); + + it("allows a guarded remote upload below the aggregate payload cap", async () => { + stat.mockResolvedValueOnce({ size: 50 * 1024 * 1024 - 1, mtimeMs: 1700000000000 }); + const { setInputFiles } = seedSingleLocatorPage(); + + await setInputFilesViaPlaywright({ + cdpUrl: "https://browser.example/cdp", + targetId: "T1", + inputRef: "e7", + paths: ["/tmp/openclaw/uploads/limit.bin"], + ssrfPolicy: {}, + }); + + expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt"); + expect(setInputFiles).toHaveBeenCalledWith([ + { + name: "ok.txt", + mimeType: "text/plain", + buffer: Buffer.from("upload contents"), + lastModifiedMs: 1700000000000, + }, + ]); + }); + + it("checks the aggregate cap across multiple guarded remote upload payloads", async () => { + stat + .mockResolvedValueOnce({ size: 30 * 1024 * 1024, mtimeMs: 1700000000000 }) + .mockResolvedValueOnce({ size: 30 * 1024 * 1024, mtimeMs: 1700000001000 }); + resolveStrictExistingUploadPaths.mockResolvedValueOnce({ + ok: true, + paths: ["/private/tmp/openclaw/uploads/one.txt", "/private/tmp/openclaw/uploads/two.txt"], + }); + const { setInputFiles } = seedSingleLocatorPage(); + + await expect( + setInputFilesViaPlaywright({ + cdpUrl: "https://browser.example/cdp", + targetId: "T1", + inputRef: "e7", + paths: ["/tmp/openclaw/uploads/one.txt", "/tmp/openclaw/uploads/two.txt"], + ssrfPolicy: {}, + }), + ).rejects.toThrow("Cannot set buffer larger than 50Mb"); + + expect(readFile).not.toHaveBeenCalled(); + expect(setInputFiles).not.toHaveBeenCalled(); + }); + + it("keeps guarded loopback uploads as path handoffs inside the browser policy guard", async () => { const { setInputFiles } = seedSingleLocatorPage(); await setInputFilesViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", + browserFilesystemLocal: true, targetId: "T1", inputRef: "e7", paths: ["/tmp/openclaw/uploads/ok.txt"], ssrfPolicy: { dangerouslyAllowPrivateNetwork: true }, }); + expect(stat).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + expect(detectMime).not.toHaveBeenCalled(); + expect(setInputFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"]); expect(withPageNavigationRequestGuard).toHaveBeenCalledTimes(1); expect(setInputFiles).toHaveBeenCalledTimes(1); expect(assertPageNavigationCompletedSafely).toHaveBeenCalledTimes(1); }); + it("converts guarded loopback uploads to payloads when the browser filesystem is remote", async () => { + const { setInputFiles, elementHandle } = seedSingleLocatorPage(); + + await setInputFilesViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + browserFilesystemLocal: false, + targetId: "T1", + inputRef: "e7", + paths: ["/tmp/openclaw/uploads/ok.txt"], + ssrfPolicy: { dangerouslyAllowPrivateNetwork: true }, + }); + + expect(stat).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt"); + expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt"); + expect(detectMime).toHaveBeenCalledWith({ + buffer: Buffer.from("upload contents"), + filePath: "/private/tmp/openclaw/uploads/ok.txt", + }); + expect(setInputFiles).toHaveBeenCalledWith([ + { + name: "ok.txt", + mimeType: "text/plain", + buffer: Buffer.from("upload contents"), + lastModifiedMs: 1700000000000, + }, + ]); + expect(withPageNavigationRequestGuard).toHaveBeenCalledTimes(1); + expect(setInputFiles).toHaveBeenCalledTimes(1); + expect(elementHandle).not.toHaveBeenCalled(); + }); + it("throws and skips setInputFiles when use-time validation fails", async () => { resolveStrictExistingUploadPaths.mockResolvedValueOnce({ ok: false, diff --git a/extensions/browser/src/browser/pw-tools-core.upload-paths.test.ts b/extensions/browser/src/browser/pw-tools-core.upload-paths.test.ts index 5f86902f3ea9..9334202f52fa 100644 --- a/extensions/browser/src/browser/pw-tools-core.upload-paths.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.upload-paths.test.ts @@ -108,9 +108,10 @@ describe("armFileUploadViaPlaywright upload path validation", () => { await Promise.resolve(); await vi.waitFor(() => { - expect(fileChooser.setFiles).toHaveBeenCalledWith([ - "/home/user/.openclaw/media/inbound/report.pdf", - ]); + expect(fileChooser.setFiles).toHaveBeenCalledWith( + ["/home/user/.openclaw/media/inbound/report.pdf"], + { timeout: expect.any(Number) }, + ); }); expect(fileChooser.setFiles).toHaveBeenCalledTimes(1); expect(fileChooser.element).not.toHaveBeenCalled(); diff --git a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index 9f71d57b3baa..2f15315ce808 100644 --- a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -14,7 +14,9 @@ const tmpDirMocks = vi.hoisted(() => ({ resolvePreferredOpenClawTmpDir: vi.fn(() => "/tmp/openclaw"), })); const chromeMocks = vi.hoisted(() => ({ - getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1/devtools/browser/mock"), + getChromeWebSocketEndpoint: vi.fn(async () => ({ + url: "ws://127.0.0.1/devtools/browser/mock", + })), })); const clientFetchMocks = vi.hoisted(() => ({ resolveBrowserRateLimitMessage: vi.fn(() => undefined), diff --git a/extensions/browser/src/browser/routes/agent.act.hooks.current-url-guard.test.ts b/extensions/browser/src/browser/routes/agent.act.hooks.current-url-guard.test.ts index c245326f07a5..83ff572141eb 100644 --- a/extensions/browser/src/browser/routes/agent.act.hooks.current-url-guard.test.ts +++ b/extensions/browser/src/browser/routes/agent.act.hooks.current-url-guard.test.ts @@ -36,25 +36,33 @@ vi.mock("../pw-ai-module.js", () => ({ const { registerBrowserAgentActHookRoutes } = await import("./agent.act.hooks.js"); -function createProfileContext() { +function createProfileContext(options?: { + attachOnly?: boolean; + driver?: "openclaw" | "extension"; + tabUrl?: string; +}) { return { profile: { + attachOnly: options?.attachOnly ?? false, cdpIsLoopback: true, cdpUrl: "http://127.0.0.1:9222", - driver: "openclaw" as const, + driver: options?.driver ?? ("openclaw" as const), name: "default", }, ensureTabAvailable: vi.fn(async () => ({ targetId: "tab-1", title: "Internal Admin", - url: "http://127.0.0.1:8080/admin", + url: options?.tabUrl ?? "http://127.0.0.1:8080/admin", type: "page", })), listTabs: vi.fn(async () => []), }; } -function createRouteContext(profileCtx: ReturnType) { +function createRouteContext( + profileCtx: ReturnType, + options?: { allowPrivateNetwork?: boolean }, +) { return { forProfile: () => profileCtx, mapTabError: vi.fn(toBrowserErrorResponse), @@ -62,7 +70,9 @@ function createRouteContext(profileCtx: ReturnType) resolved: { actionTimeoutMs: 60_000, extraArgs: [], - ssrfPolicy: { dangerouslyAllowPrivateNetwork: false }, + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: options?.allowPrivateNetwork === true, + }, }, }), }; @@ -72,9 +82,15 @@ async function callHook(params: { path: "/hooks/file-chooser" | "/hooks/dialog"; body: Record; profileCtx: ReturnType; + allowPrivateNetwork?: boolean; }) { const { app, postHandlers } = createBrowserRouteApp(); - registerBrowserAgentActHookRoutes(app, createRouteContext(params.profileCtx) as never); + registerBrowserAgentActHookRoutes( + app, + createRouteContext(params.profileCtx, { + allowPrivateNetwork: params.allowPrivateNetwork, + }) as never, + ); const handler = postHandlers.get(params.path); expect(handler).toBeTypeOf("function"); @@ -144,4 +160,52 @@ describe("agent act hook current URL guard", () => { } }, ); + + it("keeps file chooser path handoff local for extension-backed profiles", async () => { + const profileCtx = createProfileContext({ + driver: "extension", + tabUrl: "http://127.0.0.1:8080/upload", + }); + + const response = await callHook({ + path: "/hooks/file-chooser", + body: { paths: ["/tmp/upload.txt"], ref: "upload-button" }, + profileCtx, + allowPrivateNetwork: true, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(pwMocks.uploadViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + browserFilesystemLocal: true, + ref: "upload-button", + paths: ["/tmp/upload.txt"], + }), + ); + }); + + it("sends loopback attach-only uploads as payloads for a separate browser filesystem", async () => { + const profileCtx = createProfileContext({ + attachOnly: true, + tabUrl: "http://127.0.0.1:8080/upload", + }); + + const response = await callHook({ + path: "/hooks/file-chooser", + body: { paths: ["/tmp/upload.txt"], ref: "upload-button" }, + profileCtx, + allowPrivateNetwork: true, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(pwMocks.uploadViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + browserFilesystemLocal: false, + ref: "upload-button", + paths: ["/tmp/upload.txt"], + }), + ); + }); }); diff --git a/extensions/browser/src/browser/routes/agent.act.hooks.ts b/extensions/browser/src/browser/routes/agent.act.hooks.ts index 83779eb11a5a..fcb2550b66c2 100644 --- a/extensions/browser/src/browser/routes/agent.act.hooks.ts +++ b/extensions/browser/src/browser/routes/agent.act.hooks.ts @@ -55,8 +55,9 @@ export function registerBrowserAgentActHookRoutes( return; } const resolvedPaths = resolvedResult.paths; + const capabilities = getBrowserProfileCapabilities(profileCtx.profile); - if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) { + if (capabilities.usesChromeMcp) { if (element) { return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadElement); } @@ -84,12 +85,14 @@ export function registerBrowserAgentActHookRoutes( return; } + const browserFilesystemLocal = capabilities.browserFilesystemLocal; if (inputRef || element) { if (ref) { return jsonError(res, 400, "ref cannot be combined with inputRef/element"); } await pw.setInputFilesViaPlaywright({ cdpUrl, + browserFilesystemLocal, targetId: tab.targetId, inputRef, element, @@ -99,6 +102,7 @@ export function registerBrowserAgentActHookRoutes( } else if (ref) { await pw.uploadViaPlaywright({ cdpUrl, + browserFilesystemLocal, targetId: tab.targetId, paths: resolvedPaths, timeoutMs: timeoutMs ?? undefined, @@ -109,9 +113,11 @@ export function registerBrowserAgentActHookRoutes( } else { await pw.armFileUploadViaPlaywright({ cdpUrl, + browserFilesystemLocal, targetId: tab.targetId, paths: resolvedPaths, timeoutMs: timeoutMs ?? undefined, + ssrfPolicy: ctx.state().resolved.ssrfPolicy, }); } res.json({ ok: true }); diff --git a/extensions/browser/src/browser/routes/agent.snapshot.local-managed.test.ts b/extensions/browser/src/browser/routes/agent.snapshot.local-managed.test.ts index 6e47a49c6af6..060d16514bec 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.local-managed.test.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.local-managed.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js"; import type { BrowserRequest } from "./types.js"; +const tabLookup = vi.hoisted(() => vi.fn()); + const routeState = vi.hoisted(() => ({ profileCtx: { profile: { @@ -15,12 +17,13 @@ const routeState = vi.hoisted(() => ({ targetId: "7", url: "http://127.0.0.1:8080/admin", wsUrl: "ws://127.0.0.1/devtools/page/7", + wsLookup: tabLookup, })), }, })); const cdpMocks = vi.hoisted(() => ({ - getMainFrameDocumentIdentityViaCdp: vi.fn<() => Promise>( + getMainFrameDocumentIdentityViaCdp: vi.fn<(_opts?: unknown) => Promise>( async () => "cdp:test-document", ), snapshotAria: vi.fn(async () => ({ @@ -122,6 +125,7 @@ describe("local-managed browser snapshot routes", () => { cdpMocks.getMainFrameDocumentIdentityViaCdp.mockReset().mockResolvedValue("cdp:test-document"); cdpMocks.snapshotAria.mockClear(); cdpMocks.snapshotRoleViaCdp.mockClear(); + tabLookup.mockClear(); navigationGuardMocks.assertBrowserNavigationResultAllowed.mockClear(); navigationGuardMocks.withBrowserNavigationPolicy.mockClear(); }); @@ -193,6 +197,22 @@ describe("local-managed browser snapshot routes", () => { }); }); + it("uses the tab lookup pin when reading delta document identity via CDP", async () => { + navigationGuardMocks.assertBrowserNavigationResultAllowed.mockResolvedValue(undefined); + const handler = getSnapshotGetHandler(); + const response = createBrowserRouteResponse(); + + await handler?.({ params: {}, query: { format: "ai", interactive: "true" } }, response.res); + + expect(response.statusCode).toBe(200); + expect(cdpMocks.getMainFrameDocumentIdentityViaCdp).toHaveBeenCalledWith( + expect.objectContaining({ + wsUrl: "ws://127.0.0.1/devtools/page/7", + lookup: tabLookup, + }), + ); + }); + it("disables deltas when no stable document identity is available", async () => { navigationGuardMocks.assertBrowserNavigationResultAllowed.mockResolvedValue(undefined); cdpMocks.getMainFrameDocumentIdentityViaCdp.mockResolvedValue(undefined); diff --git a/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts b/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts index 29dbd41c16c0..35de1f1679fd 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts @@ -12,6 +12,7 @@ const cdpMocks = vi.hoisted(() => ({ stats: { lines: 1, chars: 15, refs: 0, interactive: 0 }, })), })); +const tabLookup = vi.hoisted(() => vi.fn()); const profileContext = vi.hoisted(() => ({ profile: { @@ -29,6 +30,7 @@ const profileContext = vi.hoisted(() => ({ targetId: "tab-1", url: "https://example.com", wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1", + wsLookup: tabLookup, })), })); @@ -80,7 +82,7 @@ vi.mock("./agent.shared.js", () => ({ async (params: { run: (ctx: { profileCtx: typeof profileContext; - tab: { targetId: string; url: string; wsUrl: string }; + tab: { targetId: string; url: string; wsUrl: string; wsLookup: typeof tabLookup }; cdpUrl: string; }) => Promise; }) => @@ -90,6 +92,7 @@ vi.mock("./agent.shared.js", () => ({ targetId: "tab-1", url: "https://example.com", wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1", + wsLookup: tabLookup, }, cdpUrl: "http://127.0.0.1:18800", }), @@ -136,6 +139,7 @@ describe("browser agent snapshot timeout routing", () => { expect(cdpMocks.snapshotAria).toHaveBeenCalledWith( expect.objectContaining({ wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1", + lookup: tabLookup, timeoutMs: 4321, }), ); @@ -151,6 +155,7 @@ describe("browser agent snapshot timeout routing", () => { expect(cdpMocks.snapshotRoleViaCdp).toHaveBeenCalledWith( expect.objectContaining({ wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1", + lookup: tabLookup, timeoutMs: 9876, }), ); @@ -169,6 +174,7 @@ describe("browser agent snapshot timeout routing", () => { expect(response.statusCode).toBe(200); expect(cdpMocks.captureScreenshot).toHaveBeenCalledWith( expect.objectContaining({ + lookup: tabLookup, timeoutMs: 2_147_483_647, }), ); diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts index df53ce925192..1c3d90a02e2e 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -569,6 +569,7 @@ export function registerBrowserAgentSnapshotRoutes( } else { buffer = await captureScreenshot({ wsUrl: tab.wsUrl ?? "", + ...(tab.wsLookup ? { lookup: tab.wsLookup } : {}), fullPage, format: type, quality: type === "jpeg" ? 85 : undefined, @@ -807,6 +808,7 @@ export function registerBrowserAgentSnapshotRoutes( } return await getMainFrameDocumentIdentityViaCdp({ wsUrl: tab.wsUrl, + ...(tab.wsLookup ? { lookup: tab.wsLookup } : {}), timeoutMs: plan.timeoutMs, }).catch(() => undefined); }; @@ -851,6 +853,7 @@ export function registerBrowserAgentSnapshotRoutes( } return await snapshotRoleViaCdp({ wsUrl: tab.wsUrl, + ...(tab.wsLookup ? { lookup: tab.wsLookup } : {}), urls: plan.urls, timeoutMs: plan.timeoutMs, maxChars: plan.resolvedMaxChars, @@ -979,6 +982,7 @@ export function registerBrowserAgentSnapshotRoutes( })() : snapshotAria({ wsUrl: tab.wsUrl ?? "", + ...(tab.wsLookup ? { lookup: tab.wsLookup } : {}), limit: plan.limit, timeoutMs: plan.timeoutMs, }); diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index 7fa63b89f905..61a954851eed 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -288,7 +288,11 @@ async function runBrowserLiveProbe(profileCtx: ProfileContext, signal: AbortSign summary: "No per-tab CDP WebSocket available for the lightweight live snapshot probe", }; } - const snap = await snapshotAria({ wsUrl: tab.wsUrl, limit: 25 }); + const snap = await snapshotAria({ + wsUrl: tab.wsUrl, + ...(tab.wsLookup ? { lookup: tab.wsLookup } : {}), + limit: 25, + }); return { id: "live-snapshot", label: "Live snapshot", diff --git a/extensions/browser/src/browser/routes/permissions.test.ts b/extensions/browser/src/browser/routes/permissions.test.ts index d269cabb9382..d11cf23999d7 100644 --- a/extensions/browser/src/browser/routes/permissions.test.ts +++ b/extensions/browser/src/browser/routes/permissions.test.ts @@ -4,7 +4,9 @@ import { BROWSER_ERROR_REASONS, BrowserProfileUnavailableError } from "../errors import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js"; const cdpMocks = vi.hoisted(() => ({ - getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18800/devtools/browser/test"), + getChromeWebSocketEndpoint: vi.fn(async () => ({ + url: "ws://127.0.0.1:18800/devtools/browser/test", + })), send: vi.fn( async ( _method: string, @@ -32,7 +34,7 @@ const pwMocks = vi.hoisted(() => ({ })); vi.mock("../chrome.js", () => ({ - getChromeWebSocketUrl: cdpMocks.getChromeWebSocketUrl, + getChromeWebSocketEndpoint: cdpMocks.getChromeWebSocketEndpoint, })); vi.mock("../cdp.helpers.js", () => ({ @@ -107,7 +109,7 @@ async function callGrant( describe("browser permission routes", () => { beforeEach(() => { - cdpMocks.getChromeWebSocketUrl.mockClear(); + cdpMocks.getChromeWebSocketEndpoint.mockClear(); cdpMocks.send.mockReset().mockResolvedValue({}); cdpMocks.withCdpSocket.mockClear(); pwMocks.getPwAiModule.mockReset().mockResolvedValue(null); @@ -163,7 +165,7 @@ describe("browser permission routes", () => { grantMethod: "cdp", }); expect(profileCtx.ensureBrowserAvailable).toHaveBeenCalled(); - expect(cdpMocks.getChromeWebSocketUrl).toHaveBeenCalledWith( + expect(cdpMocks.getChromeWebSocketEndpoint).toHaveBeenCalledWith( "http://127.0.0.1:18800", 1234, undefined, @@ -171,7 +173,7 @@ describe("browser permission routes", () => { expect(cdpMocks.withCdpSocket).toHaveBeenCalledWith( "ws://127.0.0.1:18800/devtools/browser/test", expect.any(Function), - { commandTimeoutMs: 1234, signal: expect.any(AbortSignal) }, + { commandTimeoutMs: 1234, lookup: undefined, signal: expect.any(AbortSignal) }, ); expect(cdpMocks.send).toHaveBeenCalledWith("Browser.grantPermissions", { origin: "https://meet.google.com", @@ -217,7 +219,7 @@ describe("browser permission routes", () => { displayPresent: false, }, }); - expect(cdpMocks.getChromeWebSocketUrl).not.toHaveBeenCalled(); + expect(cdpMocks.getChromeWebSocketEndpoint).not.toHaveBeenCalled(); }); it("rejects loose timeoutMs values before granting permissions", async () => { @@ -230,7 +232,7 @@ describe("browser permission routes", () => { expect(response.statusCode).toBe(400); expect(response.body).toStrictEqual({ error: "timeoutMs must be a positive integer." }); expect(profileCtx.ensureBrowserAvailable).not.toHaveBeenCalled(); - expect(cdpMocks.getChromeWebSocketUrl).not.toHaveBeenCalled(); + expect(cdpMocks.getChromeWebSocketEndpoint).not.toHaveBeenCalled(); expect(cdpMocks.send).not.toHaveBeenCalled(); }); @@ -242,7 +244,7 @@ describe("browser permission routes", () => { }); expect(response.statusCode).toBe(200); - expect(cdpMocks.getChromeWebSocketUrl).toHaveBeenCalledWith( + expect(cdpMocks.getChromeWebSocketEndpoint).toHaveBeenCalledWith( "http://127.0.0.1:18800", 1000, undefined, @@ -270,7 +272,7 @@ describe("browser permission routes", () => { ); expect(response.statusCode).toBe(200); - expect(cdpMocks.getChromeWebSocketUrl).toHaveBeenCalledWith( + expect(cdpMocks.getChromeWebSocketEndpoint).toHaveBeenCalledWith( "https://browser.example:9222", 5000, { diff --git a/extensions/browser/src/browser/routes/permissions.ts b/extensions/browser/src/browser/routes/permissions.ts index 050b52326e0e..175aca9b4fb6 100644 --- a/extensions/browser/src/browser/routes/permissions.ts +++ b/extensions/browser/src/browser/routes/permissions.ts @@ -9,7 +9,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import type { SsrFPolicy } from "../../infra/net/ssrf.js"; import { resolveCdpControlPolicy } from "../cdp-reachability-policy.js"; import { withCdpSocket } from "../cdp.helpers.js"; -import { getChromeWebSocketUrl } from "../chrome.js"; +import { getChromeWebSocketEndpoint, type ChromeWebSocketEndpoint } from "../chrome.js"; import { BrowserProfileUnavailableError, toBrowserErrorResponse } from "../errors.js"; import { getPwAiModule } from "../pw-ai-module.js"; import type { BrowserRouteContext } from "../server-context.js"; @@ -55,6 +55,7 @@ async function grantPermissions(params: { requiredPermissions: string[]; optionalPermissions: string[]; timeoutMs: number; + wsLookup?: ChromeWebSocketEndpoint["lookup"]; ssrfPolicy?: SsrFPolicy; signal: AbortSignal; }) { @@ -112,7 +113,7 @@ async function grantPermissions(params: { }); unsupportedPermissions = params.optionalPermissions; }, - { commandTimeoutMs: params.timeoutMs, signal: params.signal }, + { commandTimeoutMs: params.timeoutMs, lookup: params.wsLookup, signal: params.signal }, ); params.signal.throwIfAborted(); return { @@ -172,19 +173,20 @@ export function registerBrowserPermissionRoutes( profileCtx.profile, ctx.state().resolved.ssrfPolicy, ); - const wsUrl = await getChromeWebSocketUrl( + const endpoint = await getChromeWebSocketEndpoint( profileCtx.profile.cdpUrl, timeoutMs, cdpPolicy, ); signal.throwIfAborted(); - if (!wsUrl) { + if (!endpoint) { throw new BrowserProfileUnavailableError("browser CDP WebSocket unavailable"); } return await grantPermissions({ profileCtx, targetId, - wsUrl, + wsUrl: endpoint.url, + wsLookup: endpoint.lookup, origin, requiredPermissions, optionalPermissions, diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 355ccf8f7a85..bfcf75c168a2 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -3,7 +3,10 @@ * launch/restart, Chrome MCP attach, and profile stop handling. */ import fs from "node:fs"; -import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js"; +import { + assertChromeMcpCdpTransportAllowed, + resolveCdpReachabilityPolicy, +} from "./cdp-reachability-policy.js"; import { CHROME_MCP_ATTACH_READY_POLL_MS, CHROME_MCP_ATTACH_READY_WINDOW_MS, @@ -190,6 +193,7 @@ export function createProfileAvailability({ // countChromeMcpTabs creates the session if needed — no separate availability call required. // Status probes opt into ephemeral so they reuse a cached attach session if one exists, // but do not seed a new persistent session as a side effect of read-only status calls. + assertChromeMcpCdpTransportAllowed(profile, getCdpReachabilityPolicy()); const { countChromeMcpTabs } = await getChromeMcpModule(); const callOptions: { timeoutMs?: number; ephemeral?: boolean; signal?: AbortSignal } = {}; if (timeoutMs != null) { @@ -215,6 +219,7 @@ export function createProfileAvailability({ const isTransportAvailable = async (timeoutMs?: number, signal?: AbortSignal) => { if (capabilities.usesChromeMcp) { + assertChromeMcpCdpTransportAllowed(profile, getCdpReachabilityPolicy()); const { ensureChromeMcpAvailable } = await getChromeMcpModule(); await ensureChromeMcpAvailable(profile.name, profile, { ephemeral: true, @@ -437,6 +442,7 @@ export function createProfileAvailability({ `Browser user data directory not found for profile "${profile.name}": ${profile.userDataDir}`, ); } + assertChromeMcpCdpTransportAllowed(profile, getCdpReachabilityPolicy()); const { ensureChromeMcpAvailable } = await getChromeMcpModule(); await ensureChromeMcpAvailable(profile.name, profile, { signal }); await waitForChromeMcpReadyAfterAttach(signal); diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index b247f7ffe79f..2a9a729beb8b 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -37,6 +37,7 @@ type ChromeLiveProfile = { name?: string; cdpUrl?: string; userDataDir?: string; + mcpArgs?: string[]; }; function deferred() { @@ -128,6 +129,50 @@ afterEach(() => { }); describe("browser server-context existing-session profile", () => { + it("fails closed for Chrome MCP endpoint mcpArgs under the default CDP policy", async () => { + fs.mkdirSync("/tmp/brave-profile", { recursive: true }); + const state = makeState(); + state.resolved.ssrfPolicy = {}; + state.resolved.profiles["chrome-live"] = { + ...state.resolved.profiles["chrome-live"], + mcpArgs: ["--browserUrl", "http://127.0.0.1:9222"], + }; + const live = createBrowserRouteContext({ getState: () => state }).forProfile("chrome-live"); + + await expect(live.listTabs()).rejects.toThrow(/Chrome MCP cannot carry that pinned transport/); + await expect(live.openTab("https://example.com")).rejects.toThrow( + /remove cdpUrl and browserUrl\/wsEndpoint mcpArgs/, + ); + await expect(live.ensureBrowserAvailable()).rejects.toThrow(/host-local Chrome profile/); + + expect(chromeMcp.listChromeMcpTabs).not.toHaveBeenCalled(); + expect(chromeMcp.openChromeMcpTab).not.toHaveBeenCalled(); + expect(chromeMcp.ensureChromeMcpAvailable).not.toHaveBeenCalled(); + }); + + it("fails closed for explicit Chrome MCP cdpUrl under explicit restrictive CDP policy", async () => { + fs.mkdirSync("/tmp/brave-profile", { recursive: true }); + const state = makeState(); + state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false }; + state.resolved.profiles["chrome-live"] = { + ...state.resolved.profiles["chrome-live"], + cdpUrl: "http://127.0.0.1:9222", + }; + const live = createBrowserRouteContext({ getState: () => state }).forProfile("chrome-live"); + + await expect(live.listTabs()).rejects.toThrow(/Chrome MCP cannot carry that pinned transport/); + await expect(live.openTab("https://93.184.216.34")).rejects.toThrow( + /Use driver "openclaw" for guarded CDP endpoints/, + ); + await expect(live.ensureBrowserAvailable()).rejects.toThrow( + /remove cdpUrl and browserUrl\/wsEndpoint mcpArgs/, + ); + + expect(chromeMcp.listChromeMcpTabs).not.toHaveBeenCalled(); + expect(chromeMcp.openChromeMcpTab).not.toHaveBeenCalled(); + expect(chromeMcp.ensureChromeMcpAvailable).not.toHaveBeenCalled(); + }); + it("reports attach-only profiles as running when the MCP session is available but no page is selected", async () => { fs.mkdirSync("/tmp/brave-profile", { recursive: true }); const state = makeState(); @@ -176,6 +221,7 @@ describe("browser server-context existing-session profile", () => { state.resolved.profiles["chrome-live"], "chrome-live browser profile", ); + state.resolved.ssrfPolicy = undefined; state.resolved.profiles["chrome-live"] = { ...chromeLiveProfile, cdpUrl: "http://openclaw:relay-token@127.0.0.1:9222", diff --git a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts index bc334839a611..d9225da2a65b 100644 --- a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts +++ b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts @@ -78,6 +78,8 @@ describe("browser remote profile fallback and attachOnly behavior", () => { const tabs = await remote.listTabs(); expect(tabs.map((t) => t.targetId)).toEqual(["T1"]); + expect(tabs[0]?.wsLookup).toBeTypeOf("function"); + expect(JSON.stringify(tabs[0])).not.toContain("wsLookup"); }); it("filters browser-internal and non-page targets from raw CDP tab listing", async () => { diff --git a/extensions/browser/src/browser/server-context.selection.ts b/extensions/browser/src/browser/server-context.selection.ts index f7f905db85f3..05af9588f2a5 100644 --- a/extensions/browser/src/browser/server-context.selection.ts +++ b/extensions/browser/src/browser/server-context.selection.ts @@ -5,6 +5,7 @@ import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatErrorMessage } from "../infra/errors.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { assertChromeMcpCdpTransportAllowed } from "./cdp-reachability-policy.js"; import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; import { appendCdpPath } from "./cdp.js"; import { getChromeMcpModule } from "./chrome-mcp.runtime.js"; @@ -61,7 +62,11 @@ function mergeOpenedTabSnapshot( return tabs; } const merged = tabs.slice(); - merged[index] = { ...listedTab, wsUrl: openedTab.wsUrl }; + merged[index] = { + ...listedTab, + wsUrl: openedTab.wsUrl, + ...(openedTab.wsLookup ? { wsLookup: openedTab.wsLookup } : {}), + }; return merged; } @@ -249,6 +254,7 @@ export function createProfileSelectionOps({ const resolvedTargetId = await resolveTargetIdOrThrow(targetId, options); if (capabilities.usesChromeMcp) { + assertChromeMcpCdpTransportAllowed(profile, getCdpControlPolicy()); const { focusChromeMcpTab } = await getChromeMcpModule(); await focusChromeMcpTab(profile.name, resolvedTargetId, profile, options); runtime.lastTargetId = resolvedTargetId; @@ -283,6 +289,7 @@ export function createProfileSelectionOps({ const resolvedTargetId = await resolveTargetIdOrThrow(targetId, options); if (capabilities.usesChromeMcp) { + assertChromeMcpCdpTransportAllowed(profile, getCdpControlPolicy()); const { closeChromeMcpTab } = await getChromeMcpModule(); await closeChromeMcpTab(profile.name, resolvedTargetId, profile, options); } else { diff --git a/extensions/browser/src/browser/server-context.tab-ops.ts b/extensions/browser/src/browser/server-context.tab-ops.ts index c4ef4873d34a..596dc102851a 100644 --- a/extensions/browser/src/browser/server-context.tab-ops.ts +++ b/extensions/browser/src/browser/server-context.tab-ops.ts @@ -3,7 +3,10 @@ */ import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { resolveBrowserNavigationProxyMode } from "./browser-proxy-mode.js"; -import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js"; +import { + assertChromeMcpCdpTransportAllowed, + resolveCdpControlPolicy, +} from "./cdp-reachability-policy.js"; import { isSelectableCdpBrowserTarget } from "./cdp-target-filter.js"; import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js"; import { @@ -116,6 +119,7 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr const readTabs = async (options?: BrowserOperationOptions): Promise => { if (capabilities.usesChromeMcp) { + assertChromeMcpCdpTransportAllowed(profile, getCdpControlPolicy()); const { listChromeMcpTabs } = await getChromeMcpModule(); return await listChromeMcpTabs(profile.name, profile, options); } @@ -168,10 +172,13 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr continue; } if (tab.wsUrl) { - await assertCdpEndpointAllowed(tab.wsUrl, cdpControlPolicy, { + const wsPin = await assertCdpEndpointAllowed(tab.wsUrl, cdpControlPolicy, { source: "discovered", configuredUrl: profile.cdpUrl, }); + if (wsPin?.lookup) { + tab.wsLookup = wsPin.lookup; + } } tabs.push(tab); } @@ -288,12 +295,14 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr if (capabilities.usesChromeMcp) { await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); + const cdpPolicy = getCdpControlPolicy(); + assertChromeMcpCdpTransportAllowed(profile, cdpPolicy); const { openChromeMcpTab } = await getChromeMcpModule(); const cdpTimeouts = getRemoteCdpActionTimeouts(); const page = await openChromeMcpTab(profile.name, url, profile, { signal: opts?.signal, timeoutMs: opts?.timeoutMs, - cdpPolicy: getCdpControlPolicy(), + cdpPolicy, ...(cdpTimeouts ? { cdpTimeouts } : {}), }); await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts }); @@ -434,6 +443,12 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr } await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts }); const wsUrl = normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl); + const wsPin = wsUrl + ? await assertCdpEndpointAllowed(wsUrl, getCdpControlPolicy(), { + source: "discovered", + configuredUrl: profile.cdpUrl, + }) + : undefined; const committedUrl = wsUrl ? await waitForCdpCommittedNavigationUrl({ wsUrl, @@ -452,6 +467,7 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr title: created.title ?? "", url: resolvedUrl, wsUrl, + ...(wsPin?.lookup ? { wsLookup: wsPin.lookup } : {}), type: created.type, }, opts, @@ -465,6 +481,7 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr title: created.title ?? "", url: committedUrl, wsUrl, + ...(wsPin?.lookup ? { wsLookup: wsPin.lookup } : {}), type: created.type, }, opts, diff --git a/extensions/browser/src/browser/server-context.tab-selection-lookup.test.ts b/extensions/browser/src/browser/server-context.tab-selection-lookup.test.ts new file mode 100644 index 000000000000..a030ed02c6ea --- /dev/null +++ b/extensions/browser/src/browser/server-context.tab-selection-lookup.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withBrowserFetchPreconnect } from "../../test-fetch.js"; +import "../test-support/browser-security.mock.js"; +import "./server-context.chrome-test-harness.js"; +import * as cdpHelpersModule from "./cdp.helpers.js"; +import * as cdpModule from "./cdp.js"; +import { + createTestBrowserRouteContext, + makeState, + originalFetch, +} from "./server-context.remote-tab-ops.harness.js"; + +afterEach(async () => { + const { closePlaywrightBrowserConnection } = await import("./pw-session.js"); + await closePlaywrightBrowserConnection().catch(() => {}); + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function seedRunningProfileState( + state: ReturnType, + profileName = "openclaw", +): void { + (state.profiles as Map).set(profileName, { + profile: { name: profileName }, + running: { pid: 1234, proc: { on: vi.fn() } }, + lastTargetId: null, + }); +} + +function fetchCallUrls(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls.map(([url]) => String(url)); +} + +describe("browser server-context tab selection lookup state", () => { + it("preserves the opened tab lookup when a same-target listing lacks a WebSocket URL", async () => { + vi.spyOn(cdpModule, "createTargetViaCdp").mockRejectedValue(new Error("raw create failed")); + vi.spyOn(cdpModule, "waitForCdpCommittedNavigationUrl").mockResolvedValue(undefined); + let listCalls = 0; + const lookupHosts: string[] = []; + const fetchJson = vi.spyOn(cdpHelpersModule, "fetchJson").mockImplementation(async (url) => { + if (url.includes("/json/list")) { + listCalls += 1; + return listCalls === 1 + ? [] + : [ + { + id: "NEW", + title: "Listed", + url: "about:blank", + type: "page", + }, + ]; + } + if (url.includes("/json/new")) { + return { + id: "NEW", + title: "Opened", + url: "about:blank", + webSocketDebuggerUrl: "ws://127.0.0.1:18800/devtools/page/NEW", + type: "page", + }; + } + throw new Error(`unexpected fetchJson: ${url}`); + }); + vi.spyOn(cdpHelpersModule, "assertCdpEndpointAllowed").mockImplementation(async () => ({ + hostname: "browser.example", + addresses: ["127.0.0.1"], + lookup: ((hostname: string, _options: unknown, callback?: unknown) => { + lookupHosts.push(hostname); + if (typeof callback === "function") { + callback(null, "127.0.0.1", 4); + } + }) as never, + })); + const state = makeState("openclaw"); + state.resolved.ssrfPolicy = {}; + seedRunningProfileState(state); + const openclaw = createTestBrowserRouteContext({ getState: () => state }).forProfile( + "openclaw", + ); + + const selected = await openclaw.ensureTabAvailable(); + + expect(selected).toEqual( + expect.objectContaining({ + targetId: "NEW", + title: "Listed", + url: "about:blank", + wsUrl: "ws://127.0.0.1:18800/devtools/page/NEW", + }), + ); + expect(selected.wsLookup).toBeTypeOf("function"); + selected.wsLookup?.("browser.example", {}, () => {}); + expect(lookupHosts).toEqual(["browser.example"]); + expect(fetchJson.mock.calls.some(([url]) => url.includes("/json/new"))).toBe(true); + }); + + it("resolves friendly tab references before backend focus and close calls", async () => { + const fetchMock = vi.fn(async (url: unknown) => { + const value = String(url); + if (value.includes("/json/list")) { + return { + ok: true, + json: async () => [ + { + id: "DOCS_RAW", + title: "Docs", + url: "https://docs.example.com", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/DOCS_RAW", + type: "page", + }, + ], + } as unknown as Response; + } + if (value.includes("/json/activate/DOCS_RAW") || value.includes("/json/close/DOCS_RAW")) { + return { ok: true } as unknown as Response; + } + throw new Error(`unexpected fetch: ${value}`); + }); + + global.fetch = withBrowserFetchPreconnect(fetchMock); + const state = makeState("openclaw"); + const ctx = createTestBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + + await openclaw.labelTab("DOCS_RAW", "docs"); + await expect(openclaw.ensureTabAvailable("t1")).resolves.toEqual( + expect.objectContaining({ targetId: "DOCS_RAW" }), + ); + await openclaw.focusTab("docs"); + await openclaw.closeTab("t1"); + + expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/activate/DOCS_RAW"))).toBe( + true, + ); + expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/close/DOCS_RAW"))).toBe(true); + }); +}); diff --git a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts index 08bc2e0926b6..8608034829f3 100644 --- a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts +++ b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts @@ -984,45 +984,4 @@ describe("browser server-context tab selection state", () => { }), ]); }); - - it("resolves friendly tab references before backend focus and close calls", async () => { - const fetchMock = vi.fn(async (url: unknown) => { - const value = String(url); - if (value.includes("/json/list")) { - return { - ok: true, - json: async () => [ - { - id: "DOCS_RAW", - title: "Docs", - url: "https://docs.example.com", - webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/DOCS_RAW", - type: "page", - }, - ], - } as unknown as Response; - } - if (value.includes("/json/activate/DOCS_RAW") || value.includes("/json/close/DOCS_RAW")) { - return { ok: true } as unknown as Response; - } - throw new Error(`unexpected fetch: ${value}`); - }); - - global.fetch = withBrowserFetchPreconnect(fetchMock); - const state = makeState("openclaw"); - const ctx = createTestBrowserRouteContext({ getState: () => state }); - const openclaw = ctx.forProfile("openclaw"); - - await openclaw.labelTab("DOCS_RAW", "docs"); - await expect(openclaw.ensureTabAvailable("t1")).resolves.toEqual( - expect.objectContaining({ targetId: "DOCS_RAW" }), - ); - await openclaw.focusTab("docs"); - await openclaw.closeTab("t1"); - - expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/activate/DOCS_RAW"))).toBe( - true, - ); - expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/close/DOCS_RAW"))).toBe(true); - }); }); diff --git a/extensions/browser/src/browser/ssrf-policy-helpers.ts b/extensions/browser/src/browser/ssrf-policy-helpers.ts index bf8464f07b12..40f36291a838 100644 --- a/extensions/browser/src/browser/ssrf-policy-helpers.ts +++ b/extensions/browser/src/browser/ssrf-policy-helpers.ts @@ -2,6 +2,7 @@ * SSRF policy helpers for Browser routes that need one-off hostname grants. */ import { isPrivateNetworkAllowedByPolicy, type SsrFPolicy } from "../infra/net/ssrf.js"; +import { matchesHostnameAllowlist, normalizeHostname } from "../sdk-security-runtime.js"; // Exact-host CDP scoping replaces allowedHostnames. Preserve whether the source // policy allowed authority changes before that synthetic allowlist was added. @@ -20,6 +21,27 @@ export function allowsDiscoveredCdpAuthorityChange(ssrfPolicy?: SsrFPolicy): boo ); } +/** Return true when policy already trusts this hostname as a private-network destination. */ +export function isCdpHostnameTrustedByPolicy( + ssrfPolicy: SsrFPolicy | undefined, + hostname: string, +): boolean { + const normalizedHostname = normalizeHostname(hostname); + if (!normalizedHostname) { + return false; + } + const allowedHostnames = (ssrfPolicy?.allowedHostnames ?? []) + .map((pattern) => normalizeHostname(pattern)) + .filter(Boolean); + if (allowedHostnames.length === 0) { + return isPrivateNetworkAllowedByPolicy(ssrfPolicy); + } + if (allowedHostnames.some((pattern) => pattern === "*" || pattern === "*.")) { + return true; + } + return matchesHostnameAllowlist(normalizedHostname, allowedHostnames); +} + /** Returns an SSRF policy restricted to one exact control-plane hostname. */ export function withExactHostnamePolicy( ssrfPolicy: SsrFPolicy | undefined, From ee535c5e1d39e7037370f5e3263dbf52696398f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 03:48:17 -0700 Subject: [PATCH 04/14] fix(doctor): report legacy cache scan failures (#122585) * fix(doctor): report usage cache scan failures Treat only missing legacy cache paths as absent and surface permission or I/O failures without partial cleanup. * fix(doctor): preserve cache scan error causes --- src/commands/doctor-usage-cost-cache.test.ts | 89 +++++++++++++++++++- src/commands/doctor-usage-cost-cache.ts | 47 ++++++++++- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/src/commands/doctor-usage-cost-cache.test.ts b/src/commands/doctor-usage-cost-cache.test.ts index 4db98ac60794..2c74c34ca99b 100644 --- a/src/commands/doctor-usage-cost-cache.test.ts +++ b/src/commands/doctor-usage-cost-cache.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { closeOpenClawAgentDatabasesForTest, openOpenClawAgentDatabase, @@ -10,9 +10,15 @@ import { import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { maybeRepairLegacyRuntimeFiles } from "./doctor-usage-cost-cache.js"; +const note = vi.hoisted(() => vi.fn()); + +vi.mock("../../packages/terminal-core/src/note.js", () => ({ note })); + let root: string | undefined; afterEach(async () => { + vi.restoreAllMocks(); + note.mockReset(); closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); if (root) { @@ -125,8 +131,89 @@ describe("legacy usage-cost cache cleanup", () => { ]); } }); + + it.each([ + [ + "reports an unreadable agent root without claiming a complete scan", + "root", + "readdir", + "EACCES", + false, + true, + ], + [ + "reports an unreadable temp entry without removing partial scan results", + "entry", + "stat", + "EIO", + true, + true, + ], + ["treats a missing agent root as harmless absence", "root", "readdir", "ENOENT", true, false], + [ + "skips a temp entry that disappears between readdir and stat", + "entry", + "stat", + "ENOENT", + true, + false, + ], + ] as const)("%s", async (_name, scope, operation, code, shouldRepair, diagnostic) => { + root = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-usage-cost-${scope}-fault-`)); + const agentsDir = path.join(root, "agents"); + const sessionsDir = + scope === "root" ? path.join(root, "sessions") : path.join(agentsDir, "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + if (scope === "root") { + await fs.mkdir(agentsDir); + } + const cacheFile = path.join(sessionsDir, ".usage-cost-cache.json"); + const tempFile = path.join(sessionsDir, ".usage-cost-cache.123.tmp"); + await fs.writeFile(cacheFile, "x"); + if (scope === "entry") { + await fs.writeFile(tempFile, "x"); + } + const error = fsError(code); + if (operation === "readdir") { + vi.spyOn(fs, "readdir").mockRejectedValueOnce(error); + } else { + vi.spyOn(fs, "stat").mockRejectedValueOnce(error); + } + + await maybeRepairLegacyRuntimeFiles(shouldRepair, { + OPENCLAW_STATE_DIR: root, + } as NodeJS.ProcessEnv); + + if (diagnostic) { + const action = shouldRepair ? "scan and cleanup" : "scan"; + expect(note).toHaveBeenCalledOnce(); + expect(note).toHaveBeenCalledWith( + expect.stringMatching( + new RegExp(`usage-cost cache ${action} could not be completed`, "iu"), + ), + "Usage cost cache", + ); + expect(note.mock.calls[0]?.[0]).toContain(scope === "root" ? agentsDir : tempFile); + expect(note.mock.calls[0]?.[0]).toContain(code); + expect(note.mock.calls[0]?.[0]).toContain( + shouldRepair ? "openclaw doctor --fix" : "openclaw doctor", + ); + expect(note.mock.calls[0]?.[0]).not.toContain("Removed"); + await expect(fs.readFile(cacheFile, "utf8")).resolves.toBe("x"); + return; + } + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Removed 1 rebuildable legacy usage-cost cache file"), + "Usage cost cache", + ); + await expect(fs.stat(cacheFile)).rejects.toMatchObject({ code: "ENOENT" }); + }); }); function randomUploadId(): string { return "11111111-1111-4111-8111-111111111111"; } + +function fsError(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(`${code}: injected filesystem failure`), { code }); +} diff --git a/src/commands/doctor-usage-cost-cache.ts b/src/commands/doctor-usage-cost-cache.ts index ed58d838c1aa..d37457d268a3 100644 --- a/src/commands/doctor-usage-cost-cache.ts +++ b/src/commands/doctor-usage-cost-cache.ts @@ -4,13 +4,31 @@ import os from "node:os"; import path from "node:path"; import { note } from "../../packages/terminal-core/src/note.js"; import { resolveStateDir } from "../config/paths.js"; +import { formatErrorMessage, hasErrnoCode } from "../infra/errors.js"; import { deleteSessionCostUsageRollupsExcept } from "../infra/session-cost-usage-cache.sqlite.js"; import { listOpenClawRegisteredAgentDatabases } from "../state/openclaw-agent-db.js"; +import { shortenHomePath } from "../utils.js"; import { runDoctorAgentDatabaseOperation } from "./doctor-agent-database-operation.js"; import { maybeScrubConfigAuditLog } from "./doctor-config-audit-scrub.js"; const LEGACY_USAGE_COST_TEMP_GRACE_MS = 10_000; +async function readFilesystemEntryOrMissing( + filePath: string, + read: () => Promise, +): Promise { + try { + return await read(); + } catch (error) { + if (hasErrnoCode(error, "ENOENT")) { + return null; + } + throw new Error(`${shortenHomePath(filePath)}: ${formatErrorMessage(error)}`, { + cause: error, + }); + } +} + function isLegacyUsageCostCacheTempName(name: string): boolean { return ( /^\.usage-cost-cache\.\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/u.test( @@ -28,7 +46,10 @@ async function detectLegacyUsageCostCacheFiles(params?: { const stateDir = resolveStateDir(params?.env ?? process.env, params?.homedir ?? os.homedir); const sessionDirs = [path.join(stateDir, "sessions")]; const agentsDir = path.join(stateDir, "agents"); - const agentEntries = await fs.readdir(agentsDir, { withFileTypes: true }).catch(() => []); + const agentEntries = + (await readFilesystemEntryOrMissing(agentsDir, () => + fs.readdir(agentsDir, { withFileTypes: true }), + )) ?? []; for (const entry of agentEntries) { if (entry.isDirectory()) { sessionDirs.push(path.join(agentsDir, entry.name, "sessions")); @@ -36,7 +57,10 @@ async function detectLegacyUsageCostCacheFiles(params?: { } const files: string[] = []; for (const sessionDir of sessionDirs) { - const entries = await fs.readdir(sessionDir, { withFileTypes: true }).catch(() => []); + const entries = + (await readFilesystemEntryOrMissing(sessionDir, () => + fs.readdir(sessionDir, { withFileTypes: true }), + )) ?? []; for (const entry of entries) { if (!entry.isFile()) { continue; @@ -47,7 +71,7 @@ async function detectLegacyUsageCostCacheFiles(params?: { continue; } if (isLegacyUsageCostCacheTempName(entry.name)) { - const stats = await fs.stat(filePath).catch(() => null); + const stats = await readFilesystemEntryOrMissing(filePath, () => fs.stat(filePath)); if (stats && Date.now() - stats.mtimeMs >= LEGACY_USAGE_COST_TEMP_GRACE_MS) { files.push(filePath); } @@ -62,7 +86,22 @@ async function maybeRemoveLegacyUsageCostCacheFiles(params: { env?: NodeJS.ProcessEnv; homedir?: () => string; }): Promise { - const files = await detectLegacyUsageCostCacheFiles(params); + const files = await detectLegacyUsageCostCacheFiles(params).catch((error: unknown) => { + const command = params.shouldRepair ? "openclaw doctor --fix" : "openclaw doctor"; + const action = params.shouldRepair ? "scan and cleanup" : "scan"; + note( + [ + `Legacy usage-cost cache ${action} could not be completed; ${params.shouldRepair ? "no sidecar files were removed" : "cache state may remain uninspected"}.`, + `- ${formatErrorMessage(error)}`, + `Resolve the filesystem error and rerun \`${command}\`.`, + ].join("\n"), + "Usage cost cache", + ); + return null; + }); + if (!files) { + return; + } if (files.length === 0) { return; } From 6780d87e4f3a1a08cb948f12d7d1fc71327cd4a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 03:49:04 -0700 Subject: [PATCH 05/14] test(gateway): skip reconnect wall-clock waits (#122588) Co-authored-by: Amp --- src/gateway/client.test.ts | 45 +++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 9c8227ed9b3d..d0bb14c77e4d 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -1162,7 +1162,7 @@ describe("GatewayClient message dispatch", () => { describe("GatewayClient connect auth payload", () => { beforeEach(() => { - vi.useRealTimers(); + vi.useFakeTimers(); wsInstances.length = 0; clearDeviceAuthTokenMock.mockReset(); clearOriginDeviceTokenMock.mockReset(); @@ -1221,6 +1221,13 @@ describe("GatewayClient connect auth payload", () => { return parseConnectRequest(ws); } + async function advanceToNextReconnect(): Promise { + const previousCount = wsInstances.length; + await vi.advanceTimersToNextTimerAsync(); + expect(wsInstances).toHaveLength(previousCount + 1); + return getLatestWs(); + } + type ProtocolCompatibilityOptions = Pick< GatewayClientOptions, "role" | "mode" | "clientName" | "minProtocol" | "maxProtocol" @@ -1401,8 +1408,7 @@ describe("GatewayClient connect auth payload", () => { { expectedProtocol: MIN_NODE_PROTOCOL_VERSION }, "protocol mismatch", ); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 }); - const legacyWs = getLatestWs(); + const legacyWs = await advanceToNextReconnect(); legacyWs.emitOpen(); emitConnectChallenge(legacyWs, "nonce-v3"); const legacyConnect = connectRequestFrom(legacyWs); @@ -1454,8 +1460,7 @@ describe("GatewayClient connect auth payload", () => { { expectedProtocol: MIN_NODE_PROTOCOL_VERSION }, "protocol mismatch", ); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 }); - const v3Ws = getLatestWs(); + const v3Ws = await advanceToNextReconnect(); v3Ws.emitOpen(); emitConnectChallenge(v3Ws, "nonce-v3-initial"); const v3Connect = connectRequestFrom(v3Ws); @@ -1463,8 +1468,7 @@ describe("GatewayClient connect auth payload", () => { await waitForFast(() => expect(onHelloOk).toHaveBeenCalledOnce()); v3Ws.emitClose(1012, "gateway restarting after upgrade"); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(2), { timeout: 3_000 }); - const upgradedProbeWs = getLatestWs(); + const upgradedProbeWs = await advanceToNextReconnect(); upgradedProbeWs.emitOpen(); emitConnectChallenge(upgradedProbeWs, "nonce-v3-upgraded"); const upgradedProbeConnect = connectRequestFrom(upgradedProbeWs); @@ -1479,9 +1483,8 @@ describe("GatewayClient connect auth payload", () => { "protocol mismatch", ); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(3), { timeout: 3_000 }); + const currentReconnectWs = await advanceToNextReconnect(); expect(onHelloOk).toHaveBeenCalledOnce(); - const currentReconnectWs = getLatestWs(); currentReconnectWs.emitOpen(); emitConnectChallenge(currentReconnectWs, "nonce-v4-upgraded"); const currentReconnect = connectRequestFrom(currentReconnectWs); @@ -1494,8 +1497,7 @@ describe("GatewayClient connect auth payload", () => { await waitForFast(() => expect(onHelloOk).toHaveBeenCalledTimes(2)); currentReconnectWs.emitClose(1012, "gateway rolled back"); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(4), { timeout: 3_000 }); - const rolledBackProbeWs = getLatestWs(); + const rolledBackProbeWs = await advanceToNextReconnect(); rolledBackProbeWs.emitOpen(); emitConnectChallenge(rolledBackProbeWs, "nonce-v4-rolled-back"); const rolledBackProbeConnect = connectRequestFrom(rolledBackProbeWs); @@ -1509,8 +1511,7 @@ describe("GatewayClient connect auth payload", () => { { expectedProtocol: MIN_NODE_PROTOCOL_VERSION }, "protocol mismatch", ); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(5), { timeout: 3_000 }); - const rolledBackLegacyWs = getLatestWs(); + const rolledBackLegacyWs = await advanceToNextReconnect(); rolledBackLegacyWs.emitOpen(); emitConnectChallenge(rolledBackLegacyWs, "nonce-v3-rolled-back"); expect(connectRequestFrom(rolledBackLegacyWs).params).toMatchObject({ @@ -1561,8 +1562,7 @@ describe("GatewayClient connect auth payload", () => { { expectedProtocol: MIN_NODE_PROTOCOL_VERSION }, "protocol mismatch", ); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 }); - const v3Ws = getLatestWs(); + const v3Ws = await advanceToNextReconnect(); v3Ws.emitOpen(); emitConnectChallenge(v3Ws, "nonce-v3-ready"); const v3Connect = connectRequestFrom(v3Ws); @@ -1570,8 +1570,7 @@ describe("GatewayClient connect auth payload", () => { await waitForFast(() => expect(onHelloOk).toHaveBeenCalledOnce()); v3Ws.emitClose(1012, "gateway upgrading"); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(2), { timeout: 3_000 }); - const v3UpgradeProbeWs = getLatestWs(); + const v3UpgradeProbeWs = await advanceToNextReconnect(); v3UpgradeProbeWs.emitOpen(); emitConnectChallenge(v3UpgradeProbeWs, "nonce-v3-upgrade-probe"); const v3UpgradeProbe = connectRequestFrom(v3UpgradeProbeWs); @@ -1582,8 +1581,7 @@ describe("GatewayClient connect auth payload", () => { "protocol mismatch", ); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(3), { timeout: 3_000 }); - const v4Ws = getLatestWs(); + const v4Ws = await advanceToNextReconnect(); v4Ws.emitOpen(); emitConnectChallenge(v4Ws, "nonce-v4-before-rollback"); const v4Connect = connectRequestFrom(v4Ws); @@ -1594,8 +1592,7 @@ describe("GatewayClient connect auth payload", () => { "protocol mismatch", ); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(4), { timeout: 3_000 }); - const recoveredV3Ws = getLatestWs(); + const recoveredV3Ws = await advanceToNextReconnect(); recoveredV3Ws.emitOpen(); emitConnectChallenge(recoveredV3Ws, "nonce-v3-after-rollback"); expect(connectRequestFrom(recoveredV3Ws).params).toMatchObject({ @@ -1880,8 +1877,7 @@ describe("GatewayClient connect auth payload", () => { params.failureDetails, params.failureMessage, ); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 }); - const ws = getLatestWs(); + const ws = await advanceToNextReconnect(); ws.emitOpen(); emitConnectChallenge(ws, "nonce-2"); return connectFrameFrom(ws); @@ -2458,8 +2454,7 @@ describe("GatewayClient connect auth payload", () => { emitHelloOk(ws, connect.id); await waitForFast(() => expect(onHelloOk).toHaveBeenCalledOnce()); ws.emitClose(1006, "socket lost"); - await waitForFast(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 }); - const reconnect = getLatestWs(); + const reconnect = await advanceToNextReconnect(); reconnect.emitOpen(); emitConnectChallenge(reconnect, "nonce-reconnect"); expect(connectFrameFrom(reconnect)).toMatchObject({ From 5235269b3c3807f4be3acfc5755b169744a0ec7e Mon Sep 17 00:00:00 2001 From: James Tatum Date: Wed, 12 Aug 2026 03:55:38 -0700 Subject: [PATCH 06/14] =?UTF-8?q?fix(agents):=20heartbeats=20lose=20all=20?= =?UTF-8?q?conversation=20context=20on=20claude-cli=20chat=E2=86=94heartbe?= =?UTF-8?q?at=20transitions=20(#121509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agents): resolve session-stable reply mode for direct resolver turns Heartbeat wakes and other direct getReplyFromConfig callers bypass dispatch, so no session-stable delivery mode reached their CLI binding facts and prepare computed no messageToolPolicyHash, while dispatched chat turns hashed the stable mode. resolveCliSessionReuse treats that one-sided hash as a policy change, hard-invalidating the binding on every chat<->heartbeat transition and leaving heartbeats to run in fresh CLI sessions with no conversation history (historyPrompt=none). Extract the synthetic-turn stable-mode resolution that prepareAgentCommandExecution already used into resolveSessionStableReplyMode, and apply it in prepareReplyRunContext whenever a synthetic turn arrives without dispatch's injected mode, so every turn kind on a session derives the same binding facts and hash. Fixes #121485 Co-Authored-By: Claude Fable 5 * fix(agents): derive synthetic binding facts from the dispatch policy owner Consolidate the synthetic-turn stable-mode resolution onto dispatch's own policy pieces instead of a simplified parallel resolver: - resolveVisibleRepliesPolicy is extracted from dispatch prepare-context (configured visible-replies guard + harness default chain) and shared, so the fallback and dispatch cannot drift on harness defaults, live-vs-entry chat facts, or ExplicitDeliverRoute. - The stable fact now applies dispatch's messageToolAvailable downgrade via the canonical tool-policy resolvers: tool-only delivery with a policy-denied message tool records automatic, matching dispatch. - Synthetic turns no longer fall back to their effective turn mode: a response-tool heartbeat's message_tool_only is per-turn enforcement, not session policy, and previously bypassed the stable fallback entirely. Regression coverage: response-tool heartbeat facts match dispatched turns, and a message-tool-denied config downgrades the synthetic stable fact. Refs #121485 Co-Authored-By: Claude Fable 5 * fix(agents): keep the session-stable reply mode sender-independent Dispatch's stable-mode downgrade used the turn's sender-aware message-tool availability, so a sender-scoped message denial hashed the stable policy as automatic on chat turns while sender-less synthetic turns hashed tool-only — the same binding reset loop on another axis. resolveStableMessageToolAvailability becomes the one sender-independent owner: dispatch passes it as sessionStableMessageToolAvailable for the stable-mode resolution only (effective per-turn enforcement keeps the sender-aware verdict), and synthetic binding facts already consume it. Computed only when the visible-replies candidate is message_tool. Refs #121485 Co-Authored-By: Claude Fable 5 * fix(agents): derive stable reply facts from session surfaces, not wake plumbing Third-pass review fixes on the #121485 consolidation: - Strip system-event wake providers ("heartbeat", "cron-event") from the stable context and resolve origin-less entries as internal, so synthetic facts take the same internal-channel branch dispatch's live webchat turns do instead of falling through to harness defaults. - Fall back to persisted session facts (delivery channel/origin, groupId, groupChannel/subject, accountId) for the sender-independent availability stack, so bare-ctx callers like command prepare resolve the same group/account-scoped policies as dispatched turns; dispatch now passes its session entry too. - Collapse the redundant second mode resolution into the availability downgrade, surface dispatch's injected stable mode separately from resolvePromptSourceReplyMode so the synthetic slot has one owner, and reuse resolveTurnModelOverride instead of an inline copy. - Refresh docs/.generated/plugin-sdk-api-baseline.jsonl: closure hashes only (17 exports, zero declaration changes, verified) — plugin-sdk reply/meeting entrypoints transitively bundle the reply pipeline this branch refactors. Regression: origin-less entry heartbeat resolves internal-automatic facts. Refs #121485 Co-Authored-By: Claude Fable 5 * chore: adapt to main module moves and split API baseline Mechanical rebase refresh onto d3696f8d769: subagent-capabilities moved to src/agents/subagents/spawn/, and the plugin-sdk API baseline is now per-entrypoint content hashes (#122082) — regenerated for the reply-pipeline closure this branch touches. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Ayaan Zaidi --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- src/agents/command/prepare.ts | 48 +---- .../dispatch-from-config.harness-defaults.ts | 45 ++++- .../dispatch-from-config.prepare-context.ts | 44 +++-- src/auto-reply/reply/get-reply-run-context.ts | 37 +++- .../reply/get-reply-run-source-mode.ts | 8 +- .../reply/get-reply-run.media-only.test.ts | 145 ++++++++++++++ .../reply/session-stable-reply-mode.ts | 182 ++++++++++++++++++ .../reply/source-reply-delivery-mode.test.ts | 34 ++++ .../reply/source-reply-delivery-mode.ts | 10 +- 25 files changed, 502 insertions(+), 83 deletions(-) create mode 100644 src/auto-reply/reply/session-stable-reply-mode.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 72ef38a36716..2c86504e3d73 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"656879cbd7d8c60a593ea053ccb8ba0536d4b5eb5e4ae61755a31d3e18a49c42","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"59fb241bbe47669d5d7f99a945a18d86f318b9af8c4c281bb11f01506c84ebbf","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index c8eaa76bb235..d8bd1df6bd82 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"72bf26feecd57463e402f8ed15e6b4edba23fc8276ebc2f6ae55d016a4fef381","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"b7195e12bbe582de01faacc9d3a22f5bc30e0210f7b0c03d3b6242b969f7a0be","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index e6555eb6de7e..c916c343013f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"7c1094e554ba440a2204f18658611682a101e12dc3d79c1005ae8f40fd766d52","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"04848d6e3e592c5edf260afa6ab27452770f99ef09e110809fab0813fd1671b0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index b2f89580b410..02b270894ae1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"6bad9d034375b1641f89f78f224826127f9e8b74f29cd9086d24ac721e8bd714","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"656e29f955e218c9c35843a876eecd769b722e64fb8ea1849f266fa9a430ffd1","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index b77980e91d7b..b63227e93569 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"74ba73d4b1b1e114886afabfb3942d72e6062b07486cd2d6f910c61d7183dabe","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"f57838a5ab613e8e168c74cb309beef813587f5465ff80f18b8171320f579e7a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index cf41cb8284f8..fa5b42274078 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"e71a02577d9098115855236dfb546ca2c2a5d05c855ae0c30d90f1fdcf3fad6a","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"0b81e57bcb56659f98b52d55a1dea6f987bae23500a62d56daf4e86065929ac9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 8b06f34ab80d..5e031f0b5935 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"b902346ba2e50c3ebf787aefa8b3415cd65fb87000d42278a6db9ee4960f9aa1","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"57eaf2d515736c9a6880da9434dd6456e7744e75ac8a946709d9e5f30b166aed","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 986d3c27c4cb..872ff135b1d1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"c1deb3dd9ab64a993067f7f736ae31b40dee5b96e03e2196c3c482cd911ac0ff","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"bb239ef24687ac9b89335858dff0008f3b861546c242e3af3545fbcae1878309","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 05cfb1cee958..87ac95ea1cca 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"4f8b3a784b00784d7a250b5489bbc20d099f824b8d9ff914dd8d0c93389b07de","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"34a3ec8d182434d7682a6ae2258a4b1797ffc20f62a7a0449ca3055ccc8c69c5","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 727ebf40a932..cf22bb77fc49 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"1a0fef1510e351de8902c038e6cde7915707897dcc999455806e5063cb75af18","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"080a0e85aaff22d1e860781d779254461b05e40e4af84b37197b9ae6281ca566","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index d0bbfd77ae0f..c8116ff80bce 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"3cf394e9cb5f76a7fa44727b7e13adcd8dff1b9d4b5b48e9ac2d0adc06f17a0e","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"ecf1ca74a42f003b046e3bf2578bae03c609050c6cb16fa741f763d692937e67","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index a03fc757016b..0cc601906bfe 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"1489259884d6df7354edd810aa984442ac3851451ba5991c85f491c99357281a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"a8e907e41641913ebb36b982bb320090197af1408a0196672c15d038883cf1e0","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index d0f9cde6da22..4c888362e736 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"af318d3b8a4edec2d40ad123b97e076910c09b7d517cb03588a5f3c20b808f31","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"8d4c89bf2968217111f333c149b51bb6798dd961a62fbafeef1c19f2473e7ab6","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 0329732a7a95..27cdf069b69c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"e04b2dea0c6c27657372b826db2870cd399ed9f13d5d76d006dd71ac60024187","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"5772bc63ec00a1de6e76827a9a8f79c1ed61493d07f6c2bb21f50c18a806e187","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index b6ec2050baf4..5dc9f0ffad81 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"10bdd5bfc10ab30dad145d36d7a78a052f52960c4f8e534a0ba258f5f777dba5","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"993018e34f2e7951c2a0412a4df0cd4be50256da04f3d6a539942c3a8168da24","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 0f7db34d13d5..3d26f79a7fcb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"56937e6aa9f4060b93d632f4271bbaa9ba992a7a2fe4c9a6f4b5a5c90b0a41da","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"079ce3652967694b58caa87ed04cdaba07b3766d82c491faa9a6f974d584e18b","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/src/agents/command/prepare.ts b/src/agents/command/prepare.ts index 7924c0700430..f664926a3a49 100644 --- a/src/agents/command/prepare.ts +++ b/src/agents/command/prepare.ts @@ -1,8 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { - isSyntheticSourceReplyTurn, - resolveSourceReplyDeliveryMode, -} from "../../auto-reply/reply/source-reply-delivery-mode.js"; +import { resolveSessionStableReplyMode } from "../../auto-reply/reply/session-stable-reply-mode.js"; +import { isSyntheticSourceReplyTurn } from "../../auto-reply/reply/source-reply-delivery-mode.js"; import { formatThinkingLevels, normalizeThinkLevel, @@ -31,10 +29,6 @@ import { resolveAgentHarnessSessionContextError, } from "../../sessions/agent-harness-session-key.js"; import { resolveUserPath } from "../../utils.js"; -import { - sessionDeliveryChannel, - sessionDeliveryOrigin, -} from "../../utils/delivery-context.shared.js"; import { isDeliverableMessageChannel, resolveMessageChannel } from "../../utils/message-channel.js"; import { resolveAgentRuntimeConfig } from "../agent-runtime-config.js"; import { @@ -45,7 +39,6 @@ import { resolveAgentWorkspaceDir, } from "../agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; -import { selectAgentHarness } from "../harness/selection.js"; import { AGENT_LANE_SUBAGENT } from "../lanes.js"; import type { ModelManifestNormalizationContext } from "../model-ref-shared.js"; import { buildConfiguredModelCatalog, resolveConfiguredModelRef } from "../model-selection.js"; @@ -345,42 +338,15 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti isHeartbeat: commandOpts.bootstrapContextRunKind === "heartbeat", }) ) { - // Lifecycle turns keep their effective delivery mode, but CLI reuse belongs - // to the existing session's normal source-reply policy. - const stableReplyContext = { - CommandAuthorized: false, - ChatType: sessionEntryRaw.chatType, - Provider: sessionDeliveryOrigin(sessionEntryRaw)?.provider, - Surface: sessionDeliveryChannel(sessionEntryRaw), - InputProvenance: commandOpts.inputProvenance, - }; - const stableProvider = sessionEntryRaw.modelProvider ?? configuredModel.provider; - const stableModel = sessionEntryRaw.model ?? configuredModel.model; - const stableRuntime = resolveEffectiveAgentRuntime({ - cfg, - provider: stableProvider, - modelId: stableModel, - agentId: sessionAgentId, - sessionKey, - sessionEntry: sessionEntryRaw, - }); - const harness = selectAgentHarness({ - provider: stableProvider, - modelId: stableModel, - config: cfg, - agentId: sessionAgentId, - sessionKey, - agentHarnessRuntimeOverride: stableRuntime, - }); - const defaultVisibleReplies = - harness.deliveryDefaults?.visibleReplies ?? harness.deliveryDefaults?.sourceVisibleReplies; commandOpts = { ...commandOpts, cliSessionBindingFacts: { - sourceReplyDeliveryMode: resolveSourceReplyDeliveryMode({ + sourceReplyDeliveryMode: resolveSessionStableReplyMode({ cfg, - ctx: stableReplyContext, - defaultVisibleReplies, + ctx: { CommandAuthorized: false }, + sessionEntry: sessionEntryRaw, + sessionAgentId, + sessionKey, }), }, }; diff --git a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts index 46939198b5ca..c318fa2dba58 100644 --- a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts +++ b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts @@ -24,7 +24,6 @@ import { loadSessionStoreEntry, resolveSessionStorePathCore, } from "./dispatch-from-config.runtime.js"; -import type { DispatchFromConfigParams } from "./dispatch-from-config.types.js"; import { resolveStoredModelOverride } from "./stored-model-override.js"; type HarnessSourceVisibleRepliesDefault = "automatic" | "message_tool"; @@ -98,7 +97,7 @@ function resolveHarnessDefaultParentSessionKey(params: { } export function resolveTurnModelOverride( - replyOptions: DispatchFromConfigParams["replyOptions"], + replyOptions: { isHeartbeat?: boolean; heartbeatModelOverride?: string } | undefined, ): string | undefined { if (replyOptions?.isHeartbeat !== true) { return undefined; @@ -205,7 +204,47 @@ function resolveModelOverrideCandidate(params: { })?.ref; } -export function resolveHarnessSourceVisibleRepliesDefault(params: { +/** + * Resolves the configured visible-replies mode plus the guarded harness + * default. One owner for dispatch and synthetic-turn binding facts: both must + * derive the same session-stable delivery mode or CLI session bindings + * ping-pong across turn kinds (#121485). + */ +export function resolveVisibleRepliesPolicy(params: { + cfg: OpenClawConfig; + chatType?: string; + ctx: FinalizedMsgContext; + entry?: SessionEntry; + sessionAgentId: string; + sessionKey?: string; + sessionStore?: Record; + turnModelOverride?: string; +}): { + configuredVisibleReplies?: "automatic" | "message_tool"; + harnessDefaultVisibleReplies?: "automatic" | "message_tool"; +} { + const configuredVisibleReplies = + params.chatType === "group" || params.chatType === "channel" + ? (params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies) + : params.cfg.messages?.visibleReplies; + const harnessDefaultVisibleReplies = + configuredVisibleReplies === undefined && + params.chatType !== "group" && + params.chatType !== "channel" + ? resolveHarnessSourceVisibleRepliesDefault({ + cfg: params.cfg, + ctx: params.ctx, + entry: params.entry, + sessionAgentId: params.sessionAgentId, + sessionKey: params.sessionKey, + sessionStore: params.sessionStore, + turnModelOverride: params.turnModelOverride, + }) + : undefined; + return { configuredVisibleReplies, harnessDefaultVisibleReplies }; +} + +function resolveHarnessSourceVisibleRepliesDefault(params: { cfg: OpenClawConfig; ctx: FinalizedMsgContext; entry?: SessionEntry; diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts index 275e0236b185..fc399771a3f3 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts @@ -35,8 +35,8 @@ import { } from "./dispatch-from-config.context.js"; import type { PluginBindingTranscriptOwner } from "./dispatch-from-config.events.js"; import { - resolveHarnessSourceVisibleRepliesDefault, resolveTurnModelOverride, + resolveVisibleRepliesPolicy, } from "./dispatch-from-config.harness-defaults.js"; import { extendPreparedDispatchState } from "./dispatch-from-config.phase-state.js"; import type { PrepareDispatchDeliveryReadyState } from "./dispatch-from-config.prepare-delivery.js"; @@ -46,6 +46,7 @@ import { emitMessageReceivedHooks as emitSharedMessageReceivedHooks } from "./me import { resolveOriginMessageProvider } from "./origin-routing.js"; import { waitForReplyDispatcherIdle } from "./reply-dispatcher.js"; import { isDuplicateRestartRecoverySource } from "./restart-recovery-claim.js"; +import { resolveStableMessageToolAvailability } from "./session-stable-reply-mode.js"; import { isExplicitSourceReplyCommand, isUnauthorizedTextSlashCommand, @@ -222,22 +223,16 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli ? cfg.surfaces?.[silentReplySurface]?.silentReply : undefined, }) === "allow"; - const configuredVisibleReplies = - chatType === "group" || chatType === "channel" - ? (cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.visibleReplies) - : cfg.messages?.visibleReplies; - const harnessDefaultVisibleReplies = - configuredVisibleReplies === undefined && chatType !== "group" && chatType !== "channel" - ? resolveHarnessSourceVisibleRepliesDefault({ - cfg, - ctx, - entry: sessionStoreEntry.entry, - sessionAgentId, - sessionKey: acpDispatchSessionKey, - sessionStore: sessionStoreEntry.store, - turnModelOverride: resolveTurnModelOverride(params.replyOptions), - }) - : undefined; + const { configuredVisibleReplies, harnessDefaultVisibleReplies } = resolveVisibleRepliesPolicy({ + cfg, + chatType, + ctx, + entry: sessionStoreEntry.entry, + sessionAgentId, + sessionKey: acpDispatchSessionKey, + sessionStore: sessionStoreEntry.store, + turnModelOverride: resolveTurnModelOverride(params.replyOptions), + }); const effectiveVisibleReplies = configuredVisibleReplies ?? harnessDefaultVisibleReplies; const prefersMessageToolDelivery = params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" || @@ -299,6 +294,20 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli subagentPolicy, inheritedToolPolicy, ]); + // The stable mode's tool-only downgrade must be sender-independent, or a + // sender-scoped message denial hashes a different binding policy than the + // sender-less synthetic turns on the same session. Only tool-only candidates + // can downgrade, so skip the second policy pass otherwise. + const sessionStableMessageToolAvailable = + effectiveVisibleReplies === "message_tool" + ? resolveStableMessageToolAvailability({ + cfg, + ctx, + sessionEntry: sessionStoreEntry.entry, + sessionAgentId, + sessionKey: acpDispatchSessionKey, + }) + : undefined; const sourceReplyPolicyParams = { cfg, ctx, @@ -308,6 +317,7 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli explicitSuppressTyping: params.replyOptions?.suppressTyping === true, shouldSuppressTyping: state.shouldSuppressTyping, messageToolAvailable, + sessionStableMessageToolAvailable, isHeartbeat: params.replyOptions?.isHeartbeat, } as const; let sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({ diff --git a/src/auto-reply/reply/get-reply-run-context.ts b/src/auto-reply/reply/get-reply-run-context.ts index 36346eda876b..40971b686ff7 100644 --- a/src/auto-reply/reply/get-reply-run-context.ts +++ b/src/auto-reply/reply/get-reply-run-context.ts @@ -24,6 +24,7 @@ import { resolveEnvelopeFormatOptions } from "../envelope.js"; import { normalizeThinkLevel } from "../thinking.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { applySessionHints } from "./body.js"; +import { resolveTurnModelOverride } from "./dispatch-from-config.harness-defaults.js"; import { shouldUseReplyFastTestRuntime } from "./get-reply-fast-path.js"; import { buildExecOverridePromptHint, @@ -49,7 +50,11 @@ import { resolveBareResetBootstrapFileAccess, resolveBareSessionResetPromptState, } from "./session-reset-prompt.js"; -import { isExplicitSourceReplyCommand } from "./source-reply-delivery-mode.js"; +import { resolveSessionStableReplyMode } from "./session-stable-reply-mode.js"; +import { + isExplicitSourceReplyCommand, + isSyntheticSourceReplyTurn, +} from "./source-reply-delivery-mode.js"; import { shouldApplyStartupContext, buildSessionStartupContextPrelude } from "./startup-context.js"; import { resolveTypingMode } from "./typing-mode.js"; import { resolveRunTypingPolicy } from "./typing-policy.js"; @@ -107,8 +112,34 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) { isHeartbeat, }); const inboundEventKind = promptSessionCtx.InboundEventKind; - const { sourceReplyDeliveryMode, sessionPromptSourceReplyDeliveryMode } = - resolvePromptSourceReplyMode({ promptSessionCtx, opts }); + const { sourceReplyDeliveryMode, injectedSessionStableMode } = resolvePromptSourceReplyMode({ + promptSessionCtx, + opts, + }); + // Direct resolver callers (heartbeat wakes, system events) skip dispatch's + // stable-mode injection; resolve the same session-stable fact here so their + // binding facts and messageToolPolicyHash match dispatched chat turns — + // otherwise chat<->heartbeat transitions ping-pong the CLI session (#121485). + // Synthetic turns must not fall back to their effective turn mode: a + // response-tool heartbeat's message_tool_only is per-turn enforcement, not + // session policy, and hashing it recreates the ping-pong. + const isSyntheticTurn = isSyntheticSourceReplyTurn({ + inputProvenance: promptSessionCtx.InputProvenance, + isHeartbeat, + }); + const sessionPromptSourceReplyDeliveryMode = + injectedSessionStableMode ?? + (isSyntheticTurn && sessionEntry + ? resolveSessionStableReplyMode({ + cfg, + ctx: { ...promptSessionCtx, CommandAuthorized: false }, + sessionEntry, + sessionAgentId: agentId, + sessionKey, + sessionStore, + turnModelOverride: resolveTurnModelOverride(opts), + }) + : sourceReplyDeliveryMode); const silentReplyConversationType = resolvePromptSilentReplyConversationType({ ctx: promptSessionCtx, inboundSessionKey: ctx.SessionKey, diff --git a/src/auto-reply/reply/get-reply-run-source-mode.ts b/src/auto-reply/reply/get-reply-run-source-mode.ts index a2c80a39dd41..d99736ca5e50 100644 --- a/src/auto-reply/reply/get-reply-run-source-mode.ts +++ b/src/auto-reply/reply/get-reply-run-source-mode.ts @@ -2,6 +2,11 @@ import type { TemplateContext } from "../templating.js"; import type { InternalGetReplyOptions } from "./get-reply-run.types.js"; import { isInternalSourceReplyChannel } from "./source-reply-delivery-mode.js"; +/** + * Resolves the turn's effective source-reply mode and surfaces dispatch's + * injected session-stable mode separately, so the caller owns the synthetic + * fallback in one place instead of un-mixing the two afterwards. + */ export function resolvePromptSourceReplyMode(params: { promptSessionCtx: TemplateContext; opts?: InternalGetReplyOptions; @@ -15,7 +20,6 @@ export function resolvePromptSourceReplyMode(params: { : params.opts?.sourceReplyDeliveryMode; return { sourceReplyDeliveryMode, - sessionPromptSourceReplyDeliveryMode: - params.opts?.sessionPromptSourceReplyDeliveryMode ?? sourceReplyDeliveryMode, + injectedSessionStableMode: params.opts?.sessionPromptSourceReplyDeliveryMode, }; } diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 96c19fcb83c1..148f8a38cbbd 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -3090,6 +3090,16 @@ describe("runPreparedReply media-only handling", () => { sourceReplyDeliveryMode ?? "automatic", ].join(":"), ); + // The direct-caller heartbeat run below resolves the stable mode from + // config instead of injected opts; keep both sources agreeing per case. + const caseCfg = { + session: {}, + channels: {}, + agents: { defaults: {} }, + ...(stableMode === "message_tool_only" + ? { messages: { visibleReplies: "message_tool" as const } } + : {}), + }; const sessionEntry: SessionEntry = { sessionId: "session-telegram-group", updatedAt: 1, @@ -3107,6 +3117,7 @@ describe("runPreparedReply media-only handling", () => { }; await runPrepared({ + cfg: caseCfg, opts: { sourceReplyDeliveryMode: "message_tool_only", sessionPromptSourceReplyDeliveryMode: stableMode, @@ -3125,6 +3136,7 @@ describe("runPreparedReply media-only handling", () => { }, }); await runPrepared({ + cfg: caseCfg, opts: { sourceReplyDeliveryMode: stableMode, sessionPromptSourceReplyDeliveryMode: stableMode, @@ -3142,6 +3154,7 @@ describe("runPreparedReply media-only handling", () => { }, }); await runPrepared({ + cfg: caseCfg, opts: { isHeartbeat: true, sourceReplyDeliveryMode: stableMode, @@ -3160,10 +3173,50 @@ describe("runPreparedReply media-only handling", () => { Provider: "cron-event", }, }); + // Production heartbeat wakes call the reply resolver directly, without + // dispatch's injected delivery modes; their binding facts must still + // match dispatched turns or the CLI session ping-pongs (#121485). + await runPrepared({ + cfg: caseCfg, + opts: { isHeartbeat: true }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:telegram:-100123", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + }, + }); + // Response-tool heartbeats carry an effective message_tool_only turn + // mode; that is per-turn enforcement and must not become the session + // policy fact, or these heartbeats keep ping-ponging the binding. + await runPrepared({ + cfg: caseCfg, + opts: { isHeartbeat: true, sourceReplyDeliveryMode: "message_tool_only" }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:telegram:-100123", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + }, + }); const roomEventRun = requireRunReplyAgentCall(0).followupRun.run; const primaryRun = requireRunReplyAgentCall(1).followupRun.run; const heartbeatRun = requireRunReplyAgentCall(2).followupRun.run; + const directHeartbeatRun = requireRunReplyAgentCall(3).followupRun.run; + const responseToolHeartbeatRun = requireRunReplyAgentCall(4).followupRun.run; expect(roomEventRun.sourceReplyDeliveryMode).toBe("message_tool_only"); expect(primaryRun.sourceReplyDeliveryMode).toBe(stableMode); expect(heartbeatRun.sourceReplyDeliveryMode).toBe(stableMode); @@ -3180,9 +3233,101 @@ describe("runPreparedReply media-only handling", () => { }); expect(primaryRun.cliSessionBindingFacts).toEqual(roomEventRun.cliSessionBindingFacts); expect(heartbeatRun.cliSessionBindingFacts).toEqual(roomEventRun.cliSessionBindingFacts); + expect(directHeartbeatRun.cliSessionBindingFacts).toEqual( + roomEventRun.cliSessionBindingFacts, + ); + expect(responseToolHeartbeatRun.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(responseToolHeartbeatRun.cliSessionBindingFacts).toEqual( + roomEventRun.cliSessionBindingFacts, + ); }, ); + it("resolves origin-less sessions as internal for synthetic stable facts", async () => { + vi.mocked(buildDirectChatContext).mockReturnValue("direct-context"); + // An entry with no persisted delivery origin has only ever been driven + // internally; the wake provider ("heartbeat") must not leak into the + // stable context as a non-internal surface or the fact diverges from + // dispatch's live webchat turns. + const sessionEntry: SessionEntry = { + sessionId: "session-internal", + updatedAt: 1, + systemSent: true, + chatType: "direct", + }; + + await runPrepared({ + cfg: { session: {}, channels: {}, agents: { defaults: {} } }, + opts: { isHeartbeat: true }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:main", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + ChatType: "direct", + }, + }); + + const run = requireRunReplyAgentCall(0).followupRun.run; + expect(run.cliSessionBindingFacts?.sourceReplyDeliveryMode).toBe("automatic"); + }); + + it("downgrades the synthetic stable mode when the message tool is policy-denied", async () => { + vi.mocked(buildGroupChatContext).mockImplementation(({ sourceReplyDeliveryMode }) => + ["group", sourceReplyDeliveryMode ?? "automatic"].join(":"), + ); + const sessionEntry: SessionEntry = { + sessionId: "session-telegram-group", + updatedAt: 1, + systemSent: true, + chatType: "group", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "-100123" }, + origin: { + provider: "telegram", + surface: "telegram", + chatType: "group", + to: "-100123", + }, + }), + }; + + // Tool-only delivery configured, but the message tool is denied: dispatch + // downgrades its stable mode to automatic, so the synthetic fallback must + // record automatic too or the binding hashes diverge again. + await runPrepared({ + cfg: { + session: {}, + channels: {}, + agents: { defaults: {} }, + messages: { visibleReplies: "message_tool" as const }, + tools: { deny: ["message"] }, + }, + opts: { isHeartbeat: true }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:telegram:-100123", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + }, + }); + + const run = requireRunReplyAgentCall(0).followupRun.run; + expect(run.cliSessionBindingFacts?.sourceReplyDeliveryMode).toBe("automatic"); + }); + it("keeps per-message room-event metadata out of CLI binding facts", async () => { vi.mocked(buildGroupChatContext).mockImplementation(({ sessionCtx, sourceReplyDeliveryMode }) => [ diff --git a/src/auto-reply/reply/session-stable-reply-mode.ts b/src/auto-reply/reply/session-stable-reply-mode.ts new file mode 100644 index 000000000000..a7ed993dfef8 --- /dev/null +++ b/src/auto-reply/reply/session-stable-reply-mode.ts @@ -0,0 +1,182 @@ +// Session-stable source-reply mode for synthetic turns (heartbeat wakes, +// system events, inter-session announcements) that reach the reply resolver +// without dispatch's injected delivery-mode facts. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + resolveEffectiveToolPolicy, + resolveGroupToolPolicy, + resolveInheritedToolPolicyForSession, + resolveSubagentToolPolicyForSession, +} from "../../agents/agent-tools.policy.js"; +import { + isSubagentEnvelopeSession, + resolveSubagentCapabilityStore, +} from "../../agents/subagents/spawn/subagent-capabilities.js"; +import { isToolAllowedByPolicies } from "../../agents/tool-policy-match.js"; +import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../agents/tool-policy.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { resolveGroupSessionKey } from "../../config/sessions/group.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + deliveryContextFromSession, + sessionDeliveryChannel, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; +import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js"; +import type { FinalizedMsgContext } from "../templating.js"; +import { resolveVisibleRepliesPolicy } from "./dispatch-from-config.harness-defaults.js"; +import { isSystemEventProvider } from "./effective-reply-route.js"; +import { resolveOriginMessageProvider } from "./origin-routing.js"; +import { resolveSourceReplyDeliveryMode } from "./source-reply-delivery-mode.js"; + +/** + * Resolves the session's stable source-reply mode the way dispatch does, from + * a synthetic turn's restored context plus persisted session facts. Synthetic + * turns keep their effective delivery mode, but CLI session reuse belongs to + * the session's normal source-reply policy — every turn kind must derive the + * same messageToolPolicyHash, or chat and heartbeat turns ping-pong the CLI + * binding on each transition (#121485). + */ +export function resolveSessionStableReplyMode(params: { + cfg: OpenClawConfig; + ctx: FinalizedMsgContext; + sessionEntry: SessionEntry; + sessionAgentId: string; + sessionKey?: string; + sessionStore?: Record; + turnModelOverride?: string; +}): SourceReplyDeliveryMode { + const { cfg, ctx, sessionEntry } = params; + const chatType = + normalizeChatType(ctx.ChatType) ?? normalizeChatType(sessionEntry.chatType) ?? undefined; + // System-event provider strings ("heartbeat", "cron-event") are wake + // plumbing, not the session's surface; an entry with no persisted delivery + // origin has only ever been driven internally, so it must resolve the same + // internal-channel branch dispatch's live webchat turns do. + const stableReplyContext = { + CommandAuthorized: false, + ChatType: chatType, + Provider: + resolveStableChannelFact(ctx.Provider) ?? + sessionDeliveryOrigin(sessionEntry)?.provider ?? + INTERNAL_MESSAGE_CHANNEL, + Surface: resolveStableChannelFact(ctx.Surface) ?? sessionDeliveryChannel(sessionEntry), + ExplicitDeliverRoute: ctx.ExplicitDeliverRoute, + }; + const { harnessDefaultVisibleReplies } = resolveVisibleRepliesPolicy({ + cfg, + chatType, + ctx, + entry: sessionEntry, + sessionAgentId: params.sessionAgentId, + sessionKey: params.sessionKey, + sessionStore: params.sessionStore, + turnModelOverride: params.turnModelOverride, + }); + const candidateMode = resolveSourceReplyDeliveryMode({ + cfg, + ctx: stableReplyContext, + defaultVisibleReplies: harnessDefaultVisibleReplies, + }); + if (candidateMode !== "message_tool_only") { + return candidateMode; + } + // Dispatch downgrades tool-only delivery to automatic when the message tool + // is policy-denied (source-reply-delivery-mode.ts availability gate); with a + // stable ctx that is the boolean's only effect, so apply it directly rather + // than re-deriving the whole mode. Sender fields are deliberately absent: + // session-stable policy cannot vary by sender. + return resolveStableMessageToolAvailability(params) ? candidateMode : "automatic"; +} + +/** Strips system-event wake providers so only real channel surfaces remain. */ +function resolveStableChannelFact(value: string | undefined): string | undefined { + const normalized = normalizeOptionalString(value); + return normalized && !isSystemEventProvider(normalized) ? normalized : undefined; +} + +/** + * Sender-independent message-tool availability for the session-stable mode. + * One owner for dispatch's stable-mode downgrade and synthetic-turn binding + * facts: sender-scoped denials apply to the sender's turn, never to the + * session policy every turn kind must hash identically (#121485). + */ +export function resolveStableMessageToolAvailability(params: { + cfg: OpenClawConfig; + ctx: FinalizedMsgContext; + sessionEntry?: SessionEntry; + sessionAgentId: string; + sessionKey?: string; +}): boolean { + const { cfg, ctx, sessionEntry } = params; + const { + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, + profile, + providerProfile, + profileAlsoAllow, + providerProfileAlsoAllow, + } = resolveEffectiveToolPolicy({ + config: cfg, + sessionKey: params.sessionKey, + agentId: params.sessionAgentId, + }); + // Tool-only delivery force-allows the message tool at the profile layer + // (dispatch's runtimeProfileAlsoAllow); only outer deny layers can make it + // unavailable. + const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), [ + ...(profileAlsoAllow ?? []), + "message", + ]); + const providerProfilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(providerProfile), [ + ...(providerProfileAlsoAllow ?? []), + "message", + ]); + // Direct callers (command prepare, synthetic wakes) may carry a bare ctx; + // fall back to the persisted session facts dispatch sees on live turns, or + // group/account-scoped policies resolve differently per producer. + const groupPolicy = resolveGroupToolPolicy({ + config: cfg, + sessionKey: params.sessionKey, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: + ctx.OriginatingChannel ?? (sessionEntry ? sessionDeliveryChannel(sessionEntry) : undefined), + provider: + resolveStableChannelFact(ctx.Provider ?? ctx.Surface) ?? + (sessionEntry ? sessionDeliveryOrigin(sessionEntry)?.provider : undefined), + }), + groupId: resolveGroupSessionKey(ctx)?.id ?? sessionEntry?.groupId, + groupChannel: + normalizeOptionalString(ctx.GroupChannel) ?? + normalizeOptionalString(ctx.GroupSubject) ?? + normalizeOptionalString(sessionEntry?.groupChannel) ?? + normalizeOptionalString(sessionEntry?.subject), + groupSpace: normalizeOptionalString(ctx.GroupSpace), + accountId: + ctx.AccountId ?? + (sessionEntry ? deliveryContextFromSession(sessionEntry)?.accountId : undefined), + }); + const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, { cfg }); + const subagentPolicy = + params.sessionKey && isSubagentEnvelopeSession(params.sessionKey, { cfg, store: subagentStore }) + ? resolveSubagentToolPolicyForSession(cfg, params.sessionKey, { store: subagentStore }) + : undefined; + const inheritedToolPolicy = resolveInheritedToolPolicyForSession(cfg, params.sessionKey, { + store: subagentStore, + }); + return isToolAllowedByPolicies("message", [ + profilePolicy, + providerProfilePolicy, + globalProviderPolicy, + agentProviderPolicy, + globalPolicy, + agentPolicy, + groupPolicy, + subagentPolicy, + inheritedToolPolicy, + ]); +} diff --git a/src/auto-reply/reply/source-reply-delivery-mode.test.ts b/src/auto-reply/reply/source-reply-delivery-mode.test.ts index 20feeb74c77e..e9ef1dfc980e 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.test.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.test.ts @@ -436,6 +436,40 @@ describe("resolveSourceReplyVisibilityPolicy", () => { }, ); + it("keeps the stable mode tool-only under a sender-scoped message denial", () => { + // A sender-scoped denial downgrades the sender's effective delivery, but + // the session-stable mode feeds CLI binding facts shared by sender-less + // synthetic turns; downgrading it too splits the policy hash and resets + // the CLI session on chat<->heartbeat transitions. + expectPolicyFields( + resolveSourceReplyVisibilityPolicy({ + cfg: globalToolOnlyReplyConfig, + ctx: { ChatType: "direct" }, + sendPolicy: "allow", + messageToolAvailable: false, + sessionStableMessageToolAvailable: true, + }), + { + sourceReplyDeliveryMode: "automatic", + sessionStableSourceReplyDeliveryMode: "message_tool_only", + }, + ); + // Without a sender-independent verdict, the stable mode still follows the + // turn's availability (session-wide denials downgrade both). + expectPolicyFields( + resolveSourceReplyVisibilityPolicy({ + cfg: globalToolOnlyReplyConfig, + ctx: { ChatType: "direct" }, + sendPolicy: "allow", + messageToolAvailable: false, + }), + { + sourceReplyDeliveryMode: "automatic", + sessionStableSourceReplyDeliveryMode: "automatic", + }, + ); + }); + it("suppresses automatic source delivery for opted-in message-tool group turns without suppressing typing", () => { expectPolicyFields( resolveSourceReplyVisibilityPolicy({ diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index 72979c828adc..e476f03b2b88 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -153,6 +153,13 @@ export function resolveSourceReplyVisibilityPolicy(params: { explicitSuppressTyping?: boolean; shouldSuppressTyping?: boolean; messageToolAvailable?: boolean; + /** + * Sender-independent availability for the session-stable mode. The stable + * mode feeds CLI binding facts shared by every turn kind, so a sender-scoped + * message-tool denial must not downgrade it while sender-less synthetic + * turns resolve tool-only — that hash split resets the CLI session (#121485). + */ + sessionStableMessageToolAvailable?: boolean; defaultVisibleReplies?: "automatic" | "message_tool"; isHeartbeat?: boolean; }): SourceReplyVisibilityPolicy { @@ -175,7 +182,8 @@ export function resolveSourceReplyVisibilityPolicy(params: { : resolveSourceReplyDeliveryMode({ cfg: params.cfg, ctx: toSessionStableDeliveryModeContext(params.ctx), - messageToolAvailable: params.messageToolAvailable, + messageToolAvailable: + params.sessionStableMessageToolAvailable ?? params.messageToolAvailable, defaultVisibleReplies: params.defaultVisibleReplies, }); const sendPolicyDenied = params.sendPolicy === "deny"; From ce56c40dab3f8565fc48e4ca3f333dc48e0ef403 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 12 Aug 2026 19:02:21 +0800 Subject: [PATCH 07/14] fix(test): wait for gateway hook model activity (#122584) --- test/gateway-hook-concurrency.e2e.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/gateway-hook-concurrency.e2e.test.ts b/test/gateway-hook-concurrency.e2e.test.ts index 9daa8ab2a403..020e09f204f7 100644 --- a/test/gateway-hook-concurrency.e2e.test.ts +++ b/test/gateway-hook-concurrency.e2e.test.ts @@ -89,7 +89,10 @@ describe("Gateway hook concurrency", () => { error: "hook agent run did not start before admission timeout", runId: expect.any(String), }); - expect(modelServer.active(), instance.logs()).toBeGreaterThan(0); + await vi.waitFor(() => expect(modelServer.active(), instance.logs()).toBeGreaterThan(0), { + interval: 20, + timeout: 30_000, + }); expect(modelServer.peak(), instance.logs()).toBeLessThanOrEqual(SHARED_BUDGET); expect(modelServer.requestCount(), instance.logs()).toBeLessThanOrEqual(SHARED_BUDGET + 1); From 3e6aec1d55aeacf130ee00691b9455c31bc5e8ed Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 12 Aug 2026 19:07:15 +0800 Subject: [PATCH 08/14] fix(state): avoid startup ownership snapshots (#122518) --- package.json | 2 +- src/commands/doctor-config-preflight.ts | 4 +- src/gateway/server-startup-bootstrap.ts | 4 +- src/state/openclaw-state-ownership.test.ts | 104 ++++++++++++++++++++- src/state/openclaw-state-ownership.ts | 43 ++++++++- test/package-scripts.test.ts | 6 ++ 6 files changed, 151 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 51e1daadba21..c2f5a67b3ec7 100644 --- a/package.json +++ b/package.json @@ -1967,7 +1967,7 @@ "test:unit:fast:audit": "node --import tsx scripts/test-unit-fast-audit.mts", "test:voicecall:closedloop": "node --import tsx scripts/test-voicecall-closedloop.mts", "test:watch": "node --import tsx scripts/test-projects.mts --watch", - "test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/agents/tools/media-tool-file-url.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/ports.test.ts src/infra/advertised-lan-host.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/process-env.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/provider-local-service.env-case.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/process/terminal-pty.test.ts src/plugin-sdk/node-host.test.ts src/tui/tui.resolve-codex-bin.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts src/cli/mcp-cli.path-case.windows.test.ts extensions/memory-core/src/memory-extra-file-path.windows.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/agents/agent-tools.read.windows.test.ts src/agents/agent-tools.read.host-operations.test.ts src/agents/sessions/tools/path-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", + "test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/agents/tools/media-tool-file-url.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/state/openclaw-state-ownership.test.ts src/infra/ssh-client.windows.test.ts src/infra/ports.test.ts src/infra/advertised-lan-host.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/process-env.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/provider-local-service.env-case.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/process/terminal-pty.test.ts src/plugin-sdk/node-host.test.ts src/tui/tui.resolve-codex-bin.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts src/cli/mcp-cli.path-case.windows.test.ts extensions/memory-core/src/memory-extra-file-path.windows.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/agents/agent-tools.read.windows.test.ts src/agents/agent-tools.read.host-operations.test.ts src/agents/sessions/tools/path-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", "test:windows:schtasks:integration": "node --import tsx scripts/run-with-env.mts CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts", "tool-display:check": "node --import tsx scripts/tool-display.ts --check", "tool-display:write": "node --import tsx scripts/tool-display.ts --write", diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index b2edd77b49bc..8d133411ce0e 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -25,7 +25,7 @@ import { setActiveDegradedPlugins } from "../plugins/runtime-degraded-state.js"; import { ExitError } from "../runtime.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; -import { assertOpenClawStateWriteAllowed } from "../state/openclaw-state-ownership.js"; +import { assertOpenClawStateWriteAllowedAtPath } from "../state/openclaw-state-ownership.js"; import { resolveHomeDir } from "../utils.js"; import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js"; import { @@ -208,7 +208,7 @@ export async function runDoctorConfigPreflight( ): Promise { const stateMigrationsRequested = options.migrateState !== false; if (stateMigrationsRequested) { - assertOpenClawStateWriteAllowed({ + await assertOpenClawStateWriteAllowedAtPath({ databasePath: resolveOpenClawStateSqlitePath(process.env), env: process.env, }); diff --git a/src/gateway/server-startup-bootstrap.ts b/src/gateway/server-startup-bootstrap.ts index 5a8711035be3..29bcf941601f 100644 --- a/src/gateway/server-startup-bootstrap.ts +++ b/src/gateway/server-startup-bootstrap.ts @@ -41,7 +41,7 @@ import { getActiveGatewayRootWorkCount } from "../process/gateway-work-admission import { createLazyPromise } from "../shared/lazy-runtime.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; -import { assertOpenClawStateWriteAllowed } from "../state/openclaw-state-ownership.js"; +import { assertOpenClawStateWriteAllowedAtPath } from "../state/openclaw-state-ownership.js"; import { ADMIN_SCOPE } from "./method-scopes.js"; import { listCoreGatewayMethodNames } from "./methods/core-descriptors.js"; import { @@ -80,7 +80,7 @@ export async function prepareGatewayServerBootstrap(input: { const { port, opts, log, logSecrets, loadWorkerEnvironmentStartupModule } = input; const formatRuntimeGatewayAuthTokenWarning = input.formatRuntimeGatewayAuthTokenWarning; normalizeStateDirEnv(process.env); - assertOpenClawStateWriteAllowed({ + await assertOpenClawStateWriteAllowedAtPath({ databasePath: resolveOpenClawStateSqlitePath(process.env), env: process.env, }); diff --git a/src/state/openclaw-state-ownership.test.ts b/src/state/openclaw-state-ownership.test.ts index f5948396d975..c9c0aa39e0c9 100644 --- a/src/state/openclaw-state-ownership.test.ts +++ b/src/state/openclaw-state-ownership.test.ts @@ -2,7 +2,9 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { runDoctorConfigPreflight } from "../commands/doctor-config-preflight.js"; import { runDoctorStateSqliteCompact } from "../commands/doctor-state-sqlite-compact.js"; +import { planPristineStartupStateMigrations } from "../commands/doctor/shared/pristine-startup-state.js"; import { readConfigHealthStateFromStore, writeConfigHealthStateToStore, @@ -11,7 +13,8 @@ import { resolveGatewayLockDir } from "../config/paths.js"; import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js"; import { sha256HexPrefixCore } from "../infra/crypto-digest.js"; import { requireNodeSqlite, resolveImmutableSqliteFileUri } from "../infra/node-sqlite.js"; -import { withEnv } from "../test-utils/env.js"; +import * as sqliteReadonlyLocation from "../infra/sqlite-readonly-location.js"; +import { withEnv, withEnvAsync } from "../test-utils/env.js"; import { withOpenClawStateStartupMigrationCheckpointDatabase } from "./openclaw-state-db-startup-checkpoint.js"; import { closeOpenClawStateDatabaseForTest, @@ -25,6 +28,7 @@ import { import { resolveOpenClawStateDirForDatabasePath } from "./openclaw-state-db.paths.js"; import { claimOpenClawStateOwnership } from "./openclaw-state-ownership-operations.js"; import { + assertOpenClawStateWriteAllowedAtPath, inspectOpenClawStateOwnershipAtPath, OpenClawStateOwnershipError, OpenClawStateOwnershipMetadataError, @@ -118,7 +122,7 @@ function mockCoordinatorRollbackFailure(onRollback?: () => void) { } describe("external shared-state ownership", () => { - it("returns unowned for a missing path without creating its state tree", () => { + it("returns unowned for a missing path without creating its state tree", async () => { const rootDir = tempDirs.make("openclaw-state-ownership-missing-"); const missingStateDir = path.join(rootDir, "missing-state"); const databasePath = path.join(missingStateDir, "state", "openclaw.sqlite"); @@ -126,6 +130,33 @@ describe("external shared-state ownership", () => { expect(fs.existsSync(missingStateDir)).toBe(false); expect(inspectOpenClawStateOwnershipAtPath(databasePath)).toBeNull(); expect(fs.existsSync(missingStateDir)).toBe(false); + await assertOpenClawStateWriteAllowedAtPath({ databasePath }); + expect(fs.existsSync(missingStateDir)).toBe(false); + }); + + it("keeps missing-database admission eligible for pristine startup", async () => { + const home = tempDirs.make("openclaw-state-ownership-pristine-"); + const stateDir = path.join(home, "state"); + const configPath = path.join(stateDir, "openclaw.json"); + const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); + const env = { + HOME: home, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_STATE_DIR: stateDir, + }; + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(configPath, "{}\n"); + + expect(planPristineStartupStateMigrations(env)).toEqual({ + skipAllStateMigrations: true, + skipCoreStateMigrations: true, + }); + await assertOpenClawStateWriteAllowedAtPath({ databasePath, env }); + expect(fs.readdirSync(stateDir)).toEqual(["openclaw.json"]); + expect(planPristineStartupStateMigrations(env)).toEqual({ + skipAllStateMigrations: true, + skipCoreStateMigrations: true, + }); }); it("preserves ordinary unowned database behavior", () => { @@ -135,6 +166,37 @@ describe("external shared-state ownership", () => { expect(inspectOpenClawStateOwnershipAtPath(database.path)).toBeNull(); }); + it("checks Doctor startup admission without staging a public snapshot", async () => { + const fixture = claimFixture(); + const home = tempDirs.make("openclaw-state-ownership-doctor-"); + const snapshotStaging = vi.spyOn(sqliteReadonlyLocation, "prepareSqliteReadOnlyLocationSync"); + const runPreflight = async (env: NodeJS.ProcessEnv) => + await withEnvAsync( + { + HOME: home, + OPENCLAW_CONFIG_PATH: path.join(home, "openclaw.json"), + OPENCLAW_PROFILE: undefined, + OPENCLAW_STATE_DIR: env.OPENCLAW_STATE_DIR, + OPENCLAW_SUPERVISOR_MODE: env.OPENCLAW_SUPERVISOR_MODE, + }, + async () => + await runDoctorConfigPreflight({ + invalidConfigNote: false, + migrateLegacyConfig: false, + migrateState: true, + observe: false, + skipPristineStartupStateMigrations: true, + }), + ); + try { + await expect(runPreflight(fixture.unmarkedEnv)).rejects.toThrow(OpenClawStateOwnershipError); + await expect(runPreflight(fixture.externalEnv)).resolves.toBeDefined(); + expect(snapshotStaging).not.toHaveBeenCalled(); + } finally { + snapshotStaging.mockRestore(); + } + }); + it("reads ownership from a WAL when the SHM index is absent", () => { const env = createEnv(true); const databasePath = openOpenClawStateDatabase({ env }).path; @@ -167,6 +229,44 @@ describe("external shared-state ownership", () => { } }); + it("rejects unmarked WAL ownership without modifying the SQLite family", async () => { + const env = createEnv(true); + const databasePath = openOpenClawStateDatabase({ env }).path; + closeOpenClawStateDatabaseForTest(); + const { DatabaseSync } = requireNodeSqlite(); + const writer = new DatabaseSync(databasePath); + const ownership = { + version: 1, + mode: "external", + managerId: "wal-only-manager", + claimedAt: 1, + } as const; + const copyDir = tempDirs.make("openclaw-state-ownership-wal-rejection-"); + const copyPath = path.join(copyDir, "openclaw.sqlite"); + try { + writer.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;"); + writer + .prepare( + "INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)", + ) + .run(STATE_SUPERVISION_KEY, JSON.stringify(ownership), ownership.claimedAt); + fs.copyFileSync(databasePath, copyPath); + fs.copyFileSync(`${databasePath}-wal`, `${copyPath}-wal`); + } finally { + writer.close(); + } + + expect(fs.existsSync(`${copyPath}-shm`)).toBe(false); + const before = snapshotSqliteFamily(copyPath); + await expect( + assertOpenClawStateWriteAllowedAtPath({ + databasePath: copyPath, + env: withoutExternalMarker(env), + }), + ).rejects.toThrow(OpenClawStateOwnershipError); + expect(snapshotSqliteFamily(copyPath)).toEqual(before); + }); + it("observes committed ownership that is still resident in the live WAL", () => { const env = createEnv(); const databasePath = openOpenClawStateDatabase({ env }).path; diff --git a/src/state/openclaw-state-ownership.ts b/src/state/openclaw-state-ownership.ts index ef4374314995..7da010a751b2 100644 --- a/src/state/openclaw-state-ownership.ts +++ b/src/state/openclaw-state-ownership.ts @@ -16,7 +16,10 @@ import { runWithSqliteCoordinator, SqliteCoordinatorError, } from "../infra/sqlite-coordinator.js"; -import { prepareSqliteReadOnlyLocationSync } from "../infra/sqlite-readonly-location.js"; +import { + prepareSqliteReadOnlyLocation, + prepareSqliteReadOnlyLocationSync, +} from "../infra/sqlite-readonly-location.js"; import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db-contract.js"; import { tableExists } from "./openclaw-state-db-schema-helpers.js"; import { resolveOpenClawStateDirForDatabasePath } from "./openclaw-state-db.paths.js"; @@ -279,15 +282,45 @@ export function runWithOpenClawStateWriteAccess( ); } +/** Check path-based write admission without retaining the coordinator past this call. */ +export async function assertOpenClawStateWriteAllowedAtPath(options: { + databasePath: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const databasePath = path.resolve(options.databasePath); + if (!existsSync(databasePath)) { + return; + } + const env = options.env ?? process.env; + if (isGatewayExternallySupervised(env)) { + runWithOpenClawStateWriteAccess( + { ...options, databasePath }, + "shared state write admission", + () => undefined, + ); + return; + } + // Unmarked startup must discover ownership without opening the source writable. + // The async private snapshot keeps Windows PowerShell work off the sync startup path. + const prepared = await prepareSqliteReadOnlyLocation(databasePath); + try { + assertOwnershipAllowsWrite( + inspectOwnershipThroughConnection(prepared.location, databasePath), + databasePath, + env, + ); + } finally { + prepared.cleanup(); + } +} + /** Fence shared-state writes once an external manager has claimed ownership. */ export function assertOpenClawStateWriteAllowed(options: { - database?: DatabaseSync; + database: DatabaseSync; databasePath: string; env?: NodeJS.ProcessEnv; }): void { const resolvedPath = path.resolve(options.databasePath); - const status = options.database - ? inspectOpenClawStateOwnershipFromDatabase(options.database, resolvedPath) - : inspectOpenClawStateOwnershipAtPath(resolvedPath); + const status = inspectOpenClawStateOwnershipFromDatabase(options.database, resolvedPath); assertOwnershipAllowsWrite(status, resolvedPath, options.env ?? process.env); } diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index 27dd14adc172..13d7a51161a2 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -221,6 +221,12 @@ describe("package scripts", () => { ); }); + it("runs shared-state ownership coverage in Windows CI", () => { + expect(readPackageJson().scripts["test:windows:ci"]).toContain( + "src/state/openclaw-state-ownership.test.ts", + ); + }); + it("runs mixed-case local media file URL coverage in Windows CI", () => { expect(readPackageJson().scripts["test:windows:ci"]).toContain( "src/media/local-media-path.windows.test.ts", From 79ac476b0f70536078363dba03dc7f13aacc2e67 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 04:45:15 -0700 Subject: [PATCH 09/14] fix(test): type CDP DNS lookup mocks (#122607) Amp-Thread-ID: https://ampcode.com/threads/T-019ff3db-c467-70ad-8ed3-81f2ba94b0c0 Co-authored-by: Amp --- extensions/browser/src/browser/cdp.helpers.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index ebd2401951ec..1535f8ae0f34 100644 --- a/extensions/browser/src/browser/cdp.helpers.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.test.ts @@ -1,6 +1,7 @@ // Browser tests cover cdp.helpers plugin behavior. import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LookupFn } from "../infra/net/ssrf.js"; import { assertChromeMcpCdpTransportAllowed, resolveCdpReachabilityPolicy, @@ -13,6 +14,11 @@ const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300; const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200; const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000; +function createLookupFn(address: string): LookupFn { + const family = address.includes(":") ? 6 : 4; + return vi.fn(async () => [{ address, family }]) as unknown as LookupFn; +} + const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { @@ -167,7 +173,7 @@ describe("cdp helpers", () => { await expect( resolvePinnedHostnameWithPolicy("browser.example", { policy: scoped, - lookupFn: async () => [{ address: "10.0.0.8", family: 4 }], + lookupFn: createLookupFn("10.0.0.8"), }), ).rejects.toThrow(/private\/internal\/special-use ip address/i); }); @@ -188,7 +194,7 @@ describe("cdp helpers", () => { await expect( resolvePinnedHostnameWithPolicy("browser.example", { policy: scoped, - lookupFn: async () => [{ address: "10.0.0.8", family: 4 }], + lookupFn: createLookupFn("10.0.0.8"), }), ).resolves.toEqual(expect.objectContaining({ addresses: ["10.0.0.8"] })); }); From 220b2dcec763a716a28a08c061bc405792245777 Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:06:56 +0800 Subject: [PATCH 10/14] fix(skills): reconcile manually created proposal targets (#118676) * fix(skills): reconcile manually created proposal targets (#90388) * fix(skills): preserve stale apply lifecycle errors * fix(skills): stale existing create targets * fix(skills): dispatch reconciliation hooks outside target lease * test(skills): prove manual target reconciliation * fix(skills): serialize proposal reconciliation with collection writes * fix(skills): surface proposal reconciliation failures --------- Co-authored-by: Vincent Koc --- .../server-methods/skills.proposals.test.ts | 37 ++++ src/skills/workshop/apply-transition.ts | 17 +- .../workshop/collection-reconcile.test.ts | 121 +++++++++++ .../workshop/service-lifecycle-hooks.test.ts | 53 +++++ src/skills/workshop/service-query.ts | 137 +++++++++++-- src/skills/workshop/service.test.ts | 54 ++++- .../skills-proposal-manual-target.e2e.test.ts | 193 ++++++++++++++++++ ui/src/pages/skill-workshop/route.ts | 2 +- .../skill-workshop-page.test.ts | 92 +++++++++ 9 files changed, 684 insertions(+), 22 deletions(-) create mode 100644 test/skills-proposal-manual-target.e2e.test.ts diff --git a/src/gateway/server-methods/skills.proposals.test.ts b/src/gateway/server-methods/skills.proposals.test.ts index aac591f5f4b9..7260c846adff 100644 --- a/src/gateway/server-methods/skills.proposals.test.ts +++ b/src/gateway/server-methods/skills.proposals.test.ts @@ -212,6 +212,43 @@ describe("skills proposal gateway handlers", () => { ).resolves.toContain("Use current weather"); }); + it("marks manually created create targets stale before list and inspect responses", async () => { + const create = await callHandler("skills.proposals.create", { + name: "Manual Gateway Skill", + description: "Installed before its proposal was applied.", + content: "# Manual Gateway Skill\n", + }); + expect(create.ok).toBe(true); + const created = create.response as { + record: { id: string; target: { skillFile: string } }; + }; + await fs.mkdir(path.dirname(created.record.target.skillFile), { recursive: true }); + await fs.writeFile( + created.record.target.skillFile, + "# Manual Gateway Skill\n\nAlready installed.\n", + "utf8", + ); + + const list = await callHandler("skills.proposals.list", {}); + expect(list.ok).toBe(true); + expect( + (list.response as { proposals: Array<{ id: string; status: string }> }).proposals, + ).toEqual( + expect.arrayContaining([expect.objectContaining({ id: created.record.id, status: "stale" })]), + ); + + const inspect = await callHandler("skills.proposals.inspect", { + proposalId: created.record.id, + }); + expect(inspect.ok).toBe(true); + expect( + (inspect.response as { record: { status: string; statusReason?: string } }).record, + ).toMatchObject({ + status: "stale", + statusReason: "Target skill was created after proposal creation.", + }); + }); + it("keeps list and inspect bound to the agent after its workspace changes", async () => { const firstWorkspaceDir = mocks.workspaceDir; const first = await callHandler("skills.proposals.create", { diff --git a/src/skills/workshop/apply-transition.ts b/src/skills/workshop/apply-transition.ts index 225f84474e98..4783e20594a6 100644 --- a/src/skills/workshop/apply-transition.ts +++ b/src/skills/workshop/apply-transition.ts @@ -448,12 +448,11 @@ export async function assertSkillProposalSupportTargetUnchanged(params: { } } -export async function markSkillProposalStale(params: { +export function transitionPendingSkillProposalToStale(params: { record: SkillProposalRecord; reason: string; - message: string; input: SkillProposalTransitionInput; -}): Promise { +}): { record: SkillProposalRecord; event: SkillProposalEvent } { const now = new Date().toISOString(); const stale: SkillProposalRecord = { ...params.record, @@ -478,7 +477,17 @@ export async function markSkillProposalStale(params: { if (commit.state !== "committed" || !commit.event) { throw new Error("Failed to record stale Skill Workshop proposal."); } - throw new SkillProposalLifecycleError(params.message, stale, commit.event); + return { record: stale, event: commit.event }; +} + +export async function markSkillProposalStale(params: { + record: SkillProposalRecord; + reason: string; + message: string; + input: SkillProposalTransitionInput; +}): Promise { + const transition = transitionPendingSkillProposalToStale(params); + throw new SkillProposalLifecycleError(params.message, transition.record, transition.event); } function createSkillProposalRollback(params: { diff --git a/src/skills/workshop/collection-reconcile.test.ts b/src/skills/workshop/collection-reconcile.test.ts index ec1b0fa022ee..3171110ff535 100644 --- a/src/skills/workshop/collection-reconcile.test.ts +++ b/src/skills/workshop/collection-reconcile.test.ts @@ -18,6 +18,7 @@ import { } from "./collection-reconcile.js"; import { getArchivedSkillFiles } from "./curator.js"; import { readSkillProposalTargetTreeSha256 } from "./proposal-bundle.js"; +import { inspectSkillProposal, listSkillProposals, proposeCreateSkill } from "./service.js"; import { withSkillCollectionLock } from "./target-lock.js"; type CopyDirectoryHook = ( @@ -633,6 +634,126 @@ describe("skill collection reconciliation", () => { await expect(fs.readFile(skillFile, "utf8")).resolves.toContain("# Original"); }); + it("keeps proposal reads behind a failed collection create rollback", async () => { + const proposal = await proposeCreateSkill({ + workspaceDir, + env: testState.env, + name: "Collection Candidate", + description: "Remain pending if collection creation rolls back.", + content: "# Collection Candidate\n\nCreated by collection reconciliation.\n", + }); + const receipt = await readCollectionReceipt(); + const originalRename = fs.rename.bind(fs); + let releaseCommit: (() => void) | undefined; + let markCommitAttempted: (() => void) | undefined; + const commitAttempted = new Promise((resolve) => { + markCommitAttempted = resolve; + }); + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (oldPath, newPath) => { + if (String(oldPath).includes(`${path.sep}.pending-`)) { + markCommitAttempted?.(); + await new Promise((resolve) => { + releaseCommit = resolve; + }); + throw new Error("forced backup commit failure"); + } + await originalRename(oldPath, newPath); + }); + + const reconciliation = reconcileSkillCollection({ + workspaceDir, + env: testState.env, + ...receipt, + plan: [ + { + action: "write", + name: proposal.record.target.skillKey, + description: "Created during a collection mutation.", + content: "# Collection Candidate\n\nTransient collection content.\n", + }, + ], + }); + try { + await commitAttempted; + let listSettled = false; + let inspectSettled = false; + const listing = listSkillProposals({ workspaceDir, env: testState.env }).finally(() => { + listSettled = true; + }); + const inspection = inspectSkillProposal(proposal.record.id, { + workspaceDir, + env: testState.env, + }).finally(() => { + inspectSettled = true; + }); + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + expect(listSettled).toBe(false); + expect(inspectSettled).toBe(false); + + releaseCommit?.(); + await expect(reconciliation).rejects.toThrow("forced backup commit failure"); + await expect(listing).resolves.toMatchObject({ + proposals: [expect.objectContaining({ id: proposal.record.id, status: "pending" })], + }); + await expect(inspection).resolves.toMatchObject({ + record: { id: proposal.record.id, status: "pending" }, + }); + } finally { + releaseCommit?.(); + renameSpy.mockRestore(); + } + + await expect(fs.access(proposal.record.target.skillFile)).rejects.toThrow(); + }); + + it("surfaces proposal reads that exceed the collection lease wait", async () => { + const proposal = await proposeCreateSkill({ + workspaceDir, + env: testState.env, + name: "Contended Candidate", + description: "Surface collection lock contention.", + content: "# Contended Candidate\n", + }); + let releaseLock: (() => void) | undefined; + let markAcquired: (() => void) | undefined; + const acquired = new Promise((resolve) => { + markAcquired = resolve; + }); + const heldLock = withSkillCollectionLock( + workspaceDir, + async () => { + markAcquired?.(); + await new Promise((resolve) => { + releaseLock = resolve; + }); + }, + { env: testState.env }, + ); + await acquired; + + try { + await Promise.all([ + expect(listSkillProposals({ workspaceDir, env: testState.env })).rejects.toMatchObject({ + code: "OPENCLAW_STATE_LEASE_TIMEOUT", + }), + expect( + inspectSkillProposal(proposal.record.id, { + workspaceDir, + env: testState.env, + }), + ).rejects.toMatchObject({ + code: "OPENCLAW_STATE_LEASE_TIMEOUT", + }), + ]); + } finally { + releaseLock?.(); + await heldLock; + } + }, 15_000); + it("restores a staged drop when backup commit fails", async () => { await writeWorkspaceSkills(workspaceDir, [ { name: "obsolete", description: "Obsolete procedure", body: "# Original\n" }, diff --git a/src/skills/workshop/service-lifecycle-hooks.test.ts b/src/skills/workshop/service-lifecycle-hooks.test.ts index 882792a7f953..ab1475e57775 100644 --- a/src/skills/workshop/service-lifecycle-hooks.test.ts +++ b/src/skills/workshop/service-lifecycle-hooks.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDeferred } from "../../../test/helpers/promise.js"; import { createOpenClawTestState, type OpenClawTestState, @@ -29,6 +30,7 @@ import { listSkillProposalEvents, proposeCreateSkill, proposeUpdateSkill, + rejectSkillProposal, } from "./service.js"; const tempDirs = createTrackedTempDirs(); @@ -183,6 +185,57 @@ describe("Skill Workshop lifecycle hooks", () => { ); }); + it("releases the target lease before dispatching a reconciliation hook", async () => { + const workspaceDir = await tempDirs.make("openclaw-skill-lifecycle-reconcile-lock-"); + const first = await proposeCreateSkill({ + workspaceDir, + agentId: "main", + name: "Reconcile Lock", + description: "First proposal sharing the target.", + content: "# Reconcile Lock\n", + }); + const second = await proposeCreateSkill({ + workspaceDir, + agentId: "main", + name: "Reconcile Lock", + description: "Second proposal sharing the target.", + content: "# Reconcile Lock\n", + }); + await writeSkill({ + dir: first.record.target.skillDir, + name: "reconcile-lock", + description: "Created elsewhere", + body: "# Created Elsewhere\n", + }); + const hookEntered = createDeferred(); + const releaseHook = createDeferred(); + hookMocks.proposalChanged.mockImplementation(async (event) => { + if (event.action === "stale" && event.proposal.id === first.record.id) { + hookEntered.resolve(); + await releaseHook.promise; + } + }); + + const inspection = inspectSkillProposal(first.record.id, { workspaceDir }); + await hookEntered.promise; + try { + const rejected = await rejectSkillProposal({ + workspaceDir, + agentId: "main", + proposalId: second.record.id, + }); + expect(rejected.status).toBe("rejected"); + } finally { + releaseHook.resolve(); + } + await expect(inspection).resolves.toMatchObject({ record: { status: "stale" } }); + expect( + listSkillProposalEvents({ workspaceDir, proposalId: first.record.id }).events.map( + (event) => event.type, + ), + ).toEqual(["created", "stale"]); + }); + it("rejects apply when an untouched target asset changes after evaluation", async () => { const workspaceDir = await tempDirs.make("openclaw-skill-lifecycle-evaluation-race-"); const skillDir = path.join(workspaceDir, "skills", "existing"); diff --git a/src/skills/workshop/service-query.ts b/src/skills/workshop/service-query.ts index f4c52c39dff0..c8b284f659c2 100644 --- a/src/skills/workshop/service-query.ts +++ b/src/skills/workshop/service-query.ts @@ -1,13 +1,24 @@ +import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isPathInside } from "../../infra/path-safety.js"; import { normalizeSkillIndexName } from "../discovery/skill-index.js"; +import { + assertInsideWorkspace, + readWorkspaceSkillFile, +} from "../lifecycle/workspace-skill-write.js"; +import { transitionPendingSkillProposalToStale } from "./apply-transition.js"; +import { dispatchSkillProposalChanged } from "./plugin-hooks.js"; +import { hashSkillProposalRevision } from "./revision-hash.js"; import { readProposalSupportFiles, readSkillProposal, readSkillProposalManifest, readSkillProposalRecord, + readSkillProposalRollback, } from "./store.js"; +import { withSkillProposalCommitLock } from "./target-lock.js"; import type { SkillProposalManifest, SkillProposalReadResult } from "./types.js"; type SkillProposalScopeOptions = { @@ -25,13 +36,30 @@ function storeOptions(env?: NodeJS.ProcessEnv) { return env ? { env } : {}; } +function proposalScope(options: SkillProposalScopeOptions) { + return { + ...(options.agentId ? { agentId: options.agentId } : {}), + ...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}), + }; +} + export async function listSkillProposals( options: SkillProposalScopeOptions = {}, ): Promise { - return await readSkillProposalManifest(storeOptions(options.env), { - ...(options.agentId ? { agentId: options.agentId } : {}), - ...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}), - }); + const store = storeOptions(options.env); + const scope = proposalScope(options); + const manifest = await readSkillProposalManifest(store, scope); + await Promise.all( + manifest.proposals + .filter((proposal) => proposal.kind === "create" && proposal.status === "pending") + .map(async (proposal) => { + const read = await readSkillProposal(proposal.id, store, scope); + if (read) { + await reconcilePendingCreateProposal(read, options); + } + }), + ); + return await readSkillProposalManifest(store, scope); } export async function getSkillProposalRunProgress( @@ -58,11 +86,18 @@ export async function inspectSkillProposal( proposalId: string, options: SkillProposalScopeOptions = {}, ): Promise { - const read = await readSkillProposal(proposalId, storeOptions(options.env), options); + const read = await readSkillProposal( + proposalId, + storeOptions(options.env), + proposalScope(options), + ); if (!read) { return null; } - return await hydrateProposalSupportFiles(read, options.env); + return await hydrateProposalSupportFiles( + await reconcilePendingCreateProposal(read, options), + options.env, + ); } export async function resolvePendingSkillProposal(input: { @@ -74,11 +109,9 @@ export async function resolvePendingSkillProposal(input: { }): Promise { const proposalId = normalizeOptionalString(input.proposalId); if (proposalId) { - const direct = await readRequiredProposal( - proposalId, - input.workspaceDir, - input.env, - input.agentId, + const direct = await reconcilePendingCreateProposal( + await readRequiredProposal(proposalId, input.workspaceDir, input.env, input.agentId), + input, ); if (direct.record.status !== "pending") { throw new Error( @@ -109,11 +142,14 @@ export async function resolvePendingSkillProposal(input: { .join(", "); throw new Error(`Multiple pending skill proposals matched ${name}: ${candidates}`); } - const matched = await readRequiredProposal( - expectDefined(matches[0], "matches capture group 0").id, - input.workspaceDir, - input.env, - input.agentId, + const matched = await reconcilePendingCreateProposal( + await readRequiredProposal( + expectDefined(matches[0], "matches capture group 0").id, + input.workspaceDir, + input.env, + input.agentId, + ), + input, ); if (matched.record.status !== "pending") { throw new Error( @@ -145,6 +181,75 @@ export async function readRequiredProposal( return read; } +async function reconcilePendingCreateProposal( + read: SkillProposalReadResult, + options: SkillProposalScopeOptions, +): Promise { + const workspaceDir = options.workspaceDir; + if (!workspaceDir || read.record.kind !== "create" || read.record.status !== "pending") { + return read; + } + const resolvedWorkspaceDir = path.resolve(workspaceDir); + const resolvedTarget = path.resolve(read.record.target.skillFile); + // Agent-scoped reads intentionally include proposals bound to earlier workspaces. + // Only reconcile a target against the workspace that owns it. + if ( + options.agentId && + resolvedTarget !== resolvedWorkspaceDir && + !isPathInside(resolvedWorkspaceDir, resolvedTarget) + ) { + return read; + } + const store = storeOptions(options.env); + const scope = proposalScope(options); + const reconciled = await withSkillProposalCommitLock( + workspaceDir, + read.record, + async () => { + const current = await readSkillProposal(read.record.id, store, scope, { reconcile: false }); + if (!current || current.record.kind !== "create" || current.record.status !== "pending") { + return { read: current ?? read }; + } + assertInsideWorkspace(workspaceDir, current.record.target.skillFile, "skill file"); + if (await readSkillProposalRollback(current.record.id, store)) { + return { read: current }; + } + const targetContent = await readWorkspaceSkillFile(current.record.target.skillFile); + if (targetContent === null) { + return { read: current }; + } + const transition = transitionPendingSkillProposalToStale({ + record: current.record, + reason: "Target skill was created after proposal creation.", + input: { + workspaceDir, + ...(options.agentId ? { agentId: options.agentId } : {}), + eventActor: { type: "system" }, + ...(options.env ? { env: options.env } : {}), + }, + }); + return { + read: { + ...current, + record: transition.record, + revisionHash: hashSkillProposalRevision(transition.record), + }, + transition, + }; + }, + store, + ); + if (reconciled.transition) { + await dispatchSkillProposalChanged({ + event: reconciled.transition.event, + record: reconciled.transition.record, + workspaceDir, + ...(options.agentId ? { agentId: options.agentId } : {}), + }); + } + return reconciled.read; +} + async function hydrateProposalSupportFiles( read: SkillProposalReadResult, env?: NodeJS.ProcessEnv, diff --git a/src/skills/workshop/service.test.ts b/src/skills/workshop/service.test.ts index 9bcc47da3584..af0bbc75e438 100644 --- a/src/skills/workshop/service.test.ts +++ b/src/skills/workshop/service.test.ts @@ -460,6 +460,58 @@ describe("skill workshop proposals", () => { ).rejects.toThrow("Skill already exists"); }); + it("reconciles pending create proposals when their target skills are created manually", async () => { + const workspaceDir = await makeWorkspace(); + const listed = await proposeCreateSkill({ + workspaceDir, + name: "Listed Manual Skill", + description: "Becomes stale before proposal listing.", + content: "# Listed Manual Skill\n", + }); + const inspected = await proposeCreateSkill({ + workspaceDir, + name: "Inspected Manual Skill", + description: "Becomes stale before proposal inspection.", + content: "# Inspected Manual Skill\n", + }); + await fs.mkdir(listed.record.target.skillDir, { recursive: true }); + await fs.writeFile( + listed.record.target.skillFile, + stripProposalFrontmatterForSkill(listed.content), + "utf8", + ); + await writeSkill({ + dir: inspected.record.target.skillDir, + name: "inspected-manual-skill", + description: "Installed without the proposal.", + body: "# Inspected Manual Skill\n\nAlready active.\n", + }); + + await expect(listSkillProposals({ workspaceDir })).resolves.toMatchObject({ + proposals: expect.arrayContaining([ + expect.objectContaining({ + id: listed.record.id, + status: "stale", + }), + ]), + }); + await expect( + inspectSkillProposal(inspected.record.id, { workspaceDir }), + ).resolves.toMatchObject({ + record: { + id: inspected.record.id, + status: "stale", + statusReason: "Target skill was created after proposal creation.", + }, + }); + await expect( + resolvePendingSkillProposal({ + name: listed.record.target.skillKey, + workspaceDir, + }), + ).rejects.toThrow("No pending skill proposal matched"); + }); + it("revises pending proposals in place before approval", async () => { const workspaceDir = await makeWorkspace(); const proposal = await proposeCreateSkill({ @@ -977,7 +1029,7 @@ describe("skill workshop proposals", () => { expect(manifest.proposals).toEqual( expect.arrayContaining([ expect.objectContaining({ id: proposal.record.id, status: "applied" }), - expect.objectContaining({ id: sibling.record.id, status: "pending" }), + expect.objectContaining({ id: sibling.record.id, status: "stale" }), ]), ); await expect( diff --git a/test/skills-proposal-manual-target.e2e.test.ts b/test/skills-proposal-manual-target.e2e.test.ts new file mode 100644 index 000000000000..665b7984f32e --- /dev/null +++ b/test/skills-proposal-manual-target.e2e.test.ts @@ -0,0 +1,193 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + disconnectGatewayClient, + startGatewayWithClient, +} from "../src/gateway/test-helpers.e2e.js"; +import { captureEnv, setTestEnvValue } from "../src/test-utils/env.js"; +import { useAutoCleanupTempDirTracker } from "./helpers/temp-dir.js"; + +const TEST_TIMEOUT_MS = 30_000; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const ENV_KEYS = [ + "HOME", + "USERPROFILE", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_SKIP_CHANNELS", + "OPENCLAW_SKIP_GMAIL_WATCHER", + "OPENCLAW_SKIP_CRON", + "OPENCLAW_SKIP_CANVAS_HOST", + "OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", + "OPENCLAW_SKIP_PROVIDERS", + "OPENCLAW_TEST_MINIMAL_GATEWAY", + "OPENCLAW_BUNDLED_PLUGINS_DIR", + "OPENCLAW_DISABLE_BUNDLED_PLUGINS", +] as const; + +async function setupTempHome() { + const env = captureEnv([...ENV_KEYS]); + const home = tempDirs.make("openclaw-skill-proposal-proof-"); + const stateDir = path.join(home, ".openclaw"); + const workspace = path.join(home, "workspace"); + const bundledPlugins = path.join(home, "empty-bundled-plugins"); + await Promise.all([ + fs.mkdir(stateDir, { recursive: true }), + fs.mkdir(workspace, { recursive: true }), + fs.mkdir(bundledPlugins, { recursive: true }), + ]); + setTestEnvValue("HOME", home); + setTestEnvValue("USERPROFILE", home); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + setTestEnvValue("OPENCLAW_SKIP_CHANNELS", "1"); + setTestEnvValue("OPENCLAW_SKIP_GMAIL_WATCHER", "1"); + setTestEnvValue("OPENCLAW_SKIP_CRON", "1"); + setTestEnvValue("OPENCLAW_SKIP_CANVAS_HOST", "1"); + setTestEnvValue("OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", "1"); + setTestEnvValue("OPENCLAW_SKIP_PROVIDERS", "1"); + setTestEnvValue("OPENCLAW_BUNDLED_PLUGINS_DIR", bundledPlugins); + setTestEnvValue("OPENCLAW_DISABLE_BUNDLED_PLUGINS", "1"); + delete process.env.OPENCLAW_CONFIG_PATH; + delete process.env.OPENCLAW_TEST_MINIMAL_GATEWAY; + return { + configPath: path.join(stateDir, "openclaw.json"), + env, + workspace, + }; +} + +type ProposalRecord = { + id: string; + kind: "create"; + status: string; + statusReason?: string; + staleAt?: string; + target: { + skillKey: string; + skillFile: string; + }; +}; + +describe("Skill proposal manual-target product proof", () => { + it( + "persists stale state and rejects apply after a target is installed manually", + { timeout: TEST_TIMEOUT_MS }, + async () => { + const temp = await setupTempHome(); + const token = `skill-proposal-proof-${process.pid}`; + let started: Awaited> | undefined; + + try { + started = await startGatewayWithClient({ + cfg: { + agents: { defaults: { workspace: temp.workspace } }, + gateway: { auth: { mode: "token", token } }, + }, + configPath: temp.configPath, + token, + clientDisplayName: "skill-proposal-manual-target-proof", + }); + + const created = (await started.client.request("skills.proposals.create", { + agentId: "main", + name: "Manual Gateway Proof", + description: "Proof for a manually installed proposal target.", + content: "# Manual Gateway Proof\n\nProposal draft.\n", + })) as { record: ProposalRecord }; + expect(created.record).toMatchObject({ + kind: "create", + status: "pending", + target: { + skillKey: "manual-gateway-proof", + skillFile: path.join(temp.workspace, "skills", "manual-gateway-proof", "SKILL.md"), + }, + }); + + await fs.mkdir(path.dirname(created.record.target.skillFile), { recursive: true }); + await fs.writeFile( + created.record.target.skillFile, + "# Manual Gateway Proof\n\nInstalled manually.\n", + "utf8", + ); + + const listed = (await started.client.request("skills.proposals.list", { + agentId: "main", + })) as { proposals: ProposalRecord[] }; + const listedRecord = listed.proposals.find((proposal) => proposal.id === created.record.id); + expect(listedRecord).toMatchObject({ kind: "create", status: "stale" }); + + const inspected = (await started.client.request("skills.proposals.inspect", { + agentId: "main", + proposalId: created.record.id, + })) as { record: ProposalRecord }; + expect(inspected.record).toMatchObject({ + id: created.record.id, + status: "stale", + statusReason: "Target skill was created after proposal creation.", + staleAt: expect.any(String), + }); + + const events = (await started.client.request("skills.proposals.events.list", { + agentId: "main", + proposalId: created.record.id, + })) as { + events: Array<{ + actor: { type: string }; + proposalId: string; + type: string; + }>; + }; + expect(events.events.map((event) => event.type)).toEqual(["created", "stale"]); + expect(events.events.at(-1)).toMatchObject({ + actor: { type: "system" }, + proposalId: created.record.id, + type: "stale", + }); + + let applyError: unknown; + try { + await started.client.request("skills.proposals.apply", { + agentId: "main", + proposalId: created.record.id, + }); + } catch (error) { + applyError = error; + } + expect(applyError).toMatchObject({ + gatewayCode: "INVALID_REQUEST", + message: "Only pending proposals can be applied. Current status: stale.", + }); + + console.info( + `[skill-proposal-manual-target-proof] ${JSON.stringify({ + head: process.env.OPENCLAW_PROOF_HEAD ?? "not-specified", + transport: "loopback-token-auth-websocket", + workspaceIsolated: true, + createdStatus: created.record.status, + listStatus: listedRecord?.status, + inspectStatus: inspected.record.status, + statusReason: inspected.record.statusReason, + durableEvents: events.events.map((event) => event.type), + staleActor: events.events.at(-1)?.actor.type, + applyRejected: applyError !== undefined, + applyErrorCode: + applyError && typeof applyError === "object" && "gatewayCode" in applyError + ? applyError.gatewayCode + : undefined, + verdict: "PASS", + })}`, + ); + } finally { + try { + if (started) { + await disconnectGatewayClient(started.client).catch(() => undefined); + await started.server.close({ reason: "Skill proposal proof complete" }); + } + } finally { + temp.env.restore(); + } + } + }, + ); +}); diff --git a/ui/src/pages/skill-workshop/route.ts b/ui/src/pages/skill-workshop/route.ts index 0c79701a80b0..7b93e33c5fd3 100644 --- a/ui/src/pages/skill-workshop/route.ts +++ b/ui/src/pages/skill-workshop/route.ts @@ -18,7 +18,7 @@ export const page = definePage({ const [{ loadSkillWorkshopPageData }, { createSkillWorkshopState, skillWorkshopRouteData }] = await Promise.all([import("./history-scan-page-controller.ts"), import("./proposals.ts")]); const state = createSkillWorkshopState(); - await loadSkillWorkshopPageData({ state, context, force: false }); + await loadSkillWorkshopPageData({ state, context, force: true }); return skillWorkshopRouteData(state); }, }); diff --git a/ui/src/pages/skill-workshop/skill-workshop-page.test.ts b/ui/src/pages/skill-workshop/skill-workshop-page.test.ts index 74a6db447e52..5edad98343fa 100644 --- a/ui/src/pages/skill-workshop/skill-workshop-page.test.ts +++ b/ui/src/pages/skill-workshop/skill-workshop-page.test.ts @@ -1,3 +1,4 @@ +import type { RouteLoaderOptions } from "@openclaw/uirouter"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { SessionsListResult } from "../../api/types.ts"; @@ -5,6 +6,7 @@ import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/c import type { SkillWorkshopProposal } from "../../lib/skill-workshop/index.ts"; import { createSkillWorkshopState, skillWorkshopRouteData } from "./proposals.ts"; import type { SkillWorkshopRouteData, SkillWorkshopState } from "./proposals.ts"; +import { page as skillWorkshopRoute } from "./route.ts"; import "./skill-workshop-page.ts"; type SkillWorkshopPageTestElement = HTMLElement & { @@ -219,6 +221,96 @@ describe("SkillWorkshopPage lifecycle", () => { ); }); + it("reloads proposals on route activation and removes Apply after reconciliation", async () => { + let activation = 0; + const request = vi.fn(async (method: string) => { + if (method === "skills.proposals.list") { + activation += 1; + return { + schema: "openclaw.skill-workshop.proposals-manifest.v1", + updatedAt: "2026-08-12T00:00:00.000Z", + proposals: [ + { + id: "proposal-route-refresh", + kind: "create", + status: activation === 1 ? "pending" : "stale", + title: "Route Refresh", + description: "Refresh stale proposal state on route activation.", + skillName: "Route Refresh", + skillKey: "route-refresh", + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + scanState: "clean", + }, + ], + }; + } + if (method === "skills.proposals.inspect") { + const status = activation === 1 ? "pending" : "stale"; + return { + record: { + id: "proposal-route-refresh", + kind: "create", + status, + title: "Route Refresh", + description: "Refresh stale proposal state on route activation.", + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + proposedVersion: "v1", + draftHash: "a".repeat(64), + target: { + skillName: "Route Refresh", + skillKey: "route-refresh", + }, + }, + revisionHash: "b".repeat(64), + content: "# Route Refresh\n", + supportFiles: [], + }; + } + if (method === "skills.proposals.historyStatus") { + return { + schema: "openclaw.skill-workshop.history-scan.v1", + hasScanned: false, + reviewedSessions: 0, + ideasFound: 0, + hasMore: false, + lastScanReviewed: 0, + lastScanIdeas: 0, + }; + } + return {}; + }); + const context = createContext(request); + const options = { + signal: new AbortController().signal, + shouldRun: () => true, + revalidating: false, + location: { pathname: "/skill-workshop", search: "", hash: "" }, + deps: "", + cause: "navigation", + } satisfies RouteLoaderOptions; + if (!skillWorkshopRoute.loader) { + throw new Error("skill workshop route has no loader"); + } + + const first = (await skillWorkshopRoute.loader(context, options)) as SkillWorkshopRouteData; + const second = (await skillWorkshopRoute.loader(context, options)) as SkillWorkshopRouteData; + expect(callsFor(request, "skills.proposals.list")).toHaveLength(2); + expect(first.skillWorkshopProposals[0]?.status).toBe("pending"); + expect(second.skillWorkshopProposals[0]?.status).toBe("stale"); + + const secondPage = document.createElement( + "openclaw-skill-workshop-page", + ) as SkillWorkshopPageTestElement; + secondPage.data = second; + secondPage.context = context; + document.body.append(secondPage); + await secondPage.updateComplete; + + expect(secondPage.querySelector(".sw-action-bar .sw-btn--primary")).toBeNull(); + }); + it("does not issue duplicate list requests while a load is in flight", async () => { const manifest = deferred(); const request = vi.fn(() => manifest.promise); From 5209d6cb05f64520844ad6adaff125c421d1437a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 05:08:21 -0700 Subject: [PATCH 11/14] test(gateway): keep session search tests focused (#122603) Co-authored-by: Amp --- src/gateway/session-utils.search.test.ts | 929 +++-------------------- 1 file changed, 102 insertions(+), 827 deletions(-) diff --git a/src/gateway/session-utils.search.test.ts b/src/gateway/session-utils.search.test.ts index d094895a84eb..8be88ac690a0 100644 --- a/src/gateway/session-utils.search.test.ts +++ b/src/gateway/session-utils.search.test.ts @@ -1,474 +1,84 @@ -// Session search tests cover gateway session rows, transcript usage summaries, -// subagent state, model context limits, and cost/token display metadata. -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; -import { afterEach, beforeAll, describe, expect, test } from "vitest"; -import { ANTHROPIC_CONTEXT_1M_TOKENS } from "../agents/context-resolution.js"; -import { - addSubagentRunForTests, - resetSubagentRegistryForTests, -} from "../agents/subagents/registry/subagent-registry.test-helpers.js"; +import { describe, expect, test, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; -import { - appendTranscriptMessageSync, - replaceSessionEntry, -} from "../config/sessions/session-accessor.js"; -import { resetAgentEventsForTest } from "../infra/agent-events.js"; -import { registerAgentRunContext } from "../infra/agent-run-registry.js"; -import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; -import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; -import { - buildGatewaySessionInfo, - filterAndSortSessionEntries, - listSessionsFromStore, -} from "./session-utils.js"; +import { filterAndSortSessionEntries } from "./session-utils-list.js"; -const MAIN_SESSION_KEY = "agent:main:main"; -const MAIN_SESSION_ID = "sess-main"; -const TRANSCRIPT_TOTAL_TOKENS = 3_200; -const TRANSCRIPT_COST_USD = 0.007725; -const ANTHROPIC_MODEL = "claude-sonnet-4-6"; -const FREE_OPENAI_MODEL = "gpt-5.3-codex-spark"; +// Search selection does not render rows, read transcripts, or load ACP metadata. +// Keep those integration owners out of this focused suite and their coverage in +// session-utils.test.ts, session-utils.subagent.test.ts, and ACP runtime tests. +vi.mock("../acp/runtime/session-meta.js", () => ({ + readAcpSessionMetaBatch: () => new Map(), +})); +vi.mock("./session-transcript-title-reader.js", () => ({ + readSessionTitleFieldsFromTranscriptBatch: () => [], +})); +vi.mock("./session-utils-row.js", () => ({ + buildGatewaySessionRow: () => { + throw new Error("search selection must not render session rows"); + }, + projectSessionActor: () => undefined, +})); +vi.mock("../agents/provider-model-normalization.runtime.js", () => ({ + normalizeProviderModelIdWithRuntime: () => undefined, +})); -type TranscriptUsageFixture = { - provider: string; - model: string; - input: number; - output: number; - cacheRead: number; - costTotal: number; -}; +const baseCfg = { + session: { mainKey: "main" }, + agents: { list: [{ id: "main", default: true }] }, +} as OpenClawConfig; -const ANTHROPIC_USAGE: TranscriptUsageFixture = { - provider: "anthropic", - model: ANTHROPIC_MODEL, - input: 2_000, - output: 500, - cacheRead: 1_200, - costTotal: TRANSCRIPT_COST_USD, -}; - -const FREE_OPENAI_USAGE: TranscriptUsageFixture = { - provider: "openai", - model: FREE_OPENAI_MODEL, - input: 5_107, - output: 1_827, - cacheRead: 1_536, - costTotal: 0, -}; - -function createModelDefaultsConfig(params: { - primary: string; - models?: Record>; -}): OpenClawConfig { +function createModelDefaultsConfig(primary: string): OpenClawConfig { return { - agents: { - defaults: { - model: { primary: params.primary }, - models: params.models, - }, - }, + agents: { defaults: { model: { primary } } }, } as OpenClawConfig; } -function closeSessionSqliteDatabasesForTest(): void { - closeOpenClawAgentDatabasesForTest(); - closeOpenClawStateDatabaseForTest(); -} - -function createLegacyRuntimeListConfig( - models?: Record>, -): OpenClawConfig { - return createModelDefaultsConfig({ - primary: "google-gemini-cli/gemini-3.1-pro-preview", - ...(models ? { models } : {}), - }); -} - -function createLegacyRuntimeStore(model: string): Record { +function makeStore(now = Date.now()): Record { return { - "agent:main:main": { - sessionId: "sess-main", - updatedAt: Date.now(), - model, - } as SessionEntry, - }; -} - -function buildLegacyRuntimeRow(cfg: OpenClawConfig, model: string) { - const store = createLegacyRuntimeStore(model); - return buildGatewaySessionInfo({ - cfg, - storePath: "/tmp/sessions.json", - store, - key: MAIN_SESSION_KEY, - entry: store[MAIN_SESSION_KEY], - }); -} - -function createOpenAiPricingConfig(params: { - id: string; - label: string; - cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; -}): OpenClawConfig { - return { - session: { mainKey: "main" }, - agents: { list: [{ id: "main", default: true }] }, - models: { - providers: { - openai: { - models: [ - { - id: params.id, - label: params.label, - baseUrl: "https://api.openai.com/v1", - cost: params.cost, - }, - ], - }, - }, - }, - } as unknown as OpenClawConfig; -} - -type DefaultTranscriptFixtureParams = { - prefix: string; - transcriptId?: string; - run: (fixture: { storePath: string; now: number }) => Promise | T; -}; - -function appendUsageTranscriptMessage(params: { - sessionId: string; - sessionKey: string; - storePath: string; - usage: TranscriptUsageFixture; -}) { - appendTranscriptMessageSync( - { - agentId: "main", - sessionId: params.sessionId, - sessionKey: params.sessionKey, - storePath: params.storePath, - }, - { - message: { - role: "assistant", - provider: params.usage.provider, - model: params.usage.model, - usage: { - input: params.usage.input, - output: params.usage.output, - cacheRead: params.usage.cacheRead, - cost: { total: params.usage.costTotal }, - }, - }, - }, - ); -} - -async function withTranscriptFixture( - usage: TranscriptUsageFixture, - params: DefaultTranscriptFixtureParams, -): Promise { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), params.prefix)); - const storePath = path.join(tmpDir, "sessions.json"); - const transcriptId = params.transcriptId ?? MAIN_SESSION_ID; - const now = Date.now(); - - try { - await replaceSessionEntry( - { - agentId: "main", - sessionKey: MAIN_SESSION_KEY, - storePath, - }, - { sessionId: transcriptId, updatedAt: now }, - ); - appendUsageTranscriptMessage({ - sessionId: transcriptId, - sessionKey: MAIN_SESSION_KEY, - storePath, - usage, - }); - return await params.run({ storePath, now }); - } finally { - closeSessionSqliteDatabasesForTest(); - fs.rmSync(tmpDir, { recursive: true, force: true }); - } -} - -const withAnthropicTranscriptFixture = (params: DefaultTranscriptFixtureParams) => - withTranscriptFixture(ANTHROPIC_USAGE, params); - -const withFreeOpenAiTranscriptFixture = (params: DefaultTranscriptFixtureParams) => - withTranscriptFixture(FREE_OPENAI_USAGE, params); - -function createAnthropicContext1mConfig(): OpenClawConfig { - return { - session: { mainKey: "main" }, - agents: { - list: [{ id: "main", default: true }], - defaults: { - models: { - [`anthropic/${ANTHROPIC_MODEL}`]: { params: { context1m: true } }, - }, - }, - }, - } as unknown as OpenClawConfig; -} - -function listSingleSession(params: { - cfg: OpenClawConfig; - storePath: string; - key: string; - entry: SessionEntry; -}) { - return listSessionsFromStore({ - cfg: params.cfg, - storePath: params.storePath, - store: { - [params.key]: params.entry, - }, - opts: {}, - }); -} - -function listMainSession(params: { cfg: OpenClawConfig; storePath: string; entry: SessionEntry }) { - return listSingleSession({ - cfg: params.cfg, - storePath: params.storePath, - key: MAIN_SESSION_KEY, - entry: params.entry, - }); -} - -function registerRunningSubagent(params: { - runId: string; - childSessionKey: string; - model: string; - now: number; -}) { - addSubagentRunForTests({ - runId: params.runId, - childSessionKey: params.childSessionKey, - controllerSessionKey: MAIN_SESSION_KEY, - requesterSessionKey: MAIN_SESSION_KEY, - requesterDisplayKey: "main", - task: "child task", - cleanup: "keep", - createdAt: params.now - 5_000, - startedAt: params.now - 4_000, - model: params.model, - }); - registerAgentRunContext(params.runId, { - sessionKey: params.childSessionKey, - }); -} - -type ListedSession = ReturnType["sessions"][number]; - -function expectSessionModel( - session: ListedSession | undefined, - expected: { key: string; provider: string; model: string }, -) { - expect(session?.key).toBe(expected.key); - expect(session?.modelProvider).toBe(expected.provider); - expect(session?.model).toBe(expected.model); -} - -function expectTranscriptBackfill( - session: ListedSession | undefined, - expected?: { contextTokens?: number; estimatedCostUsd?: number }, -) { - expect(session?.totalTokens).toBe(TRANSCRIPT_TOTAL_TOKENS); - expect(session?.totalTokensFresh).toBe(true); - if (expected?.contextTokens !== undefined) { - expect(session?.contextTokens).toBe(expected.contextTokens); - } - if (expected?.estimatedCostUsd !== undefined) { - expect(session?.estimatedCostUsd).toBeCloseTo(expected.estimatedCostUsd, 8); - } -} - -function sessionEntry(overrides: Partial = {}, updatedAt = Date.now()): SessionEntry { - return { - sessionId: MAIN_SESSION_ID, - updatedAt, - ...overrides, - } as SessionEntry; -} - -function mainSessionStore(entry: SessionEntry): Record { - return { [MAIN_SESSION_KEY]: entry }; -} - -function transcriptFallbackEntry(now: number, overrides: Partial = {}): SessionEntry { - return sessionEntry( - { - totalTokens: 0, - totalTokensFresh: false, - ...overrides, - }, - now, - ); -} - -function expectAnthropicBackfill(session: ListedSession | undefined) { - expectTranscriptBackfill(session, { - contextTokens: ANTHROPIC_CONTEXT_1M_TOKENS, - estimatedCostUsd: TRANSCRIPT_COST_USD, - }); -} - -function expectOpenAiGpt54Backfill(session: ListedSession | undefined) { - expectSessionModel(session, { - key: MAIN_SESSION_KEY, - provider: "openai", - model: "gpt-5.4", - }); - expectTranscriptBackfill(session); -} - -function freeOpenAiUsageEntry(): SessionEntry { - return sessionEntry({ - modelProvider: "openai", - model: FREE_OPENAI_MODEL, - inputTokens: FREE_OPENAI_USAGE.input, - outputTokens: FREE_OPENAI_USAGE.output, - cacheRead: FREE_OPENAI_USAGE.cacheRead, - cacheWrite: 0, - }); -} - -function anthropicUsageEntry(now: number, overrides: Partial = {}): SessionEntry { - return { - sessionId: MAIN_SESSION_ID, - updatedAt: now, - totalTokens: 0, - totalTokensFresh: false, - inputTokens: ANTHROPIC_USAGE.input, - outputTokens: ANTHROPIC_USAGE.output, - cacheRead: ANTHROPIC_USAGE.cacheRead, - ...overrides, - } as SessionEntry; -} - -function zeroUsageTranscriptEntry( - now: number, - overrides: Partial = {}, -): SessionEntry { - return transcriptFallbackEntry(now, { - inputTokens: 0, - outputTokens: 0, - cacheRead: 0, - cacheWrite: 0, - ...overrides, - }); -} - -function childTranscriptEntry(sessionId: string, now: number): SessionEntry { - return transcriptFallbackEntry(now, { - sessionId, - spawnedBy: MAIN_SESSION_KEY, - }); -} - -describe("listSessionsFromStore search", () => { - beforeAll(() => { - listSessionsFromStore({ - cfg: createModelDefaultsConfig({ primary: "anthropic/claude-sonnet-4-6" }), - store: { - "agent:main:warm-runtime": { - sessionId: "sess-warm-runtime", - updatedAt: Date.now(), - } as SessionEntry, - }, - storePath: "/tmp/openclaw-session-search-warm.json", - opts: { search: "anthropic" }, - }); - }); - - beforeAll(() => { - listSessionsFromStore({ - cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }), - storePath: "/tmp/sessions.json", - store: { - "agent:main:main": { - sessionId: "sess-main", - updatedAt: 1, - modelProvider: "openai", - model: "gpt-5.4", - }, - }, - opts: { search: "openai" }, - }); - }); - - afterEach(() => { - resetAgentEventsForTest({ preserveListeners: true }); - resetSubagentRegistryForTests(); - closeSessionSqliteDatabasesForTest(); - }); - - const baseCfg = { - session: { mainKey: "main" }, - agents: { list: [{ id: "main", default: true }] }, - } as OpenClawConfig; - - const makeStore = (): Record => ({ "agent:main:work-project": { sessionId: "sess-work-1", - updatedAt: Date.now(), + updatedAt: now, displayName: "Work Project Alpha", label: "work", } as SessionEntry, "agent:main:personal-chat": { sessionId: "sess-personal-1", - updatedAt: Date.now() - 1000, + updatedAt: now - 1_000, displayName: "Personal Chat", subject: "Family Reunion Planning", } as SessionEntry, "agent:main:discord:group:dev-team": { sessionId: "sess-discord-1", - updatedAt: Date.now() - 2000, + updatedAt: now - 2_000, label: "discord", subject: "Dev Team Discussion", } as SessionEntry, - }); + }; +} - function listSearchSessions(params: { - opts: Parameters[0]["opts"]; - cfg?: OpenClawConfig; - store?: Record; - }) { - return listSessionsFromStore({ - cfg: params.cfg ?? baseCfg, - storePath: "/tmp/sessions.json", - store: params.store ?? makeStore(), - opts: params.opts, - }); - } - - function listConfiguredMainSession(cfg: OpenClawConfig, entry: SessionEntry) { - return listSearchSessions({ - cfg, - store: mainSessionStore(entry), - opts: {}, - }); - } +function selectSessionKeys(params: { + opts: Parameters[0]["opts"]; + cfg?: OpenClawConfig; + store?: Record; + now?: number; +}): string[] { + const now = params.now ?? Date.now(); + return filterAndSortSessionEntries({ + cfg: params.cfg ?? baseCfg, + store: params.store ?? makeStore(now), + opts: params.opts, + now, + }).map(([key]) => key); +} +describe("filterAndSortSessionEntries search", () => { test("returns all sessions when search is empty or missing", () => { - const cases = [{ opts: { search: "" } }, { opts: {} }] as const; - for (const testCase of cases) { - const result = listSearchSessions({ opts: testCase.opts }); - expect(result.sessions).toHaveLength(3); + for (const opts of [{ search: "" }, {}]) { + expect(selectSessionKeys({ opts })).toHaveLength(3); } }); - test("filters sessions across display metadata and key fields", () => { + test("filters across display metadata and key fields", () => { const cases = [ { search: "WORK PROJECT", expectedKey: "agent:main:work-project" }, { search: "reunion", expectedKey: "agent:main:personal-chat" }, @@ -481,23 +91,14 @@ describe("listSessionsFromStore search", () => { ] as const; for (const testCase of cases) { - const result = listSearchSessions({ opts: { search: testCase.search } }); - if (!testCase.expectedKey) { - expect(result.sessions).toHaveLength(0); - continue; - } - expect(result.sessions).toHaveLength(1); - expect(expectDefined(result.sessions[0], "result.sessions[0] test invariant").key).toBe( - testCase.expectedKey, - ); + const keys = selectSessionKeys({ opts: { search: testCase.search } }); + expect(keys).toEqual(testCase.expectedKey ? [testCase.expectedKey] : []); } }); - test("filters sessions by the displayed provider and model identity", () => { + test("filters by displayed provider and model identity", () => { const now = Date.now(); - const cfg = createModelDefaultsConfig({ - primary: "anthropic/claude-sonnet-4-6", - }); + const cfg = createModelDefaultsConfig("anthropic/claude-sonnet-4-6"); const store: Record = { "agent:main:inherited-default": { sessionId: "sess-inherited-default", @@ -529,64 +130,58 @@ describe("listSessionsFromStore search", () => { ] as const; for (const testCase of cases) { - const entries = filterAndSortSessionEntries({ - cfg, - store, - opts: { search: testCase.search }, - now, - }); - - expect(entries.map(([key]) => key)).toEqual([testCase.expectedKey]); + expect( + selectSessionKeys({ + cfg, + store, + opts: { search: testCase.search }, + now, + }), + ).toEqual([testCase.expectedKey]); } }); test("keeps derived model search for colon model ids", () => { const now = Date.now(); - const cfg = createModelDefaultsConfig({ - primary: "ollama/qwen3:0.6b", - }); - const result = listSearchSessions({ - cfg, - store: { - "agent:main:inherited-local-model": { - sessionId: "sess-inherited-local-model", - updatedAt: now, - label: "Inherited local model", - } as SessionEntry, - }, - opts: { search: "qwen3:0.6b" }, - }); - - expect(result.sessions.map((session) => session.key)).toEqual([ - "agent:main:inherited-local-model", - ]); - expect(result.totalCount).toBe(1); + expect( + selectSessionKeys({ + cfg: createModelDefaultsConfig("ollama/qwen3:0.6b"), + store: { + "agent:main:inherited-local-model": { + sessionId: "sess-inherited-local-model", + updatedAt: now, + label: "Inherited local model", + } as SessionEntry, + }, + opts: { search: "qwen3:0.6b" }, + now, + }), + ).toEqual(["agent:main:inherited-local-model"]); }); - test("hides cron run alias session keys from sessions list", () => { + test("hides cron run alias session keys", () => { const now = Date.now(); - const store: Record = { - "agent:main:cron:job-1": { - sessionId: "run-abc", - updatedAt: now, - label: "Cron: job-1", - } as SessionEntry, - "agent:main:cron:job-1:run:run-abc": { - sessionId: "run-abc", - updatedAt: now, - label: "Cron: job-1", - } as SessionEntry, - }; - - const result = listSearchSessions({ - store, - opts: {}, - }); - - expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]); + expect( + selectSessionKeys({ + store: { + "agent:main:cron:job-1": { + sessionId: "run-abc", + updatedAt: now, + label: "Cron: job-1", + } as SessionEntry, + "agent:main:cron:job-1:run:run-abc": { + sessionId: "run-abc", + updatedAt: now, + label: "Cron: job-1", + } as SessionEntry, + }, + opts: {}, + now, + }), + ).toEqual(["agent:main:cron:job-1"]); }); - test("ranks sessions by real interaction without heartbeat or cron noise", () => { + test("ranks by real interaction without heartbeat or cron noise", () => { const now = Date.now(); const store: Record = { "agent:main:main": { @@ -617,332 +212,12 @@ describe("listSessionsFromStore search", () => { } as SessionEntry, }; - const result = listSearchSessions({ - store, - opts: { - requireLastInteraction: true, - sortBy: "lastInteractionAt", - }, - }); - - expect(result.sessions.map((session) => session.key)).toEqual([ - "agent:main:main", - "agent:main:heartbeat-noise", - ]); - expect(result.sessions[0]?.lastInteractionAt).toBe(now - 1_000); - }); - - test.each([ - { - name: "does not guess provider for legacy runtime model without modelProvider", - cfg: createLegacyRuntimeListConfig(), - runtimeModel: "claude-sonnet-4-6", - expectedProvider: undefined, - }, - { - name: "infers provider for legacy runtime model when allowlist match is unique", - cfg: createLegacyRuntimeListConfig({ "anthropic/claude-sonnet-4-6": {} }), - runtimeModel: "claude-sonnet-4-6", - expectedProvider: "anthropic", - }, - { - name: "infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique", - cfg: createLegacyRuntimeListConfig({ - "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {}, + expect( + selectSessionKeys({ + store, + opts: { requireLastInteraction: true, sortBy: "lastInteractionAt" }, + now, }), - runtimeModel: "anthropic/claude-sonnet-4-6", - expectedProvider: "vercel-ai-gateway", - }, - ])("$name", ({ cfg, runtimeModel, expectedProvider }) => { - const row = buildLegacyRuntimeRow(cfg, runtimeModel); - - expect(row.modelProvider).toBe(expectedProvider); - expect(row.model).toBe(runtimeModel); - }); - - test("exposes unknown totals when freshness is stale or missing", () => { - const now = Date.now(); - const store: Record = { - "agent:main:fresh": { - sessionId: "sess-fresh", - updatedAt: now, - totalTokens: 1200, - totalTokensFresh: true, - totalTokensVersion: 1, - } as SessionEntry, - "agent:main:stale": { - sessionId: "sess-stale", - updatedAt: now - 1000, - totalTokens: 2200, - totalTokensFresh: false, - } as SessionEntry, - "agent:main:missing": { - sessionId: "sess-missing", - updatedAt: now - 2000, - inputTokens: 100, - outputTokens: 200, - } as SessionEntry, - }; - - const result = listSearchSessions({ - store, - opts: {}, - }); - - const fresh = result.sessions.find((row) => row.key === "agent:main:fresh"); - const stale = result.sessions.find((row) => row.key === "agent:main:stale"); - const missing = result.sessions.find((row) => row.key === "agent:main:missing"); - expect(fresh?.totalTokens).toBe(1200); - expect(fresh?.totalTokensFresh).toBe(true); - expect(stale?.totalTokens).toBeUndefined(); - expect(stale?.totalTokensFresh).toBe(false); - expect(missing?.totalTokens).toBeUndefined(); - expect(missing?.totalTokensFresh).toBe(false); - }); - - test("includes estimated session cost when model pricing is configured", () => { - const cfg = createOpenAiPricingConfig({ - id: "gpt-5.4", - label: "GPT 5.4", - cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.5 }, - }); - const result = listConfiguredMainSession( - cfg, - sessionEntry({ - modelProvider: "openai", - model: "gpt-5.4", - inputTokens: 2_000, - outputTokens: 500, - cacheRead: 1_000, - cacheWrite: 200, - }), - ); - - expect(result.sessions[0]?.estimatedCostUsd).toBeCloseTo(TRANSCRIPT_COST_USD, 8); - }); - - test("prefers persisted estimated session cost from the store", async () => { - await withAnthropicTranscriptFixture({ - prefix: "openclaw-session-utils-store-cost-", - run: ({ storePath, now }) => { - const result = listMainSession({ - cfg: baseCfg, - storePath, - entry: transcriptFallbackEntry(now, { - modelProvider: "anthropic", - model: ANTHROPIC_MODEL, - estimatedCostUsd: 0.1234, - }), - }); - - expect(result.sessions[0]?.estimatedCostUsd).toBe(0.1234); - expect(result.sessions[0]?.totalTokens).toBe(TRANSCRIPT_TOTAL_TOKENS); - }, - }); - }); - - test("keeps zero estimated session cost when configured model pricing resolves to free", () => { - const cfg = createOpenAiPricingConfig({ - id: FREE_OPENAI_MODEL, - label: "GPT 5.3 Codex Spark", - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }); - const result = listConfiguredMainSession(cfg, freeOpenAiUsageEntry()); - - expect(result.sessions[0]?.estimatedCostUsd).toBe(0); - }); - - test("falls back to transcript usage for totalTokens and zero estimatedCostUsd", async () => { - await withFreeOpenAiTranscriptFixture({ - prefix: "openclaw-session-utils-zero-cost-", - run: ({ storePath, now }) => { - const result = listMainSession({ - cfg: baseCfg, - storePath, - entry: zeroUsageTranscriptEntry(now, { - modelProvider: "openai", - model: FREE_OPENAI_MODEL, - }), - }); - - expect(result.sessions[0]?.totalTokens).toBe(6_643); - expect(result.sessions[0]?.totalTokensFresh).toBe(true); - expect(result.sessions[0]?.estimatedCostUsd).toBe(0); - }, - }); - }); - - test("falls back to transcript usage for totalTokens and estimatedCostUsd, and derives contextTokens from the resolved model", async () => { - await withAnthropicTranscriptFixture({ - prefix: "openclaw-session-utils-", - run: ({ storePath, now }) => { - const result = listMainSession({ - cfg: createAnthropicContext1mConfig(), - storePath, - entry: zeroUsageTranscriptEntry(now, { - modelProvider: "anthropic", - model: ANTHROPIC_MODEL, - }), - }); - - expectAnthropicBackfill(result.sessions[0]); - }, - }); - }); - - test("chat history session metadata keeps model context and projects a catalog-pinned harness", async () => { - await withAnthropicTranscriptFixture({ - prefix: "openclaw-session-info-context-", - run: ({ storePath, now }) => { - const entry: SessionEntry = { - sessionId: MAIN_SESSION_ID, - updatedAt: now, - modelProvider: "local-test", - model: "test-model", - agentHarnessId: "codex", - modelSelectionLocked: true, - pluginExtensions: { - codex: { - supervision: { - sourceThreadId: "019f-codex-thread", - modelLocked: true, - }, - }, - }, - }; - const row = buildGatewaySessionInfo({ - cfg: { - models: { - providers: { - "local-test": { - models: [{ id: "test-model", contextTokens: 123_456 }], - }, - }, - }, - } as unknown as OpenClawConfig, - storePath, - key: MAIN_SESSION_KEY, - entry, - store: { [MAIN_SESSION_KEY]: entry }, - }); - - expect(row.totalTokens).toBeUndefined(); - expect(row.totalTokensFresh).toBe(false); - expect(row.estimatedCostUsd).toBeUndefined(); - expect(row.contextTokens).toBe(123_456); - expect(row.modelSelectionLocked).toBe(true); - expect(row.agentRuntime).toEqual({ id: "codex", source: "session" }); - }, - }); - }); - - test("uses subagent run model immediately for child sessions while transcript usage fills live totals", async () => { - await withAnthropicTranscriptFixture({ - prefix: "openclaw-session-utils-subagent-", - transcriptId: "sess-child", - run: ({ storePath, now }) => { - registerRunningSubagent({ - runId: "run-child-live", - childSessionKey: "agent:main:subagent:child-live", - model: `anthropic/${ANTHROPIC_MODEL}`, - now, - }); - - const result = listSingleSession({ - cfg: createAnthropicContext1mConfig(), - storePath, - key: "agent:main:subagent:child-live", - entry: childTranscriptEntry("sess-child", now), - }); - - expectSessionModel(result.sessions[0], { - key: "agent:main:subagent:child-live", - provider: "anthropic", - model: ANTHROPIC_MODEL, - }); - expect(result.sessions[0]?.status).toBe("running"); - expectAnthropicBackfill(result.sessions[0]); - }, - }); - }); - - test("keeps a running subagent model when transcript fallback still reflects an older run", async () => { - await withAnthropicTranscriptFixture({ - prefix: "openclaw-session-utils-subagent-stale-model-", - transcriptId: "sess-child-stale", - run: ({ storePath, now }) => { - registerRunningSubagent({ - runId: "run-child-live-new-model", - childSessionKey: "agent:main:subagent:child-live-stale-transcript", - model: "openai/gpt-5.4", - now, - }); - - const result = listSingleSession({ - cfg: createAnthropicContext1mConfig(), - storePath, - key: "agent:main:subagent:child-live-stale-transcript", - entry: childTranscriptEntry("sess-child-stale", now), - }); - - expectSessionModel(result.sessions[0], { - key: "agent:main:subagent:child-live-stale-transcript", - provider: "openai", - model: "gpt-5.4", - }); - expect(result.sessions[0]?.status).toBe("running"); - expectTranscriptBackfill(result.sessions[0]); - }, - }); - }); - - test("keeps the selected override model when runtime identity was intentionally cleared", async () => { - await withAnthropicTranscriptFixture({ - prefix: "openclaw-session-utils-cleared-runtime-model-", - transcriptId: "sess-override", - run: ({ storePath, now }) => { - const result = listMainSession({ - cfg: createAnthropicContext1mConfig(), - storePath, - entry: transcriptFallbackEntry(now, { - sessionId: "sess-override", - providerOverride: "openai", - modelOverride: "gpt-5.4", - }), - }); - - expectOpenAiGpt54Backfill(result.sessions[0]); - }, - }); - }); - - test("does not replace the current runtime model when transcript fallback is only for missing pricing", async () => { - await withAnthropicTranscriptFixture({ - prefix: "openclaw-session-utils-pricing-", - transcriptId: "sess-pricing", - run: ({ storePath, now }) => { - const result = listMainSession({ - cfg: { - session: { mainKey: "main" }, - agents: { - list: [{ id: "main", default: true }], - }, - } as unknown as OpenClawConfig, - storePath, - entry: anthropicUsageEntry(now, { - sessionId: "sess-pricing", - modelProvider: "openai", - model: "gpt-5.4", - contextTokens: 200_000, - totalTokens: TRANSCRIPT_TOTAL_TOKENS, - totalTokensFresh: true, - totalTokensVersion: 1, - }), - }); - - expectOpenAiGpt54Backfill(result.sessions[0]); - expect(result.sessions[0]?.contextTokens).toBe(200_000); - }, - }); + ).toEqual(["agent:main:main", "agent:main:heartbeat-noise"]); }); }); From c97f3207a07ce12c79ed6ce7761d9fe923945a4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 05:09:32 -0700 Subject: [PATCH 12/14] fix(outbound): report broadcast delivery truth (#122605) Derive broadcast success from structured send outcomes instead of treating every returned send as delivered. --- ...sage-action-runner.plugin-dispatch.test.ts | 122 ++++++++++++++++++ src/infra/outbound/message-action-runner.ts | 32 ++++- 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 418b3c4252b3..86b82dc53093 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -16,6 +16,7 @@ import { runMessageAction, setMessageActionTestPlugin as setTestPlugin, } from "./message-action-runner.test-helpers.js"; +import type { MessageSendResult } from "./message.js"; const requireLabeledRecord = createRequireRecord("record", "expected-label"); @@ -375,6 +376,127 @@ describe("runMessageAction plugin dispatch", () => { expect(mocks.executeSendAction).not.toHaveBeenCalled(); }); + it.each<{ + name: string; + delivery: Partial; + outcome: { ok: boolean; error?: string; sentBeforeError?: true }; + }>([ + { + name: "sent", + delivery: { deliveryStatus: "sent" }, + outcome: { ok: true }, + }, + { + name: "suppressed", + delivery: { + deliveryStatus: "suppressed", + suppressionReason: "cancelled_by_message_sending_hook", + }, + outcome: { + ok: false, + error: "Broadcast send suppressed: cancelled_by_message_sending_hook.", + }, + }, + { + name: "failed", + delivery: { + deliveryStatus: "failed", + error: "provider rejected the message", + }, + outcome: { ok: false, error: "provider rejected the message" }, + }, + { + name: "failed without an error", + delivery: { deliveryStatus: "failed" }, + outcome: { ok: false, error: "Broadcast send failed." }, + }, + { + name: "partial_failed", + delivery: { + deliveryStatus: "partial_failed", + error: "second payload failed", + sentBeforeError: true, + }, + outcome: { ok: false, error: "second payload failed", sentBeforeError: true }, + }, + { + name: "partial_failed without an error", + delivery: { deliveryStatus: "partial_failed", sentBeforeError: true }, + outcome: { + ok: false, + error: "Broadcast send partially failed.", + sentBeforeError: true, + }, + }, + { + name: "legacy result without deliveryStatus", + delivery: { + via: "gateway", + result: { messageId: "legacy-message-1" }, + }, + outcome: { ok: true }, + }, + ])("derives broadcast truth from a $name send result", async ({ delivery, outcome }) => { + const nestedPayload = { ok: true, nested: "payload" }; + const sendResult = { + channel: "gatewaychat", + to: "user-123", + via: "direct", + mediaUrl: null, + ...delivery, + } satisfies MessageSendResult; + const gatewayPlugin = createGatewayActionPlugin({ + pluginId: "gatewaychat", + label: "Gateway Chat", + blurb: "Gateway Chat delivery truth test plugin.", + actions: ["send"], + messaging: { + targetResolver: { + looksLikeId: () => true, + }, + }, + handleAction: vi.fn(async () => jsonResult({ ok: true })), + }); + setTestPlugin(gatewayPlugin, "gatewaychat"); + mocks.executeSendAction.mockResolvedValue({ + handledBy: "core", + payload: nestedPayload, + sendResult, + }); + + const result = await runMessageAction({ + cfg: { + channels: { + gatewaychat: { + enabled: true, + }, + }, + } as OpenClawConfig, + action: "broadcast", + params: { + channel: "gatewaychat", + targets: ["user-123"], + message: "hello from broadcast", + }, + }); + + expect(result.kind).toBe("broadcast"); + if (result.kind !== "broadcast") { + throw new Error("expected broadcast result"); + } + expect(result.payload.results).toEqual([ + { + channel: "gatewaychat", + to: "user-123", + ...outcome, + payload: nestedPayload, + result: sendResult, + }, + ]); + expect(result.payload.results[0]?.payload).toBe(nestedPayload); + expect(result.payload.results[0]?.result).toBe(sendResult); + }); + it("preserves partial-delivery evidence from failed broadcast sends", async () => { const gatewayPlugin = createGatewayActionPlugin({ pluginId: "gatewaychat", diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 3c3ebe15532e..521e0163d394 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -57,6 +57,34 @@ function withSendNormalization( return normalization && result.kind === "send" ? { ...result, normalization } : result; } +function deriveBroadcastEntryOutcome( + sendResult?: MessageSendResult, +): { ok: true } | { ok: false; error: string; sentBeforeError?: true } { + if ( + !sendResult || + sendResult.deliveryStatus === undefined || + sendResult.deliveryStatus === "sent" + ) { + return { ok: true }; + } + switch (sendResult.deliveryStatus) { + case "suppressed": + return { + ok: false, + error: `Broadcast send suppressed: ${sendResult.suppressionReason ?? "unknown reason"}.`, + }; + case "failed": + return { ok: false, error: sendResult.error ?? "Broadcast send failed." }; + case "partial_failed": + return { + ok: false, + error: sendResult.error ?? "Broadcast send partially failed.", + sentBeforeError: true, + }; + } + return sendResult.deliveryStatus satisfies never; +} + async function handleBroadcastAction( input: MessageActionInput, params: Record, @@ -147,7 +175,9 @@ async function handleBroadcastAction( results.push({ channel: targetChannel, to: resolved.to, - ok: true, + ...deriveBroadcastEntryOutcome( + sendResult.kind === "send" ? sendResult.sendResult : undefined, + ), payload: sendResult.kind === "send" ? sendResult.payload : undefined, result: sendResult.kind === "send" ? sendResult.sendResult : undefined, }); From fc14a5a587b18c45fdf183bc71bdccf846963421 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 12 Aug 2026 20:11:27 +0800 Subject: [PATCH 13/14] fix(sessions): reject missing explicit session targets (#122564) * fix(sessions): reject missing explicit session targets Punchcard-Session: brisk-willow-summit-k0 * fix(sessions): scope strict resolution to send and history Punchcard-Session: brisk-willow-summit-k0 --- src/agents/openclaw-tools.sessions.test.ts | 5 +- .../tools/sessions-history-tool.test.ts | 76 ++++- src/agents/tools/sessions-history-tool.ts | 19 +- src/agents/tools/sessions-resolution.test.ts | 302 ++++++++++++++---- src/agents/tools/sessions-resolution.ts | 137 +++++--- src/agents/tools/sessions-send-tool.ts | 112 ++++--- src/agents/tools/sessions.test.ts | 42 ++- src/gateway/server.sessions-send.test.ts | 56 +++- .../server.sessions.preview-resolve.test.ts | 12 + 9 files changed, 569 insertions(+), 192 deletions(-) diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index c0ad761cbf87..95b129a7a2c2 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -1164,6 +1164,9 @@ describe("sessions tools", () => { callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as GatewayCall; calls.push(request); + if (request.method === "sessions.resolve") { + return { key: targetSessionKey }; + } if (request.method === "agent") { return { runId: "run-scoped", status: "accepted", acceptedAt: 1 }; } @@ -1193,8 +1196,8 @@ describe("sessions tools", () => { watched: false, }); expect(calls.map((call) => call.method)).toEqual([ - "sessions.list", "sessions.resolve", + "sessions.list", "agent", ]); } finally { diff --git a/src/agents/tools/sessions-history-tool.test.ts b/src/agents/tools/sessions-history-tool.test.ts index 7206a5bcc632..0d2341db8409 100644 --- a/src/agents/tools/sessions-history-tool.test.ts +++ b/src/agents/tools/sessions-history-tool.test.ts @@ -146,6 +146,76 @@ describe("sessions_history redaction", () => { ); }); + it("returns not-found for an unknown explicit key without reading history", async () => { + const requests: CallGatewayRequest[] = []; + const sessionKey = "agent:main:missing"; + const tool = createSessionsHistoryTool({ + config: { tools: { sessions: { visibility: "all" } } }, + callGateway: async >(request: CallGatewayRequest): Promise => { + requests.push(request); + if (request.method === "sessions.resolve") { + throw new Error(`No session found: ${sessionKey}`); + } + return { messages: [] } as T; + }, + }); + + const result = await tool.execute("missing-explicit-key", { sessionKey }); + + expect(result.details).toEqual({ + status: "error", + error: `No session found: ${sessionKey}`, + }); + expect(requests.map((request) => request.method)).toEqual(["sessions.resolve"]); + }); + + it("conceals missing explicit keys denied by session visibility", async () => { + const requests: CallGatewayRequest[] = []; + const tool = createSessionsHistoryTool({ + agentSessionKey: "agent:main:main", + config: { tools: { sessions: { visibility: "self" } } }, + callGateway: async >(request: CallGatewayRequest): Promise => { + requests.push(request); + throw new Error("No session found: agent:main:missing"); + }, + }); + + const result = await tool.execute("hidden-missing-key", { + sessionKey: "agent:main:missing", + }); + + expect(result.details).toMatchObject({ status: "forbidden" }); + expect(requests.map((request) => request.method)).toEqual(["sessions.resolve"]); + }); + + it("returns an empty history for an existing explicit key", async () => { + const requests: CallGatewayRequest[] = []; + const sessionKey = "agent:main:empty"; + const tool = createSessionsHistoryTool({ + config: { tools: { sessions: { visibility: "all" } } }, + callGateway: async >(request: CallGatewayRequest): Promise => { + requests.push(request); + if (request.method === "sessions.resolve") { + return { key: sessionKey } as T; + } + return { messages: [] } as T; + }, + }); + + const result = await tool.execute("existing-empty-key", { sessionKey }); + + expect(result.details).toMatchObject({ + sessionKey, + messages: [], + bytes: 2, + }); + expect(requests.map((request) => request.method)).toEqual([ + "sessions.resolve", + "sessions.list", + "chat.history", + ]); + }); + it("redacts recalled session text even when log redaction is disabled", async () => { // Recalled transcript content is model-visible, so it is always redacted // even when normal logging redaction is configured off. @@ -445,7 +515,11 @@ describe("sessions_history redaction", () => { sessionKey: targetSessionKey, messages: [{ role: "assistant", content: "visible" }], }); - expect(requests.map((request) => request.method)).toEqual(["sessions.list", "chat.history"]); + expect(requests.map((request) => request.method)).toEqual([ + "sessions.resolve", + "sessions.list", + "chat.history", + ]); } finally { unregister(); } diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index a84065478855..28b9f1a0161b 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -33,6 +33,7 @@ import { import { runWithScopedSessionAccess } from "./scoped-session-access.js"; import { createSessionVisibilityGuard, + createSessionVisibilityRowChecker, createAgentToAgentPolicy, resolveEffectiveSessionToolsVisibility, resolveSessionReference, @@ -398,12 +399,25 @@ export function createSessionsHistoryTool(opts?: { if (!resolvedSession.ok) { return jsonResult({ status: resolvedSession.status, error: resolvedSession.error }); } + const a2aPolicy = createAgentToAgentPolicy(cfg); + const visibility = resolveEffectiveSessionToolsVisibility({ + cfg, + sandboxed: opts?.sandboxed === true, + }); + const resolutionAccess = createSessionVisibilityRowChecker({ + action: "history", + defaultAgentId: resolveDefaultAgentId(cfg), + requesterSessionKey: effectiveRequesterKey, + visibility, + a2aPolicy, + }).check({ key: resolvedSession.key }); const visibleSession = await resolveVisibleSessionReference({ action: "history", resolvedSession, requesterSessionKey: effectiveRequesterKey, restrictToSpawned, visibilitySessionKey: sessionKeyParam, + concealResolutionError: resolutionAccess.allowed ? undefined : resolutionAccess.error, callGateway: gatewayCall, }); if (!visibleSession.ok) { @@ -416,11 +430,6 @@ export function createSessionsHistoryTool(opts?: { const resolvedKey = visibleSession.key; const displayKey = visibleSession.displayKey; - const a2aPolicy = createAgentToAgentPolicy(cfg); - const visibility = resolveEffectiveSessionToolsVisibility({ - cfg, - sandboxed: opts?.sandboxed === true, - }); const visibilityGuard = await createSessionVisibilityGuard({ action: "history", defaultAgentId: resolveDefaultAgentId(cfg), diff --git a/src/agents/tools/sessions-resolution.test.ts b/src/agents/tools/sessions-resolution.test.ts index 8e42750581ee..a2e345d0d081 100644 --- a/src/agents/tools/sessions-resolution.test.ts +++ b/src/agents/tools/sessions-resolution.test.ts @@ -160,7 +160,7 @@ describe("resolved session visibility checks", () => { await expect( resolveVisibleSessionReference({ - action: "history", + action: "status", resolvedSession: { ok: true, key: sessionKey, @@ -210,7 +210,7 @@ describe("resolved session visibility checks", () => { for (const testCase of cases) { callGatewayMock.mockResolvedValueOnce({ key: testCase.targetSessionKey }); const result = resolveVisibleSessionReference({ - action: "history", + action: "status", resolvedSession: { ok: true, key: testCase.targetSessionKey, @@ -253,7 +253,7 @@ describe("resolved session visibility checks", () => { await expect( resolveVisibleSessionReference({ - action: "history", + action: "status", resolvedSession: { ok: true, key: "agent:main:subagent:worker-999", @@ -281,7 +281,7 @@ describe("resolved session visibility checks", () => { await expect( resolveVisibleSessionReference({ - action: "history", + action: "status", resolvedSession: { ok: true, key: "agent:main:subagent:worker", @@ -362,71 +362,6 @@ describe("resolveSessionReference", () => { }); }); - it("retries literal current probes without allowMissing for older gateways", async () => { - const unsupportedAllowMissing = () => - new GatewayClientRequestError({ - code: "INVALID_REQUEST", - message: "invalid sessions.resolve params: at root: unexpected property 'allowMissing'", - }); - callGatewayMock - .mockRejectedValueOnce(unsupportedAllowMissing()) - .mockRejectedValueOnce( - new GatewayClientRequestError({ - code: "INVALID_REQUEST", - message: "No session found: current", - }), - ) - .mockRejectedValueOnce(unsupportedAllowMissing()) - .mockResolvedValueOnce({ key: "agent:ops:main" }); - - const result = await resolveSessionReference({ - sessionKey: "current", - alias: "main", - mainKey: "main", - requesterInternalKey: "agent:main:subagent:child", - restrictToSpawned: false, - }); - expectResolvedSessionReference(result, { - key: "agent:ops:main", - displayKey: "agent:ops:main", - resolvedViaSessionId: true, - }); - expect(callGatewayMock).toHaveBeenNthCalledWith(1, { - method: "sessions.resolve", - params: { - key: "current", - spawnedBy: undefined, - allowMissing: true, - }, - }); - expect(callGatewayMock).toHaveBeenNthCalledWith(2, { - method: "sessions.resolve", - params: { - key: "current", - spawnedBy: undefined, - }, - }); - expect(callGatewayMock).toHaveBeenNthCalledWith(3, { - method: "sessions.resolve", - params: { - sessionId: "current", - spawnedBy: undefined, - includeGlobal: true, - includeUnknown: true, - allowMissing: true, - }, - }); - expect(callGatewayMock).toHaveBeenNthCalledWith(4, { - method: "sessions.resolve", - params: { - sessionId: "current", - spawnedBy: undefined, - includeGlobal: true, - includeUnknown: true, - }, - }); - }); - it("does not compatibility-retry unrelated gateway failures", async () => { callGatewayMock.mockRejectedValueOnce(new Error("gateway timeout")).mockResolvedValueOnce({}); @@ -486,4 +421,233 @@ describe("resolveSessionReference", () => { }); expect(callGatewayMock).not.toHaveBeenCalled(); }); + + it("preserves the main alias without probing configured-main bootstrap", async () => { + const result = await resolveSessionReference({ + sessionKey: "main", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:dashboard:requester", + restrictToSpawned: false, + }); + + expectResolvedSessionReference(result, { + key: "main", + displayKey: "main", + resolvedViaSessionId: false, + }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("defers explicit-key lookup to action-aware visibility resolution", async () => { + const result = await resolveSessionReference({ + sessionKey: "agent:main:worker", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + + expect(result).toEqual({ + ok: true, + key: "agent:main:worker", + displayKey: "agent:main:worker", + resolvedViaSessionId: false, + }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("rejects an unknown explicit session key for history", async () => { + callGatewayMock.mockRejectedValueOnce( + new GatewayClientRequestError({ + code: "INVALID_REQUEST", + message: "No session found: agent:main:missing", + }), + ); + + const resolvedSession = await resolveSessionReference({ + sessionKey: "agent:main:missing", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "history", + resolvedSession, + requesterSessionKey: "agent:main:main", + restrictToSpawned: false, + visibilitySessionKey: "agent:main:missing", + }); + + expect(result).toEqual({ + ok: false, + status: "error", + error: "No session found: agent:main:missing", + displayKey: "agent:main:missing", + }); + expect(callGatewayMock).toHaveBeenCalledWith({ + method: "sessions.resolve", + params: { + key: "agent:main:missing", + spawnedBy: undefined, + }, + }); + }); + + it("canonicalizes an existing explicit session key", async () => { + callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:main" }); + + const resolvedSession = await resolveSessionReference({ + sessionKey: "agent:OPS:main", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "send", + resolvedSession, + requesterSessionKey: "agent:main:main", + restrictToSpawned: false, + visibilitySessionKey: "agent:OPS:main", + }); + + expect(result).toEqual({ + ok: true, + key: "agent:ops:main", + displayKey: "agent:ops:main", + }); + }); + + it("rejects an explicit key that canonicalizes to an incognito session", async () => { + callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:dashboard:incognito-private" }); + + const resolvedSession = await resolveSessionReference({ + sessionKey: "agent:OPS:dashboard:private", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "history", + resolvedSession, + requesterSessionKey: "agent:main:main", + restrictToSpawned: false, + visibilitySessionKey: "agent:OPS:dashboard:private", + }); + + expect(result).toEqual({ + ok: false, + status: "forbidden", + error: "Session not visible from session tools: agent:OPS:dashboard:private", + displayKey: "agent:ops:dashboard:incognito-private", + }); + }); + + it("conceals a missing explicit key from sandboxed callers", async () => { + callGatewayMock.mockRejectedValueOnce(new Error("No session found: agent:main:missing")); + + const resolvedSession = await resolveSessionReference({ + sessionKey: "agent:main:missing", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:subagent:child", + restrictToSpawned: true, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "history", + resolvedSession, + requesterSessionKey: "agent:main:subagent:child", + restrictToSpawned: true, + visibilitySessionKey: "agent:main:missing", + }); + + expect(result).toEqual({ + ok: false, + status: "forbidden", + error: "Session not visible from this sandboxed agent session: agent:main:missing", + displayKey: "agent:main:missing", + }); + }); + + it("propagates explicit-key gateway failures", async () => { + callGatewayMock.mockRejectedValueOnce(new Error("gateway unavailable")); + + const resolvedSession = await resolveSessionReference({ + sessionKey: "agent:main:worker", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:main", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "send", + resolvedSession, + requesterSessionKey: "agent:main:main", + restrictToSpawned: false, + visibilitySessionKey: "agent:main:worker", + }); + + expect(result).toEqual({ + ok: false, + status: "error", + error: "gateway unavailable", + displayKey: "agent:main:worker", + }); + }); + + it("reports an allowed missing explicit key for deliberate bootstrap", async () => { + callGatewayMock.mockResolvedValueOnce({}); + + const resolvedSession = await resolveSessionReference({ + sessionKey: "agent:main:main", + alias: "main", + mainKey: "main", + requesterInternalKey: "agent:main:dashboard:requester", + restrictToSpawned: false, + }); + if (!resolvedSession.ok) { + throw new Error("Expected session reference"); + } + const result = await resolveVisibleSessionReference({ + action: "send", + resolvedSession, + requesterSessionKey: "agent:main:dashboard:requester", + restrictToSpawned: false, + visibilitySessionKey: "agent:main:main", + allowMissingKey: true, + }); + + expect(result).toEqual({ + ok: true, + key: "agent:main:main", + displayKey: "agent:main:main", + missing: true, + }); + expect(callGatewayMock).toHaveBeenCalledWith({ + method: "sessions.resolve", + params: { + key: "agent:main:main", + spawnedBy: undefined, + allowMissing: true, + }, + }); + }); }); diff --git a/src/agents/tools/sessions-resolution.ts b/src/agents/tools/sessions-resolution.ts index be6ee23b114d..13c668c781f7 100644 --- a/src/agents/tools/sessions-resolution.ts +++ b/src/agents/tools/sessions-resolution.ts @@ -9,7 +9,6 @@ import { normalizeGatewayClientId, } from "../../../packages/gateway-protocol/src/client-info.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { GatewayClientRequestError } from "../../gateway/client.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSessionVisibilityChecker, @@ -164,10 +163,11 @@ type VisibleSessionReferenceResolution = ok: true; key: string; displayKey: string; + missing?: true; } | { ok: false; - status: "forbidden"; + status: "error" | "forbidden"; error: string; displayKey: string; }; @@ -190,36 +190,35 @@ function buildResolvedSessionReference(params: { }; } +function buildFailedSessionReference( + error: unknown, + raw: string, + restrictToSpawned: boolean, +): Extract { + return restrictToSpawned + ? { + ok: false, + status: "forbidden", + error: `Session not visible from this sandboxed agent session: ${raw}`, + } + : { + ok: false, + status: "error", + error: + formatErrorMessage(error) || + `Session not found: ${raw} (use the full sessionKey from sessions_list)`, + }; +} + async function requestResolvedSessionKey( params: Record & { allowMissing?: boolean }, callGateway: GatewayCaller, ): Promise { - try { - const result = await callGateway<{ key?: unknown }>({ - method: "sessions.resolve", - params, - }); - return normalizeOptionalString(result?.key); - } catch (error) { - const olderGatewayRejectedProbe = - params.allowMissing === true && - error instanceof GatewayClientRequestError && - error.gatewayCode === "INVALID_REQUEST" && - error.message.includes("invalid sessions.resolve params") && - error.message.includes("unexpected property 'allowMissing'"); - if (!olderGatewayRejectedProbe) { - throw error; - } - // Protocol v4 gateways predating allowMissing reject the additive field. - // Retry without it for mixed-version correctness; remove at the next protocol break. - const legacyParams: Record = { ...params }; - delete legacyParams.allowMissing; - const result = await callGateway<{ key?: unknown }>({ - method: "sessions.resolve", - params: legacyParams, - }); - return normalizeOptionalString(result?.key); - } + const result = await callGateway<{ key?: unknown }>({ + method: "sessions.resolve", + params, + }); + return normalizeOptionalString(result?.key); } function buildSessionResolveQuery(params: { @@ -310,20 +309,7 @@ export async function resolveSessionReference(params: { } return buildReference(key, true); } catch (error) { - if (params.restrictToSpawned) { - return { - ok: false, - status: "forbidden", - error: `Session not visible from this sandboxed agent session: ${raw}`, - }; - } - return { - ok: false, - status: "error", - error: - formatErrorMessage(error) || - `Session not found: ${raw} (use the full sessionKey from sessions_list)`, - }; + return buildFailedSessionReference(error, raw, params.restrictToSpawned); } } @@ -333,12 +319,7 @@ export async function resolveSessionReference(params: { mainKey: params.mainKey, requesterInternalKey: params.requesterInternalKey, }); - const displayKey = resolveDisplaySessionKey({ - key: resolvedKey, - alias: params.alias, - mainKey: params.mainKey, - }); - return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false }; + return buildReference(resolvedKey, false); } export async function resolveVisibleSessionReference(params: { @@ -347,10 +328,13 @@ export async function resolveVisibleSessionReference(params: { requesterSessionKey: string; restrictToSpawned: boolean; visibilitySessionKey: string; + allowMissingKey?: boolean; + concealResolutionError?: string; callGateway?: GatewayCaller; }): Promise { - const resolvedKey = params.resolvedSession.key; - const displayKey = params.resolvedSession.displayKey; + let resolvedKey = params.resolvedSession.key; + let displayKey = params.resolvedSession.displayKey; + let missing = false; // Cross-session tools persist their results into the caller transcript; an // incognito target must remain unreachable even from an incognito requester. if (isIncognitoSessionKey(resolvedKey)) { @@ -361,6 +345,57 @@ export async function resolveVisibleSessionReference(params: { displayKey, }; } + const input = params.visibilitySessionKey.trim(); + const isExplicitKey = + !params.resolvedSession.resolvedViaSessionId && + input !== "current" && + input !== "main" && + input !== "global" && + input !== "unknown" && + !shouldResolveSessionIdInput(input); + if (isExplicitKey && (params.action === "history" || params.action === "send")) { + try { + const key = await requestResolvedSessionKey( + buildSessionResolveQuery({ + input: resolvedKey, + kind: "key", + requesterInternalKey: params.requesterSessionKey, + restrictToSpawned: params.restrictToSpawned, + allowMissing: params.allowMissingKey, + }), + params.callGateway ?? callAgentToolGatewayRequest, + ); + if (key) { + resolvedKey = key; + displayKey = key; + } else if (params.allowMissingKey) { + missing = true; + } + } catch (error) { + if (params.concealResolutionError && !params.restrictToSpawned) { + return { + ok: false, + status: "forbidden", + error: params.concealResolutionError, + displayKey, + }; + } + const failed = buildFailedSessionReference( + error, + params.visibilitySessionKey, + params.restrictToSpawned, + ); + return { ...failed, displayKey }; + } + } + if (isIncognitoSessionKey(resolvedKey)) { + return { + ok: false, + status: "forbidden", + error: `Session not visible from session tools: ${params.visibilitySessionKey}`, + displayKey, + }; + } const shouldVerifySpawnedVisibility = params.restrictToSpawned && !params.resolvedSession.resolvedViaSessionId && @@ -389,5 +424,5 @@ export async function resolveVisibleSessionReference(params: { displayKey, }; } - return { ok: true, key: resolvedKey, displayKey }; + return { ok: true, key: resolvedKey, displayKey, ...(missing ? { missing: true } : {}) }; } diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 1c5f6ee367f1..3496f90104aa 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -69,6 +69,7 @@ import { import { runWithScopedSessionAccess } from "./scoped-session-access.js"; import { createSessionVisibilityGuard, + createSessionVisibilityRowChecker, createAgentToAgentPolicy, resolveEffectiveSessionToolsVisibility, resolveSessionReference, @@ -205,57 +206,39 @@ function isConfiguredAgentMainSessionKey(params: { ); } -async function ensureConfiguredAgentMainSession(params: { +async function createConfiguredAgentMainSession(params: { cfg: OpenClawConfig; callGateway: GatewayCaller; sessionKey: string; - mainKey: string; requesterSessionKey?: string; useTrustedInProcessCreation: boolean; }): Promise<{ ok: true } | { ok: false; error: string }> { - if ( - !isConfiguredAgentMainSessionKey({ - cfg: params.cfg, - sessionKey: params.sessionKey, - mainKey: params.mainKey, - }) - ) { - return { ok: true }; - } - try { - await params.callGateway({ - method: "sessions.resolve", - params: { key: params.sessionKey }, - timeoutMs: 10_000, - }); - return { ok: true }; - } catch { - try { - const createParams = { - key: params.sessionKey, - agentId: resolveAgentIdFromSessionKey(params.sessionKey, resolveDefaultAgentId(params.cfg)), - }; - if ( - params.useTrustedInProcessCreation && - params.requesterSessionKey && - hasInProcessGatewayToolContext() - ) { - await callInProcessGatewayToolWithCreation("sessions.create", createParams, { - via: "internal", - actor: { type: "agent", id: params.requesterSessionKey }, - }); - } else { - await params.callGateway({ - method: "sessions.create", - params: createParams, - timeoutMs: 10_000, - }); - } - return { ok: true }; - } catch (err) { - return { ok: false, error: formatErrorMessage(err) }; + const createParams = { + key: params.sessionKey, + agentId: resolveAgentIdFromSessionKey(params.sessionKey, resolveDefaultAgentId(params.cfg)), + }; + if ( + params.useTrustedInProcessCreation && + params.requesterSessionKey && + hasInProcessGatewayToolContext() + ) { + // sessions.create serializes keyed creation and adopts an existing row, + // so concurrent first sends can safely race after the missing resolution. + await callInProcessGatewayToolWithCreation("sessions.create", createParams, { + via: "internal", + actor: { type: "agent", id: params.requesterSessionKey }, + }); + } else { + await params.callGateway({ + method: "sessions.create", + params: createParams, + timeoutMs: 10_000, + }); } + return { ok: true }; + } catch (err) { + return { ok: false, error: formatErrorMessage(err) }; } } @@ -591,6 +574,11 @@ export function createSessionsSendTool(opts?: { error: "Either sessionKey or label is required", }); } + const allowMissingKey = isConfiguredAgentMainSessionKey({ + cfg, + sessionKey, + mainKey, + }); const resolvedSession = await resolveSessionReference({ sessionKey, alias, @@ -606,12 +594,21 @@ export function createSessionsSendTool(opts?: { error: resolvedSession.error, }); } + const resolutionAccess = createSessionVisibilityRowChecker({ + action: "send", + defaultAgentId: resolveDefaultAgentId(cfg), + requesterSessionKey: effectiveRequesterKey, + visibility: sessionVisibility, + a2aPolicy, + }).check({ key: resolvedSession.key }); const visibleSession = await resolveVisibleSessionReference({ action: "send", resolvedSession, requesterSessionKey: effectiveRequesterKey, restrictToSpawned, visibilitySessionKey: sessionKey, + allowMissingKey, + concealResolutionError: resolutionAccess.allowed ? undefined : resolutionAccess.error, callGateway: gatewayCall, }); const unresolvedDisplayKey = sessionKey; @@ -772,21 +769,22 @@ export function createSessionsSendTool(opts?: { ...(opts?.signal ? { signal: opts.signal } : {}), targetSessionKey: resolvedKey, run: async () => { - const ensuredSession = await ensureConfiguredAgentMainSession({ - cfg, - callGateway: gatewayCall, - sessionKey: resolvedKey, - mainKey, - requesterSessionKey, - useTrustedInProcessCreation: opts?.callGateway === undefined, - }); - if (!ensuredSession.ok) { - return jsonResult({ - runId: crypto.randomUUID(), - status: "error", - error: ensuredSession.error, - sessionKey: displayKey, + if (visibleSession.missing) { + const createdSession = await createConfiguredAgentMainSession({ + cfg, + callGateway: gatewayCall, + sessionKey: resolvedKey, + requesterSessionKey, + useTrustedInProcessCreation: opts?.callGateway === undefined, }); + if (!createdSession.ok) { + return jsonResult({ + runId: crypto.randomUUID(), + status: "error", + error: createdSession.error, + sessionKey: displayKey, + }); + } } const requesterChannel = opts?.agentChannel; diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index e6b84993bb77..a108c9e6a9bd 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -838,6 +838,31 @@ describe("sessions_send gating", () => { expect(requireGatewayRequest().method).toBe("sessions.resolve"); }); + it("conceals missing explicit keys denied by session visibility", async () => { + callGatewayMock.mockRejectedValueOnce(new Error("No session found: agent:main:missing")); + const tool = createSessionsSendTool({ + agentSessionKey: MAIN_AGENT_SESSION_KEY, + callGateway: callGatewayMock, + config: { + session: { scope: "per-sender", mainKey: "main" }, + tools: { + agentToAgent: { enabled: false }, + sessions: { visibility: "self" }, + }, + } as never, + }); + + const result = await tool.execute("call-hidden-missing-key", { + sessionKey: "agent:main:missing", + message: "hi", + timeoutSeconds: 0, + }); + + expect(requireDetails(result).status).toBe("forbidden"); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + expect(requireGatewayRequest().method).toBe("sessions.resolve"); + }); + it("prefers sessionKey over a redundant label", async () => { const tool = createMainSessionsSendTool(); @@ -989,8 +1014,9 @@ describe("sessions_send gating", () => { timeoutSeconds: 0, }); - expect(callGatewayMock).toHaveBeenCalledTimes(1); - expect(requireGatewayRequest().method).toBe("sessions.list"); + expect(callGatewayMock).toHaveBeenCalledTimes(2); + expect(requireGatewayRequest().method).toBe("sessions.resolve"); + expect(requireGatewayRequest(1).method).toBe("sessions.list"); expect(requireDetails(result).status).toBe("forbidden"); }); @@ -1017,7 +1043,8 @@ describe("sessions_send gating", () => { expect((result.details as { error?: string } | undefined)?.error ?? "").toContain( "cannot target a thread session", ); - expect(callGatewayMock).not.toHaveBeenCalled(); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + expect(requireGatewayRequest().method).toBe("sessions.resolve"); }); it("rejects Telegram topic session targets before dispatching an agent run", async () => { @@ -1056,7 +1083,8 @@ describe("sessions_send gating", () => { expect((result.details as { error?: string } | undefined)?.error ?? "").toContain( "cannot target a thread session", ); - expect(callGatewayMock).not.toHaveBeenCalled(); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + expect(requireGatewayRequest().method).toBe("sessions.resolve"); }); it("rejects label targets that resolve to canonical thread sessions", async () => { @@ -1083,8 +1111,9 @@ describe("sessions_send gating", () => { expect((result.details as { error?: string } | undefined)?.error ?? "").toContain( "cannot target a thread session", ); - expect(callGatewayMock).toHaveBeenCalledTimes(1); + expect(callGatewayMock).toHaveBeenCalledTimes(2); expect(requireGatewayRequest().method).toBe("sessions.resolve"); + expect(requireGatewayRequest(1).method).toBe("sessions.resolve"); }); it("does not disclose a resolved thread session key from a sessionId target", async () => { @@ -1653,8 +1682,7 @@ describe("sessions_send agent-main materialization provenance", () => { callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string }; if (request.method === "sessions.resolve") { - // Unmaterialized agent main: the probe fails, forcing creation. - throw new Error("unknown session: agent:main:main"); + return {}; } if (request.method === "sessions.create") { throw new Error("plain sessions.create must not be used for trusted materialization"); diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 54b4b2429af3..cf12a8f209fe 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -3,7 +3,18 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { testing as agentStepTesting } from "../agents/tools/agent-step.test-support.js"; import { runSessionsSendA2AFlow } from "../agents/tools/sessions-send-tool.a2a.js"; import { @@ -33,6 +44,7 @@ let server: Awaited>; let gatewayPort: number; const gatewayToken = "test-gateway-token-1234567890"; let envSnapshot: ReturnType; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); type SessionSendTool = ReturnType[number]; const SESSION_SEND_E2E_TIMEOUT_MS = 10_000; @@ -153,6 +165,48 @@ afterAll(async () => { }); describe("sessions_send gateway loopback", () => { + it("rejects a missing explicit key without creating or running a session", async () => { + const dir = tempDirs.make("openclaw-sessions-send-missing-"); + const missingKey = "agent:main:missing"; + const spy = agentCommandMock as unknown as Mock<(opts: unknown) => Promise>; + testState.sessionStorePath = path.join(dir, "sessions.json"); + try { + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + }); + spy.mockClear(); + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:main", + config: { tools: { sessions: { visibility: "all" } } }, + }).find((candidate) => candidate.name === "sessions_send"); + if (!tool) { + throw new Error("missing sessions_send tool"); + } + + const result = await tool.execute("call-missing-key", { + sessionKey: missingKey, + message: "ping", + timeoutSeconds: 0, + }); + + expect(result.details).toMatchObject({ + status: "error", + error: `No session found: ${missingKey}`, + }); + expect(spy).not.toHaveBeenCalled(); + expect( + loadSessionEntry({ sessionKey: missingKey, storePath: testState.sessionStorePath }), + ).toBe(undefined); + } finally { + testState.sessionStorePath = undefined; + } + }); + it("returns reply when lifecycle ends before agent.wait", async () => { const spy = agentCommandMock as unknown as Mock<(opts: unknown) => Promise>; spy.mockImplementation(async (opts: unknown) => diff --git a/src/gateway/server.sessions.preview-resolve.test.ts b/src/gateway/server.sessions.preview-resolve.test.ts index 2645da186ff6..266ab67f67d6 100644 --- a/src/gateway/server.sessions.preview-resolve.test.ts +++ b/src/gateway/server.sessions.preview-resolve.test.ts @@ -114,6 +114,18 @@ test("sessions.resolve can probe a missing selector without returning an RPC err expect(resolved.payload).toEqual({ ok: false }); }); +test("sessions.resolve rejects a missing key by default", async () => { + await createSessionStoreDir(); + const { ws } = await openClient(); + + const resolved = await rpcReq(ws, "sessions.resolve", { + key: "agent:main:missing", + }); + + expect(resolved.ok).toBe(false); + expect(resolved.error?.message).toBe("No session found: agent:main:missing"); +}); + test("sessions.resolve returns short-id ambiguity as a protocol-success result", async () => { await createSessionStoreDir(); await writeSessionStore({ From 40cb6f0e3bb1833b96eac33ceac976cf2a69e1f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 05:11:46 -0700 Subject: [PATCH 14/14] feat(ui): keep Where picker destinations live (#122531) * feat(gateway): enrich environment summaries * chore(sdk): refresh plugin API surface surface drift approved: additive EnvironmentSummary enrichment (runners plan rev 2, milestone 4) * feat(ui): keep where picker destinations live * docs: link picker liveness milestone PR * fix(ui): deduplicate picker locale keys * test(ui): update picker section expectations * fix(ssrf): narrow lookup resolver contract * fix(gateway): publish authoritative environment facts --- .../OpenClawProtocol/GatewayModels.swift | 48 +++++++ .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-inbound.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-pairing.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../gateway-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../infra-runtime.json | 2 +- .../media-runtime.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../runtime-store.json | 2 +- .../security-runtime.json | 2 +- .../session-catalog.json | 2 +- .../plugin-sdk-api-baseline/ssrf-policy.json | 2 +- .../plugin-sdk-api-baseline/ssrf-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- docs/plan/runners.md | 30 ++-- .../feishu/src/app-registration.test.ts | 7 +- extensions/feishu/src/streaming-card.test.ts | 7 +- extensions/msteams/src/attachments/shared.ts | 6 +- .../src/schema/environments.test.ts | 21 ++- .../src/schema/environments.ts | 8 ++ .../app-sdk-composed-resources.e2e.test.ts | 3 + packages/sdk/src/types.ts | 4 + src/gateway/server-lifecycle.ts | 2 +- .../server-methods/environments.test.ts | 26 ++++ src/gateway/server-methods/environments.ts | 10 ++ .../worker-environments/service-contract.ts | 1 + src/infra/net/ssrf.ts | 6 +- .../new-session-page.places-live.e2e.test.ts | 135 ++++++++++++++++++ .../e2e/new-session-page.places.e2e.test.ts | 32 +---- ...w-session-page.projects-places.e2e.test.ts | 6 +- ui/src/i18n/locales/en.ts | 2 +- ui/src/pages/new-session/discovery.test.ts | 11 +- ui/src/pages/new-session/discovery.ts | 12 +- .../pages/new-session/draft-gateway-state.ts | 4 + ui/src/pages/new-session/draft-place-state.ts | 12 +- ui/src/pages/new-session/new-session-page.ts | 55 +++++++ ui/src/pages/new-session/place-picker.test.ts | 37 ++++- ui/src/pages/new-session/place-picker.ts | 72 +++++----- 51 files changed, 471 insertions(+), 138 deletions(-) create mode 100644 ui/src/e2e/new-session-page.places-live.e2e.test.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 68cb49478c88..d9db1417b903 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1904,6 +1904,9 @@ public struct EnvironmentSummary: Codable, Sendable { public let type: String public let label: String? public let status: EnvironmentStatus + public let platform: String? + public let sessionhost: Bool? + public let trust: String? public let capabilities: [String]? public let worker: WorkerEnvironmentMetadata? @@ -1912,6 +1915,9 @@ public struct EnvironmentSummary: Codable, Sendable { type: String, label: String? = nil, status: EnvironmentStatus, + platform: String? = nil, + sessionhost: Bool? = nil, + trust: String? = nil, capabilities: [String]? = nil, worker: WorkerEnvironmentMetadata? = nil) { @@ -1919,6 +1925,9 @@ public struct EnvironmentSummary: Codable, Sendable { self.type = type self.label = label self.status = status + self.platform = platform + self.sessionhost = sessionhost + self.trust = trust self.capabilities = capabilities self.worker = worker } @@ -1928,6 +1937,9 @@ public struct EnvironmentSummary: Codable, Sendable { case type case label case status + case platform + case sessionhost = "sessionHost" + case trust case capabilities case worker } @@ -1956,6 +1968,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable { public let type: String public let label: String? public let status: EnvironmentStatus + public let platform: String? + public let sessionhost: Bool? + public let trust: String? public let capabilities: [String]? public let worker: WorkerEnvironmentMetadata? @@ -1964,6 +1979,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable { type: String, label: String? = nil, status: EnvironmentStatus, + platform: String? = nil, + sessionhost: Bool? = nil, + trust: String? = nil, capabilities: [String]? = nil, worker: WorkerEnvironmentMetadata? = nil) { @@ -1971,6 +1989,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable { self.type = type self.label = label self.status = status + self.platform = platform + self.sessionhost = sessionhost + self.trust = trust self.capabilities = capabilities self.worker = worker } @@ -1980,6 +2001,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable { case type case label case status + case platform + case sessionhost = "sessionHost" + case trust case capabilities case worker } @@ -2008,6 +2032,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { public let type: String public let label: String? public let status: EnvironmentStatus + public let platform: String? + public let sessionhost: Bool? + public let trust: String? public let capabilities: [String]? public let worker: WorkerEnvironmentMetadata? @@ -2016,6 +2043,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { type: String, label: String? = nil, status: EnvironmentStatus, + platform: String? = nil, + sessionhost: Bool? = nil, + trust: String? = nil, capabilities: [String]? = nil, worker: WorkerEnvironmentMetadata? = nil) { @@ -2023,6 +2053,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { self.type = type self.label = label self.status = status + self.platform = platform + self.sessionhost = sessionhost + self.trust = trust self.capabilities = capabilities self.worker = worker } @@ -2032,6 +2065,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { case type case label case status + case platform + case sessionhost = "sessionHost" + case trust case capabilities case worker } @@ -2076,6 +2112,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable { public let type: String public let label: String? public let status: EnvironmentStatus + public let platform: String? + public let sessionhost: Bool? + public let trust: String? public let capabilities: [String]? public let worker: WorkerEnvironmentMetadata? @@ -2084,6 +2123,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable { type: String, label: String? = nil, status: EnvironmentStatus, + platform: String? = nil, + sessionhost: Bool? = nil, + trust: String? = nil, capabilities: [String]? = nil, worker: WorkerEnvironmentMetadata? = nil) { @@ -2091,6 +2133,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable { self.type = type self.label = label self.status = status + self.platform = platform + self.sessionhost = sessionhost + self.trust = trust self.capabilities = capabilities self.worker = worker } @@ -2100,6 +2145,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable { case type case label case status + case platform + case sessionhost = "sessionHost" + case trust case capabilities case worker } diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 2c86504e3d73..d99ac38ab48e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"59fb241bbe47669d5d7f99a945a18d86f318b9af8c4c281bb11f01506c84ebbf","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"e4a7ffbb2daf39077b19f28d0dbf9267bb645be42ce18e24161966e34b16bdc7","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index d8bd1df6bd82..1833f5059110 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"b7195e12bbe582de01faacc9d3a22f5bc30e0210f7b0c03d3b6242b969f7a0be","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"517a4d6efc324c135d1761b9d8e77ed573162bcc8b662372cebdfd7c5e3083c1","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index c916c343013f..203834274d5b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"04848d6e3e592c5edf260afa6ab27452770f99ef09e110809fab0813fd1671b0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"007c23cfcc4af2cc62967750beabdb9825352b54afc3057c989c7e9179790ad0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 02b270894ae1..bf8bc40c34e2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"656e29f955e218c9c35843a876eecd769b722e64fb8ea1849f266fa9a430ffd1","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"454a8b3a3ee15a1a691970f4b5cb15878b8f2f4e166d6600bf639f57292232f3","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index 14dcaef32df9..9b88c0385485 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"efed0dfd7d5ade67b9f4549b1505e8def917262e1940c9cbe10f761ff154811b","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"aaadbf677c001d81a784acbe65ce9acb4d76794be31fecff9d2c9b8d984ec37c","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index b63227e93569..114a6844663d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"f57838a5ab613e8e168c74cb309beef813587f5465ff80f18b8171320f579e7a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"8bafa462130e5c1da30329c7face6a483957d4a0c3cc689b1632f1974420b69f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index fa5b42274078..18108cd706e1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"0b81e57bcb56659f98b52d55a1dea6f987bae23500a62d56daf4e86065929ac9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"9e02fe7d5c9f80328c7f557420ee84687954f753dfd84b19e1469eb94b8dd168","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json index 8b48b54f231d..cf53792a6a90 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json @@ -1 +1 @@ -{"contentHash":"7375a14d1a2ead1dce9d13bc9a6c5c94dc7e809cde37860a7ace9fce480b6751","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} +{"contentHash":"982f42cd1ef5594aff26969ac256e5cf2f124f409b7e020fcad5117fc8322055","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 5e031f0b5935..dd26b93016a8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"57eaf2d515736c9a6880da9434dd6456e7744e75ac8a946709d9e5f30b166aed","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"a0b2dd06b690fe208c45c02e858934b527ed3c419f1a7ebd394423fc295cbff9","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 872ff135b1d1..5e312e48516c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"bb239ef24687ac9b89335858dff0008f3b861546c242e3af3545fbcae1878309","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"402dc7fb02d64dc390510abbf1eb240ccb807b1bf3e26ddaa582c9393bdfb57a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 87ac95ea1cca..05ecf007aa82 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"34a3ec8d182434d7682a6ae2258a4b1797ffc20f62a7a0449ca3055ccc8c69c5","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"4b0344b65821cc88ea69e7d6e8ca37ddfd21d258035e6ba7d613748df743c761","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 9c7c77875450..754a94714b19 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"38dbab19f1c23c82c20443339807822f5dfc626d42171dbb71f3322992d01097","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"ffbc6c5c809ed0ceb135ea42926e321edf4265eb96c80e0f9df536d7eb472811","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index cf22bb77fc49..39d224aff695 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"080a0e85aaff22d1e860781d779254461b05e40e4af84b37197b9ae6281ca566","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"0ebc21dbb6435d83c96ddae60291abbf36a47292288533e1b921bf9501581895","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json b/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json index 2c582c8f4fce..6190e3aba025 100644 --- a/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/infra-runtime.json @@ -1 +1 @@ -{"contentHash":"d83147f1ddbab6a62f7afc6ec7bbf5ec960b3abf428e6c9abe4a0f9518b0a8c7","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"} +{"contentHash":"463ab9088e5cbf0f008716d00676aaf2a4bdf0f13a6621cd42a76aac34f5b7a0","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/media-runtime.json b/docs/.generated/plugin-sdk-api-baseline/media-runtime.json index 55532189f010..3a43ce46cb20 100644 --- a/docs/.generated/plugin-sdk-api-baseline/media-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/media-runtime.json @@ -1 +1 @@ -{"contentHash":"04ca7bf038cd8ad69f3145ebd3c774b8b27b18ed96fa8227e289abcecdbd83b5","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"} +{"contentHash":"fe35d8eebcbfbdb20fc4a1e3b903e2dc7891dc2f12feb3e6130fec5dcd605376","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index c8116ff80bce..6dcd8cb9bea4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"ecf1ca74a42f003b046e3bf2578bae03c609050c6cb16fa741f763d692937e67","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"458825733f53d1c06d468287d7059d2400202572082a8300d7806c71856498c7","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 0cc601906bfe..71d11bee323e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"a8e907e41641913ebb36b982bb320090197af1408a0196672c15d038883cf1e0","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"0cbd356b5509eb2bd0f744987a48e34726525e8932fe5a6bb9c7eb5884b55329","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 4c888362e736..aa8b3e7e588c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"8d4c89bf2968217111f333c149b51bb6798dd961a62fbafeef1c19f2473e7ab6","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"f662a31a8c5db27d767489160e9d443df45190429e8f4fb9f6e184d12bac3f19","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 27cdf069b69c..2c41811f6d01 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"5772bc63ec00a1de6e76827a9a8f79c1ed61493d07f6c2bb21f50c18a806e187","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"d0c70224a6b22aef9680ea81d32d071b11a2533c885dc8665080c6c00255ea4d","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json index 79cdc352ffaa..7b0ea384c738 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json @@ -1 +1 @@ -{"contentHash":"9a243e9ff6e6512ef4b635ed503159631e4e000a5dbc4d7743ffd091dfb1ef1d","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} +{"contentHash":"babd3de9e3fbbc52a328d73ae815f4df4dbc57377aa0a0ec5de08636b87ab542","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} diff --git a/docs/.generated/plugin-sdk-api-baseline/security-runtime.json b/docs/.generated/plugin-sdk-api-baseline/security-runtime.json index be8317cb10de..6eaafdeaf00a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/security-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/security-runtime.json @@ -1 +1 @@ -{"contentHash":"ea0c884272571c2494865e709f61fd3a33e0da4e2096c301ab5d292783b923bb","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"} +{"contentHash":"d293aefd15df648be5808cdbe6194cb363cc3410e9e0ae01cf1d2162f8a475c5","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json index bb1b8d7247f9..0138ede67eee 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json @@ -1 +1 @@ -{"contentHash":"382193e8ef6f05d7bc0d872abefba471c374737bcc86db3810be780b893b72b4","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} +{"contentHash":"e843ae3223098e08b57d4af6efd78cc1693bb5ed3b7f589dee10c223d914b1bb","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} diff --git a/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json b/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json index d8b388e663d2..bf5d2f79c5fc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json +++ b/docs/.generated/plugin-sdk-api-baseline/ssrf-policy.json @@ -1 +1 @@ -{"contentHash":"c0619235a9636dc3fc22ab5ac74e7d14a01bf9c40594ced596a17143b6eb90c4","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"} +{"contentHash":"f15daa0e828f7f32901d14f7174b31883799c1bd6c1498d4295a85cbd045eeb6","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"} diff --git a/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json b/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json index 499a5a4c03b0..51c9c6382458 100644 --- a/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/ssrf-runtime.json @@ -1 +1 @@ -{"contentHash":"77efaea8a61aa4e0dccdffcf2d6e6bb4bcc758587691126c17905ffdf11ddd23","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"} +{"contentHash":"9df7566aad3ff6c1d41e693351d47e09f77f30a0b6d43a828afe7439b84fe36f","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 5dc9f0ffad81..fc44f2286ac0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"993018e34f2e7951c2a0412a4df0cd4be50256da04f3d6a539942c3a8168da24","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"4962f66547dd18b573f4c3ee7ad08f35f220417559437ed2653ef1e3f07fb4be","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 3d26f79a7fcb..6f05bba24054 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"079ce3652967694b58caa87ed04cdaba07b3766d82c491faa9a6f974d584e18b","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"326217df400252a236ce5f5e1f98adfcbbec79d3a5d068cf44fc3b7a70e0debe","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/plan/runners.md b/docs/plan/runners.md index 88fab30f0dfc..b76e8e8aaf79 100644 --- a/docs/plan/runners.md +++ b/docs/plan/runners.md @@ -13,21 +13,21 @@ Proposal, revision 2. Supersedes revision 1 in place (2026-08-11, operator decision). Implementation in progress; update this table in every PR that advances a milestone. -| # | Milestone | Status | PRs | -| --- | ---------------------------------------------------------- | ----------- | ------- | -| 0 | This plan (revision 2) | landed | #122454 | -| 1a | Naming: session copy revert | landed | #120667 | -| 1b | Naming: devices consolidation | landed | #120689 | -| 1c | Cleanup: node-pairing → device-pairing merge | not started | — | -| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 | -| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — | -| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804 | -| 5 | Public worker ingress path | not started | — | -| 6 | Node worker provider (device runners) | not started | — | -| 7 | Bundle push consent + runner updates | not started | — | -| 8 | Stop-and-continue moves | not started | — | -| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — | -| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — | +| # | Milestone | Status | PRs | +| --- | ---------------------------------------------------------- | ----------- | ---------------- | +| 0 | This plan (revision 2) | landed | #122454 | +| 1a | Naming: session copy revert | landed | #120667 | +| 1b | Naming: devices consolidation | landed | #120689 | +| 1c | Cleanup: node-pairing → device-pairing merge | not started | — | +| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 | +| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — | +| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531 | +| 5 | Public worker ingress path | not started | — | +| 6 | Node worker provider (device runners) | not started | — | +| 7 | Bundle push consent + runner updates | not started | — | +| 8 | Stop-and-continue moves | not started | — | +| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — | +| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — | Revision history: revision 1 (2026-08-08) established the session/runner vocabulary, the naming rulings, and the milestone skeleton after a diff --git a/extensions/feishu/src/app-registration.test.ts b/extensions/feishu/src/app-registration.test.ts index ff191b9b44ba..b46e6937a4ac 100644 --- a/extensions/feishu/src/app-registration.test.ts +++ b/extensions/feishu/src/app-registration.test.ts @@ -35,10 +35,9 @@ type RegistrationFetchOptions = { const HERMETIC_PUBLIC_LOOKUP_ADDRESS = "93.184.216.34"; -const hermeticPublicLookup: LookupFn = (async (_hostname: string, _options?: unknown) => ({ - address: HERMETIC_PUBLIC_LOOKUP_ADDRESS, - family: 4, -})) as LookupFn; +const hermeticPublicLookup: LookupFn = async () => [ + { address: HERMETIC_PUBLIC_LOOKUP_ADDRESS, family: 4 }, +]; async function startLocalServer( handler: (req: IncomingMessage, res: ServerResponse) => void, diff --git a/extensions/feishu/src/streaming-card.test.ts b/extensions/feishu/src/streaming-card.test.ts index cb9a5985521b..1efafe66b2c0 100644 --- a/extensions/feishu/src/streaming-card.test.ts +++ b/extensions/feishu/src/streaming-card.test.ts @@ -43,10 +43,9 @@ type StreamingRequest = { const serverStops: Array<() => Promise> = []; const HERMETIC_PUBLIC_LOOKUP_ADDRESS = "93.184.216.34"; -const hermeticPublicLookup: LookupFn = (async (_hostname: string, _options?: unknown) => ({ - address: HERMETIC_PUBLIC_LOOKUP_ADDRESS, - family: 4, -})) as LookupFn; +const hermeticPublicLookup: LookupFn = async () => [ + { address: HERMETIC_PUBLIC_LOOKUP_ADDRESS, family: 4 }, +]; async function readRequestBody(req: IncomingMessage): Promise { let body = ""; diff --git a/extensions/msteams/src/attachments/shared.ts b/extensions/msteams/src/attachments/shared.ts index a49ce3d491d2..914425391eaa 100644 --- a/extensions/msteams/src/attachments/shared.ts +++ b/extensions/msteams/src/attachments/shared.ts @@ -677,6 +677,10 @@ async function safeFetch(params: { } if (!hasDispatcher) { + const lookupFn: LookupFn = async (hostname) => { + const resolved = await resolveFn(hostname); + return [{ ...resolved, family: resolved.address.includes(":") ? 6 : 4 }]; + }; const guarded = await fetchWithSsrFGuard({ url: currentUrl, fetchImpl: resolveGuardedFetchImpl({ @@ -690,7 +694,7 @@ async function safeFetch(params: { maxRedirects: MAX_SAFE_REDIRECTS, requireHttps: true, policy: resolveMediaSsrfPolicy(params.allowHosts), - lookupFn: resolveFn as LookupFn, + lookupFn, retainAuthorizationRedirectHostnameAllowlist: resolveRetainedAuthorizationRedirectHostnameAllowlist(params.authorizationAllowHosts), auditContext: "msteams.attachment", diff --git a/packages/gateway-protocol/src/schema/environments.test.ts b/packages/gateway-protocol/src/schema/environments.test.ts index 895122a3f36c..23abddbc29fd 100644 --- a/packages/gateway-protocol/src/schema/environments.test.ts +++ b/packages/gateway-protocol/src/schema/environments.test.ts @@ -82,7 +82,12 @@ describe("worker environment protocol schemas", () => { }); it("accepts worker metadata additively across summary and mutation results", () => { - const requested = workerSummary("requested"); + const requested = { + ...workerSummary("requested"), + platform: "linux", + sessionHost: false, + trust: "disposable", + }; const destroyedBase = workerSummary("destroyed", "unavailable"); const destroyed = { ...destroyedBase, @@ -140,7 +145,7 @@ describe("worker environment protocol schemas", () => { expect( Value.Check(EnvironmentsListResultSchema, { environments: [], - profiles: [{ id: "aws", providerId: "crabbox" }], + profiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }], }), ).toBe(true); expect( @@ -149,6 +154,12 @@ describe("worker environment protocol schemas", () => { profiles: [{ id: "aws", providerId: "crabbox", settings: { token: "hidden" } }], }), ).toBe(false); + expect( + Value.Check(EnvironmentsListResultSchema, { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox", trust: "temporary" }], + }), + ).toBe(false); }); it("preserves summaries without worker metadata and rejects malformed worker metadata", () => { @@ -180,5 +191,11 @@ describe("worker environment protocol schemas", () => { worker: { ...workerSummary("failed").worker, error: "" }, }), ).toBe(false); + expect( + Value.Check(EnvironmentSummarySchema, { + ...workerSummary("ready", "available"), + trust: "temporary", + }), + ).toBe(false); }); }); diff --git a/packages/gateway-protocol/src/schema/environments.ts b/packages/gateway-protocol/src/schema/environments.ts index 22b621b8e69f..c1b0d5ce3a44 100644 --- a/packages/gateway-protocol/src/schema/environments.ts +++ b/packages/gateway-protocol/src/schema/environments.ts @@ -14,6 +14,10 @@ export const EnvironmentStatusSchema = Type.String({ enum: ["available", "unavailable", "starting", "stopping", "error"], }); +const EnvironmentTrustSchema = Type.String({ + enum: ["persistent", "disposable"], +}); + /** Durable lifecycle states for plugin-provisioned worker environments. */ export const WorkerEnvironmentStateSchema = Type.Union([ Type.Literal("requested"), @@ -65,6 +69,9 @@ function createEnvironmentSummarySchema() { type: NonEmptyString, label: Type.Optional(NonEmptyString), status: EnvironmentStatusSchema, + platform: Type.Optional(NonEmptyString), + sessionHost: Type.Optional(Type.Boolean()), + trust: Type.Optional(EnvironmentTrustSchema), capabilities: Type.Optional(Type.Array(NonEmptyString)), worker: Type.Optional(WorkerEnvironmentMetadataSchema), }); @@ -80,6 +87,7 @@ export const EnvironmentsListParamsSchema = closedObject({}); const WorkerEnvironmentProfileSummarySchema = closedObject({ id: NonEmptyString, providerId: NonEmptyString, + trust: Type.Optional(EnvironmentTrustSchema), }); /** List response containing all gateway-visible environment summaries. */ diff --git a/packages/sdk/src/app-sdk-composed-resources.e2e.test.ts b/packages/sdk/src/app-sdk-composed-resources.e2e.test.ts index 16ece49b2bed..7cd426180c65 100644 --- a/packages/sdk/src/app-sdk-composed-resources.e2e.test.ts +++ b/packages/sdk/src/app-sdk-composed-resources.e2e.test.ts @@ -756,6 +756,9 @@ async function proveRealGatewayContracts(): Promise { type: "local", label: "Gateway local", status: "available", + platform: process.platform, + sessionHost: true, + trust: "persistent", capabilities: ["agent.run", "sessions", "tools", "workspace"], }); const gatewayEnvironment = await oc.environments.status("gateway"); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 95850555d827..d0c2ec7443b6 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -79,6 +79,9 @@ export type EnvironmentSummary = { type: "local" | "gateway" | "node" | "managed" | "ephemeral" | (string & {}); label?: string; status: "available" | "unavailable" | "starting" | "stopping" | "error"; + platform?: string; + sessionHost?: boolean; + trust?: "persistent" | "disposable"; capabilities?: string[]; worker?: WorkerEnvironmentMetadata; }; @@ -91,6 +94,7 @@ export type EnvironmentCreateParams = { export type WorkerEnvironmentProfileSummary = { id: string; providerId: string; + trust?: "persistent" | "disposable"; }; export type EnvironmentsListResult = { diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index 8ff49fbac1e7..b2a2fb68033f 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -202,7 +202,7 @@ export async function prepareGatewayLifecycle(params: { instanceId: session.nodeId, reason: "connect", }); - incrementPresenceVersion(); + broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion }); recordRemoteNodeInfo({ nodeId: session.nodeId, connId: session.connId, diff --git a/src/gateway/server-methods/environments.test.ts b/src/gateway/server-methods/environments.test.ts index 8283489e461f..938612464594 100644 --- a/src/gateway/server-methods/environments.test.ts +++ b/src/gateway/server-methods/environments.test.ts @@ -99,6 +99,7 @@ function workerRecord(overrides: Partial = {}): TestWorkerReco profileSnapshot: { settings: {} }, provisionOperationId: "provision:worker-1", leaseId: "lease-1", + sharedHost: false, desktop: null, sshEndpoint: { host: "worker.example.test", @@ -208,6 +209,9 @@ describe("environment gateway methods", () => { type: "local", label: "Gateway local", status: "available", + platform: process.platform, + sessionHost: true, + trust: "persistent", capabilities: ["agent.run", "sessions", "tools", "workspace"], }, { @@ -215,6 +219,9 @@ describe("environment gateway methods", () => { type: "node", label: "Live Node", status: "available", + platform: "ios", + sessionHost: false, + trust: "persistent", capabilities: ["camera", "system.run"], }, { @@ -222,6 +229,8 @@ describe("environment gateway methods", () => { type: "node", label: "Offline Node", status: "unavailable", + sessionHost: false, + trust: "persistent", capabilities: ["camera.snap", "screen"], }, ], @@ -254,6 +263,7 @@ describe("environment gateway methods", () => { id: "worker-1", type: "worker", status: "available", + trust: "disposable", worker: { providerId: "static-ssh", leaseId: "lease-1", @@ -285,6 +295,18 @@ describe("environment gateway methods", () => { expect(summarizeWorkerEnvironment(workerRecord({ state }), NOW).status).toBe(status); }); + it("projects trust from recorded worker isolation without guessing unknown leases", () => { + expect(summarizeWorkerEnvironment(workerRecord({ sharedHost: true }), NOW).trust).toBe( + "persistent", + ); + expect(summarizeWorkerEnvironment(workerRecord({ sharedHost: false }), NOW).trust).toBe( + "disposable", + ); + expect(summarizeWorkerEnvironment(workerRecord({ sharedHost: null }), NOW)).not.toHaveProperty( + "trust", + ); + }); + it("projects recorded errors only for terminal error states", () => { expect( summarizeWorkerEnvironment( @@ -324,6 +346,9 @@ describe("environment gateway methods", () => { type: "node", label: "Live Node", status: "available", + platform: "ios", + sessionHost: false, + trust: "persistent", capabilities: ["camera", "system.run"], }); }); @@ -341,6 +366,7 @@ describe("environment gateway methods", () => { expect(payload).toMatchObject({ id: "worker-1", status: "available", + trust: "disposable", worker: { state: "attached", ageMs: 9_000 }, }); expect(get).toHaveBeenCalledWith("worker-1"); diff --git a/src/gateway/server-methods/environments.ts b/src/gateway/server-methods/environments.ts index 100123cebdcd..abd31671bf17 100644 --- a/src/gateway/server-methods/environments.ts +++ b/src/gateway/server-methods/environments.ts @@ -25,6 +25,9 @@ const GATEWAY_ENVIRONMENT: EnvironmentSummary = { type: "local", label: "Gateway local", status: "available", + platform: process.platform, + sessionHost: true, + trust: "persistent", capabilities: ["agent.run", "sessions", "tools", "workspace"], }; const WORKER_STATUS: Record = { @@ -54,11 +57,15 @@ function summarizeNodeEnvironment(node: NodeListNode): EnvironmentSummary { // Expose both declared capabilities and command names so older node // runtimes still advertise useful execution surfaces in one stable list. const capabilities = uniqueSortedStrings(node.caps, node.commands); + const platform = node.platform?.trim(); return { id: `node:${node.nodeId}`, type: "node", label: node.displayName ?? node.nodeId, status: node.connected ? "available" : "unavailable", + ...(platform ? { platform } : {}), + sessionHost: false, + trust: "persistent", ...(capabilities.length > 0 ? { capabilities } : {}), }; } @@ -71,6 +78,9 @@ export function summarizeWorkerEnvironment( id: record.environmentId, type: "worker", status: WORKER_STATUS[record.state], + ...(record.sharedHost === null + ? {} + : { trust: record.sharedHost ? "persistent" : "disposable" }), worker: { providerId: record.providerId, ...(record.leaseId ? { leaseId: record.leaseId } : {}), diff --git a/src/gateway/worker-environments/service-contract.ts b/src/gateway/worker-environments/service-contract.ts index fedfff02d9e1..c7a4db4c17f0 100644 --- a/src/gateway/worker-environments/service-contract.ts +++ b/src/gateway/worker-environments/service-contract.ts @@ -24,6 +24,7 @@ export type WorkerEnvironmentServiceRecord = { environmentId: string; providerId: string; leaseId: string | null; + sharedHost: boolean | null; state: WorkerEnvironmentState; ownerEpoch: number; createdAtMs: number; diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index cd8f03d2f997..2fe00508a78b 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -43,7 +43,7 @@ export class SsrFBlockedError extends Error { } } -export type LookupFn = typeof dnsLookup; +export type LookupFn = (hostname: string, options: { all: true }) => Promise; export type SsrFPolicy = { allowPrivateNetwork?: boolean; @@ -607,9 +607,7 @@ export async function resolvePinnedHostnameWithPolicy( ); const lookupFn = params.lookupFn ?? dnsLookup; - const results = normalizeLookupResults( - (await lookupFn(normalized, { all: true })) as LookupResult, - ); + const results = normalizeLookupResults(await lookupFn(normalized, { all: true })); if (results.length === 0) { throw new Error(`Unable to resolve hostname: ${hostname}`); } diff --git a/ui/src/e2e/new-session-page.places-live.e2e.test.ts b/ui/src/e2e/new-session-page.places-live.e2e.test.ts new file mode 100644 index 000000000000..9c3c1628a35d --- /dev/null +++ b/ui/src/e2e/new-session-page.places-live.e2e.test.ts @@ -0,0 +1,135 @@ +import { expect, it } from "vitest"; +import { + WORKSPACE, + captureUiProof, + captureUiProofEnabled, + createNewSessionPageE2eSuite, + installMockGateway, + pollLocatorText, +} from "./new-session-page.test-support.ts"; + +const suite = createNewSessionPageE2eSuite(); + +suite.define(() => { + it("hides the destination axis when the Gateway is the only place", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { nodes: [] }, + "environments.list": { environments: [], profiles: [] }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); + await trigger.click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + expect(await place.getByText("This gateway", { exact: true }).count()).toBe(0); + await place.getByText("Runs on Gateway · local", { exact: true }).waitFor(); + } finally { + await context.close(); + } + }); + + it("refreshes destinations from gateway events while the picker stays open", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + ...(captureUiProofEnabled + ? { + recordVideo: { + dir: ".artifacts/control-ui-e2e/picker-liveness", + size: { height: 900, width: 1280 }, + }, + viewport: { height: 900, width: 1280 }, + } + : {}), + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "existing-mac", + displayName: "Existing Mac", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { environments: [], profiles: [] }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + await gateway.waitForRequest("environments.list"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Existing Mac" }).waitFor(); + const nodeRequests = (await gateway.getRequests("node.list")).length; + const environmentRequests = (await gateway.getRequests("environments.list")).length; + await gateway.setMethodResponse("node.list", { + nodes: [ + { + nodeId: "existing-mac", + displayName: "Existing Mac", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "new-mac", + displayName: "New Mac", + connected: true, + commands: ["system.run"], + }, + ], + }); + await gateway.emitGatewayEvent("presence", { + presence: [ + { deviceId: "existing-mac", mode: "node", reason: "connect", ts: 1 }, + { deviceId: "new-mac", mode: "node", reason: "connect", ts: 2 }, + ], + }); + + await expect + .poll(async () => (await gateway.getRequests("node.list")).length) + .toBeGreaterThan(nodeRequests); + await place.getByRole("button", { name: "New Mac" }).waitFor(); + await place.getByText("This gateway", { exact: true }).waitFor(); + await place.getByText("Your devices", { exact: true }).waitFor(); + expect(await place.getAttribute("open")).not.toBeNull(); + expect(await gateway.getRequests("environments.list")).toHaveLength(environmentRequests); + + await gateway.setMethodResponse("environments.list", { + environments: [], + profiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }], + }); + await gateway.emitGatewayEvent("config.changed", { + path: "/tmp/openclaw.json", + hash: "picker-cloud-refresh", + ts: 3, + }); + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBeGreaterThan(environmentRequests); + await place.getByText("Cloud", { exact: true }).waitFor(); + await place.getByRole("button", { name: "Cloud · aws" }).waitFor(); + expect(await place.getAttribute("open")).not.toBeNull(); + await captureUiProof(page, "picker-after-live-regroup.png"); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/new-session-page.places.e2e.test.ts b/ui/src/e2e/new-session-page.places.e2e.test.ts index eee3326b3b36..b162a31e6ff0 100644 --- a/ui/src/e2e/new-session-page.places.e2e.test.ts +++ b/ui/src/e2e/new-session-page.places.e2e.test.ts @@ -522,32 +522,6 @@ suite.define(() => { } }); - it("hides the destination axis when the Gateway is the only place", async () => { - const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); - const page = await context.newPage(); - const gateway = await installMockGateway(page, { - workspace: WORKSPACE, - workspaceGit: true, - methodResponses: { - "node.list": { nodes: [] }, - "environments.list": { environments: [], profiles: [] }, - }, - }); - - try { - await page.goto(`${suite.server.baseUrl}new`); - await gateway.waitForRequest("node.list"); - const trigger = page.locator("#new-session-place-trigger"); - await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); - await trigger.click(); - const place = page.locator("wa-popover.new-session-page__place-popover"); - expect(await place.getByText("Places", { exact: true }).count()).toBe(0); - await place.getByText("Runs on Gateway · local", { exact: true }).waitFor(); - } finally { - await context.close(); - } - }); - it("uses advertised system info for Gateway place labels", async () => { const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); const page = await context.newPage(); @@ -925,7 +899,7 @@ suite.define(() => { const placeLabel = placeTrigger.locator(".new-session-page__trigger-label"); const browserEntries = page.locator(".new-session-page__browser-list"); - // Pick the node from Places. + // Pick the node from Your devices. await placeTrigger.click(); await placeSelect.getByRole("button", { name: "MacBook" }).click(); await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); @@ -950,9 +924,9 @@ suite.define(() => { placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), ) .toBe(true); - await placeSelect.getByText("Places", { exact: true }).waitFor(); + await placeSelect.getByText("Your devices", { exact: true }).waitFor(); - // Destination selection stays in Places; browsing is fixed to the current target. + // Destination selection stays in the destination sections; browsing is fixed to the target. await placeSelect.getByRole("button", { name: "Gateway · local" }).click(); await pollLocatorText(placeLabel).toBe("openclaw · Gateway · local"); await placeTrigger.click(); diff --git a/ui/src/e2e/new-session-page.projects-places.e2e.test.ts b/ui/src/e2e/new-session-page.projects-places.e2e.test.ts index f98b5d2c5759..aa189ff9b0e2 100644 --- a/ui/src/e2e/new-session-page.projects-places.e2e.test.ts +++ b/ui/src/e2e/new-session-page.projects-places.e2e.test.ts @@ -249,7 +249,7 @@ suite.define(() => { await gateway.waitForRequest("environments.list"); await page.locator("#new-session-place-trigger").click(); const place = page.locator("wa-popover.new-session-page__place-popover"); - await place.getByText("Devices", { exact: true }).waitFor(); + await place.getByText("Your devices", { exact: true }).waitFor(); await place.getByRole("button", { name: "Fallback device" }).waitFor(); await captureUiProof(page, "04-catalog-unavailable-device-fallback.png"); } finally { @@ -368,7 +368,7 @@ suite.define(() => { .locator(".new-session-page__menu-title") .getByText("This gateway", { exact: true }) .waitFor(); - await place.getByText("Devices", { exact: true }).waitFor(); + await place.getByText("Your devices", { exact: true }).waitFor(); const first = page.locator('[data-value="node:11111111aaaaaaaa"]'); const second = page.locator('[data-value="node:22222222bbbbbbbb"]'); const phone = page.locator('[data-value="node:33333333cccccccc"]'); @@ -713,7 +713,7 @@ suite.define(() => { .locator(".new-session-page__menu-title") .getByText("This gateway", { exact: true }) .waitFor(); - await placeSelect.getByText("Devices", { exact: true }).waitFor(); + await placeSelect.getByText("Your devices", { exact: true }).waitFor(); await placeSelect.getByRole("button", { name: "MacBook" }).click(); await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); // Node sessions cannot use managed worktrees, so the menu drops the item. diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index afd1971312fc..0f0d39acff90 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -717,7 +717,7 @@ export const en: TranslationMap = { cloudSyncsFolder: "Syncs {folder} to the cloud worker", folder: "Folder", folderPlaceholder: "Agent workspace", - places: "Places", + yourDevices: "Your devices", projects: "Projects", projectsAdminHint: "Admins can register projects from Browse folders", projectSearchPlaceholder: "Search projects or paste a Git URL", diff --git a/ui/src/pages/new-session/discovery.test.ts b/ui/src/pages/new-session/discovery.test.ts index 91a642c43a1e..83843388da03 100644 --- a/ui/src/pages/new-session/discovery.test.ts +++ b/ui/src/pages/new-session/discovery.test.ts @@ -38,17 +38,20 @@ describe("readDraftCloudProfiles", () => { { id: " zeta ", providerId: " static-ssh ", + trust: "disposable", settings: { token: "hidden" }, }, - { id: "aws", providerId: "crabbox" }, + { id: "aws", providerId: "crabbox", trust: "persistent" }, { id: "legacy", providerId: "static-ssh" }, + { id: "invalid-trust", providerId: "crabbox", trust: "temporary" }, { id: "", providerId: "crabbox" }, { id: "missing-provider" }, ]), ).toEqual([ - { id: "aws", providerId: "crabbox" }, - { id: "legacy", providerId: "static-ssh" }, - { id: "zeta", providerId: "static-ssh" }, + { id: "aws", providerId: "crabbox", trust: "persistent" }, + { id: "invalid-trust", providerId: "crabbox", trust: undefined }, + { id: "legacy", providerId: "static-ssh", trust: undefined }, + { id: "zeta", providerId: "static-ssh", trust: "disposable" }, ]); }); }); diff --git a/ui/src/pages/new-session/discovery.ts b/ui/src/pages/new-session/discovery.ts index 153a74549d3b..d2cd5255044a 100644 --- a/ui/src/pages/new-session/discovery.ts +++ b/ui/src/pages/new-session/discovery.ts @@ -30,6 +30,7 @@ export type DraftNode = { export type DraftCloudProfile = { id: string; providerId: string; + trust?: "persistent" | "disposable"; }; export type DraftEnvironment = { @@ -92,16 +93,17 @@ export function readDraftCloudProfiles(value: unknown): DraftCloudProfile[] { if (!raw || typeof raw !== "object") { return []; } - const profile = raw as { - id?: unknown; - providerId?: unknown; - }; + const profile = raw as { id?: unknown; providerId?: unknown; trust?: unknown }; const id = normalizeOptionalString(profile.id); const providerId = normalizeOptionalString(profile.providerId); if (!id || !providerId) { return []; } - return [{ id, providerId }]; + const trust: DraftCloudProfile["trust"] = + profile.trust === "persistent" || profile.trust === "disposable" + ? profile.trust + : undefined; + return [{ id, providerId, trust }]; }) .toSorted((left, right) => left.id.localeCompare(right.id)); } diff --git a/ui/src/pages/new-session/draft-gateway-state.ts b/ui/src/pages/new-session/draft-gateway-state.ts index f10b124d9e51..8672f019e43c 100644 --- a/ui/src/pages/new-session/draft-gateway-state.ts +++ b/ui/src/pages/new-session/draft-gateway-state.ts @@ -188,6 +188,10 @@ export class DraftGatewayState { return this.preferenceModeValue === "loading"; } + refreshCloudProfiles() { + return this.cloudProfileTask.run(); + } + synchronize(gateway: ApplicationContext["gateway"]) { const snapshot = gateway.snapshot; const connected = snapshot.phase === "connected"; diff --git a/ui/src/pages/new-session/draft-place-state.ts b/ui/src/pages/new-session/draft-place-state.ts index d198189e1a97..28b0ea48379d 100644 --- a/ui/src/pages/new-session/draft-place-state.ts +++ b/ui/src/pages/new-session/draft-place-state.ts @@ -151,6 +151,10 @@ export class DraftPlaceState { ); } + refreshNodes() { + return this.loadNodes({ quiet: true }); + } + isAdmin(): boolean { return hasOperatorAdminAccess(this.read().context?.gateway.snapshot.hello?.auth ?? null); } @@ -592,9 +596,11 @@ export class DraftPlaceState { }); } - private async loadNodes() { + private async loadNodes(options: { quiet?: boolean } = {}) { const requestId = ++this.nodesRequestToken; - this.nodesHydrated = false; + if (!options.quiet) { + this.nodesHydrated = false; + } const snapshot = this.read().context?.gateway.snapshot; const client = snapshot?.client; if (snapshot?.phase !== "connected" || !client || !this.isAdmin()) { @@ -626,7 +632,7 @@ export class DraftPlaceState { } this.callbacks.requestUpdate(); } catch { - if (requestId === this.nodesRequestToken) { + if (requestId === this.nodesRequestToken && !options.quiet) { this.nodesValue = []; this.nodesHydrated = true; this.callbacks.requestUpdate(); diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 7cd4848659f8..93bbe0638769 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -1,6 +1,7 @@ import { consume } from "@lit/context"; import { html, nothing, type ReactiveController, type ReactiveControllerHost } from "lit"; import { property } from "lit/decorators.js"; +import type { PresenceEntry } from "../../api/types.ts"; import { selectApplicationSession } from "../../app/agent-selection.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { beginNativeWindowDragFromTopInset } from "../../app/native-window-drag.ts"; @@ -29,6 +30,24 @@ import type { NewSessionRouteData } from "./location.ts"; import { renderPlaceSelect } from "./place-picker.ts"; import { renderAgentSelect } from "./target-controls.ts"; +function readPresence(value: unknown): PresenceEntry[] | null { + const presence = + value && typeof value === "object" ? (value as { presence?: unknown }).presence : null; + return Array.isArray(presence) ? (presence as PresenceEntry[]) : null; +} + +function presenceConnectivitySignature(entries: PresenceEntry[]): string { + const states = new Map(); + for (const entry of entries) { + const id = (entry.deviceId ?? entry.instanceId)?.trim().toLowerCase(); + if (!id || entry.mode?.trim().toLowerCase() === "gateway") { + continue; + } + states.set(id, entry.reason?.trim().toLowerCase() === "disconnect" ? "offline" : "connected"); + } + return JSON.stringify([...states].toSorted(([left], [right]) => left.localeCompare(right))); +} + function controllerHost(element: OpenClawLightDomElement): ReactiveControllerHost { return { addController: (controller: ReactiveController) => element.addController(controller), @@ -49,6 +68,7 @@ class NewSessionPage extends OpenClawLightDomElement { private openedFor: string | null = null; private openedAgentId = ""; private messageOwnerKey = ""; + private presenceSignature = ""; private readonly gateway: DraftGatewayState; private readonly browser: DraftPlaceBrowser; private readonly place: DraftPlaceState; @@ -147,6 +167,41 @@ class NewSessionPage extends OpenClawLightDomElement { (gateway, notify) => gateway.subscribe(notify), (gateway) => this.gateway.synchronize(gateway), ) + .effect( + () => this.context?.gateway, + (gateway) => { + this.presenceSignature = presenceConnectivitySignature( + readPresence(gateway.snapshot.hello?.snapshot) ?? [], + ); + return gateway.subscribeEvents((event) => { + if (this.context?.gateway !== gateway) { + return; + } + if (event.event === "config.changed") { + void this.gateway.refreshCloudProfiles(); + return; + } + if ( + event.event === "node.pair.requested" || + event.event === "node.pair.resolved" || + event.event === "device.pair.requested" || + event.event === "device.pair.resolved" + ) { + void this.place.refreshNodes(); + return; + } + const presence = event.event === "presence" ? readPresence(event.payload) : null; + if (!presence) { + return; + } + const signature = presenceConnectivitySignature(presence); + if (signature !== this.presenceSignature) { + this.presenceSignature = signature; + void this.place.refreshNodes(); + } + }); + }, + ) .watch( () => this.context?.agents, (agents, notify) => agents.subscribe(notify), diff --git a/ui/src/pages/new-session/place-picker.test.ts b/ui/src/pages/new-session/place-picker.test.ts index c09f2546fd12..a4e10cfd11fc 100644 --- a/ui/src/pages/new-session/place-picker.test.ts +++ b/ui/src/pages/new-session/place-picker.test.ts @@ -88,6 +88,41 @@ describe("project picker", () => { expect(projectCloneInput(value) !== null).toBe(expected); }); + it("groups gateway, device, and cloud destinations without status copy", () => { + const container = document.createElement("div"); + render( + renderPlaceSelect( + placeParams({ + showDestinations: true, + worktreeAvailable: true, + execNodes: [ + { + nodeId: "macbook", + displayName: "MacBook", + connected: true, + canExec: true, + canBrowse: true, + }, + ], + cloudProfiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }], + }), + ), + container, + ); + + const destinationHeadings = [ + ...container.querySelectorAll(".new-session-page__menu-title"), + ] + .map((element) => element.textContent?.trim()) + .filter((label) => ["This gateway", "Your devices", "Cloud", "Places"].includes(label ?? "")); + expect(destinationHeadings).toEqual(["This gateway", "Your devices", "Cloud"]); + expect(container.querySelector('[data-value="gateway"]')).not.toBeNull(); + expect(container.querySelector('[data-value="node:macbook"]')).not.toBeNull(); + expect(container.querySelector('[data-value="cloud:aws"]')).not.toBeNull(); + expect(container.textContent).not.toContain("persistent"); + expect(container.textContent).not.toContain("disposable"); + }); + it("renders local matches before remote clone results and explains missing credentials", () => { const onCloneProject = vi.fn(); const container = document.createElement("div"); @@ -258,7 +293,7 @@ describe("Where picker", () => { const titles = [...container.querySelectorAll(".new-session-page__menu-title")].map((element) => element.textContent?.trim(), ); - expect(titles).toEqual(["Folder", "Projects", "Places", "This gateway", "Devices", "Cloud"]); + expect(titles).toEqual(["Folder", "Projects", "This gateway", "Your devices", "Cloud"]); expect(container.querySelector('[data-value="node:macbook"]')).not.toBeNull(); for (const nodeId of [ "worker", diff --git a/ui/src/pages/new-session/place-picker.ts b/ui/src/pages/new-session/place-picker.ts index 488048ec65b4..631d719c89e1 100644 --- a/ui/src/pages/new-session/place-picker.ts +++ b/ui/src/pages/new-session/place-picker.ts @@ -533,7 +533,6 @@ export function renderPlaceSelect(params: { ${params.showDestinations ? html` -
${t("newSession.places")}
${t("newSession.thisGateway")}
${renderSessionMenuItem( { @@ -547,7 +546,9 @@ export function renderPlaceSelect(params: { )} ${deviceNodes.length > 0 ? html` -
${t("tabs.devices")}
+
+ ${t("newSession.yourDevices")} +
${deviceNodes.map((node, index) => renderSessionMenuItem( { @@ -567,41 +568,42 @@ export function renderPlaceSelect(params: { ` : nothing} ${cloudProfiles.length > 0 || (params.cloudProfileId && !activeProfile) - ? html`
- ${t("newSession.cloud")} -
` - : nothing} - ${renderCloudProfileMenuItems({ - profiles: cloudProfiles, - selectedId: params.cloudProfileId, - submitting: params.submitting, - icon: icons.server, - disabled: !params.worktreeAvailable || Boolean(params.cloudDisabledReason), - disabledReason: params.cloudDisabledReason, - onSelect: params.onSelectCloudProfile, - })} - ${params.cloudProfileId && !activeProfile - ? renderSessionMenuItem( - { - value: `cloud:${params.cloudProfileId}`, - label: t("newSession.cloudWorker", { - profile: params.cloudProfileId, - }), + ? html` +
${t("newSession.cloud")}
+ ${renderCloudProfileMenuItems({ + profiles: cloudProfiles, + selectedId: params.cloudProfileId, + submitting: params.submitting, icon: icons.server, - checked: true, - disabled: true, - title: t("newSession.catalogUnavailable"), - onSelect: () => undefined, - }, - params.submitting, - ) - : nothing} - ${params.cloudProfileId && params.syncFolder - ? html`
- ${t("newSession.cloudSyncsFolder", { - folder: folderDisplayName(params.syncFolder), + disabled: + !params.worktreeAvailable || Boolean(params.cloudDisabledReason), + disabledReason: params.cloudDisabledReason, + onSelect: params.onSelectCloudProfile, })} -
` + ${params.cloudProfileId && !activeProfile + ? renderSessionMenuItem( + { + value: `cloud:${params.cloudProfileId}`, + label: t("newSession.cloudWorker", { + profile: params.cloudProfileId, + }), + icon: icons.server, + checked: true, + disabled: true, + title: t("newSession.catalogUnavailable"), + onSelect: () => undefined, + }, + params.submitting, + ) + : nothing} + ${params.cloudProfileId && params.syncFolder + ? html`
+ ${t("newSession.cloudSyncsFolder", { + folder: folderDisplayName(params.syncFolder), + })} +
` + : nothing} + ` : nothing} ` : nothing}